- 编辑页标题改 filled 变体;标签改 tags 选择器(可勾选既有/输入新建) - 笔记发布日期可自选(schema v2 新增 published_at 并回填),前台展示发布日期 - 登录页移除「请输入管理密码以继续」「连续失败将被暂时锁定」文案 - 站点设置每页条数收敛为 10/20/30/50 选择器,后端白名单同源校验 - 站点设置两卡片宽屏左右/窄屏上下;修改密码按钮改常规大小 - 后台各页顶栏固定高度,切换页面不再抖动 - 站点 Logo:settings 新增 site_logo(站内路径),gc/孤儿清单豁免 Logo 图片, 博客 header 站点名前展示,设置页支持上传/更换/清除 - 回收站右上角一键清空(DELETE /api/admin/trash,含确认弹窗) - pn init 写入 Markdown 语法示例文档(slug welcome,公开)与程序生成的示例图片
215 lines
5.5 KiB
Go
215 lines
5.5 KiB
Go
package store
|
||
|
||
import (
|
||
"errors"
|
||
"path/filepath"
|
||
"testing"
|
||
)
|
||
|
||
// TestMigrationsFromEmpty 空库 → 最新版本;重复打开幂等(§13 迁移组)。
|
||
func TestMigrationsFromEmpty(t *testing.T) {
|
||
dir := t.TempDir()
|
||
dbPath := filepath.Join(dir, "pn.db")
|
||
s1, err := Open(dbPath, false)
|
||
if err != nil {
|
||
t.Fatalf("首次打开失败: %v", err)
|
||
}
|
||
v1, err := s1.SchemaVersion()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if v1 != MaxSchemaVersion {
|
||
t.Fatalf("期望版本 %d 实际 %d", MaxSchemaVersion, v1)
|
||
}
|
||
s1.Close()
|
||
|
||
// 重开:幂等,不再执行迁移
|
||
s2, err := Open(dbPath, false)
|
||
if err != nil {
|
||
t.Fatalf("二次打开失败: %v", err)
|
||
}
|
||
defer s2.Close()
|
||
v2, err := s2.SchemaVersion()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if v2 != v1 {
|
||
t.Fatalf("迁移不幂等: %d → %d", v1, v2)
|
||
}
|
||
}
|
||
|
||
// TestMigrationNewerRejected user_version 越界拒绝启动;--allow-newer 放行(§10.4)。
|
||
func TestMigrationNewerRejected(t *testing.T) {
|
||
dir := t.TempDir()
|
||
dbPath := filepath.Join(dir, "pn.db")
|
||
s1, err := Open(dbPath, false)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := s1.DB().Exec("PRAGMA user_version = 99"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
s1.Close()
|
||
|
||
_, err = Open(dbPath, false)
|
||
if !errors.Is(err, ErrSchemaNewer) {
|
||
t.Fatalf("期望 ErrSchemaNewer,实际 %v", err)
|
||
}
|
||
|
||
s2, err := Open(dbPath, true) // --allow-newer
|
||
if err != nil {
|
||
t.Fatalf("allow-newer 应放行: %v", err)
|
||
}
|
||
defer s2.Close()
|
||
}
|
||
|
||
// TestForeignKeysCascade 外键级联生效(image_refs 依赖 foreign_keys=1)。
|
||
func TestForeignKeysCascade(t *testing.T) {
|
||
s := openTestStore(t)
|
||
nid, err := s.CreateNote(&Note{Slug: "casc", Title: "级联", Status: "public",
|
||
Tags: []string{}, CreatedAt: 1, UpdatedAt: 1})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
imgID, err := s.UpsertImage("aa", "image/png", 1, []byte{1}, 1)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := s.RebuildImageRefs(nid, "+")"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := s.DeleteNoteForever(nid); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// 级联后图片变孤儿
|
||
orphans, err := s.ListOrphanImages()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
found := false
|
||
for _, o := range orphans {
|
||
if o.ID == imgID {
|
||
found = true
|
||
}
|
||
}
|
||
if !found {
|
||
t.Fatal("物理删除笔记后其引用关系应被级联删除(图片转为孤儿)")
|
||
}
|
||
}
|
||
|
||
// TestOpenDataNoMigrate 维护命令打开方式:不迁移、不做版本守卫(D6,评审 round2 P1-5)。
|
||
func TestOpenDataNoMigrate(t *testing.T) {
|
||
dir := t.TempDir()
|
||
dbPath := filepath.Join(dir, "pn.db")
|
||
|
||
// 全新路径:OpenData 不创建 schema
|
||
s, err := OpenData(dbPath)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
v, err := s.SchemaVersion()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if v != 0 {
|
||
t.Fatalf("OpenData 不应执行迁移,user_version=%d", v)
|
||
}
|
||
var one int
|
||
if err := s.db.QueryRow("SELECT 1 FROM notes LIMIT 1").Scan(&one); err == nil {
|
||
t.Fatal("OpenData 不应创建表")
|
||
}
|
||
s.Close()
|
||
|
||
// 更高 schema 版本:OpenData 不被守卫拒绝(升级/回滚场景备份仍可用)
|
||
s2, err := Open(dbPath, false)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := s2.DB().Exec("PRAGMA user_version = 99"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
s2.Close()
|
||
if _, err := Open(dbPath, false); !errors.Is(err, ErrSchemaNewer) {
|
||
t.Fatalf("Open 仍应被守卫拒绝,实际 %v", err)
|
||
}
|
||
s3, err := OpenData(dbPath)
|
||
if err != nil {
|
||
t.Fatalf("OpenData 不做版本守卫,应放行: %v", err)
|
||
}
|
||
s3.Close()
|
||
}
|
||
|
||
// TestAdjacentPublicNote 邻接查询:列表序 pinned DESC, updated_at DESC, id DESC
|
||
// 的紧邻语义(评审 round2 P1-1:同秒 tie-break 与 pinned 参与排序;仅统计公开)。
|
||
func TestAdjacentPublicNote(t *testing.T) {
|
||
s := openTestStore(t)
|
||
mk := func(slug string, at int64, pinned bool, status string) {
|
||
t.Helper()
|
||
if _, err := s.CreateNote(&Note{Slug: slug, Title: slug, Status: status,
|
||
Tags: []string{}, Pinned: pinned, CreatedAt: at, UpdatedAt: at}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
mk("a", 100, false, "public") // id=1
|
||
mk("b", 100, false, "public") // id=2(与 a 同秒,列表中排在 a 之前)
|
||
mk("c", 80, false, "public") // id=3
|
||
mk("p", 50, true, "public") // id=4 置顶 → 列表首
|
||
mk("priv", 200, false, "private") // 更新的私有笔记:不得出现在邻接中
|
||
|
||
get := func(slug string) *Note {
|
||
n, err := s.GetNoteBySlug(slug)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return n
|
||
}
|
||
// 公开列表序:p > b > a > c(priv 被过滤)
|
||
cases := []struct{ slug, prev, next string }{
|
||
{"p", "", "b"}, // 置顶首篇
|
||
{"b", "p", "a"},
|
||
{"a", "b", "c"}, // a 与 b 同秒:prev 应取同秒中紧邻的 b(id 更大),而非跳过
|
||
{"c", "a", ""},
|
||
}
|
||
for _, tc := range cases {
|
||
prevSlug, _, err := s.AdjacentPublicNote(get(tc.slug), "prev")
|
||
if err != nil {
|
||
t.Fatalf("%s prev: %v", tc.slug, err)
|
||
}
|
||
nextSlug, _, err := s.AdjacentPublicNote(get(tc.slug), "next")
|
||
if err != nil {
|
||
t.Fatalf("%s next: %v", tc.slug, err)
|
||
}
|
||
if prevSlug != tc.prev || nextSlug != tc.next {
|
||
t.Errorf("%s: 期望 prev=%q next=%q,实际 prev=%q next=%q",
|
||
tc.slug, tc.prev, tc.next, prevSlug, nextSlug)
|
||
}
|
||
}
|
||
if _, _, err := s.AdjacentPublicNote(get("a"), "bad"); err == nil {
|
||
t.Error("非法 dir 应返回错误")
|
||
}
|
||
}
|
||
|
||
func openTestStore(t *testing.T) *Store {
|
||
t.Helper()
|
||
s, err := Open(filepath.Join(t.TempDir(), "test.db"), false)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
t.Cleanup(func() { s.Close() })
|
||
return s
|
||
}
|
||
|
||
func itoa64(v int64) string {
|
||
if v == 0 {
|
||
return "0"
|
||
}
|
||
var b [20]byte
|
||
i := len(b)
|
||
for v > 0 {
|
||
i--
|
||
b[i] = byte('0' + v%10)
|
||
v /= 10
|
||
}
|
||
return string(b[i:])
|
||
}
|