|
2 | 2 |
|
3 | 3 | /** |
4 | 4 | * ADR-0146 D10 — the `__c` reservation and the base-field display-property |
5 | | - * whitelist, measured against `FieldSchema`. |
| 5 | + * whitelist, measured against `FieldSchema`, and the extension slot that |
| 6 | + * carries a patch (`ObjectExtensionSchema.fieldPatches`). |
6 | 7 | */ |
7 | 8 |
|
8 | 9 | import { describe, expect, it } from 'vitest'; |
9 | 10 | import type { z } from 'zod'; |
10 | 11 | import { BASE_FIELD_DISPLAY_WHITELIST, EXTENSION_FIELD_SUFFIX, isExtensionFieldName } from './field-customization'; |
11 | 12 | import { FieldSchema, SelectOptionSchema } from './field.zod'; |
| 13 | +import { ObjectExtensionSchema } from './object.zod'; |
12 | 14 |
|
13 | 15 | /** The object shape behind a (possibly optional) schema. */ |
14 | 16 | function shapeOf(schema: unknown): Record<string, z.ZodTypeAny> { |
@@ -100,3 +102,119 @@ describe('the base-field display-property whitelist', () => { |
100 | 102 | expect(BASE_FIELD_DISPLAY_WHITELIST.option.filter((k) => structuralOption.has(k))).toEqual([]); |
101 | 103 | }); |
102 | 104 | }); |
| 105 | + |
| 106 | +// --------------------------------------------------------------------------- |
| 107 | +// The slot that carries a patch: `ObjectExtensionSchema.fieldPatches` |
| 108 | +// (ADR-0146 D10, second rule). Its key sets are derived from the whitelist; |
| 109 | +// `field-patches-derived.test.ts` proves a widened whitelist widens it. |
| 110 | +// --------------------------------------------------------------------------- |
| 111 | + |
| 112 | +type Issue = { code: string; path: PropertyKey[]; message: string; keys?: string[] }; |
| 113 | + |
| 114 | +/** Parse an extension carrying `patch` for the base field `industry`; return the issues. */ |
| 115 | +function patchIssues(patch: unknown): Issue[] { |
| 116 | + const r = ObjectExtensionSchema.safeParse({ extend: 'crm_account', fieldPatches: { industry: patch } }); |
| 117 | + return r.success ? [] : (r.error.issues as unknown as Issue[]); |
| 118 | +} |
| 119 | + |
| 120 | +describe('`ObjectExtensionSchema.fieldPatches` — the per-field display patch slot', () => { |
| 121 | + it('accepts every field member of the whitelist, read from the constant', () => { |
| 122 | + const patch = Object.fromEntries(BASE_FIELD_DISPLAY_WHITELIST.field.map((k) => [k, `Patched ${k}`])); |
| 123 | + const parsed = ObjectExtensionSchema.parse({ extend: 'crm_account', fieldPatches: { industry: patch } }); |
| 124 | + expect(parsed.fieldPatches?.industry).toEqual(patch); |
| 125 | + }); |
| 126 | + |
| 127 | + it.each(BASE_FIELD_DISPLAY_WHITELIST.field.map((k) => [k]))('accepts the field member `%s` on its own', (key) => { |
| 128 | + expect(patchIssues({ [key]: 'Display text' })).toEqual([]); |
| 129 | + }); |
| 130 | + |
| 131 | + it.each(BASE_FIELD_DISPLAY_WHITELIST.option.map((k) => [k]))('accepts the option member `%s`, the option keyed by its stored value', (key) => { |
| 132 | + const parsed = ObjectExtensionSchema.parse({ |
| 133 | + extend: 'crm_account', |
| 134 | + fieldPatches: { industry: { options: { tech: { [key]: 'Technology' } } } }, |
| 135 | + }); |
| 136 | + expect(parsed.fieldPatches?.industry?.options?.tech).toEqual({ [key]: 'Technology' }); |
| 137 | + }); |
| 138 | + |
| 139 | + it.each(STRUCTURAL_FIELD_KEYS.filter((k) => k !== 'options').map((k) => [k]))( |
| 140 | + 'refuses the structural key `%s` by name, with the D6 prescription naming the whitelist', |
| 141 | + (key) => { |
| 142 | + const issues = patchIssues({ label: 'Sector', [key]: true }); |
| 143 | + expect(issues).toHaveLength(1); |
| 144 | + expect(issues[0]!.code).toBe('unrecognized_keys'); |
| 145 | + expect(issues[0]!.path).toEqual(['fieldPatches', 'industry']); |
| 146 | + expect(issues[0]!.keys).toEqual([key]); |
| 147 | + expect(issues[0]!.message).toContain('BASE_FIELD_DISPLAY_WHITELIST'); |
| 148 | + expect(issues[0]!.message).toContain('ADR-0146 D6'); |
| 149 | + }, |
| 150 | + ); |
| 151 | + |
| 152 | + it('refuses `options` membership: the field\'s option LIST is refused, the map keyed by stored value is the only form', () => { |
| 153 | + const issues = patchIssues({ options: [{ label: 'Technology', value: 'tech' }] }); |
| 154 | + expect(issues).toHaveLength(1); |
| 155 | + expect(issues[0]!.code).toBe('invalid_type'); |
| 156 | + expect(issues[0]!.path).toEqual(['fieldPatches', 'industry', 'options']); |
| 157 | + expect(issues[0]!.message).toContain('ADR-0146 D6'); |
| 158 | + }); |
| 159 | + |
| 160 | + it('refuses every FieldSchema key the whitelist does not admit — the refused set is derived, not listed', () => { |
| 161 | + const fieldKeys = Object.keys(shapeOf(FieldSchema)); |
| 162 | + const admitted = new Set<string>([...BASE_FIELD_DISPLAY_WHITELIST.field, 'options']); |
| 163 | + const refused = fieldKeys.filter((k) => !admitted.has(k)); |
| 164 | + expect(refused).toEqual(expect.arrayContaining(STRUCTURAL_FIELD_KEYS.filter((k) => k !== 'options'))); |
| 165 | + for (const key of refused) { |
| 166 | + const issues = patchIssues({ [key]: true }); |
| 167 | + expect(issues.map((i) => i.code), key).toEqual(['unrecognized_keys']); |
| 168 | + expect(issues[0]!.message, key).toContain('BASE_FIELD_DISPLAY_WHITELIST'); |
| 169 | + } |
| 170 | + }); |
| 171 | + |
| 172 | + it('an option patch takes `label` only: the stored `value` is refused with its own prescription', () => { |
| 173 | + const issues = patchIssues({ options: { tech: { label: 'Technology', value: 'technology' } } }); |
| 174 | + expect(issues).toHaveLength(1); |
| 175 | + expect(issues[0]!.code).toBe('unrecognized_keys'); |
| 176 | + expect(issues[0]!.path).toEqual(['fieldPatches', 'industry', 'options', 'tech']); |
| 177 | + expect(issues[0]!.keys).toEqual(['value']); |
| 178 | + expect(issues[0]!.message).toContain('stored value'); |
| 179 | + expect(issues[0]!.message).toContain('ADR-0146 D6'); |
| 180 | + }); |
| 181 | + |
| 182 | + it('an option patch refuses every other option key with the D6 prescription — derived from the option shape', () => { |
| 183 | + const optionKeys = Object.keys(shapeOf(SelectOptionSchema)); |
| 184 | + const refused = optionKeys.filter((k) => !(BASE_FIELD_DISPLAY_WHITELIST.option as readonly string[]).includes(k) && k !== 'value'); |
| 185 | + expect(refused.length).toBeGreaterThan(0); |
| 186 | + for (const key of refused) { |
| 187 | + const issues = patchIssues({ options: { tech: { [key]: true } } }); |
| 188 | + expect(issues.map((i) => i.code), key).toEqual(['unrecognized_keys']); |
| 189 | + expect(issues[0]!.message, key).toContain('BASE_FIELD_DISPLAY_WHITELIST'); |
| 190 | + } |
| 191 | + }); |
| 192 | + |
| 193 | + it('refuses a key spelled as an extension field (`__c`): declare it in `fields`, it is your own field', () => { |
| 194 | + const r = ObjectExtensionSchema.safeParse({ extend: 'crm_account', fieldPatches: { region__c: { label: 'Region' } } }); |
| 195 | + expect(r.success).toBe(false); |
| 196 | + const issues = r.success ? [] : (r.error.issues as unknown as Issue[]); |
| 197 | + expect(issues).toHaveLength(1); |
| 198 | + expect(issues[0]!.code).toBe('custom'); |
| 199 | + expect(issues[0]!.path).toEqual(['fieldPatches', 'region__c']); |
| 200 | + expect(issues[0]!.message).toContain('`fields`'); |
| 201 | + }); |
| 202 | + |
| 203 | + it('is pure shape: a field or option the target may not have still parses (the server judges it)', () => { |
| 204 | + expect(patchIssues({ options: { no_such_option: { label: 'Whatever' } } })).toEqual([]); |
| 205 | + expect(ObjectExtensionSchema.safeParse({ extend: 'crm_account', fieldPatches: { no_such_field: { label: 'X' } } }).success).toBe(true); |
| 206 | + expect(patchIssues({})).toEqual([]); |
| 207 | + }); |
| 208 | + |
| 209 | + it('is optional: an extension without it parses to the same output as before the slot', () => { |
| 210 | + const parsed = ObjectExtensionSchema.parse({ extend: 'contact', fields: {} }); |
| 211 | + expect(parsed).toEqual({ extend: 'contact', fields: {}, priority: 200 }); |
| 212 | + expect('fieldPatches' in parsed).toBe(false); |
| 213 | + }); |
| 214 | + |
| 215 | + it('reads `[EXPERIMENTAL — not enforced]` and names the merge card until the merge applies it', () => { |
| 216 | + const description = shapeOf(ObjectExtensionSchema).fieldPatches!.description ?? ''; |
| 217 | + expect(description.startsWith('[EXPERIMENTAL — not enforced]')).toBe(true); |
| 218 | + expect(description).toContain('N-23d'); |
| 219 | + }); |
| 220 | +}); |
0 commit comments