package db import ( "context" "errors" "testing" "sourcecraft.dev/bigbes/sr-ht-spec/core" ) func mkAnchor() *core.CommentAnchor { return &core.CommentAnchor{ DocID: "SPEC-0007", HeadingPath: []string{"Storage", "Trade-offs"}, Index: 2, BlockHash: "b3a1f0", Side: core.SideNew, } } func mkComment(proposalID int) *Comment { return &Comment{ ProposalID: proposalID, Anchor: mkAnchor(), DocPath: "specs/0007-storage.md", Body: "This paragraph contradicts SPEC-0003.", Author: "bigbes", Kind: AuthorHuman, } } // The anchor is the whole point of the table, so it must survive the round trip // intact — including the heading path, which is a Postgres array and the one // column a driver mismatch would quietly mangle. func TestCommentAnchorRoundTrips(t *testing.T) { s, _, cleanup := newTestStore(t) defer cleanup() ctx := context.Background() space := mkSpace(t, s, ctx, "bigbes", "rfcs") p := mkProposal(t, s, ctx, space.ID, "Add storage model section") added, err := s.AddComment(ctx, mkComment(p.ID)) if err != nil { t.Fatalf("AddComment: %v", err) } if added.ID == 0 || added.Created.IsZero() { t.Fatalf("AddComment returned id %d created %v", added.ID, added.Created) } list, err := s.ListComments(ctx, p.ID) if err != nil { t.Fatalf("ListComments: %v", err) } if len(list) != 1 { t.Fatalf("ListComments returned %d comments, want 1", len(list)) } got := list[0] if !got.Root() { t.Errorf("comment is not a root; ParentID = %d", got.ParentID) } if got.Anchor == nil { t.Fatal("comment came back with no anchor") } want := mkAnchor() if got.Anchor.DocID != want.DocID || got.Anchor.Index != want.Index || got.Anchor.BlockHash != want.BlockHash || got.Anchor.Side != want.Side { t.Errorf("anchor = %+v, want %+v", got.Anchor, want) } if len(got.Anchor.HeadingPath) != 2 || got.Anchor.HeadingPath[0] != "Storage" || got.Anchor.HeadingPath[1] != "Trade-offs" { t.Errorf("heading path = %v, want [Storage Trade-offs]", got.Anchor.HeadingPath) } if got.Resolved != nil { t.Errorf("a new thread is born unresolved, got resolved at %v", got.Resolved) } } // A block at the space root, or before the first heading, has no enclosing // headings at all. That is an ordinary anchor, not a missing value, and an empty // array must not come back as a one-element array holding "". func TestCommentOnAPreambleBlockHasAnEmptyHeadingPath(t *testing.T) { s, _, cleanup := newTestStore(t) defer cleanup() ctx := context.Background() space := mkSpace(t, s, ctx, "bigbes", "rfcs") p := mkProposal(t, s, ctx, space.ID, "Rewrite the preamble") c := mkComment(p.ID) c.Anchor.HeadingPath = nil c.Anchor.Index = 0 if _, err := s.AddComment(ctx, c); err != nil { t.Fatalf("AddComment: %v", err) } list, err := s.ListComments(ctx, p.ID) if err != nil { t.Fatalf("ListComments: %v", err) } if got := list[0].Anchor.HeadingPath; len(got) != 0 { t.Errorf("heading path = %v (len %d), want empty", got, len(got)) } } // A reply inherits its thread's anchor and its proposal. Carrying its own copy // would be a second thing that can disagree about where the thread is attached. func TestReplyInheritsTheThreadAndRefusesItsOwnAnchor(t *testing.T) { s, _, cleanup := newTestStore(t) defer cleanup() ctx := context.Background() space := mkSpace(t, s, ctx, "bigbes", "rfcs") p := mkProposal(t, s, ctx, space.ID, "Add storage model section") root, err := s.AddComment(ctx, mkComment(p.ID)) if err != nil { t.Fatalf("AddComment: %v", err) } reply, err := s.ReplyComment(ctx, root.ID, &Comment{ Body: "Fixed in the next revision.", Author: "claude-code/spec-writer", Kind: AuthorAgent, Session: "8fb9c9a4-b078-4af1-89eb-d97c522f9921", }) if err != nil { t.Fatalf("ReplyComment: %v", err) } if reply.ParentID != root.ID { t.Errorf("reply parent = %d, want %d", reply.ParentID, root.ID) } if reply.ProposalID != p.ID { t.Errorf("reply proposal = %d, want %d inherited from its thread", reply.ProposalID, p.ID) } if reply.Anchor != nil { t.Errorf("reply carries an anchor %+v; it must inherit its root's", reply.Anchor) } // A reply that brings its own anchor is refused rather than having it // dropped, so a caller cannot believe it anchored something. _, err = s.ReplyComment(ctx, root.ID, &Comment{ Anchor: mkAnchor(), Body: "b", Author: "bigbes", Kind: AuthorHuman, }) if err == nil { t.Error("ReplyComment accepted a reply carrying its own anchor") } // Threads are one level deep: a review conversation between one human and // one agent has no use for a tree, and a flat thread renders without a walk. _, err = s.ReplyComment(ctx, reply.ID, &Comment{ Body: "c", Author: "bigbes", Kind: AuthorHuman, }) if err == nil { t.Error("ReplyComment threaded a reply under another reply") } if _, err := s.ReplyComment(ctx, 999999, &Comment{ Body: "d", Author: "bigbes", Kind: AuthorHuman, }); !errors.Is(err, ErrNotFound) { t.Errorf("reply to a missing thread = %v, want ErrNotFound", err) } } // Provenance is not optional, on the same rule as proposal: one shared token // still yields an audit trail because the identity strings say who did what. An // agent comment with no session is unattributable, not merely incomplete. func TestCommentProvenanceIsEnforced(t *testing.T) { s, _, cleanup := newTestStore(t) defer cleanup() ctx := context.Background() space := mkSpace(t, s, ctx, "bigbes", "rfcs") p := mkProposal(t, s, ctx, space.ID, "Add storage model section") for name, mutate := range map[string]func(*Comment){ "agent with no session": func(c *Comment) { c.Kind = AuthorAgent; c.Session = "" }, "human carrying session": func(c *Comment) { c.Kind = AuthorHuman; c.Session = "sess" }, "unknown author kind": func(c *Comment) { c.Kind = "robot" }, "no author": func(c *Comment) { c.Author = "" }, "empty body": func(c *Comment) { c.Body = "" }, } { c := mkComment(p.ID) mutate(c) if _, err := s.AddComment(ctx, c); err == nil { t.Errorf("%s: AddComment succeeded, want a refusal", name) } } } // Resolution is a property of the conversation, not of one message in it, so // only a root resolves. The parent_id IS NULL guard is what makes resolving a // reply a miss rather than a silent write to the wrong row. func TestOnlyAThreadRootResolves(t *testing.T) { s, _, cleanup := newTestStore(t) defer cleanup() ctx := context.Background() space := mkSpace(t, s, ctx, "bigbes", "rfcs") p := mkProposal(t, s, ctx, space.ID, "Add storage model section") root, err := s.AddComment(ctx, mkComment(p.ID)) if err != nil { t.Fatalf("AddComment: %v", err) } reply, err := s.ReplyComment(ctx, root.ID, &Comment{ Body: "ack", Author: "bigbes", Kind: AuthorHuman, }) if err != nil { t.Fatalf("ReplyComment: %v", err) } if err := s.ResolveComment(ctx, reply.ID, true); !errors.Is(err, ErrNotFound) { t.Errorf("resolving a reply = %v, want ErrNotFound", err) } if err := s.ResolveComment(ctx, root.ID, true); err != nil { t.Fatalf("ResolveComment: %v", err) } list, err := s.ListComments(ctx, p.ID) if err != nil { t.Fatalf("ListComments: %v", err) } for _, c := range list { if c.Root() && c.Resolved == nil { t.Error("root came back unresolved after being resolved") } if !c.Root() && c.Resolved != nil { t.Error("reply carries its own resolution") } } // Reopening is the same call, so a thread resolved by mistake is not a // dead end. if err := s.ResolveComment(ctx, root.ID, false); err != nil { t.Fatalf("reopen: %v", err) } list, _ = s.ListComments(ctx, p.ID) if list[0].Resolved != nil { t.Error("reopened thread still carries a resolution timestamp") } } // The auto-merge gate's whole question. An unresolved thread suppresses a policy // merge — the owner engaged, so it must not slip through unattended — and a // resolved one stops suppressing it, so a stale comment cannot wedge a proposal. func TestHasUnresolvedCommentsDrivesTheAutoMergeGate(t *testing.T) { s, _, cleanup := newTestStore(t) defer cleanup() ctx := context.Background() space := mkSpace(t, s, ctx, "bigbes", "rfcs") p := mkProposal(t, s, ctx, space.ID, "Add storage model section") unresolved, err := s.HasUnresolvedComments(ctx, p.ID) if err != nil { t.Fatalf("HasUnresolvedComments: %v", err) } if unresolved { t.Error("a proposal with no comments reports unresolved threads") } root, err := s.AddComment(ctx, mkComment(p.ID)) if err != nil { t.Fatalf("AddComment: %v", err) } if unresolved, _ = s.HasUnresolvedComments(ctx, p.ID); !unresolved { t.Error("an open thread must suppress policy auto-merge") } // A reply does not resolve anything: the agent answering a critique is not // the owner accepting the answer. if _, err := s.ReplyComment(ctx, root.ID, &Comment{ Body: "Fixed.", Author: "claude-code/spec-writer", Kind: AuthorAgent, Session: "sess", }); err != nil { t.Fatalf("ReplyComment: %v", err) } if unresolved, _ = s.HasUnresolvedComments(ctx, p.ID); !unresolved { t.Error("an agent reply resolved the thread; only the owner may") } if err := s.ResolveComment(ctx, root.ID, true); err != nil { t.Fatalf("ResolveComment: %v", err) } if unresolved, _ = s.HasUnresolvedComments(ctx, p.ID); unresolved { t.Error("a resolved thread still suppresses auto-merge") } } // Comments belong to their proposal and to nothing else: dropping the proposal // takes its whole review conversation with it rather than leaving rows pointing // at an id that no longer resolves. func TestCommentsCascadeWithTheirProposal(t *testing.T) { s, pool, cleanup := newTestStore(t) defer cleanup() ctx := context.Background() space := mkSpace(t, s, ctx, "bigbes", "rfcs") p := mkProposal(t, s, ctx, space.ID, "Add storage model section") root, err := s.AddComment(ctx, mkComment(p.ID)) if err != nil { t.Fatalf("AddComment: %v", err) } if _, err := s.ReplyComment(ctx, root.ID, &Comment{ Body: "ack", Author: "bigbes", Kind: AuthorHuman, }); err != nil { t.Fatalf("ReplyComment: %v", err) } if _, err := pool.ExecContext(ctx, `DELETE FROM proposal WHERE id = $1`, p.ID); err != nil { t.Fatalf("delete proposal: %v", err) } var n int if err := pool.QueryRowContext(ctx, `SELECT count(*) FROM comment`).Scan(&n); err != nil { t.Fatalf("count comments: %v", err) } if n != 0 { t.Errorf("%d comments outlived their proposal", n) } } // The anchor columns are one value spread over six columns, so a half-written // anchor must be unwritable rather than merely unwritten by this package's // happy path. Go-level validation is not the guard here; the constraint is. func TestHalfAnAnchorIsUnwritable(t *testing.T) { s, pool, cleanup := newTestStore(t) defer cleanup() ctx := context.Background() space := mkSpace(t, s, ctx, "bigbes", "rfcs") p := mkProposal(t, s, ctx, space.ID, "Add storage model section") for name, q := range map[string]string{ "doc id without a hash": ` INSERT INTO comment (proposal_id, doc_id, doc_path, heading_path, block_index, side, body, author, author_kind) VALUES ($1, 'SPEC-0007', 'specs/a.md', ARRAY['Storage'], 0, 'new', 'b', 'bigbes', 'human')`, "root with no anchor at all": ` INSERT INTO comment (proposal_id, body, author, author_kind) VALUES ($1, 'b', 'bigbes', 'human')`, "side outside new|old": ` INSERT INTO comment (proposal_id, doc_id, doc_path, heading_path, block_index, block_hash, side, body, author, author_kind) VALUES ($1, 'SPEC-0007', 'specs/a.md', ARRAY['Storage'], 0, 'h', 'both', 'b', 'bigbes', 'human')`, "negative block index": ` INSERT INTO comment (proposal_id, doc_id, doc_path, heading_path, block_index, block_hash, side, body, author, author_kind) VALUES ($1, 'SPEC-0007', 'specs/a.md', ARRAY['Storage'], -1, 'h', 'new', 'b', 'bigbes', 'human')`, } { if _, err := pool.ExecContext(ctx, q, p.ID); err == nil { t.Errorf("%s: the database accepted it", name) } } }