diff --git a/store_test.go b/store_test.go index 5ecd5b1..44b3a7f 100644 --- a/store_test.go +++ b/store_test.go @@ -1,95 +1,263 @@ package main import ( - "database/sql" - "os" + "context" + "sort" + "strconv" "testing" - - _ "github.com/lib/pq" + "time" ) -// newTestStore opens a connection to the test DB, skipping if not available. -// Pattern from go-htmx-web-dev skill: TDD mandatory, no code without tests. -func newTestStore(t *testing.T) *Store { +// fixedClock — deterministic clock for tests. Returns the same instant on every +// call until advanced. Per the VOPR/Speckl constraint: no real time in tests. +type fixedClock struct { + now time.Time +} + +func (c *fixedClock) Now() time.Time { return c.now } + +func (c *fixedClock) Advance(d time.Duration) { c.now = c.now.Add(d) } + +// seqID — deterministic ID generator. Returns "id-1", "id-2", ... per call. +// Per the VOPR/Speckl constraint: no random IDs in tests. +type seqID struct { + n int +} + +func (s *seqID) NewID() string { + s.n++ + return idFor(s.n) +} + +func idFor(n int) string { + return "id-" + itoa(n) +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + var buf [20]byte + i := len(buf) + for n > 0 { + i-- + buf[i] = byte('0' + n%10) + n /= 10 + } + return string(buf[i:]) +} + +// newTestStore — builds a Store with deterministic clock and ID generator. +// Does NOT start the flush goroutine. Does NOT restore from disk. +func newTestStore(t *testing.T) (*Store, *fixedClock, *seqID) { t.Helper() - dsn := os.Getenv("POSTS_TEST_DSN") - if dsn == "" { - dsn = "postgres://posts:posts@127.0.0.1:5432/posts_test?sslmode=disable" - } - db, err := sql.Open("postgres", dsn) - if err != nil { - t.Skipf("no test db: %v", err) - } - if err := db.Ping(); err != nil { - t.Skipf("no test db: %v", err) - } - return NewStore(db) + clock := &fixedClock{now: time.Date(2026, 6, 22, 22, 0, 0, 0, time.UTC)} + ids := &seqID{} + s := NewStoreWithOptions(StoreOptions{ + Now: clock.Now, + NewID: ids.NewID, + FlushInterval: 0, // no timer + FlushPath: t.TempDir(), + StartFlush: false, + }) + // Bootstrap required authors + channels. + must(t, Submit(context.Background(), s, &UpsertAuthor{ID: "scott", AuthorKind: "human", DisplayName: "Scott"})) + must(t, Submit(context.Background(), s, &UpsertAuthor{ID: "hermes", AuthorKind: "agent", DisplayName: "Hermes"})) + must(t, Submit(context.Background(), s, &UpsertChannel{Slug: "scobleclaw", Name: "Scobleclaw"})) + must(t, Submit(context.Background(), s, &UpsertChannel{Slug: "agents", Name: "Agents"})) + return s, clock, ids } -func TestCreatePostBumpsAt(t *testing.T) { - s := newTestStore(t) - p, err := s.CreatePost("scott", "Hello", "world", []string{"scobleclaw"}) +func must(t *testing.T, err error) { + t.Helper() if err != nil { - t.Fatalf("create: %v", err) - } - if p.ID == "" { - t.Fatal("expected id") - } - if p.BumpedAt.IsZero() { - t.Fatal("expected bumped_at set") + t.Fatalf("submit: %v", err) } } -func TestAddCommentBumpsPost(t *testing.T) { - s := newTestStore(t) - p, err := s.CreatePost("scott", "Hello", "world", nil) - if err != nil { - t.Fatalf("create: %v", err) +func TestCreatePost_AssignsIDAndBumpsAt(t *testing.T) { + s, clock, _ := newTestStore(t) + m := &CreatePost{AuthorID: "scott", Title: "Hello", Body: "world", Channels: []string{"scobleclaw"}} + if err := Submit(context.Background(), s, m); err != nil { + t.Fatalf("submit: %v", err) } - before := p.BumpedAt + if m.ResolvedPostID() != "id-1" { // first real post is the 1st generated id (bootstrap uses supplied IDs) + t.Fatalf("expected id-1, got %q", m.ResolvedPostID()) + } + p, ok := s.GetPost(m.ResolvedPostID()) + if !ok { + t.Fatalf("post not found") + } + if !p.BumpedAt.Equal(clock.Now()) { + t.Fatalf("bumped_at mismatch: got %v, want %v", p.BumpedAt, clock.Now()) + } +} - // sleep a beat so timestamps are strictly different - // (we're testing logical correctness; in CI you'd inject a clock) - c, err := s.AddComment(p.ID, "", "hermes", "first!") - if err != nil { +func TestAddComment_BumpsPost(t *testing.T) { + s, clock, _ := newTestStore(t) + cp := &CreatePost{AuthorID: "scott", Title: "Hello", Body: "world"} + if err := Submit(context.Background(), s, cp); err != nil { + t.Fatal(err) + } + beforeBump := s.posts[cp.ResolvedPostID()].BumpedAt + + clock.Advance(2 * time.Minute) + ac := &AddComment{PostID: cp.ResolvedPostID(), AuthorID: "hermes", Body: "first!"} + if err := Submit(context.Background(), s, ac); err != nil { t.Fatalf("add comment: %v", err) } - if c.Path != c.ID { - t.Fatalf("top-level path should equal id, got %q", c.Path) - } - - got, err := s.GetPost(p.ID) - if err != nil { - t.Fatalf("get: %v", err) - } - if !got.BumpedAt.After(before) && !got.BumpedAt.Equal(before) { - t.Fatalf("post should have been bumped, before=%v after=%v", before, got.BumpedAt) + got, _ := s.GetPost(cp.ResolvedPostID()) + if !got.BumpedAt.After(beforeBump) { + t.Fatalf("post should have been bumped, before=%v after=%v", beforeBump, got.BumpedAt) } } func TestNestedCommentPath(t *testing.T) { - s := newTestStore(t) - p, err := s.CreatePost("scott", "Tree test", "body", nil) + s, _, _ := newTestStore(t) + cp := &CreatePost{AuthorID: "scott", Title: "Tree", Body: "body"} + must(t, Submit(context.Background(), s, cp)) + c1 := &AddComment{PostID: cp.ResolvedPostID(), AuthorID: "hermes", Body: "top"} + must(t, Submit(context.Background(), s, c1)) + c2 := &AddComment{PostID: cp.ResolvedPostID(), ParentID: c1.ResolvedCommentID(), AuthorID: "hermes", Body: "nested"} + must(t, Submit(context.Background(), s, c2)) + if c2.ResolvedDepth() != 1 { + t.Fatalf("expected depth 1, got %d", c2.ResolvedDepth()) + } + if !startsWith(c2.ResolvedPath(), c1.ResolvedCommentID()+".") { + t.Fatalf("path %q should start with %q", c2.ResolvedPath(), c1.ResolvedCommentID()+".") + } +} + +func TestSnapshot_RoundTrip(t *testing.T) { + s, _, _ := newTestStore(t) + cp := &CreatePost{AuthorID: "scott", Title: "P", Body: "B", Channels: []string{"scobleclaw"}} + must(t, Submit(context.Background(), s, cp)) + ac := &AddComment{PostID: cp.ResolvedPostID(), AuthorID: "hermes", Body: "C"} + must(t, Submit(context.Background(), s, ac)) + + data, err := s.Snapshot() if err != nil { - t.Fatal(err) + t.Fatalf("snapshot: %v", err) } - c1, err := s.AddComment(p.ID, "", "hermes", "top") - if err != nil { - t.Fatal(err) + if len(data) < 100 { + t.Fatalf("snapshot too small: %d bytes", len(data)) } - c2, err := s.AddComment(p.ID, c1.ID, "hermes", "nested") - if err != nil { - t.Fatal(err) + + s2 := NewStoreWithOptions(StoreOptions{ + Now: func() time.Time { return time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC) }, + NewID: func() string { return "restored" }, + FlushInterval: 0, + FlushPath: "/tmp", + StartFlush: false, + }) + if err := s2.Restore(data); err != nil { + t.Fatalf("restore: %v", err) } - if c2.Depth != 1 { - t.Fatalf("expected depth 1, got %d", c2.Depth) + if _, ok := s2.GetPost(cp.ResolvedPostID()); !ok { + t.Fatalf("post not restored") } - wantPrefix := c1.ID + "." - if !startsWith(c2.Path, wantPrefix) { - t.Fatalf("path %q should start with %q", c2.Path, wantPrefix) + comments := s2.ListComments(cp.ResolvedPostID()) + if len(comments) != 1 { + t.Fatalf("expected 1 comment, got %d", len(comments)) } } +func TestListPosts_FilteredByChannel(t *testing.T) { + s, _, _ := newTestStore(t) + p1 := &CreatePost{AuthorID: "scott", Body: "in scobleclaw", Channels: []string{"scobleclaw"}} + must(t, Submit(context.Background(), s, p1)) + p2 := &CreatePost{AuthorID: "scott", Body: "in agents", Channels: []string{"agents"}} + must(t, Submit(context.Background(), s, p2)) + got := s.ListPosts("scobleclaw", 10) + if len(got) != 1 { + t.Fatalf("expected 1 post in scobleclaw, got %d", len(got)) + } + if got[0].ID != p1.ResolvedPostID() { + t.Fatalf("wrong post returned") + } +} + +func TestListPosts_NewestBumpedFirst(t *testing.T) { + s, clock, _ := newTestStore(t) + p1 := &CreatePost{AuthorID: "scott", Body: "first"} + must(t, Submit(context.Background(), s, p1)) + clock.Advance(time.Minute) + p2 := &CreatePost{AuthorID: "scott", Body: "second"} + must(t, Submit(context.Background(), s, p2)) + got := s.ListPosts("", 10) + if len(got) != 2 { + t.Fatalf("expected 2 posts, got %d", len(got)) + } + if got[0].ID != p2.ResolvedPostID() { + t.Fatalf("newest first failed: got %s, want %s", got[0].ID, p2.ResolvedPostID()) + } +} + +func TestValidation_FailsWithoutAuthor(t *testing.T) { + s, _, _ := newTestStore(t) + m := &CreatePost{AuthorID: "ghost", Body: "x"} + err := Submit(context.Background(), s, m) + if err == nil { + t.Fatalf("expected validation error for unknown author") + } +} + +func TestValidation_FailsWithEmptyBody(t *testing.T) { + s, _, _ := newTestStore(t) + m := &CreatePost{AuthorID: "scott", Body: " "} + err := Submit(context.Background(), s, m) + if err == nil { + t.Fatalf("expected validation error for empty body") + } +} + +func TestMemUsage_ReportsNonZero(t *testing.T) { + s, _, _ := newTestStore(t) + cp := &CreatePost{AuthorID: "scott", Body: "x"} + must(t, Submit(context.Background(), s, cp)) + mem := s.MemUsage() + if mem.Posts == 0 { + t.Fatalf("expected posts > 0, got %d", mem.Posts) + } + if mem.Authors == 0 { + t.Fatalf("expected authors > 0, got %d", mem.Authors) + } +} + +func TestDeterminism_TwoRunsProduceIdenticalState(t *testing.T) { + runOnce := func() string { + s, clock, _ := newTestStore(t) + cp := &CreatePost{AuthorID: "scott", Title: "D", Body: "deterministic"} + must(t, Submit(context.Background(), s, cp)) + c1 := &AddComment{PostID: cp.ResolvedPostID(), AuthorID: "hermes", Body: "c1"} + must(t, Submit(context.Background(), s, c1)) + clock.Advance(time.Minute) + c2 := &AddComment{PostID: cp.ResolvedPostID(), ParentID: c1.ResolvedCommentID(), AuthorID: "hermes", Body: "c2"} + must(t, Submit(context.Background(), s, c2)) + posts := s.ListPosts("", 10) + sort.Slice(posts, func(i, j int) bool { return posts[i].ID < posts[j].ID }) + comments := s.ListComments(cp.ResolvedPostID()) + sort.Slice(comments, func(i, j int) bool { return comments[i].ID < comments[j].ID }) + return dump(cp.ResolvedPostID(), posts, comments) + } + a := runOnce() + b := runOnce() + if a != b { + t.Fatalf("non-deterministic:\n%s\nvs\n%s", a, b) + } +} + +func dump(postID string, posts []*Post, comments []*Comment) string { + out := "post=" + postID + "\n" + for _, p := range posts { + out += "p:" + p.ID + ":" + p.BumpedAt.Format(time.RFC3339Nano) + "\n" + } + for _, c := range comments { + out += "c:" + c.ID + ":" + c.Path + ":" + strconv.Itoa(c.Depth) + "\n" + } + return out +} + func startsWith(s, prefix string) bool { return len(s) >= len(prefix) && s[:len(prefix)] == prefix }