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)
}
}
}