|
| 1 | +/** |
| 2 | + * importTargetFields — writable vs match-only derivation for the ImportWizard |
| 3 | + * (#020: "编号存在则更新" — the record number must be usable as an upsert match |
| 4 | + * key even though it is autonumber/readonly and therefore not writable). |
| 5 | + */ |
| 6 | +import { describe, it, expect } from 'vitest'; |
| 7 | +import { importTargetFields, type ImportFieldPerms } from './importTargetFields'; |
| 8 | + |
| 9 | +const fields = { |
| 10 | + code: { type: 'autonumber', label: '编号', required: true }, |
| 11 | + name: { type: 'text', label: '名称', required: true }, |
| 12 | + slot: { type: 'text', label: '机位号' }, |
| 13 | + total: { type: 'formula', label: '合计' }, |
| 14 | + child_sum: { type: 'summary', label: '子表合计' }, |
| 15 | + locked_note: { type: 'text', label: '锁定备注', readonly: true }, |
| 16 | +}; |
| 17 | + |
| 18 | +const permsAllowing = (allow: (field: string, op: 'read' | 'write') => boolean): ImportFieldPerms => ({ |
| 19 | + isLoaded: true, |
| 20 | + checkField: (_obj, field, op) => allow(field, op), |
| 21 | +}); |
| 22 | + |
| 23 | +describe('importTargetFields', () => { |
| 24 | + it('keeps writable fields as write targets and marks autonumber/readonly as match-only', () => { |
| 25 | + const out = importTargetFields('device', fields); |
| 26 | + const byName = Object.fromEntries(out.map((f) => [f.name, f])); |
| 27 | + |
| 28 | + expect(byName.name).toMatchObject({ required: true }); |
| 29 | + expect(byName.name.matchOnly).toBeUndefined(); |
| 30 | + expect(byName.slot.matchOnly).toBeUndefined(); |
| 31 | + |
| 32 | + // The record number is present again — but only for matching. |
| 33 | + expect(byName.code).toMatchObject({ matchOnly: true, type: 'autonumber' }); |
| 34 | + expect(byName.locked_note).toMatchObject({ matchOnly: true }); |
| 35 | + // A match-only column is never a required WRITE column, even when the |
| 36 | + // schema declares required (the runtime owns the value). |
| 37 | + expect(byName.code.required).toBe(false); |
| 38 | + }); |
| 39 | + |
| 40 | + it('still drops computed fields — no storage column to match or write', () => { |
| 41 | + const out = importTargetFields('device', fields); |
| 42 | + const names = out.map((f) => f.name); |
| 43 | + expect(names).not.toContain('total'); |
| 44 | + expect(names).not.toContain('child_sum'); |
| 45 | + }); |
| 46 | + |
| 47 | + it('match-only targets are gated on FLS read; write targets on FLS write', () => { |
| 48 | + // Reader who cannot write `name` and cannot read `locked_note`. |
| 49 | + const out = importTargetFields('device', fields, permsAllowing((field, op) => |
| 50 | + op === 'write' ? field === 'slot' : field !== 'locked_note', |
| 51 | + )); |
| 52 | + const names = out.map((f) => f.name); |
| 53 | + expect(names).toContain('slot'); // writable |
| 54 | + expect(names).toContain('code'); // readable → match-only |
| 55 | + expect(names).not.toContain('locked_note'); // unreadable → gone entirely |
| 56 | + // `name` lost write access → degrades to a match-only (readable) target |
| 57 | + // rather than disappearing: it can still locate rows. |
| 58 | + expect(out.find((f) => f.name === 'name')).toMatchObject({ matchOnly: true, required: false }); |
| 59 | + }); |
| 60 | + |
| 61 | + it('without a loaded perms channel, falls back to schema-only derivation', () => { |
| 62 | + const out = importTargetFields('device', fields, { isLoaded: false, checkField: () => false }); |
| 63 | + expect(out.map((f) => f.name).sort()).toEqual( |
| 64 | + ['code', 'locked_note', 'name', 'slot'], |
| 65 | + ); |
| 66 | + }); |
| 67 | +}); |
0 commit comments