|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#27465] The runner's half of the rule the import door enforces: a match key |
| 5 | + * naming a field that holds a list or a structured value cannot identify a |
| 6 | + * record. A caller that reaches {@link runImport} without the door (the |
| 7 | + * connector pull writes a mapping's `upsertKey` straight through) gets every |
| 8 | + * row failed with that refusal, naming the field, before any row is coerced, |
| 9 | + * read or written. At the base the matcher put the coerced id array straight |
| 10 | + * into an implicit `$eq`, and every row failed on the driver's raw refusal. |
| 11 | + * |
| 12 | + * The rule itself, {@link multiValueMatchKeyRefusal}, is pinned on the whole |
| 13 | + * JSON-column population and on its boundary (a single-value lookup, a |
| 14 | + * single-value file field, and a name the map does not carry stay legal keys). |
| 15 | + */ |
| 16 | + |
| 17 | +import { describe, it, expect, vi } from 'vitest'; |
| 18 | +import { multiValueMatchKeyRefusal, runImport, type ImportProtocolLike } from './import-runner'; |
| 19 | +import type { ExportFieldMeta } from './import-field-meta.js'; |
| 20 | + |
| 21 | +type FindArgs = Parameters<ImportProtocolLike['findData']>[0]; |
| 22 | + |
| 23 | +const meta = (name: string, type: string, extra: Partial<ExportFieldMeta> = {}): [string, ExportFieldMeta] => |
| 24 | + [name, { name, type, ...extra }]; |
| 25 | + |
| 26 | +const metaMap = new Map<string, ExportFieldMeta>([ |
| 27 | + meta('code', 'text'), |
| 28 | + meta('title', 'text'), |
| 29 | + meta('work_object', 'lookup', { reference: 'pm_project', displayField: 'name', multiple: true }), |
| 30 | + meta('owner', 'lookup', { reference: 'pm_project', displayField: 'name' }), |
| 31 | + meta('watchers', 'user', { reference: 'sys_user', multiple: true }), |
| 32 | + meta('flags', 'select', { multiple: true, options: [{ label: 'A', value: 'a' }] }), |
| 33 | + meta('topics', 'multiselect', { options: [{ label: 'Red', value: 'red' }] }), |
| 34 | + meta('picks', 'checkboxes', { options: [{ label: 'X', value: 'x' }] }), |
| 35 | + meta('labels', 'tags'), |
| 36 | + meta('photos', 'image', { multiple: true }), |
| 37 | + meta('contract', 'file'), |
| 38 | + meta('meta', 'json'), |
| 39 | + meta('hq', 'address'), |
| 40 | + meta('spot', 'location'), |
| 41 | + meta('embedding', 'vector'), |
| 42 | +]); |
| 43 | + |
| 44 | +function makeProtocol() { |
| 45 | + return { |
| 46 | + findData: vi.fn(async (_args: FindArgs) => ({ records: [] })), |
| 47 | + createData: vi.fn(async () => ({ id: 'new' })), |
| 48 | + createManyData: vi.fn(async (args: { records: any[] }) => ({ records: args.records.map((r, i) => ({ id: `n${i}`, ...r })) })), |
| 49 | + updateData: vi.fn(async () => ({ id: 'upd' })), |
| 50 | + }; |
| 51 | +} |
| 52 | + |
| 53 | +const baseOpts = { |
| 54 | + objectName: 'pm_task', |
| 55 | + metaMap, |
| 56 | + runAutomations: false, |
| 57 | + trimWhitespace: true, |
| 58 | + createMissingOptions: false, |
| 59 | + skipBlankMatchKey: false, |
| 60 | +}; |
| 61 | + |
| 62 | +const ROWS = [ |
| 63 | + { code: 'T-1', title: 'one', work_object: 'Apollo;Borealis' }, |
| 64 | + { code: 'T-2', title: 'two', work_object: 'Borealis' }, |
| 65 | +]; |
| 66 | + |
| 67 | +describe('[#27465] multiValueMatchKeyRefusal: which fields cannot be a match key', () => { |
| 68 | + it.each([ |
| 69 | + ['a `multiple: true` lookup', 'work_object', '"work_object" (lookup, multiple: true)'], |
| 70 | + ['a `multiple: true` user', 'watchers', '"watchers" (user, multiple: true)'], |
| 71 | + ['a `multiple: true` select', 'flags', '"flags" (select, multiple: true)'], |
| 72 | + ['a multiselect', 'topics', '"topics" (multiselect)'], |
| 73 | + ['a checkboxes field', 'picks', '"picks" (checkboxes)'], |
| 74 | + ['a tags field', 'labels', '"labels" (tags)'], |
| 75 | + ['a `multiple: true` image', 'photos', '"photos" (image, multiple: true)'], |
| 76 | + ['a json field', 'meta', '"meta" (json)'], |
| 77 | + ['an address', 'hq', '"hq" (address)'], |
| 78 | + ['a location', 'spot', '"spot" (location)'], |
| 79 | + ['a vector', 'embedding', '"embedding" (vector)'], |
| 80 | + ])('%s is refused, named with its kind', (_label, field, named) => { |
| 81 | + const refused = multiValueMatchKeyRefusal(['code', field], metaMap); |
| 82 | + expect(refused?.fields).toEqual([field]); |
| 83 | + expect(refused?.message).toContain(named); |
| 84 | + }); |
| 85 | + |
| 86 | + it('every refused field of every ordered key set is named once, in key order', () => { |
| 87 | + const refused = multiValueMatchKeyRefusal([['code', 'labels'], ['meta'], ['labels', 'title']], metaMap, 'Mapping "feed" upsertKey'); |
| 88 | + expect(refused?.fields).toEqual(['labels', 'meta']); |
| 89 | + expect(refused?.message.startsWith('Mapping "feed" upsertKey names "labels" (tags), "meta" (json)')).toBe(true); |
| 90 | + }); |
| 91 | + |
| 92 | + it.each([ |
| 93 | + ['scalar text fields', ['code', 'title']], |
| 94 | + ['a single-value lookup', ['owner']], |
| 95 | + ['a single-value file field (a scalar id)', ['contract']], |
| 96 | + ['a name the map does not carry', ['nowhere']], |
| 97 | + ['ordered key sets of legal keys', [['code'], ['owner', 'title']]], |
| 98 | + ])('%s are legal keys', (_label, matchFields) => { |
| 99 | + expect(multiValueMatchKeyRefusal(matchFields as string[] | string[][], metaMap)).toBeUndefined(); |
| 100 | + }); |
| 101 | +}); |
| 102 | + |
| 103 | +describe('[#27465] runImport fails every row of a key naming a multi-value field, before any read or write', () => { |
| 104 | + it.each([ |
| 105 | + ['upsert, commit', 'upsert', false], |
| 106 | + ['upsert, dry run', 'upsert', true], |
| 107 | + ['update, commit', 'update', false], |
| 108 | + ] as const)('%s', async (_label, writeMode, dryRun) => { |
| 109 | + const p = makeProtocol(); |
| 110 | + const onProgress = vi.fn(); |
| 111 | + const summary = await runImport({ |
| 112 | + ...baseOpts, p, rows: ROWS, writeMode, dryRun, matchFields: ['code', 'work_object'], onProgress, |
| 113 | + }); |
| 114 | + |
| 115 | + const refusal = multiValueMatchKeyRefusal(['work_object'], metaMap)!.message; |
| 116 | + expect(summary.results).toEqual([ |
| 117 | + { row: 1, ok: false, action: 'failed', field: 'work_object', code: 'VALIDATION_ERROR', error: refusal }, |
| 118 | + { row: 2, ok: false, action: 'failed', field: 'work_object', code: 'VALIDATION_ERROR', error: refusal }, |
| 119 | + ]); |
| 120 | + expect(summary).toMatchObject({ total: 2, processed: 2, ok: 0, errors: 2, created: 0, updated: 0, skipped: 0, cancelled: false }); |
| 121 | + expect(onProgress).toHaveBeenCalledTimes(1); |
| 122 | + // Nothing was read: not the matcher's query, not the lookup cells' own resolution. |
| 123 | + for (const fn of Object.values(p)) expect(fn).not.toHaveBeenCalled(); |
| 124 | + }); |
| 125 | + |
| 126 | + it('ordered key sets naming one in a later set are refused all the same', async () => { |
| 127 | + const p = makeProtocol(); |
| 128 | + const summary = await runImport({ ...baseOpts, p, rows: ROWS, writeMode: 'upsert', dryRun: false, matchFields: [['code'], ['labels']] }); |
| 129 | + expect(summary.results.map((r) => [r.ok, r.code, r.field])).toEqual([[false, 'VALIDATION_ERROR', 'labels'], [false, 'VALIDATION_ERROR', 'labels']]); |
| 130 | + for (const fn of Object.values(p)) expect(fn).not.toHaveBeenCalled(); |
| 131 | + }); |
| 132 | + |
| 133 | + it('preserved: an insert carrying such a key matches nothing and writes its rows', async () => { |
| 134 | + const p = makeProtocol(); |
| 135 | + const summary = await runImport({ |
| 136 | + ...baseOpts, p, rows: [{ code: 'T-9', title: 'nine' }], writeMode: 'insert', dryRun: false, matchFields: ['work_object'], |
| 137 | + }); |
| 138 | + expect(summary).toMatchObject({ total: 1, ok: 1, created: 1, errors: 0 }); |
| 139 | + expect(p.findData).not.toHaveBeenCalled(); |
| 140 | + }); |
| 141 | + |
| 142 | + it('preserved: a scalar key still queries one value per field', async () => { |
| 143 | + const p = makeProtocol(); |
| 144 | + const summary = await runImport({ |
| 145 | + ...baseOpts, p, rows: [{ code: 'T-1', title: 'one' }], writeMode: 'upsert', dryRun: false, matchFields: ['code'], |
| 146 | + }); |
| 147 | + expect(summary).toMatchObject({ total: 1, ok: 1, errors: 0 }); |
| 148 | + expect(p.findData).toHaveBeenCalledWith(expect.objectContaining({ query: expect.objectContaining({ where: { code: 'T-1' } }) })); |
| 149 | + }); |
| 150 | +}); |
0 commit comments