|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// ADR-0061 §Conformance — the dogfood proof behind the search-conformance |
| 4 | +// ledger: a MULTI-FIELD `$search` match over the REAL HTTP API. The ADR's own |
| 5 | +// example, verbatim: searching "retail" must return an account matched by |
| 6 | +// `industry`, not `name` (the showcase seed's Northwind row carries |
| 7 | +// `industry: 'retail'` and no account name contains "retail"). Also proves |
| 8 | +// the `$searchFields` narrowing (can only narrow, never widen) so the |
| 9 | +// executor's security posture is pinned at the HTTP level, not just in |
| 10 | +// `search-filter.test.ts` unit tests. |
| 11 | + |
| 12 | +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
| 13 | +import showcaseStack from '@objectstack/example-showcase'; |
| 14 | +import { bootStack, type VerifyStack } from '@objectstack/verify'; |
| 15 | + |
| 16 | +describe('showcase: $search over the HTTP API (ADR-0061 conformance proof)', () => { |
| 17 | + let stack: VerifyStack; |
| 18 | + let token: string; |
| 19 | + |
| 20 | + const query = async (body: Record<string, unknown>) => { |
| 21 | + const res = await stack.apiAs(token, 'POST', '/data/showcase_account/query', body); |
| 22 | + expect(res.status).toBe(200); |
| 23 | + const data = await res.json(); |
| 24 | + return (data?.data?.records ?? data?.records ?? []) as Array<Record<string, unknown>>; |
| 25 | + }; |
| 26 | + |
| 27 | + beforeAll(async () => { |
| 28 | + stack = await bootStack(showcaseStack); |
| 29 | + token = await stack.signIn(); |
| 30 | + }, 60_000); |
| 31 | + afterAll(async () => { await stack?.stop(); }); |
| 32 | + |
| 33 | + it('multi-field match: "retail" returns Northwind via industry, not name', async () => { |
| 34 | + const records = await query({ search: 'retail' }); |
| 35 | + const names = records.map((r) => String(r.name)); |
| 36 | + expect(names).toContain('Northwind'); |
| 37 | + // Guard the premise: Northwind's NAME does not contain "retail", so its |
| 38 | + // presence in the result can only come from a non-name field… |
| 39 | + expect(names.find((n) => n === 'Northwind')!.toLowerCase()).not.toContain('retail'); |
| 40 | + // …and prove it positively: restricting the same search to `industry` |
| 41 | + // alone still returns Northwind — the hit IS the industry field. |
| 42 | + const viaIndustry = await query({ search: 'retail', searchFields: ['industry'] }); |
| 43 | + expect(viaIndustry.map((r) => r.name)).toContain('Northwind'); |
| 44 | + // (The seed also has an account literally NAMED "acme retail" — a useful |
| 45 | + // control: the unrestricted search returns it via `name`, proving the |
| 46 | + // cross-field OR spans both fields in one query.) |
| 47 | + expect(names.some((n) => n.toLowerCase().includes('retail'))).toBe(true); |
| 48 | + }); |
| 49 | + |
| 50 | + it('select label→value mapping: the capitalized label "Retail" also matches', async () => { |
| 51 | + const records = await query({ search: 'Retail' }); |
| 52 | + expect(records.map((r) => r.name)).toContain('Northwind'); |
| 53 | + }); |
| 54 | + |
| 55 | + it('$searchFields narrows: "retail" restricted to name matches nothing', async () => { |
| 56 | + const records = await query({ search: 'retail', searchFields: ['name'] }); |
| 57 | + expect(records.map((r) => r.name)).not.toContain('Northwind'); |
| 58 | + }); |
| 59 | + |
| 60 | + it('terms AND: "retail northwind" still matches; "retail contoso" does not', async () => { |
| 61 | + const both = await query({ search: 'retail northwind' }); |
| 62 | + expect(both.map((r) => r.name)).toContain('Northwind'); |
| 63 | + const cross = await query({ search: 'retail contoso' }); |
| 64 | + expect(cross.map((r) => r.name)).not.toContain('Northwind'); |
| 65 | + expect(cross.map((r) => r.name)).not.toContain('Contoso'); |
| 66 | + }); |
| 67 | +}); |
0 commit comments