| 1 | package memoria |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "io" |
| 7 | "log/slog" |
| 8 | "path/filepath" |
| 9 | "reflect" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | |
| 13 | "amadan.net/rastrillo/rastrillo/db" |
| 14 | "amadan.net/rastrillo/rastrillo/migrate" |
| 15 | |
| 16 | "amadan.net/oficina/suite" |
| 17 | |
| 18 | "amadan.net/oficina/memoria/internal/memoria/index" |
| 19 | "amadan.net/oficina/memoria/internal/memoria/seal" |
| 20 | "amadan.net/oficina/memoria/internal/memoria/sibling" |
| 21 | ) |
| 22 | |
| 23 | // fixedCustody rules every candidate the same way and records what it was |
| 24 | // asked. It is the seam that lets §4.1's table be exercised end to end |
| 25 | // without a live sibling. |
| 26 | type fixedCustody struct { |
| 27 | // apps is the configured roster. A source not in it does not resolve. |
| 28 | apps map[string]sibling.Sibling |
| 29 | give suite.Authorization |
| 30 | // asked is what reached the client: the asker, and the pairs. |
| 31 | asker suite.Subject |
| 32 | pairs []suite.ItemRevision |
| 33 | } |
| 34 | |
| 35 | func (f *fixedCustody) Sibling(source string) (sibling.Sibling, bool) { |
| 36 | s, ok := f.apps[source] |
| 37 | return s, ok |
| 38 | } |
| 39 | |
| 40 | func (f *fixedCustody) Rule(ctx context.Context, asker suite.Subject, cands []sibling.Candidate) []sibling.Disposition { |
| 41 | f.asker = asker |
| 42 | out := make([]sibling.Disposition, len(cands)) |
| 43 | for i, c := range cands { |
| 44 | f.pairs = append(f.pairs, c.Item) |
| 45 | out[i] = sibling.Decide(f.give) |
| 46 | } |
| 47 | return out |
| 48 | } |
| 49 | |
| 50 | func docsRoster() map[string]sibling.Sibling { |
| 51 | return map[string]sibling.Sibling{ |
| 52 | "Docs": {Name: "Docs", BaseURL: "https://docs.example", Token: "t"}, |
| 53 | } |
| 54 | } |
| 55 | |
| 56 | // storeWithASiblingDocument seeds one document owned by Docs and one owned |
| 57 | // by memoria, both matching the same query. |
| 58 | // |
| 59 | // **Two documents, not one, and that is the presence assertion.** A test |
| 60 | // with only a sibling document cannot tell "the ruling withheld it" from |
| 61 | // "the search found nothing"; the memoria-owned row is the control that |
| 62 | // stays on the page in every case below, so an empty result is always |
| 63 | // attributable. |
| 64 | func storeWithASiblingDocument(t *testing.T, custody Custody) *Store { |
| 65 | t.Helper() |
| 66 | s := storeForTest(t) |
| 67 | s.custody = custody |
| 68 | ctx := context.Background() |
| 69 | if _, err := s.Ingest(ctx, IngestSpec{ |
| 70 | Source: "Docs", Key: "docs/handbook", Title: "Handbook", |
| 71 | Body: "The quarterly planning process runs in March.", |
| 72 | ItemID: "docs-item-991", |
| 73 | SiblingRevision: 7, |
| 74 | }); err != nil { |
| 75 | t.Fatalf("ingest sibling doc: %v", err) |
| 76 | } |
| 77 | if _, err := s.Ingest(ctx, IngestSpec{ |
| 78 | Source: "upload", Key: "notes.txt", Title: "Notes", |
| 79 | Body: "My own notes on the quarterly planning process.", |
| 80 | }); err != nil { |
| 81 | t.Fatalf("ingest owned doc: %v", err) |
| 82 | } |
| 83 | return s |
| 84 | } |
| 85 | |
| 86 | // §4.1 end to end, through the only method that returns a renderable |
| 87 | // passage. |
| 88 | // |
| 89 | // This is the gate the sibling package's unit tests cannot be: they prove |
| 90 | // `Decide` maps values and `Rule` fans out, and neither proves that the |
| 91 | // thing which actually renders a page consults either. The failure they |
| 92 | // leave open is a `Search` that computes a disposition and ignores it. |
| 93 | // |
| 94 | // MUTATION-TESTED 2026-09-06, each verified red then reverted: |
| 95 | // |
| 96 | // Search returns `out` without calling rule() |
| 97 | // -> every withholding case red |
| 98 | // rule() keeps a passage when d.Notices() rather than d.Renders() |
| 99 | // -> VISIBLE case red, NOT_VISIBLE case red |
| 100 | // rule() treats an unresolvable source as memoria-owned |
| 101 | // -> the unconfigured-sibling case red |
| 102 | // rule() skips the nil-custody branch |
| 103 | // -> nil case red (and it renders, which is the disclosure) |
| 104 | // Search asks about the memoria-owned document too |
| 105 | // -> the pairs assertion red |
| 106 | func TestASiblingPassageIsNeverSpokenUnruled(t *testing.T) { |
| 107 | ctx := context.Background() |
| 108 | |
| 109 | for _, c := range []struct { |
| 110 | name string |
| 111 | give suite.Authorization |
| 112 | wantSibling bool |
| 113 | wantNotice string |
| 114 | }{ |
| 115 | {"VISIBLE renders", suite.VISIBLE, true, ""}, |
| 116 | {"NOT_VISIBLE omits silently", suite.NOT_VISIBLE, false, ""}, |
| 117 | {"STALE withholds loudly", suite.STALE, false, "Docs has moved on from 1 result; refetching."}, |
| 118 | {"UNKNOWN withholds loudly", suite.UNKNOWN, false, "Docs did not answer; 1 result withheld."}, |
| 119 | } { |
| 120 | t.Run(c.name, func(t *testing.T) { |
| 121 | cus := &fixedCustody{apps: docsRoster(), give: c.give} |
| 122 | s := storeWithASiblingDocument(t, cus) |
| 123 | |
| 124 | page, err := s.Search(ctx, testAsker, "quarterly planning", 10) |
| 125 | if err != nil { |
| 126 | t.Fatalf("search: %v", err) |
| 127 | } |
| 128 | |
| 129 | var sawSibling, sawOwned bool |
| 130 | for _, r := range page.Results { |
| 131 | switch r.Source { |
| 132 | case "Docs": |
| 133 | sawSibling = true |
| 134 | case "upload": |
| 135 | sawOwned = true |
| 136 | } |
| 137 | } |
| 138 | // The control. Without it, every "the sibling passage is not |
| 139 | // here" assertion below is satisfied by a search that found |
| 140 | // nothing at all. |
| 141 | if !sawOwned { |
| 142 | t.Fatal("memoria's own document did not render, so this test " + |
| 143 | "cannot tell withholding from an empty search") |
| 144 | } |
| 145 | if sawSibling != c.wantSibling { |
| 146 | t.Errorf("sibling passage rendered = %v, want %v", sawSibling, c.wantSibling) |
| 147 | } |
| 148 | |
| 149 | switch c.wantNotice { |
| 150 | case "": |
| 151 | if len(page.Notices) != 0 { |
| 152 | t.Errorf("notices = %v, want none — %q is not a loud value", |
| 153 | page.Notices, c.give) |
| 154 | } |
| 155 | default: |
| 156 | if len(page.Notices) != 1 || page.Notices[0] != c.wantNotice { |
| 157 | t.Errorf("notices = %v, want [%q]", page.Notices, c.wantNotice) |
| 158 | } |
| 159 | } |
| 160 | |
| 161 | // The question that went out names the sibling's own |
| 162 | // coordinate and this person — and names memoria's own |
| 163 | // document to nobody, because §4.2 makes memoria its app of |
| 164 | // record and there is nobody else to ask. |
| 165 | if len(cus.pairs) != 1 { |
| 166 | t.Fatalf("asked about %d items, want 1: memoria's own content "+ |
| 167 | "has no app of record to ask", len(cus.pairs)) |
| 168 | } |
| 169 | want := suite.ItemRevision{ItemID: "docs-item-991", Revision: 7} |
| 170 | if cus.pairs[0] != want { |
| 171 | t.Errorf("asked about %+v, want %+v", cus.pairs[0], want) |
| 172 | } |
| 173 | if cus.asker != testAsker { |
| 174 | t.Errorf("asked on behalf of %+v, want %+v", cus.asker, testAsker) |
| 175 | } |
| 176 | }) |
| 177 | } |
| 178 | } |
| 179 | |
| 180 | // The two ways there is nobody to ask, which must not become "nobody |
| 181 | // objects". |
| 182 | // |
| 183 | // A sibling dropped from configuration leaves its documents in the store. |
| 184 | // So does an instance wired without any custody at all. In both, memoria |
| 185 | // holds a passage whose app of record it cannot reach — and the tempting |
| 186 | // reading, that an unreachable owner imposes no restriction, is the |
| 187 | // disclosure §4.1 exists to prevent. |
| 188 | // |
| 189 | // MUTATION-TESTED 2026-09-06: make either branch fall through to |
| 190 | // rendering and the matching case goes red, showing the sibling passage on |
| 191 | // the page. Reverted. |
| 192 | func TestNobodyToAskIsNotNobodyObjecting(t *testing.T) { |
| 193 | ctx := context.Background() |
| 194 | |
| 195 | t.Run("a source with no configured app of record", func(t *testing.T) { |
| 196 | // The roster knows Sheets. The document came from Docs. |
| 197 | cus := &fixedCustody{ |
| 198 | apps: map[string]sibling.Sibling{"Sheets": {Name: "Sheets"}}, |
| 199 | give: suite.VISIBLE, |
| 200 | } |
| 201 | s := storeWithASiblingDocument(t, cus) |
| 202 | |
| 203 | page, err := s.Search(ctx, testAsker, "quarterly planning", 10) |
| 204 | if err != nil { |
| 205 | t.Fatalf("search: %v", err) |
| 206 | } |
| 207 | assertOnlyOwnedRendered(t, page) |
| 208 | if len(cus.pairs) != 0 { |
| 209 | t.Errorf("asked %d questions of a sibling that is not configured", len(cus.pairs)) |
| 210 | } |
| 211 | }) |
| 212 | |
| 213 | t.Run("no custody at all", func(t *testing.T) { |
| 214 | s := storeWithASiblingDocument(t, nil) |
| 215 | page, err := s.Search(ctx, testAsker, "quarterly planning", 10) |
| 216 | if err != nil { |
| 217 | t.Fatalf("search: %v", err) |
| 218 | } |
| 219 | assertOnlyOwnedRendered(t, page) |
| 220 | }) |
| 221 | } |
| 222 | |
| 223 | func assertOnlyOwnedRendered(t *testing.T, page Page) { |
| 224 | t.Helper() |
| 225 | var sawOwned bool |
| 226 | for _, r := range page.Results { |
| 227 | if r.Source == "Docs" { |
| 228 | t.Errorf("a passage from an app of record memoria cannot reach was "+ |
| 229 | "spoken: %q. An unreachable owner is not an absent one", r.Title) |
| 230 | } |
| 231 | if r.Source == "upload" { |
| 232 | sawOwned = true |
| 233 | } |
| 234 | } |
| 235 | if !sawOwned { |
| 236 | t.Fatal("memoria's own document did not render, so this proves nothing") |
| 237 | } |
| 238 | if len(page.Notices) != 1 || !strings.Contains(page.Notices[0], "did not answer") { |
| 239 | t.Errorf("notices = %v, want one saying Docs did not answer — a passage "+ |
| 240 | "dropped without a notice is a page that reads as complete", page.Notices) |
| 241 | } |
| 242 | } |
| 243 | |
| 244 | // A search with nobody on it is refused, not answered. |
| 245 | // |
| 246 | // §4.1's question is "may THIS PERSON read this passage", and there is no |
| 247 | // answer to it for nobody. Refusing here rather than sending an empty |
| 248 | // subject also keeps the failure on memoria's side of the wire, where it |
| 249 | // can be read, instead of arriving at a sibling as an unanswerable call. |
| 250 | // |
| 251 | // MUTATION-TESTED 2026-09-06: drop the guard and the search returns |
| 252 | // memoria-owned passages to an unidentified caller. Red. Reverted. |
| 253 | func TestASearchWithNoAskerIsRefused(t *testing.T) { |
| 254 | s := storeWithASiblingDocument(t, &fixedCustody{apps: docsRoster(), give: suite.VISIBLE}) |
| 255 | page, err := s.Search(context.Background(), suite.Subject{}, "quarterly planning", 10) |
| 256 | if err == nil { |
| 257 | t.Fatalf("a search with no asker returned %d results", len(page.Results)) |
| 258 | } |
| 259 | if len(page.Results) != 0 { |
| 260 | t.Errorf("a refused search still returned passages: %d", len(page.Results)) |
| 261 | } |
| 262 | } |
| 263 | |
| 264 | // Both or neither: a sibling document needs an item id and a revision. |
| 265 | // |
| 266 | // Either half alone is worse than neither. An item id with revision 0 asks |
| 267 | // a sibling about a body it never issued; a revision with no item id is a |
| 268 | // document nothing can be asked about, which would then render as |
| 269 | // memoria's own. |
| 270 | // |
| 271 | // MUTATION-TESTED 2026-09-06: default the missing half instead of |
| 272 | // refusing, and both cases go red — the second one by rendering a sibling |
| 273 | // document with no ruling at all. Reverted. |
| 274 | func TestASiblingDocumentNeedsBothHalvesOfItsCoordinate(t *testing.T) { |
| 275 | ctx := context.Background() |
| 276 | for _, c := range []struct { |
| 277 | name string |
| 278 | spec IngestSpec |
| 279 | }{ |
| 280 | {"an item id with no revision", IngestSpec{ |
| 281 | Source: "Docs", Key: "k1", Title: "T", Body: "body", ItemID: "docs-1", |
| 282 | }}, |
| 283 | {"a revision with no item id", IngestSpec{ |
| 284 | Source: "Docs", Key: "k2", Title: "T", Body: "body", SiblingRevision: 4, |
| 285 | }}, |
| 286 | } { |
| 287 | t.Run(c.name, func(t *testing.T) { |
| 288 | s := storeForTest(t) |
| 289 | if _, err := s.Ingest(ctx, c.spec); err == nil { |
| 290 | t.Error("stored a half-coordinated sibling document") |
| 291 | } |
| 292 | }) |
| 293 | } |
| 294 | |
| 295 | t.Run("both halves store", func(t *testing.T) { |
| 296 | // Without this the two refusals above are satisfied by an Ingest |
| 297 | // that refuses every sibling document. |
| 298 | s := storeForTest(t) |
| 299 | if _, err := s.Ingest(ctx, IngestSpec{ |
| 300 | Source: "Docs", Key: "k3", Title: "T", Body: "body", |
| 301 | ItemID: "docs-3", SiblingRevision: 9, |
| 302 | }); err != nil { |
| 303 | t.Errorf("a fully-coordinated sibling document was refused: %v", err) |
| 304 | } |
| 305 | }) |
| 306 | } |
| 307 | |
| 308 | // storeForTest is a store with a real database and a real index and no |
| 309 | // custody set — the caller supplies whatever apps of record the test is |
| 310 | // about. |
| 311 | func storeForTest(t *testing.T) *Store { |
| 312 | t.Helper() |
| 313 | logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 314 | d, err := db.Open(filepath.Join(t.TempDir(), "store.db"), logger) |
| 315 | if err != nil { |
| 316 | t.Fatalf("db.Open: %v", err) |
| 317 | } |
| 318 | t.Cleanup(func() { d.Close() }) |
| 319 | if _, err := migrate.Apply(context.Background(), d, BootSchema); err != nil { |
| 320 | t.Fatalf("migrate: %v", err) |
| 321 | } |
| 322 | dek := make([]byte, 32) |
| 323 | for i := range dek { |
| 324 | dek[i] = byte(i*11 + 3) |
| 325 | } |
| 326 | sealer, err := seal.New(dek) |
| 327 | if err != nil { |
| 328 | t.Fatalf("seal.New: %v", err) |
| 329 | } |
| 330 | ix, err := index.Open() |
| 331 | if err != nil { |
| 332 | t.Fatalf("index.Open: %v", err) |
| 333 | } |
| 334 | t.Cleanup(func() { ix.Close() }) |
| 335 | return NewStore(d, sealer, ix, nil) |
| 336 | } |
| 337 | |
| 338 | // A page's notices are in a fixed order, and the order is the reader's. |
| 339 | // |
| 340 | // **This is here because the first version was not.** `rule()` built its |
| 341 | // unresolved-source notices by ranging a `map[int]string`, so a page with |
| 342 | // two unreachable apps listed them differently on every reload — Go |
| 343 | // randomises map iteration exactly so that this surfaces, and it did. A |
| 344 | // notice that moves is a notice a reader stops trusting. |
| 345 | // |
| 346 | // The order asserted is the order the results themselves are in, which is |
| 347 | // the order the reader meets the passages the lines are about. |
| 348 | // |
| 349 | // MUTATION-TESTED 2026-09-06, each verified red then reverted: |
| 350 | // |
| 351 | // build the notices from a map keyed by result index |
| 352 | // -> flaky, then red at -count=20 (the failure the fix is for) |
| 353 | // sort the notices alphabetically |
| 354 | // -> red: Sheets would precede Wiki regardless of the ranking |
| 355 | // record only the dropped passages, not every result |
| 356 | // -> green here, and that is fine — Record ignores a Render. Kept in |
| 357 | // this list so the next reader knows it was tried and why it is |
| 358 | // not a finding. |
| 359 | func TestNoticesAreInResultOrder(t *testing.T) { |
| 360 | // Two apps, neither on the roster, so both are unreachable and both |
| 361 | // have something to say. Wiki's document scores first because the |
| 362 | // query words are denser in it. |
| 363 | cus := &fixedCustody{apps: map[string]sibling.Sibling{}, give: suite.VISIBLE} |
| 364 | s := storeForTest(t) |
| 365 | s.custody = cus |
| 366 | ctx := context.Background() |
| 367 | for _, d := range []struct { |
| 368 | source, key, title, body, item string |
| 369 | }{ |
| 370 | {"Wiki", "wiki/1", "Wiki", "Quarterly planning, quarterly planning, quarterly planning.", "wiki-1"}, |
| 371 | {"Sheets", "sheets/1", "Sheet", "A note about quarterly planning somewhere in a longer document that dilutes it considerably with many other words.", "sheets-1"}, |
| 372 | } { |
| 373 | if _, err := s.Ingest(ctx, IngestSpec{ |
| 374 | Source: d.source, Key: d.key, Title: d.title, Body: d.body, |
| 375 | ItemID: d.item, SiblingRevision: 3, |
| 376 | }); err != nil { |
| 377 | t.Fatalf("ingest %s: %v", d.source, err) |
| 378 | } |
| 379 | } |
| 380 | |
| 381 | want := []string{ |
| 382 | "Wiki did not answer; 1 result withheld.", |
| 383 | "Sheets did not answer; 1 result withheld.", |
| 384 | } |
| 385 | for i := 0; i < 20; i++ { |
| 386 | page, err := s.Search(ctx, testAsker, "quarterly planning", 10) |
| 387 | if err != nil { |
| 388 | t.Fatalf("search: %v", err) |
| 389 | } |
| 390 | if len(page.Results) != 0 { |
| 391 | t.Fatalf("run %d: %d passages rendered from two unreachable apps", |
| 392 | i, len(page.Results)) |
| 393 | } |
| 394 | if !reflect.DeepEqual(page.Notices, want) { |
| 395 | t.Fatalf("run %d: notices = %v, want %v — a page whose notices "+ |
| 396 | "reorder between reloads is a page a reader learns to skip", |
| 397 | i, page.Notices, want) |
| 398 | } |
| 399 | } |
| 400 | } |
| 401 | |
| 402 | // **A count in a notice counts passages, not documents**, and this pins it |
| 403 | // because nothing did. |
| 404 | // |
| 405 | // The two readings differ the moment a document contributes more than one |
| 406 | // chunk, which is the ordinary case. "3 results withheld" for a |
| 407 | // three-passage document is right, because the page counts results and a |
| 408 | // result is a passage — the reader is being told how much shorter their |
| 409 | // page is, not how many files exist. A review found the choice unmade: |
| 410 | // recording per distinct pair instead of per candidate left every test |
| 411 | // green and quietly changed the sentence. |
| 412 | // |
| 413 | // MUTATION-TESTED 2026-09-06, each verified red then reverted: |
| 414 | // |
| 415 | // Store.rule records per distinct (item, revision) instead of per result |
| 416 | // -> "1 result withheld"; red |
| 417 | // Record is moved after the keep check so only drops are recorded |
| 418 | // -> green, and correctly so: Record ignores a Render. Listed here so |
| 419 | // the next reader knows it was tried and is not a finding. |
| 420 | func TestAWithholdingCountCountsPassages(t *testing.T) { |
| 421 | ctx := context.Background() |
| 422 | |
| 423 | // One document that chunks into several passages, all of which carry |
| 424 | // the same (item_id, revision). Paragraphs, because the chunker splits |
| 425 | // on them — a first version repeated one long line and produced a |
| 426 | // single chunk, which made the assertion below vacuous. |
| 427 | var body strings.Builder |
| 428 | for i := 0; i < 12; i++ { |
| 429 | body.WriteString("The quarterly planning process runs in March. ") |
| 430 | body.WriteString(strings.Repeat("Supporting detail follows in some length. ", 20)) |
| 431 | body.WriteString("\n\n") |
| 432 | } |
| 433 | seed := func(t *testing.T, give suite.Authorization) (*Store, *fixedCustody) { |
| 434 | t.Helper() |
| 435 | cus := &fixedCustody{apps: docsRoster(), give: give} |
| 436 | s := storeForTest(t) |
| 437 | s.custody = cus |
| 438 | if _, err := s.Ingest(ctx, IngestSpec{ |
| 439 | Source: "Docs", Key: "docs/long", Title: "Long", |
| 440 | Body: body.String(), ItemID: "docs-long", SiblingRevision: 2, |
| 441 | }); err != nil { |
| 442 | t.Fatalf("ingest: %v", err) |
| 443 | } |
| 444 | return s, cus |
| 445 | } |
| 446 | |
| 447 | // **How many passages this document contributes is asked, not |
| 448 | // assumed.** Chunking is the chunker's business and it changes; a test |
| 449 | // that hard-codes the number tests the chunker by accident and goes |
| 450 | // green on 1 when the assertion needs >1 to mean anything. |
| 451 | visible, _ := seed(t, suite.VISIBLE) |
| 452 | shown, err := visible.Search(ctx, testAsker, "quarterly planning", 10) |
| 453 | if err != nil { |
| 454 | t.Fatalf("search: %v", err) |
| 455 | } |
| 456 | passages := len(shown.Results) |
| 457 | if passages < 2 { |
| 458 | t.Fatalf("the fixture document contributes %d passage(s); with fewer "+ |
| 459 | "than 2 this test cannot tell passages from documents", passages) |
| 460 | } |
| 461 | |
| 462 | withheld, cus := seed(t, suite.UNKNOWN) |
| 463 | page, err := withheld.Search(ctx, testAsker, "quarterly planning", 10) |
| 464 | if err != nil { |
| 465 | t.Fatalf("search: %v", err) |
| 466 | } |
| 467 | if len(page.Results) != 0 { |
| 468 | t.Fatalf("%d passages rendered from an UNKNOWN sibling", len(page.Results)) |
| 469 | } |
| 470 | // The store hands the client **one candidate per passage**, and the |
| 471 | // pairs are identical because they are the same document. Collapsing |
| 472 | // them into one question is the client's job (`sibling.Client.Rule`, |
| 473 | // and TestOneDocumentIsAskedAboutOnce is where that is gated) — it has |
| 474 | // to be, because the client is what knows about batches and the wire. |
| 475 | // |
| 476 | // Keeping the store's side one-per-passage is what makes the mapping |
| 477 | // back from answers to results a plain index, which is the thing that |
| 478 | // must not go wrong. Asserted here so the division is written down |
| 479 | // rather than inferred from whichever test happens to fail. |
| 480 | if len(cus.pairs) != passages { |
| 481 | t.Errorf("the store offered %d candidates for %d passages of one "+ |
| 482 | "document, want one each", len(cus.pairs), passages) |
| 483 | } |
| 484 | for _, p := range cus.pairs { |
| 485 | if want := (suite.ItemRevision{ItemID: "docs-long", Revision: 2}); p != want { |
| 486 | t.Errorf("candidate %+v, want %+v — every passage of a document "+ |
| 487 | "carries that document's coordinate", p, want) |
| 488 | } |
| 489 | } |
| 490 | want := []string{fmt.Sprintf("Docs did not answer; %d results withheld.", passages)} |
| 491 | if !reflect.DeepEqual(page.Notices, want) { |
| 492 | t.Errorf("notices = %v, want %v — the count is passages, which is what "+ |
| 493 | "the page calls results, and not documents", page.Notices, want) |
| 494 | } |
| 495 | } |
| 496 | |
| 497 | // Two siblings both withholding on one page, which is §6.5's ordinary |
| 498 | // cold-wake case and had no fixture at all. |
| 499 | // |
| 500 | // It is here for two reasons. The obvious one is the copy: two apps, two |
| 501 | // lines, in result order. The other is that this is the shape that makes a |
| 502 | // concurrency bug in notice accumulation observable — a reviewer moved the |
| 503 | // recording inside the fan-out goroutines and it stayed green under |
| 504 | // `-race` purely because no test ever had two loud siblings at once. The |
| 505 | // aggregation has since moved out of the concurrent path entirely, and |
| 506 | // this runs with `-race` in CI so the fixture is there if it ever moves |
| 507 | // back. |
| 508 | func TestTwoSiblingsWithholdingOnOnePage(t *testing.T) { |
| 509 | // Neither on the roster, so both are unreachable. |
| 510 | cus := &fixedCustody{apps: map[string]sibling.Sibling{}, give: suite.VISIBLE} |
| 511 | s := storeForTest(t) |
| 512 | s.custody = cus |
| 513 | ctx := context.Background() |
| 514 | for _, d := range []struct{ source, key, body, item string }{ |
| 515 | {"Sheets", "s/1", "Quarterly planning, quarterly planning, quarterly planning, quarterly planning.", "s-1"}, |
| 516 | {"Sheets", "s/2", "Quarterly planning, quarterly planning, quarterly planning.", "s-2"}, |
| 517 | {"Docs", "d/1", "A shorter note on quarterly planning buried in a great many other unrelated words indeed.", "d-1"}, |
| 518 | } { |
| 519 | if _, err := s.Ingest(ctx, IngestSpec{ |
| 520 | Source: d.source, Key: d.key, Title: d.key, Body: d.body, |
| 521 | ItemID: d.item, SiblingRevision: 1, |
| 522 | }); err != nil { |
| 523 | t.Fatalf("ingest %s: %v", d.key, err) |
| 524 | } |
| 525 | } |
| 526 | |
| 527 | page, err := s.Search(ctx, testAsker, "quarterly planning", 10) |
| 528 | if err != nil { |
| 529 | t.Fatalf("search: %v", err) |
| 530 | } |
| 531 | if len(page.Results) != 0 { |
| 532 | t.Fatalf("%d passages rendered from two unreachable apps", len(page.Results)) |
| 533 | } |
| 534 | want := []string{ |
| 535 | "Sheets did not answer; 2 results withheld.", |
| 536 | "Docs did not answer; 1 result withheld.", |
| 537 | } |
| 538 | if !reflect.DeepEqual(page.Notices, want) { |
| 539 | t.Errorf("notices = %v, want %v — grouped per app, counted per "+ |
| 540 | "passage, ordered by the results they are about", |
| 541 | page.Notices, want) |
| 542 | } |
| 543 | } |
| 544 | |
| 545 | // storeAt is storeForTest over a directory the caller keeps, so a test can |
| 546 | // close one Store and open another over the same database — which is what |
| 547 | // a wake is, and the only way to assert that anything is durable rather |
| 548 | // than in memory. |
| 549 | func storeAt(t *testing.T, dir string) *Store { |
| 550 | t.Helper() |
| 551 | logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 552 | d, err := db.Open(filepath.Join(dir, "store.db"), logger) |
| 553 | if err != nil { |
| 554 | t.Fatalf("db.Open: %v", err) |
| 555 | } |
| 556 | t.Cleanup(func() { d.Close() }) |
| 557 | if _, err := migrate.Apply(context.Background(), d, BootSchema); err != nil { |
| 558 | t.Fatalf("migrate: %v", err) |
| 559 | } |
| 560 | // The SAME key both times. A fresh DEK would make every sealed row |
| 561 | // unreadable, and the test would then be asserting that a restart |
| 562 | // loses the counters rather than keeps them. |
| 563 | dek := make([]byte, 32) |
| 564 | for i := range dek { |
| 565 | dek[i] = byte(i*11 + 3) |
| 566 | } |
| 567 | sealer, err := seal.New(dek) |
| 568 | if err != nil { |
| 569 | t.Fatalf("seal.New: %v", err) |
| 570 | } |
| 571 | ix, err := index.Open() |
| 572 | if err != nil { |
| 573 | t.Fatalf("index.Open: %v", err) |
| 574 | } |
| 575 | t.Cleanup(func() { ix.Close() }) |
| 576 | return NewStore(d, sealer, ix, nil) |
| 577 | } |
| 578 | |