263 lines
8.2 KiB
Go
263 lines
8.2 KiB
Go
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
|
|
}
|