package store import ( "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) } } // 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) } }