Files
pad/internal/server/relation_backlinks_test.go
T
xarmian 63adbebb76 feat(relations): materialised reverse index and Referenced-by (PLAN-2857 U5 / TASK-2997) (#1328)
PLAN-2857 unit U5, closes TASK-2997.

A MATERIALISED reverse index for `relation` field values —
`item_relation_links` per the ratified Q3 shape — plus a write hook, a
startup backfill, a viewer-scoped query, an endpoint, and the
"Referenced by N" section.

THE HOOK LIVES AT THE STORE'S FIELD-WRITING SITES, not at the doors the
row named. A door-level hook is a per-door wiring claim over a population
that GROWS; a store-level one cannot be bypassed by a door that does not
exist yet, and it is the shape migration 061 set for the content index.
The sites were enumerated with a grep rather than by eye, and two of them
are not in U1's resolver population at all.

Three placements are not obvious from the call sites: UPDATE re-reads the
blob FROM THE ROW, because a field write is a server-side MERGE and
indexing the submission would drop every key the caller did not mention;
MOVE indexes against the DESTINATION collection, the one write where blob
and schema change together; and the attachment remap calls the hook even
though it cannot change a relation value, because omitting it would be a
standing claim about what that function can touch.

A SCHEMA CHANGE REINDEXES THE COLLECTION (lead ruling), inside the
schema-update transaction and under the workspace lock, gated on the
schema or the values actually moving — an icon edit must not pay it.
Measured: 947ms-1.8s for 10k items, which is why it is inline and not a
job. The backfill batches by workspace in bounded chunks: 10k items in
1.06s across ~20 transactions, where one-per-item would have been 10,000.

THE COUNT IS THE VIEWER'S. ResolveBacklinksVisibility reused verbatim, so
"Referenced by 3" means "by 3 you can see" — a true count leaks the
existence of items the viewer cannot open. Filtering is in SQL so LIMIT
counts visible rows.

EIGHT CODEX ROUNDS, ELEVEN P1s; the first seven each found something
real. The ones worth keeping:

  - the backfill's guard was "does the table have any row", and rows
    commit per item, so an interrupted pass left it non-empty and
    INCOMPLETE and skipped forever. Completion is recorded explicitly,
    after the work.
  - RESTORE is an eighth hook site. I enumerated writes of one COLUMN;
    the index depends on (blob, schema, LIVENESS). That miss produced the
    input matrix on TASK-2997's trail, which round 4 then validated row
    by row.
  - the backfill SKIPPED zero-edge items as an optimisation — precisely
    the case that preserves a stale row and then marks the pass complete.
  - a source in a SOFT-DELETED COLLECTION was disclosed; its items stay
    live and an Unrestricted viewer has no collection filter to catch it.
  - rounds 3 and 4 were the same miss at two scopes: I locked what I was
    READING and not what whose change invalidates the read. Round 3 also
    refuted a claim I had put in a commit message, and the record was
    corrected rather than left standing.
  - the field-label lookup added in round 6 BECAME round 7's P1: a
    per-collection SELECT while the rows cursor was open, which is
    BUG-2409's deadlock class. One query now, with a MaxOpenConns(1)
    guard that the restored shape kills.

Three times a comment described code that had changed under it,
including one claiming a counterfactual TEST that does not exist — the
counterfactual is a mutation, recorded per round with its verdict.

Q3'S COUNTERFACTUAL, re-run after every rewrite: hook removed, mutant
COMPILES, all legs fail. Per-site too, because "all sites wired" is
otherwise untested.

NOT COVERED, said plainly: the Postgres interleavings are argued from the
isolation level and the lock order, not demonstrated — SQLite serialises
writes and cannot express the race. Two hook sites are uncovered, one
unfalsifiable by construction and one with no caller.

BUG-3017 files the pre-existing attachment-remap lost update this
branch's workspace lock closes, so it is on the record rather than
absorbed here.

Gates: go test ./... 0, vet 0, gofmt clean, lint 0 issues, svelte-check 0
errors, make test-pg 0 with all 17 relation tests confirmed run BY NAME
on Postgres. All 8 CI checks green on 8c0fe775.

Claude-Session: https://claude.ai/code/session_01PRcuviXka4Un1FGrBrX8jc
2026-09-11 00:57:47 -04:00

114 lines
4.7 KiB
Go

package server
import (
"encoding/json"
"net/http"
"testing"
"github.com/PerpetualSoftware/pad/internal/models"
)
// The "Referenced by N" door (PLAN-2857 U5 / TASK-2997).
//
// The store tests own the index's correctness. These own the two things only
// the door can answer: that the endpoint is wired to the index at all, and
// that the number it reports is the VIEWER'S — which is the ratified
// visibility decision, and the one with a disclosure consequence.
type relationBacklinksResponse struct {
RelationBacklinks []struct {
SourceItemID string `json:"source_item_id"`
SourceRef string `json:"source_ref"`
SourceTitle string `json:"source_title"`
FieldKey string `json:"field_key"`
} `json:"relation_backlinks"`
Total int `json:"total"`
}
// getRelationBacklinks drives the endpoint as `user` and decodes the payload.
func (f *doorFixture) getRelationBacklinks(user *models.User, role string, target *models.Item) relationBacklinksResponse {
f.t.Helper()
var rr = f.callAs(user, role, f.srv.handleGetItemRelationBacklinks, "GET",
"/api/v1/workspaces/"+f.ws.Slug+"/items/"+target.Slug+"/relation-backlinks",
map[string]string{"itemSlug": target.Slug}, nil)
if rr.Code != http.StatusOK {
f.t.Fatalf("relation-backlinks: expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var out relationBacklinksResponse
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
f.t.Fatalf("decode: %v\nbody: %s", err, rr.Body.String())
}
return out
}
// TestRelationBacklinksDoor_ReportsTheEdgeWithItsFieldKey is the wiring claim:
// the endpoint reads the index, and it carries the field key that makes a
// relation backlink different from a wiki one.
func TestRelationBacklinksDoor_ReportsTheEdgeWithItsFieldKey(t *testing.T) {
f := newDoorFixture(t)
source := f.seed(`{"owner_ref":"` + f.target.ID + `"}`)
got := f.getRelationBacklinks(f.owner, "owner", f.target)
if got.Total != 1 {
t.Fatalf("total = %d, want 1 — the endpoint is not reading the index", got.Total)
}
if len(got.RelationBacklinks) != 1 {
t.Fatalf("want 1 backlink, got %d", len(got.RelationBacklinks))
}
b := got.RelationBacklinks[0]
if b.SourceItemID != source.ID {
t.Errorf("source = %s, want %s", b.SourceItemID, source.ID)
}
if b.FieldKey != "owner_ref" {
t.Errorf("field_key = %q, want %q — without it the panel cannot say WHICH field points here, which is the whole reason this is not a wiki backlink", b.FieldKey, "owner_ref")
}
if b.SourceTitle == "" || b.SourceRef == "" {
t.Errorf("the backlink lost the source's identity: %+v", b)
}
}
// TestRelationBacklinksDoor_TheCountIsTheViewersNotTheTruth is the ratified
// visibility decision, and the reason it needs a test is that the "wrong"
// behaviour here looks more correct: reporting the true count is what a
// naive implementation does, and it leaks the existence of items the viewer
// cannot see.
func TestRelationBacklinksDoor_TheCountIsTheViewersNotTheTruth(t *testing.T) {
f := newDoorFixture(t)
// Two sources point at the same target. The restricted viewer is granted
// exactly one of them.
visibleSource := f.seed(`{"owner_ref":"` + f.target.ID + `"}`)
f.seed(`{"owner_ref":"` + f.target.ID + `"}`) // the one they cannot see
if got := f.getRelationBacklinks(f.owner, "owner", f.target); got.Total != 2 {
t.Fatalf("the OWNER should see both (total %d); without that the restricted leg proves nothing", got.Total)
}
blind := mustUser(t, f.srv, "relation-backlinks-blind@example.com", "relbacklinksblind", "")
if err := f.srv.store.AddWorkspaceMember(f.ws.ID, blind.ID, "editor"); err != nil {
t.Fatalf("AddWorkspaceMember: %v", err)
}
// Can see the target's collection, and among the SOURCES only the one
// granted below.
if err := f.srv.store.SetMemberCollectionAccess(f.ws.ID, blind.ID, "specific",
[]string{f.people.ID}); err != nil {
t.Fatalf("SetMemberCollectionAccess: %v", err)
}
if _, err := f.srv.store.CreateItemGrant(f.ws.ID, visibleSource.ID, blind.ID, "read", f.owner.ID); err != nil {
t.Fatalf("CreateItemGrant: %v", err)
}
got := f.getRelationBacklinks(blind, "editor", f.target)
if got.Total != 1 {
t.Errorf("total = %d, want 1 — the count must be the VIEWER'S; reporting 2 tells them an item they cannot see exists", got.Total)
}
if len(got.RelationBacklinks) != 1 || got.RelationBacklinks[0].SourceItemID != visibleSource.ID {
t.Errorf("the page leaked a source the viewer cannot see: %+v", got.RelationBacklinks)
}
// The count and the page must agree. A header promising a number the list
// cannot produce is the drift CountBacklinks documents for the wiki side.
if got.Total != len(got.RelationBacklinks) {
t.Errorf("total %d but %d rows — the count and the page ran under different filters", got.Total, len(got.RelationBacklinks))
}
}