|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#24981] An import cell is resolved against a lookup TARGET's own title |
| 5 | + * field: ADR-0079's `resolveDisplayField`, the one rule the list export writes |
| 6 | + * a lookup cell by. It is asked after the id and the lookup's own |
| 7 | + * `displayField`, and before the fixed fallback keys, which are unchanged. |
| 8 | + * |
| 9 | + * Only a STORED title is asked. A title the target declares virtual (a |
| 10 | + * `formula`) has no column, and the engine's filter seam |
| 11 | + * (`assertFilterIsMaterializable`) refuses every `where` over one by the spec |
| 12 | + * predicate `isVirtualSearchField`; the resolver asks the same predicate and |
| 13 | + * never sends that query. How a formula title should match is the open half of |
| 14 | + * the card, with the maintainer, so such a cell still answers |
| 15 | + * `reference_not_found`. |
| 16 | + * |
| 17 | + * The fake's `findData` answers like the engine: equality over the stored row, |
| 18 | + * and an `INVALID_FIELD` / 400 refusal for a `where` over a formula field. |
| 19 | + */ |
| 20 | + |
| 21 | +import { describe, it, expect, vi } from 'vitest'; |
| 22 | +import { runImport, type ImportProtocolLike } from './import-runner'; |
| 23 | +import type { ExportFieldMeta } from './import-field-meta.js'; |
| 24 | + |
| 25 | +type FindArgs = Parameters<ImportProtocolLike['findData']>[0]; |
| 26 | +type Row = Record<string, unknown>; |
| 27 | +type Declaration = { fields: Record<string, Row>; nameField?: string; displayNameField?: string; enable?: unknown }; |
| 28 | + |
| 29 | +const TEXT = { type: 'text' }; |
| 30 | +const FORMULA = { type: 'formula', returnType: 'text' }; |
| 31 | + |
| 32 | +/** The card's three targets, plus one whose stored title two records share. */ |
| 33 | +const DECLARATIONS: Record<string, Declaration> = { |
| 34 | + // `Wren`: a STORED title off the fixed key list. |
| 35 | + xt_person: { displayNameField: 'nickname', fields: { first_name: TEXT, nickname: TEXT } }, |
| 36 | + // `John Smith`: a FORMULA title whose name IS on the fixed key list. |
| 37 | + xt_contact: { |
| 38 | + nameField: 'full_name', |
| 39 | + fields: { first_name: TEXT, last_name: TEXT, title: TEXT, full_name: { ...FORMULA, expression: 'first_name + " " + last_name' } }, |
| 40 | + }, |
| 41 | + // `Acme (EMEA)`: a FORMULA title off the fixed key list. |
| 42 | + xt_account: { |
| 43 | + nameField: 'display_title', |
| 44 | + fields: { name: TEXT, region: TEXT, display_title: { ...FORMULA, expression: 'name + " (" + region + ")"' } }, |
| 45 | + }, |
| 46 | + xt_member: { nameField: 'nickname', fields: { nickname: TEXT } }, |
| 47 | +}; |
| 48 | + |
| 49 | +const ROWS: Record<string, Row[]> = { |
| 50 | + xt_person: [{ id: 'p1', first_name: 'Ann', nickname: 'Wren' }], |
| 51 | + xt_contact: [{ id: 'c1', first_name: 'John', last_name: 'Smith', title: 'VP of Engineering' }], |
| 52 | + xt_account: [{ id: 'a1', name: 'Acme', region: 'EMEA' }], |
| 53 | + xt_member: [{ id: 'm1', nickname: 'Robin' }, { id: 'm2', nickname: 'Robin' }], |
| 54 | +}; |
| 55 | + |
| 56 | +const lookup = (name: string, reference: string, extra: Partial<ExportFieldMeta> = {}): [string, ExportFieldMeta] => |
| 57 | + [name, { name, type: 'lookup', reference, ...extra }]; |
| 58 | + |
| 59 | +const metaMap = new Map<string, ExportFieldMeta>([ |
| 60 | + ['name', { name: 'name', type: 'text' }], |
| 61 | + // The lookup's own `displayField` is asked before the target's title. |
| 62 | + lookup('person', 'xt_person', { displayField: 'first_name' }), |
| 63 | + lookup('contact', 'xt_contact'), |
| 64 | + lookup('account', 'xt_account'), |
| 65 | + lookup('member', 'xt_member'), |
| 66 | +]); |
| 67 | + |
| 68 | +function protocol(overrides: Record<string, Partial<Declaration>> = {}) { |
| 69 | + const refusals: Array<{ object: string; field: string; code: string; status: number }> = []; |
| 70 | + const findData = vi.fn(async (args: FindArgs) => { |
| 71 | + const decl = { ...DECLARATIONS[args.object], ...overrides[args.object] }; |
| 72 | + const [[field, value]] = Object.entries(args.query!.where!); |
| 73 | + if (decl.fields?.[field]?.type === 'formula') { |
| 74 | + const err = Object.assign(new Error(`filters on '${field}', a virtual formula field`), { code: 'INVALID_FIELD', status: 400 }); |
| 75 | + refusals.push({ object: args.object, field, code: err.code, status: err.status }); |
| 76 | + throw err; |
| 77 | + } |
| 78 | + return (ROWS[args.object] ?? []).filter((r) => r[field] === value); |
| 79 | + }); |
| 80 | + const getMetaItem = vi.fn(async ({ name }: { type: string; name: string }) => |
| 81 | + ({ type: 'object', name, item: { name, ...DECLARATIONS[name], ...overrides[name] } })); |
| 82 | + const p: ImportProtocolLike = { findData, getMetaItem, createData: vi.fn(async (a) => ({ id: 'new', ...a.data })), updateData: vi.fn() }; |
| 83 | + return { p, findData, getMetaItem, refusals }; |
| 84 | +} |
| 85 | + |
| 86 | +const run = (p: ImportProtocolLike, rows: Row[]) => |
| 87 | + runImport({ |
| 88 | + p, context: { userId: 'u_member', roles: ['member'] }, objectName: 'xt_source', metaMap, writeMode: 'insert', |
| 89 | + matchFields: [], dryRun: true, runAutomations: false, trimWhitespace: true, createMissingOptions: false, |
| 90 | + skipBlankMatchKey: false, rows, |
| 91 | + }); |
| 92 | + |
| 93 | +/** The `where` keys asked of one target, in order. */ |
| 94 | +const askedOf = (findData: ReturnType<typeof protocol>['findData'], object: string) => |
| 95 | + findData.mock.calls.filter(([a]) => a.object === object).map(([a]) => Object.keys(a.query!.where!)[0]); |
| 96 | + |
| 97 | +const verdict = (r: { ok: boolean; code?: string; field?: string }) => ({ ok: r.ok, code: r.code, field: r.field }); |
| 98 | +const OK = { ok: true, code: undefined, field: undefined }; |
| 99 | + |
| 100 | +describe('[#24981] import resolves a lookup cell by the target object\'s stored title field', () => { |
| 101 | + for (const [label, override] of [ |
| 102 | + ['displayNameField (the card\'s cell, as authored)', {}], |
| 103 | + ['nameField (the alias as the registry normalizes it)', { displayNameField: undefined, nameField: 'nickname' }], |
| 104 | + ] as const) { |
| 105 | + it(`Wren resolves through a stored ${label}, asked after the id and the lookup's displayField`, async () => { |
| 106 | + const { p, findData, refusals } = protocol({ xt_person: override }); |
| 107 | + const summary = await run(p, [{ name: 'a', person: 'Wren' }]); |
| 108 | + expect(summary.results.map(verdict)).toEqual([OK]); |
| 109 | + expect(askedOf(findData, 'xt_person')).toEqual(['id', 'first_name', 'nickname']); |
| 110 | + expect(refusals).toEqual([]); |
| 111 | + }); |
| 112 | + } |
| 113 | + |
| 114 | + it('the stored title stores the matched record\'s id', async () => { |
| 115 | + const { p, getMetaItem } = protocol(); |
| 116 | + const createData = vi.fn(async (a: { data: Row }) => ({ id: 'new', ...a.data })); |
| 117 | + p.createData = createData; |
| 118 | + const summary = await runImport({ |
| 119 | + p, context: {}, objectName: 'xt_source', metaMap, writeMode: 'insert', matchFields: [], dryRun: false, |
| 120 | + runAutomations: false, trimWhitespace: true, createMissingOptions: false, skipBlankMatchKey: false, |
| 121 | + rows: [{ name: 'a', person: 'Wren' }, { name: 'b', person: 'Wren' }], |
| 122 | + }); |
| 123 | + expect(summary.results.map(verdict)).toEqual([OK, OK]); |
| 124 | + expect(createData.mock.calls.map(([a]) => a.data.person)).toEqual(['p1', 'p1']); |
| 125 | + // The title comes off the declaration the exposure check already reads: |
| 126 | + // one metadata read per target per import, however many rows ask it. |
| 127 | + expect(getMetaItem.mock.calls.map(([r]) => r.name)).toEqual(['xt_person']); |
| 128 | + }); |
| 129 | + |
| 130 | + for (const [cell, object, field, title] of [ |
| 131 | + ['John Smith', 'xt_contact', 'contact', 'full_name'], |
| 132 | + ['Acme (EMEA)', 'xt_account', 'account', 'display_title'], |
| 133 | + ] as const) { |
| 134 | + it(`a formula title (${object}.${title}) is never asked by where: '${cell}' still answers reference_not_found`, async () => { |
| 135 | + const { p, findData, refusals } = protocol(); |
| 136 | + const summary = await run(p, [{ name: 'a', [field]: cell }]); |
| 137 | + expect(summary.results.map(verdict)).toEqual([{ ok: false, code: 'reference_not_found', field }]); |
| 138 | + const asked = askedOf(findData, object); |
| 139 | + expect(asked).not.toContain(title); |
| 140 | + // The fixed fallback keys are unchanged; only a member the target |
| 141 | + // declares virtual is skipped, which the engine could only refuse. |
| 142 | + expect(asked).toEqual(['id', 'name', 'title', 'label', 'full_name', 'email', 'username'].filter((f) => f !== title)); |
| 143 | + expect(refusals).toEqual([]); |
| 144 | + }); |
| 145 | + } |
| 146 | + |
| 147 | + it('two records sharing a stored title answer the import\'s ambiguity refusal, and link neither', async () => { |
| 148 | + const { p, findData } = protocol(); |
| 149 | + const summary = await run(p, [{ name: 'a', member: 'Robin' }]); |
| 150 | + expect(summary.results.map(verdict)).toEqual([{ ok: false, code: 'reference_ambiguous', field: 'member' }]); |
| 151 | + expect(askedOf(findData, 'xt_member')).toEqual(['id', 'nickname']); |
| 152 | + }); |
| 153 | + |
| 154 | + it('[#22739] a target whose exposure refuses list still answers reference_not_found, and is never read', async () => { |
| 155 | + const { p, findData, getMetaItem } = protocol({ xt_person: { enable: { apiEnabled: false } } }); |
| 156 | + const summary = await run(p, [{ name: 'a', person: 'Wren' }, { name: 'b', person: 'Missing' }]); |
| 157 | + const NOT_FOUND = { ok: false, code: 'reference_not_found', field: 'person' }; |
| 158 | + expect(summary.results.map(verdict)).toEqual([NOT_FOUND, NOT_FOUND]); |
| 159 | + expect(findData).not.toHaveBeenCalled(); |
| 160 | + expect(getMetaItem).toHaveBeenCalledTimes(1); |
| 161 | + }); |
| 162 | +}); |
0 commit comments