Files
xarmian 1e9b87759e feat(store): @mention index on comments (TASK-3917) (#2243)
* feat(store): comment_mentions index, parser, backfill (TASK-3917)

Index of which humans a live comment @-mentions, written in the comment's
transaction at every writer (create, edit, tombstone, fenced app doors,
import), with a startup backfill. Migrations 154 (sqlite) and 123 (pg).

Claude-Session: https://claude.ai/code/session_01PpCx2rykxHGFCxdu3PCuJE

* test(appstore): allow-list reindexCommentMentionsTx in the fence boundary (TASK-3917)

Claude-Session: https://claude.ai/code/session_01PpCx2rykxHGFCxdu3PCuJE

* test(appstore): the comment write census permits comment_mentions (TASK-3917)

Claude-Session: https://claude.ai/code/session_01PpCx2rykxHGFCxdu3PCuJE

* fix(store): comment mention index r1 fixes: skip imported, cap, parser rules, created_at, guard (TASK-3917 r1)

F1 imported comments are never indexed (import, edit, backfill); F2 cap of
50 names; F3 parser "/" "\" "_" "." non-ASCII rules; F4 guard rewritten with
per-decl statement counts over internal/ and cmd/; F5 created_at follows the
comment (edit time for a newly added mention); F6 FOR KEY SHARE on PG;
F8 backfill logs per-comment failures.

Claude-Session: https://claude.ai/code/session_01PpCx2rykxHGFCxdu3PCuJE

* test(store): pin the backfill created_at against an aged comment (TASK-3917 r1)

Claude-Session: https://claude.ai/code/session_01PpCx2rykxHGFCxdu3PCuJE

* fix(links,store): follower and prefix rules, cap after resolution, guard ignores comment lines (TASK-3917 r2)

Claude-Session: https://claude.ai/code/session_01PpCx2rykxHGFCxdu3PCuJE

* test(links),docs(store): mid-word "_" prefix row; F6 skip comment names what reopens it (TASK-3917 r2)

Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3

* fix(links): linear URL check for "/" before "@"; store docs and guard test (TASK-3917 r3)

r3 F1: the "/" prefix rule walked back to the previous whitespace for every
match, so "/@a" repeated was quadratic (240 KB took ~10 s, in the comment
write transaction). A forward scan now tracks the run start and the last
"://" once per call. F2: the reindex doc no longer says ONE query. F3: the
guard test legs are isolated. F4: callsReindex documents its fail-open cases.

Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3

* fix(links): mention code-span check walks the spans once per call (TASK-3917 r4)

isInRanges scanned every code range for every match, so many inline code
spans followed by many mentions was quadratic (360 KB took ~1.1 s). A cursor
over the sorted, disjoint ranges advances with the increasing match position.
ExtractWikiLinks has the same shape and is left for its own item.

Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3

* test(links): a row where the code-span cursor skips several spans at once (TASK-3917 r5)

Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3

* docs(links): CJK letter after a mention is a documented limit (TASK-3917 r3 F5, ruling 6250d6ec)

Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3
2026-10-19 23:27:16 -04:00

425 lines
19 KiB
Go

package appstore
import (
"context"
"database/sql"
"errors"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/PerpetualSoftware/pad/internal/models"
"github.com/PerpetualSoftware/pad/internal/store"
"github.com/PerpetualSoftware/pad/internal/store/storetest"
)
// U2b (TASK-3391): app comment writes. Every mutation runs under the write
// census, and every refusal must capture NOTHING.
// allowedAppCommentWrite is DOC-3371 §4's allowed-writes table for comment
// writes: the comment (and, on a delete, its reactions), the "commented"
// activity, the event and the decision job, and (TASK-3917) the comment's
// own comment_mentions rows. No attachment row is written: an
// app comment's references are checked, not stamped (store.checkItemAttachmentRefs).
// On SQLite the FTS5 shadow tables of comments_fts are observed by name.
func allowedAppCommentWrite(w storetest.Write) bool {
switch w.Table {
case "comments", "activities", "event_outbox", "decision_jobs":
return true
case "comment_reactions":
return w.Op == "DELETE"
case "comment_mentions":
// The derived @-mention index of the comment's own body (TASK-3917):
// rows leave with a hard-deleted comment (the FK cascade, seen on
// Postgres) and are diffed in by the write itself.
return w.Op == "DELETE" || w.Op == "INSERT"
}
return strings.HasPrefix(w.Table, "comments_fts_")
}
func assertOnlyAllowedCommentWrites(t *testing.T, writes []storetest.Write) {
t.Helper()
for _, w := range writes {
if !allowedAppCommentWrite(w) {
t.Errorf("app comment write touched %s (%s), which the allowed-writes table does not permit", w.Table, w.Op)
}
}
}
type commentFixture struct {
appFixture
mine, other *models.Item // two app items in the companion collection
hidden *models.Item // a human item in a non-companion collection
}
func newCommentFixture(t *testing.T) commentFixture {
t.Helper()
f := newAppFixture(t, Options{})
ctx := context.Background()
mine, err := f.a.CreateItem(ctx, f.spec, f.companion.ID, AppItemCreate{Title: "Mine"}, f.actor)
if err != nil {
t.Fatal(err)
}
other, err := f.a.CreateItem(ctx, f.spec, f.companion.ID, AppItemCreate{Title: "Other"}, f.actor)
if err != nil {
t.Fatal(err)
}
hidden, err := f.s.CreateItem(f.ws.ID, f.private.ID, models.ItemCreate{Title: "Hidden"})
if err != nil {
t.Fatal(err)
}
return commentFixture{appFixture: f, mine: mine, other: other, hidden: hidden}
}
// attachment inserts an attachment row bound to itemID ("" = unbound),
// optionally a variant of parentID.
func (f commentFixture) attachment(t *testing.T, itemID, parentID string) string {
t.Helper()
a := &models.Attachment{
ID: uuid.NewString(), WorkspaceID: f.ws.ID, UploadedBy: f.owner.ID,
StorageKey: "fs:" + uuid.NewString(), ContentHash: uuid.NewString(), MimeType: "image/png",
SizeBytes: 1, Filename: "a.png", FilenameSource: "caller",
}
if itemID != "" {
a.ItemID = &itemID
}
if parentID != "" {
v := "thumb-sm"
a.ParentID, a.Variant = &parentID, &v
}
if err := f.s.CreateAttachment(a); err != nil {
t.Fatal(err)
}
return a.ID
}
func (f commentFixture) stamped(t *testing.T, id string) bool {
t.Helper()
var at sql.NullString
if err := f.s.DB().QueryRow(f.s.D().Rebind(`SELECT last_referenced_at FROM attachments WHERE id = ?`), id).Scan(&at); err != nil {
t.Fatal(err)
}
return at.Valid && at.String != ""
}
func ref(id string) string { return "![x](pad-attachment:" + id + ")" }
func TestAppCreateComment_WritesOnlyWhatTheSpecAllows(t *testing.T) {
f := newCommentFixture(t)
onItem := f.attachment(t, f.mine.ID, "")
variant := f.attachment(t, f.mine.ID, onItem)
onOther := f.attachment(t, f.other.ID, "")
var c *models.Comment
writes := storetest.CaptureWrites(t, f.s, func() {
var err error
c, err = f.a.CreateComment(context.Background(), f.spec, f.mine.ID, AppCommentCreate{Body: "Looking into it " + ref(onItem), Author: "Helpdesk"}, f.actor)
if err != nil {
t.Fatalf("create: %v", err)
}
})
assertOnlyAllowedCommentWrites(t, writes)
tables := storetest.Tables(writes)
for _, want := range []string{"comments", "activities", "event_outbox"} {
if !contains(tables, want) {
t.Errorf("create did not write %s: %v", want, tables)
}
}
var source, createdBy, viaApp, userID, activityID, author string
if err := f.s.DB().QueryRow(f.s.D().Rebind(`SELECT source, created_by, via_app, user_id, activity_id, author FROM comments WHERE id = ?`), c.ID).
Scan(&source, &createdBy, &viaApp, &userID, &activityID, &author); err != nil {
t.Fatal(err)
}
if source != "app" || createdBy != "user" || viaApp != f.spec.InstallID || userID != f.owner.ID || author != "Helpdesk" {
t.Errorf("comment attribution: source=%s created_by=%s via_app=%s user=%s author=%s", source, createdBy, viaApp, userID, author)
}
if n := f.count(t, `SELECT COUNT(*) FROM activities WHERE id = ? AND action = 'commented' AND via_app = ? AND document_id = ?`, activityID, f.spec.InstallID, f.mine.ID); n != 1 {
t.Errorf("the comment's activity is not the app's commented row: %d", n)
}
if n := f.count(t, `SELECT COUNT(*) FROM event_outbox WHERE event_type = 'comment.created' AND subject_id = ?`, c.ID); n != 1 {
t.Errorf("comment.created events: %d", n)
}
for _, id := range []string{onItem, variant, onOther} {
if f.stamped(t, id) {
t.Errorf("attachment %s was stamped; an app comment checks references and writes nothing to attachments", id)
}
}
}
func TestAppUpdateAndDeleteComment_WriteOnlyWhatTheSpecAllows(t *testing.T) {
f := newCommentFixture(t)
ctx := context.Background()
parent, err := f.a.CreateComment(ctx, f.spec, f.mine.ID, AppCommentCreate{Body: "parent"}, f.actor)
if err != nil {
t.Fatal(err)
}
reply, err := f.a.CreateComment(ctx, f.spec, f.mine.ID, AppCommentCreate{Body: "reply", ParentID: parent.ID}, f.actor)
if err != nil {
t.Fatal(err)
}
writes := storetest.CaptureWrites(t, f.s, func() {
if _, err := f.a.UpdateComment(ctx, f.spec, f.mine.ID, reply.ID, "reply, edited", f.actor); err != nil {
t.Fatalf("update: %v", err)
}
})
assertOnlyAllowedCommentWrites(t, writes)
if n := f.count(t, `SELECT COUNT(*) FROM event_outbox WHERE event_type = 'comment.updated' AND subject_id = ?`, reply.ID); n != 1 {
t.Errorf("comment.updated events: %d", n)
}
// The parent has a reply: a delete tombstones it.
writes = storetest.CaptureWrites(t, f.s, func() {
if err := f.a.DeleteComment(ctx, f.spec, f.mine.ID, parent.ID, f.actor); err != nil {
t.Fatalf("delete parent: %v", err)
}
})
assertOnlyAllowedCommentWrites(t, writes)
if n := f.count(t, `SELECT COUNT(*) FROM comments WHERE id = ? AND deleted_at IS NOT NULL AND body = ''`, parent.ID); n != 1 {
t.Fatal("the parent with a reply was not tombstoned")
}
// Deleting the last reply removes it and reaps the emptied tombstone.
writes = storetest.CaptureWrites(t, f.s, func() {
if err := f.a.DeleteComment(ctx, f.spec, f.mine.ID, reply.ID, f.actor); err != nil {
t.Fatalf("delete reply: %v", err)
}
})
assertOnlyAllowedCommentWrites(t, writes)
if n := f.count(t, `SELECT COUNT(*) FROM comments WHERE id IN (?, ?)`, parent.ID, reply.ID); n != 0 {
t.Fatalf("reply and its tombstone should both be gone, %d left", n)
}
// The reply's deletion event carries the projection frozen before the
// reap: its parent, on the same item, is still named.
var payload string
if err := f.s.DB().QueryRow(f.s.D().Rebind(`SELECT payload FROM event_outbox WHERE event_type = 'comment.deleted' AND subject_id = ?`), reply.ID).Scan(&payload); err != nil {
t.Fatal(err)
}
if !strings.Contains(payload, parent.ID) || !strings.Contains(payload, f.mine.ID) {
t.Errorf("comment.deleted payload lost its projection: %s", payload)
}
}
func TestAppCommentRefusalsWriteNothing(t *testing.T) {
f := newCommentFixture(t)
ctx := context.Background()
onItem := f.attachment(t, f.mine.ID, "")
onOther := f.attachment(t, f.other.ID, "")
unbound := f.attachment(t, "", "")
gone := f.attachment(t, f.mine.ID, "")
if _, err := f.s.DB().Exec(f.s.D().Rebind(`UPDATE attachments SET deleted_at = ? WHERE id = ?`), time.Now().UTC().Format(time.RFC3339), gone); err != nil {
t.Fatal(err)
}
mine, err := f.a.CreateComment(ctx, f.spec, f.mine.ID, AppCommentCreate{Body: "mine"}, f.actor)
if err != nil {
t.Fatal(err)
}
onOtherItem, err := f.a.CreateComment(ctx, f.spec, f.other.ID, AppCommentCreate{Body: "on the other item"}, f.actor)
if err != nil {
t.Fatal(err)
}
human, err := f.s.CreateComment(f.ws.ID, f.mine.ID, f.owner.ID, models.CommentCreate{Body: "a human wrote this", CreatedBy: "user", Source: "web"})
if err != nil {
t.Fatal(err)
}
// Another install's comment on this item, by the same user: seeded as a
// row, since no install can write through a collection stamped with
// another's via_app (the fence checks the stamp live, TASK-3401 U6c). It
// is reachable as history: an item moved here, or a re-stamped companion.
otherInstall := insertInstall(t, f.s, f.ws.ID)
theirsID := uuid.NewString()
tsTheirs := time.Now().UTC().Format(time.RFC3339)
if _, err := f.s.DB().Exec(f.s.D().Rebind(`INSERT INTO comments (id, item_id, workspace_id, author, user_id, body, created_by, source, created_at, updated_at, via_app)
VALUES (?, ?, ?, 'a', ?, 'another install', 'user', 'app', ?, ?, ?)`),
theirsID, f.mine.ID, f.ws.ID, f.actor.UserID, tsTheirs, tsTheirs, otherInstall); err != nil {
t.Fatal(err)
}
theirs := &models.Comment{ID: theirsID}
user2, err := f.s.CreateUser(models.UserCreate{Email: "u2-" + uuid.NewString()[:8] + "@example.com", Name: "Two", Password: "password123"})
if err != nil {
t.Fatal(err)
}
actor2 := store.FencedActor{Kind: "user", UserID: user2.ID}
byUser2, err := f.a.CreateComment(ctx, f.spec, f.mine.ID, AppCommentCreate{Body: "same install, other user"}, actor2)
if err != nil {
t.Fatal(err)
}
tomb, err := f.a.CreateComment(ctx, f.spec, f.mine.ID, AppCommentCreate{Body: "to be tombstoned"}, f.actor)
if err != nil {
t.Fatal(err)
}
if _, err := f.a.CreateComment(ctx, f.spec, f.mine.ID, AppCommentCreate{Body: "keeps it", ParentID: tomb.ID}, f.actor); err != nil {
t.Fatal(err)
}
if err := f.a.DeleteComment(ctx, f.spec, f.mine.ID, tomb.ID, f.actor); err != nil {
t.Fatal(err)
}
// A legacy thread that crosses items (written before BUG-3346): a
// comment on mine whose parent is on the other item.
crossed := uuid.NewString()
ts := time.Now().UTC().Format(time.RFC3339)
if _, err := f.s.DB().Exec(f.s.D().Rebind(`INSERT INTO comments (id, item_id, workspace_id, author, user_id, body, created_by, source, parent_id, created_at, updated_at, via_app)
VALUES (?, ?, ?, 'a', ?, 'crossed', 'user', 'app', ?, ?, ?, ?)`),
crossed, f.mine.ID, f.ws.ID, f.owner.ID, onOtherItem.ID, ts, ts, f.spec.InstallID); err != nil {
t.Fatal(err)
}
stale := f.spec
stale.Epoch = 2
// 400 distinct bound attachments fill the check's first chunk, so the
// foreign ref after them lands in the second.
var many strings.Builder
for i := 0; i < 400; i++ {
many.WriteString(ref(f.attachment(t, f.mine.ID, "")))
}
manyRefs := many.String()
is := func(target error) func(error) bool { return func(err error) bool { return errors.Is(err, target) } }
create := func(spec store.FenceSpec, itemID string, in AppCommentCreate) func() error {
return func() error { _, err := f.a.CreateComment(ctx, spec, itemID, in, f.actor); return err }
}
update := func(itemID, commentID, body string, actor store.FencedActor) func() error {
return func() error { _, err := f.a.UpdateComment(ctx, f.spec, itemID, commentID, body, actor); return err }
}
del := func(itemID, commentID string, actor store.FencedActor) func() error {
return func() error { return f.a.DeleteComment(ctx, f.spec, itemID, commentID, actor) }
}
for name, tc := range map[string]struct {
run func() error
want func(error) bool
}{
"stale epoch": {create(stale, f.mine.ID, AppCommentCreate{Body: "x"}), is(store.ErrFenceStale)},
"non-companion item": {create(f.spec, f.hidden.ID, AppCommentCreate{Body: "x"}), is(store.ErrNotCompanion)},
"unknown item": {create(f.spec, uuid.NewString(), AppCommentCreate{Body: "x"}), is(store.ErrNotCompanion)},
"blank body": {create(f.spec, f.mine.ID, AppCommentCreate{Body: " \n"}), IsInputError},
"missing parent": {create(f.spec, f.mine.ID, AppCommentCreate{Body: "x", ParentID: uuid.NewString()}), is(store.ErrAppCommentNotFound)},
"parent on another item": {create(f.spec, f.mine.ID, AppCommentCreate{Body: "x", ParentID: onOtherItem.ID}), is(store.ErrAppCommentNotFound)},
"tombstoned parent": {create(f.spec, f.mine.ID, AppCommentCreate{Body: "x", ParentID: tomb.ID}), is(store.ErrCommentDeleted)},
"attachment on another item": {create(f.spec, f.mine.ID, AppCommentCreate{Body: ref(onOther)}), is(store.ErrAppAttachmentUnavailable)},
"unbound attachment": {create(f.spec, f.mine.ID, AppCommentCreate{Body: ref(unbound)}), is(store.ErrAppAttachmentUnavailable)},
"unknown attachment": {create(f.spec, f.mine.ID, AppCommentCreate{Body: ref(uuid.NewString())}), is(store.ErrAppAttachmentUnavailable)},
"deleted attachment": {create(f.spec, f.mine.ID, AppCommentCreate{Body: ref(gone)}), is(store.ErrAppAttachmentUnavailable)},
"one good and one foreign": {create(f.spec, f.mine.ID, AppCommentCreate{Body: ref(onItem) + ref(onOther)}), is(store.ErrAppAttachmentUnavailable)},
"a full chunk of good refs, then a foreign one": {create(f.spec, f.mine.ID, AppCommentCreate{Body: manyRefs + ref(onOther)}), is(store.ErrAppAttachmentUnavailable)},
"foreign attachment, odd spelling": {create(f.spec, f.mine.ID, AppCommentCreate{Body: "PAD-Attachment:" + onOther}), is(store.ErrAppAttachmentUnavailable)},
"bad actor": {func() error {
_, err := f.a.CreateComment(ctx, f.spec, f.mine.ID, AppCommentCreate{Body: "x"}, store.FencedActor{Kind: "system", UserID: f.owner.ID})
return err
}, is(store.ErrAppBadActor)},
"update: comment on another item": {update(f.mine.ID, onOtherItem.ID, "x", f.actor), is(store.ErrAppCommentNotFound)},
"update: unknown comment": {update(f.mine.ID, uuid.NewString(), "x", f.actor), is(store.ErrAppCommentNotFound)},
"update: a human's comment": {update(f.mine.ID, human.ID, "x", f.actor), is(store.ErrAppNotCommentAuthor)},
"update: another install's comment": {update(f.mine.ID, theirs.ID, "x", f.actor), is(store.ErrAppNotCommentAuthor)},
"update: another user's app comment": {update(f.mine.ID, byUser2.ID, "x", f.actor), is(store.ErrAppNotCommentAuthor)},
"update: tombstone": {update(f.mine.ID, tomb.ID, "x", f.actor), is(store.ErrCommentDeleted)},
"update: foreign attachment": {update(f.mine.ID, mine.ID, ref(onOther), f.actor), is(store.ErrAppAttachmentUnavailable)},
"update: blank body": {update(f.mine.ID, mine.ID, "", f.actor), IsInputError},
"update: non-companion item": {update(f.hidden.ID, mine.ID, "x", f.actor), is(store.ErrNotCompanion)},
"delete: comment on another item": {del(f.mine.ID, onOtherItem.ID, f.actor), is(store.ErrAppCommentNotFound)},
"delete: a human's comment": {del(f.mine.ID, human.ID, f.actor), is(store.ErrAppNotCommentAuthor)},
"delete: another install's comment": {del(f.mine.ID, theirs.ID, f.actor), is(store.ErrAppNotCommentAuthor)},
"delete: another user's app comment": {del(f.mine.ID, byUser2.ID, f.actor), is(store.ErrAppNotCommentAuthor)},
"delete: tombstone": {del(f.mine.ID, tomb.ID, f.actor), is(store.ErrAppCommentNotFound)},
"delete: thread crossing items": {del(f.mine.ID, crossed, f.actor), is(store.ErrAppCrossItemThread)},
"delete: stale epoch": {func() error {
return f.a.DeleteComment(ctx, stale, f.mine.ID, mine.ID, f.actor)
}, is(store.ErrFenceStale)},
} {
t.Run(name, func(t *testing.T) {
var got error
writes := storetest.CaptureWrites(t, f.s, func() { got = tc.run() })
if got == nil || !tc.want(got) {
t.Fatalf("got %v", got)
}
if len(writes) != 0 {
t.Fatalf("a refusal wrote: %v", writes)
}
})
}
if f.stamped(t, onItem) {
t.Error("a refused write stamped an attachment it was allowed to reference")
}
}
// A human move of the item into a private collection, committing while an
// app comment write is in flight, must not land under it: the app write
// waits on the workspace seq lock every item move takes, then sees the move
// and refuses (codex round 3). One case per method.
func TestAppComment_ConcurrentMoveOutOfCompanionsRefuses(t *testing.T) {
for name, run := range map[string]func(f commentFixture, c *models.Comment) error{
"create": func(f commentFixture, _ *models.Comment) error {
_, err := f.a.CreateComment(context.Background(), f.spec, f.mine.ID, AppCommentCreate{Body: "x"}, f.actor)
return err
},
"update": func(f commentFixture, c *models.Comment) error {
_, err := f.a.UpdateComment(context.Background(), f.spec, f.mine.ID, c.ID, "edited", f.actor)
return err
},
"delete": func(f commentFixture, c *models.Comment) error {
return f.a.DeleteComment(context.Background(), f.spec, f.mine.ID, c.ID, f.actor)
},
} {
t.Run(name, func(t *testing.T) {
f := newCommentFixture(t)
if f.s.D().Driver() != store.DriverPostgres {
t.Skip("SQLite serializes writers at BEGIN")
}
c, err := f.a.CreateComment(context.Background(), f.spec, f.mine.ID, AppCommentCreate{Body: "before"}, f.actor)
if err != nil {
t.Fatal(err)
}
// The human move, held open: the seq lock MoveItem takes, then the
// collection change.
human, err := f.s.DB().Begin()
if err != nil {
t.Fatal(err)
}
defer func() { _ = human.Rollback() }()
if _, err := human.Exec(`SELECT pg_advisory_xact_lock(hashtext($1))`, f.ws.ID); err != nil {
t.Fatal(err)
}
if _, err := human.Exec(`UPDATE items SET collection_id = $1 WHERE id = $2`, f.private.ID, f.mine.ID); err != nil {
t.Fatal(err)
}
done := make(chan error, 1)
go func() { done <- run(f, c) }()
deadline := time.Now().Add(10 * time.Second)
for {
select {
case err := <-done:
t.Fatalf("the app write finished while the move was uncommitted: %v", err)
default:
}
var waiting int
if err := f.s.DB().QueryRow(`SELECT COUNT(*) FROM pg_stat_activity WHERE datname = current_database() AND wait_event_type = 'Lock'`).Scan(&waiting); err != nil {
t.Fatal(err)
}
if waiting > 0 {
break
}
if time.Now().After(deadline) {
t.Fatal("the app write never waited on the move")
}
time.Sleep(20 * time.Millisecond)
}
if err := human.Commit(); err != nil {
t.Fatal(err)
}
if err := <-done; !errors.Is(err, store.ErrNotCompanion) {
t.Fatalf("got %v, want ErrNotCompanion", err)
}
if n := f.count(t, `SELECT COUNT(*) FROM comments WHERE item_id = ?`, f.mine.ID); n != 1 {
t.Errorf("comments on the moved item: %d, want the 1 written before the move", n)
}
})
}
}