test(store): deterministic fixtures, snapshot round-trip, determinism check

This commit is contained in:
Hermes 2026-06-23 01:31:06 -07:00
parent 4927eb6a11
commit c248816aa5

View file

@ -1,95 +1,263 @@
package main package main
import ( import (
"database/sql" "context"
"os" "sort"
"strconv"
"testing" "testing"
"time"
_ "github.com/lib/pq"
) )
// newTestStore opens a connection to the test DB, skipping if not available. // fixedClock — deterministic clock for tests. Returns the same instant on every
// Pattern from go-htmx-web-dev skill: TDD mandatory, no code without tests. // call until advanced. Per the VOPR/Speckl constraint: no real time in tests.
func newTestStore(t *testing.T) *Store { 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() t.Helper()
dsn := os.Getenv("POSTS_TEST_DSN") clock := &fixedClock{now: time.Date(2026, 6, 22, 22, 0, 0, 0, time.UTC)}
if dsn == "" { ids := &seqID{}
dsn = "postgres://posts:posts@127.0.0.1:5432/posts_test?sslmode=disable" s := NewStoreWithOptions(StoreOptions{
} Now: clock.Now,
db, err := sql.Open("postgres", dsn) NewID: ids.NewID,
if err != nil { FlushInterval: 0, // no timer
t.Skipf("no test db: %v", err) FlushPath: t.TempDir(),
} StartFlush: false,
if err := db.Ping(); err != nil { })
t.Skipf("no test db: %v", err) // Bootstrap required authors + channels.
} must(t, Submit(context.Background(), s, &UpsertAuthor{ID: "scott", AuthorKind: "human", DisplayName: "Scott"}))
return NewStore(db) 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) { func must(t *testing.T, err error) {
s := newTestStore(t) t.Helper()
p, err := s.CreatePost("scott", "Hello", "world", []string{"scobleclaw"})
if err != nil { if err != nil {
t.Fatalf("create: %v", err) t.Fatalf("submit: %v", err)
}
if p.ID == "" {
t.Fatal("expected id")
}
if p.BumpedAt.IsZero() {
t.Fatal("expected bumped_at set")
} }
} }
func TestAddCommentBumpsPost(t *testing.T) { func TestCreatePost_AssignsIDAndBumpsAt(t *testing.T) {
s := newTestStore(t) s, clock, _ := newTestStore(t)
p, err := s.CreatePost("scott", "Hello", "world", nil) m := &CreatePost{AuthorID: "scott", Title: "Hello", Body: "world", Channels: []string{"scobleclaw"}}
if err != nil { if err := Submit(context.Background(), s, m); err != nil {
t.Fatalf("create: %v", err) 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())
}
} }
before := p.BumpedAt
// sleep a beat so timestamps are strictly different func TestAddComment_BumpsPost(t *testing.T) {
// (we're testing logical correctness; in CI you'd inject a clock) s, clock, _ := newTestStore(t)
c, err := s.AddComment(p.ID, "", "hermes", "first!") cp := &CreatePost{AuthorID: "scott", Title: "Hello", Body: "world"}
if err != nil { 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) t.Fatalf("add comment: %v", err)
} }
if c.Path != c.ID { got, _ := s.GetPost(cp.ResolvedPostID())
t.Fatalf("top-level path should equal id, got %q", c.Path) if !got.BumpedAt.After(beforeBump) {
} t.Fatalf("post should have been bumped, before=%v after=%v", beforeBump, got.BumpedAt)
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)
} }
} }
func TestNestedCommentPath(t *testing.T) { func TestNestedCommentPath(t *testing.T) {
s := newTestStore(t) s, _, _ := newTestStore(t)
p, err := s.CreatePost("scott", "Tree test", "body", nil) 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 { if err != nil {
t.Fatal(err) t.Fatalf("snapshot: %v", err)
} }
c1, err := s.AddComment(p.ID, "", "hermes", "top") if len(data) < 100 {
if err != nil { t.Fatalf("snapshot too small: %d bytes", len(data))
t.Fatal(err)
} }
c2, err := s.AddComment(p.ID, c1.ID, "hermes", "nested")
if err != nil { s2 := NewStoreWithOptions(StoreOptions{
t.Fatal(err) 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 { if _, ok := s2.GetPost(cp.ResolvedPostID()); !ok {
t.Fatalf("expected depth 1, got %d", c2.Depth) t.Fatalf("post not restored")
} }
wantPrefix := c1.ID + "." comments := s2.ListComments(cp.ResolvedPostID())
if !startsWith(c2.Path, wantPrefix) { if len(comments) != 1 {
t.Fatalf("path %q should start with %q", c2.Path, wantPrefix) 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 { func startsWith(s, prefix string) bool {
return len(s) >= len(prefix) && s[:len(prefix)] == prefix return len(s) >= len(prefix) && s[:len(prefix)] == prefix
} }