package main import ( "context" "sort" "strconv" "testing" "time" ) // 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() 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 must(t *testing.T, err error) { t.Helper() if err != nil { t.Fatalf("submit: %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) } 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()) } } 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) } 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) 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.Fatalf("snapshot: %v", err) } if len(data) < 100 { t.Fatalf("snapshot too small: %d bytes", len(data)) } 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 _, ok := s2.GetPost(cp.ResolvedPostID()); !ok { t.Fatalf("post not restored") } 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 }