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, "![x](/api/images/"+itoa64(imgID)+")"); 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:]) }