test(store): deterministic fixtures, snapshot round-trip, determinism check
This commit is contained in:
parent
4927eb6a11
commit
c248816aa5
1 changed files with 231 additions and 63 deletions
294
store_test.go
294
store_test.go
|
|
@ -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)
|
||||||
}
|
}
|
||||||
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
|
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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue