diff options
| author | ivar <i@oiee.no> | 2026-04-04 16:10:52 +0200 |
|---|---|---|
| committer | ivar <i@oiee.no> | 2026-04-04 16:10:52 +0200 |
| commit | f045a49e6feb6f448faea073821a9661f8b710a3 (patch) | |
| tree | 8dcb53284d3f35b8ae2e4d1ca180c223b5d02784 /internal | |
| parent | d239993644aae8c29d5fc37e8c6209850140807c (diff) | |
| download | nebbet.no-f045a49e6feb6f448faea073821a9661f8b710a3.tar.xz nebbet.no-f045a49e6feb6f448faea073821a9661f8b710a3.zip | |
feat: add RenamePost with transactional rename and redirect creation
Implement RenamePost method that atomically:
1. Fetches the post record by old slug
2. Inserts a new record with the new slug
3. Deletes the old slug post
4. Collapses redirect chains (updates any redirects pointing to oldSlug to point to newSlug)
5. Creates a redirect from oldSlug to newSlug
All operations are transactional (all-or-nothing). Includes two comprehensive tests:
- TestRenamePost: basic rename with redirect creation and old slug deletion
- TestRenamePost_CollapsesChain: verifies redirect chains are collapsed correctly
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/db/posts.go | 60 | ||||
| -rw-r--r-- | internal/db/posts_test.go | 70 |
2 files changed, 130 insertions, 0 deletions
diff --git a/internal/db/posts.go b/internal/db/posts.go index f6c23cc..d391c3e 100644 --- a/internal/db/posts.go +++ b/internal/db/posts.go @@ -137,3 +137,63 @@ func (m *MetaDB) CollapseRedirects(oldSlug, newSlug string) error { ) return err } + +// RenamePost atomically renames a post from oldSlug to newSlug, +// updates any existing redirect chains, and adds a redirect from oldSlug to newSlug. +func (m *MetaDB) RenamePost(oldSlug, newSlug string) error { + tx, err := m.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + // Fetch existing record + var p PostRecord + var tagsJSON string + var draft int + row := tx.QueryRow( + `SELECT slug, title, date, tags, draft, blocks, updated_at FROM posts WHERE slug = ?`, oldSlug) + if err := row.Scan(&p.Slug, &p.Title, &p.Date, &tagsJSON, &draft, &p.Blocks, &p.UpdatedAt); err != nil { + return err + } + p.Draft = draft != 0 + _ = json.Unmarshal([]byte(tagsJSON), &p.Tags) + + // Insert with new slug + tags, _ := json.Marshal(p.Tags) + draftInt := 0 + if p.Draft { + draftInt = 1 + } + _, err = tx.Exec(` + INSERT INTO posts (slug, title, date, tags, draft, blocks, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + newSlug, p.Title, p.Date, string(tags), draftInt, p.Blocks, p.UpdatedAt, + ) + if err != nil { + return err + } + + // Delete old record + if _, err = tx.Exec(`DELETE FROM posts WHERE slug = ?`, oldSlug); err != nil { + return err + } + + // Collapse existing redirect chains: anything pointing to oldSlug now points to newSlug + if _, err = tx.Exec( + `UPDATE redirects SET to_slug = ? WHERE to_slug = ?`, newSlug, oldSlug, + ); err != nil { + return err + } + + // Add redirect from oldSlug to newSlug + if _, err = tx.Exec(` + INSERT INTO redirects (from_slug, to_slug) VALUES (?, ?) + ON CONFLICT(from_slug) DO UPDATE SET to_slug = excluded.to_slug`, + oldSlug, newSlug, + ); err != nil { + return err + } + + return tx.Commit() +} diff --git a/internal/db/posts_test.go b/internal/db/posts_test.go index ec596c3..26664ce 100644 --- a/internal/db/posts_test.go +++ b/internal/db/posts_test.go @@ -78,3 +78,73 @@ func TestAddRedirect_Upsert(t *testing.T) { t.Errorf("got %q, want %q", got, "second") } } + +func TestRenamePost(t *testing.T) { + m := openTestDB(t) + + original := PostRecord{ + Slug: "original-slug", + Title: "My Post", + Date: "2026-04-04", + Tags: []string{"go"}, + Draft: false, + Blocks: `[{"type":"paragraph","data":{"text":"hello"}}]`, + UpdatedAt: 1000000, + } + if err := m.UpsertPost(original); err != nil { + t.Fatalf("UpsertPost: %v", err) + } + + if err := m.RenamePost("original-slug", "new-slug"); err != nil { + t.Fatalf("RenamePost: %v", err) + } + + // New slug exists with same content + got, err := m.GetPost("new-slug") + if err != nil { + t.Fatalf("GetPost new-slug: %v", err) + } + if got.Title != "My Post" { + t.Errorf("title: got %q, want %q", got.Title, "My Post") + } + + // Old slug is gone + _, err = m.GetPost("original-slug") + if err == nil { + t.Error("expected old slug to be deleted, but GetPost returned no error") + } + + // Redirect exists + toSlug, err := m.GetRedirect("original-slug") + if err != nil { + t.Fatalf("GetRedirect: %v", err) + } + if toSlug != "new-slug" { + t.Errorf("redirect: got %q, want %q", toSlug, "new-slug") + } +} + +func TestRenamePost_CollapsesChain(t *testing.T) { + m := openTestDB(t) + + // Create post at "b" + if err := m.UpsertPost(PostRecord{Slug: "b", Title: "B", Blocks: "[]", UpdatedAt: 1}); err != nil { + t.Fatalf("UpsertPost: %v", err) + } + // Existing redirect a -> b + if err := m.AddRedirect("a", "b"); err != nil { + t.Fatalf("AddRedirect: %v", err) + } + // Rename b -> c: should collapse a -> c + if err := m.RenamePost("b", "c"); err != nil { + t.Fatalf("RenamePost: %v", err) + } + + got, err := m.GetRedirect("a") + if err != nil { + t.Fatalf("GetRedirect a: %v", err) + } + if got != "c" { + t.Errorf("chain collapse: got %q, want %q", got, "c") + } +} |
