package store import ( "errors" "strings" "testing" "oneblog/internal/db" "oneblog/internal/model" ) func TestSlugify(t *testing.T) { cases := map[string]string{ "Hello World": "hello-world", " Rebuild ONE ": "rebuild-one", "Go 语言 / 2026": "go-语言-2026", "!!!": "", } for in, want := range cases { if got := Slugify(in); got != want { t.Errorf("Slugify(%q) = %q, want %q", in, got, want) } } } func TestListOptionsRebind(t *testing.T) { // 列表查询的占位符数量必须和参数数量一致,否则在 PostgreSQL 上会直接报错 d := &db.DB{Dialect: db.Postgres} q := d.Q(`SELECT id FROM posts WHERE kind = ? AND status = ? AND title LIKE ? LIMIT ? OFFSET ?`) if strings.Count(q, "$") != 5 { t.Errorf("expected 5 placeholders, got %q", q) } } func TestPostInputDefaults(t *testing.T) { in := model.PostInput{Kind: model.KindShort, ContentMd: "一句话。"} if in.Kind != "short" { t.Errorf("kind = %q", in.Kind) } if in.Status != "" { t.Errorf("empty status means draft is applied by the API layer, got %q", in.Status) } } func TestNormalizeStatus(t *testing.T) { cases := map[string]string{ "": model.StatusDraft, "draft": model.StatusDraft, "published": model.StatusPublished, " Published ": model.StatusPublished, "pending": model.StatusDraft, // unknown → draft } for in, want := range cases { if got := NormalizeStatus(in); got != want { t.Errorf("NormalizeStatus(%q) = %q, want %q", in, got, want) } } } func TestRebindBulk(t *testing.T) { // BulkUpdateStatus uses an IN clause with len(ids) placeholders + 1 for status. d := &db.DB{Dialect: db.Postgres} q := d.Q(`UPDATE posts SET status=? WHERE id IN (?,?,?)`) if got := strings.Count(q, "$"); got != 4 { t.Errorf("expected 4 placeholders, got %d", got) } } func TestSettingsThemeID(t *testing.T) { s := openTestStore(t) st, err := s.GetSettings() if err != nil { t.Fatalf("get: %v", err) } if st.LightSkinID != "paper" { t.Errorf("default light_skin_id should be 'paper', got %q", st.LightSkinID) } st.LightSkinID = "sage" st.SiteTitle = "ONE" if err := s.UpdateSettings(st); err != nil { t.Fatalf("update: %v", err) } got, err := s.GetSettings() if err != nil { t.Fatalf("get again: %v", err) } if got.LightSkinID != "sage" { t.Errorf("light_skin_id should persist, got %q", got.LightSkinID) } if got.ThemeID != "sage" { t.Errorf("legacy theme_id should mirror light_skin_id, got %q", got.ThemeID) } if got.SiteTitle != "ONE" { t.Errorf("site_title should persist, got %q", got.SiteTitle) } } func TestSettingsLightSkinFallback(t *testing.T) { s := openTestStore(t) // Simulate an older DB that only has theme_id set. if _, err := s.db.Exec(s.db.Q(`INSERT INTO settings(key,value) VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`), "theme_id", "rose"); err != nil { t.Fatalf("seed theme_id: %v", err) } st, err := s.GetSettings() if err != nil { t.Fatalf("get: %v", err) } if st.LightSkinID != "rose" { t.Errorf("expected fallback to 'rose' from legacy theme_id, got %q", st.LightSkinID) } } func TestSettingsUIFallback(t *testing.T) { s := openTestStore(t) st, err := s.GetSettings() if err != nil { t.Fatalf("get: %v", err) } if st.UIID != UIClassic { t.Errorf("default ui_id should be %q, got %q", UIClassic, st.UIID) } if st.CustomCSS == nil { t.Error("CustomCSS should never be nil") } // An unknown ui_id is coerced to the default on write. st.UIID = "bogus" if err := s.UpdateSettings(st); err != nil { t.Fatalf("update: %v", err) } got, _ := s.GetSettings() if got.UIID != UIClassic { t.Errorf("bogus ui_id should fall back to %q, got %q", UIClassic, got.UIID) } // A valid one persists. st.UIID = UIVivid if err := s.UpdateSettings(st); err != nil { t.Fatalf("update again: %v", err) } got, _ = s.GetSettings() if got.UIID != UIVivid { t.Errorf("ui_id should persist, got %q", got.UIID) } } func TestSettingsUIFallbackOnRead(t *testing.T) { s := openTestStore(t) // A hand-edited / legacy row must not leak an unknown UI to clients. if _, err := s.db.Exec(s.db.Q(`INSERT INTO settings(key,value) VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`), "ui_id", "neon"); err != nil { t.Fatalf("seed ui_id: %v", err) } st, err := s.GetSettings() if err != nil { t.Fatalf("get: %v", err) } if st.UIID != UIClassic { t.Errorf("expected read-side fallback to %q, got %q", UIClassic, st.UIID) } } func TestSettingsCustomCSSRoundTrip(t *testing.T) { s := openTestStore(t) // Seeding a raw row with an unknown section + a valid one. if _, err := s.db.Exec(s.db.Q(`INSERT INTO settings(key,value) VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`), "custom_css", `{"home":"body{--v-accent:#f0f}","bogus":"x{}"}`); err != nil { t.Fatalf("seed custom_css: %v", err) } st, _ := s.GetSettings() if st.CustomCSS["home"] != "body{--v-accent:#f0f}" { t.Errorf("home css not read back: %+v", st.CustomCSS) } if _, ok := st.CustomCSS["bogus"]; ok { t.Error("unknown section should be dropped on read") } // Write side: unknown sections dropped, XML-hostile chars survive, nil clears. st.CustomCSS = map[string]string{ "home": `a::after{content:"<&>"}`, "bogus": "x{}", } if err := s.UpdateSettings(st); err != nil { t.Fatalf("update: %v", err) } got, _ := s.GetSettings() if got.CustomCSS["home"] != `a::after{content:"<&>"}` { t.Errorf("css mangled in round trip: %q", got.CustomCSS["home"]) } if len(got.CustomCSS) != 1 { t.Errorf("unknown section should be dropped on write, got %+v", got.CustomCSS) } // A nil map is a full clear, and still comes back as a non-nil empty map. got.CustomCSS = nil if err := s.UpdateSettings(got); err != nil { t.Fatalf("clear: %v", err) } after, _ := s.GetSettings() if after.CustomCSS == nil { t.Error("CustomCSS should be non-nil after clearing") } if len(after.CustomCSS) != 0 { t.Errorf("expected cleared map, got %+v", after.CustomCSS) } } func TestSettingsCustomCSSOverLongSectionDropped(t *testing.T) { s := openTestStore(t) st, _ := s.GetSettings() st.CustomCSS = map[string]string{ "home": strings.Repeat("a", maxSectionCSSBytes+1), "about": "b{color:red}", } if err := s.UpdateSettings(st); err != nil { t.Fatalf("update: %v", err) } got, _ := s.GetSettings() if _, ok := got.CustomCSS["home"]; ok { t.Error("over-long section should be dropped") } if got.CustomCSS["about"] != "b{color:red}" { t.Errorf("valid section should survive, got %+v", got.CustomCSS) } } // TestStoreSQLite runs a small integration pass on top of an in-memory SQLite // database to exercise the new fields (cover_url, tag color) and the merge // + bulk + dashboard paths. func TestStoreSQLite(t *testing.T) { s := openTestStore(t) p1, err := s.Create(model.PostInput{ Kind: model.KindLong, Title: "第一篇", Slug: "first", Summary: "first summary", CoverURL: "https://example.com/a.png", ContentMd: "# hi\n这是一段正文。", Status: model.StatusPublished, Tags: []string{"Go", "博客"}, }) if err != nil { t.Fatalf("create p1: %v", err) } if p1.CoverURL == "" { t.Errorf("cover_url not persisted") } if len(p1.Tags) != 2 { t.Errorf("expected 2 tags, got %v", p1.Tags) } p2, err := s.Create(model.PostInput{ Kind: model.KindShort, ContentMd: "碎片想法。", Status: model.StatusDraft, Tags: []string{"碎片"}, }) if err != nil { t.Fatalf("create p2: %v", err) } // Update cover_url and tags. Frontend sends the current CoverURL back so // the value is preserved across updates. upd, err := s.Update(p1.ID, model.PostInput{ Kind: model.KindLong, Title: "第一篇", CoverURL: p1.CoverURL, ContentMd: "# hi\n这是新版本。", Tags: []string{"Go"}, }) if err != nil { t.Fatalf("update: %v", err) } if upd.CoverURL != p1.CoverURL { t.Errorf("cover_url not preserved when echoed back, got %q", upd.CoverURL) } // An empty CoverURL is treated as an explicit clear. upd2, err := s.Update(p1.ID, model.PostInput{ Kind: model.KindLong, Title: "第一篇", CoverURL: "", Tags: []string{"Go"}, }) if err != nil { t.Fatalf("update clear: %v", err) } if upd2.CoverURL != "" { t.Errorf("empty cover_url should clear, got %q", upd2.CoverURL) } // restore for downstream tests if _, err := s.Update(p1.ID, model.PostInput{ Kind: model.KindLong, Title: "第一篇", CoverURL: p1.CoverURL, Tags: []string{"Go"}, }); err != nil { t.Fatalf("restore: %v", err) } if len(upd.Tags) != 1 || upd.Tags[0] != "Go" { t.Errorf("tags after update: %v", upd.Tags) } // BulkUpdateStatus flips drafts to published n, err := s.BulkUpdateStatus([]int64{p2.ID}, model.StatusPublished) if err != nil { t.Fatalf("bulk: %v", err) } if n != 1 { t.Errorf("expected 1 updated, got %d", n) } // Tag merge: create two distinct slug tags, attach both to p1, merge. if _, err := s.UpdateTag(0, "", ""); err == nil { t.Errorf("expected error for empty name") } tags, err := s.ListTags() if err != nil { t.Fatalf("list tags: %v", err) } var goID int64 for _, tg := range tags { if tg.Name == "Go" { goID = tg.ID } } if goID == 0 { t.Fatalf("Go tag not found") } // Give the surviving target tag a color so we can confirm it sticks. if _, err := s.UpdateTag(goID, "Go", "#3d7f9c"); err != nil { t.Fatalf("color target: %v", err) } // CreateTagFull uses upsertTag so "GoLang" with its own slug is a fresh row. golang, err := s.CreateTagFull("GoLang", "") if err != nil { t.Fatalf("create golang: %v", err) } if _, err := s.db.Exec(s.db.Q(`INSERT INTO post_tags(post_id, tag_id) VALUES (?, ?)`), p1.ID, golang.ID); err != nil { t.Fatalf("attach golang: %v", err) } merged, err := s.MergeTags(golang.ID, goID) if err != nil { t.Fatalf("merge: %v", err) } if merged.ID != goID { t.Errorf("merge should return target tag, got %d want %d", merged.ID, goID) } if merged.Color != "#3d7f9c" { t.Errorf("target tag color lost, got %q", merged.Color) } // Dashboard d, err := s.Dashboard() if err != nil { t.Fatalf("dashboard: %v", err) } if d.TotalPosts != 2 { t.Errorf("total_posts = %d", d.TotalPosts) } if d.PublishedPosts != 2 { t.Errorf("published_posts = %d", d.PublishedPosts) } if len(d.RecentPosts) == 0 { t.Errorf("recent posts empty") } if len(d.TopTags) == 0 { t.Errorf("top tags empty") } } func openTestStore(t *testing.T) *Store { t.Helper() d, err := db.Open("sqlite", ":memory:") if err != nil { t.Fatalf("open sqlite: %v", err) } s, err := New(d) if err != nil { t.Fatalf("new store: %v", err) } return s } func TestSanitizeOrder(t *testing.T) { if got := sanitizeOrder("published_at ASC"); got != "published_at asc" { t.Errorf("whitelisted order rejected: %q", got) } for _, bad := range []string{ "published_at DESC; DROP TABLE posts; --", "(SELECT 1) DESC", "1 DESC", "published_at DESC, (SELECT COUNT(*) FROM sqlite_master) ASC", } { if got := sanitizeOrder(bad); got != "published_at desc" { t.Errorf("sanitizeOrder(%q) = %q, want fallback", bad, got) } } } func TestListWithMaliciousOrderByFailsSafe(t *testing.T) { s := openTestStore(t) page, err := s.List(ListOptions{Status: "any", OrderBy: "published_at DESC; DROP TABLE posts; --"}) if err != nil { t.Fatalf("List: %v", err) } if page.Total != 0 { t.Errorf("unexpected total %d", page.Total) } } func TestProjectCRUD(t *testing.T) { s := openTestStore(t) // empty list is a non-nil slice list, err := s.ListProjects("") if err != nil { t.Fatalf("ListProjects: %v", err) } if list == nil { t.Fatal("ListProjects returned nil slice") } created, err := s.CreateProject(model.ProjectInput{ Title: "Folo", Summary: "This AI RSS reader reads the internet for you", CoverURL: "https://folo.is/cover.webp", URL: "https://folo.is", RepoURL: "https://github.com/DIYgod/RSSHub-Radar", Status: model.StatusPublished, Position: 2, }) if err != nil { t.Fatalf("CreateProject: %v", err) } if created.ID == 0 { t.Fatal("created project has no ID") } if created.Slug != "folo" { t.Errorf("slug = %q, want folo", created.Slug) } // duplicate slug gets disambiguated dup, err := s.CreateProject(model.ProjectInput{Title: "Folo", Status: model.StatusPublished}) if err != nil { t.Fatalf("CreateProject dup: %v", err) } if dup.Slug == created.Slug { t.Errorf("duplicate slug not disambiguated: %q", dup.Slug) } // public list excludes drafts; admin ("") includes them draft, err := s.CreateProject(model.ProjectInput{Title: "Secret", Status: model.StatusDraft}) if err != nil { t.Fatalf("CreateProject draft: %v", err) } pub, _ := s.ListProjects(model.StatusPublished) if len(pub) != 2 { t.Errorf("published list len = %d, want 2", len(pub)) } all, _ := s.ListProjects("") if len(all) != 3 { t.Errorf("all list len = %d, want 3", len(all)) } // update upd, err := s.UpdateProject(created.ID, model.ProjectInput{Summary: "Updated summary", Position: 1}) if err != nil { t.Fatalf("UpdateProject: %v", err) } if upd.Summary != "Updated summary" || upd.Position != 1 { t.Errorf("update did not apply: %+v", upd) } // get got, err := s.GetProject(draft.ID) if err != nil { t.Fatalf("GetProject: %v", err) } if got.Status != model.StatusDraft { t.Errorf("status = %q, want draft", got.Status) } // delete if err := s.DeleteProject(created.ID); err != nil { t.Fatalf("DeleteProject: %v", err) } _, err = s.GetProject(created.ID) if !errors.Is(err, ErrNotFound) { t.Errorf("after delete: got %v, want ErrNotFound", err) } }