|
5 | 5 | validateObjectReferences as validateObjectReferencesUnrecorded, |
6 | 6 | OBJECT_REFERENCE_UNKNOWN, |
7 | 7 | OBJECT_REFERENCE_UNREGISTERED_PLATFORM, |
| 8 | + USER_FIELD_FOREIGN_REFERENCE, |
8 | 9 | } from './validate-object-references.js'; |
| 10 | +import { Field, ObjectSchema, referenceTargetOf } from '@objectstack/spec/data'; |
9 | 11 | import { runAuthoringRules, splitBySeverity } from './authoring-rules.js'; |
10 | 12 | import { PLATFORM_OBJECT_PREFIXES } from '@objectstack/spec/system'; |
11 | 13 | import { explainRule } from './rule-explanations.js'; |
@@ -707,10 +709,99 @@ describe('[#19289] validateObjectReferences — a `user` target comes from the T |
707 | 709 | it('control: the rule still REPORTS a real miss — an explicit carrier naming an unknown object errors', () => { |
708 | 710 | // Without this, "no findings" above reads equally well as a rule that |
709 | 711 | // stopped judging `user` fields altogether. |
| 712 | + // [#24808] A `user` field naming any object but `sys_user` also draws the |
| 713 | + // foreign-target warning, so the unknown-object error is read by rule. |
710 | 714 | const findings = validateObjectReferences(userField({ reference: 'zzz_nope' })); |
| 715 | + expect(findings.filter((f) => f.rule === OBJECT_REFERENCE_UNKNOWN)).toHaveLength(1); |
| 716 | + expect(findings.map((f) => f.rule).sort()).toEqual([OBJECT_REFERENCE_UNKNOWN, USER_FIELD_FOREIGN_REFERENCE].sort()); |
| 717 | + }); |
| 718 | +}); |
| 719 | + |
| 720 | +/** |
| 721 | + * [#24808] A `user` field's target is FIXED to `sys_user` by the spec, and |
| 722 | + * nothing enforced it: the reproduction below parses through `ObjectSchema` |
| 723 | + * and resolves through `referenceTargetOf` to the authored object. Under the |
| 724 | + * 「甲」 ruling the 18.x half is this warning (no ADR-0087 conversion can carry |
| 725 | + * the refusal: a `lookup` joins the referential delete walk a `user` field |
| 726 | + * sits outside), so the parse half below pins that the shape still LOADS. |
| 727 | + */ |
| 728 | +describe('[#24808] validateObjectReferences — a `user` field naming another object warns', () => { |
| 729 | + const stackWith = (assignee: Record<string, unknown>) => ({ |
| 730 | + objects: [ |
| 731 | + { name: 'probe_account', fields: { name: { type: 'text', label: 'Name' } } }, |
| 732 | + { name: 'probe_task', fields: { name: { type: 'text', label: 'Name' }, owner: assignee } }, |
| 733 | + ], |
| 734 | + }); |
| 735 | + const PROBE = { type: 'user', label: 'Owner', reference: 'probe_account' }; |
| 736 | + |
| 737 | + it('reproduction: the shape parses and the arbiter follows the authored object', () => { |
| 738 | + const parsed = ObjectSchema.safeParse({ name: 'probe_task', fields: { owner: PROBE } }); |
| 739 | + expect(parsed.success).toBe(true); |
| 740 | + expect(referenceTargetOf(PROBE)).toBe('probe_account'); |
| 741 | + }); |
| 742 | + |
| 743 | + it('warns — never errors — and names the field, the target and the lookup spelling', () => { |
| 744 | + const findings = validateObjectReferences(stackWith(PROBE)); |
711 | 745 | expect(findings).toHaveLength(1); |
712 | | - expect(findings[0].rule).toBe(OBJECT_REFERENCE_UNKNOWN); |
| 746 | + const [f] = findings; |
| 747 | + expect(f.rule).toBe(USER_FIELD_FOREIGN_REFERENCE); |
| 748 | + expect(f.severity).toBe('warning'); |
| 749 | + expect(f.path).toBe('objects[1].fields.owner.reference'); |
| 750 | + expect(f.where).toBe('object "probe_task" · field "owner"'); |
| 751 | + expect(f.message).toContain('"probe_account"'); |
| 752 | + expect(f.hint).toContain("{ type: 'lookup', reference: 'probe_account' }"); |
| 753 | + }); |
| 754 | + |
| 755 | + it('warns on an object extension too — the verdict needs no artifact context', () => { |
| 756 | + const findings = validateObjectReferences({ |
| 757 | + objectExtensions: [{ extend: 'sys_user_profile', fields: { mentor: { ...PROBE } } }], |
| 758 | + }); |
| 759 | + expect(findings.filter((f) => f.rule === USER_FIELD_FOREIGN_REFERENCE)).toEqual([ |
| 760 | + expect.objectContaining({ path: 'objectExtensions[0].fields.mentor.reference', severity: 'warning' }), |
| 761 | + ]); |
713 | 762 | }); |
| 763 | + |
| 764 | + it('warns on the map form of `fields` and on the array form of `objects` alike', () => { |
| 765 | + const findings = validateObjectReferences({ |
| 766 | + objects: { probe_task: { fields: { owner: PROBE } }, probe_account: { fields: {} } }, |
| 767 | + }); |
| 768 | + expect(findings.map((f) => f.rule)).toEqual([USER_FIELD_FOREIGN_REFERENCE]); |
| 769 | + }); |
| 770 | + |
| 771 | + it('control: the two legal spellings — no `reference`, and `reference: "sys_user"` — stay silent', () => { |
| 772 | + expect(validateObjectReferences(stackWith({ type: 'user', label: 'Owner' }))).toEqual([]); |
| 773 | + expect(validateObjectReferences(stackWith({ type: 'user', label: 'Owner', reference: 'sys_user' }))).toEqual([]); |
| 774 | + expect(validateObjectReferences(stackWith(Field.user({ label: 'Owner' })))).toEqual([]); |
| 775 | + }); |
| 776 | + |
| 777 | + it('control: a `lookup` to the same object is the canonical spelling and draws nothing', () => { |
| 778 | + expect(validateObjectReferences(stackWith(Field.lookup('probe_account', { label: 'Owner' })))).toEqual([]); |
| 779 | + expect(validateObjectReferences(stackWith({ type: 'lookup', reference: 'probe_account' }))).toEqual([]); |
| 780 | + }); |
| 781 | + |
| 782 | + it('control: an unreadable carrier still REFUSES rather than reading as a foreign target', () => { |
| 783 | + expect(() => validateObjectReferences(stackWith({ type: 'user', reference: { object: 'sys_user' } }))) |
| 784 | + .toThrow(/`reference` is an object/); |
| 785 | + }); |
| 786 | + |
| 787 | + it('prints one line each, and `os explain` carries the reasoning the verdict leaves out', () => { |
| 788 | + const [f] = validateObjectReferences(stackWith(PROBE)); |
| 789 | + for (const text of [f.message, f.hint]) { |
| 790 | + expect(text).not.toContain('\n'); |
| 791 | + expect(text.length, text).toBeLessThanOrEqual(200); |
| 792 | + } |
| 793 | + const entry = explainRule(USER_FIELD_FOREIGN_REFERENCE); |
| 794 | + expect(entry?.rule).toBe(USER_FIELD_FOREIGN_REFERENCE); |
| 795 | + expect(entry!.paragraphs.join('\n')).toContain("deleteBehavior: 'set_null'"); |
| 796 | + }); |
| 797 | + |
| 798 | + for (const command of ['validate', 'build', 'lint'] as const) { |
| 799 | + it(`reaches \`os ${command}\` through the authoring registry, as an advisory and never a gate`, () => { |
| 800 | + const { errors, advisories } = splitBySeverity(runAuthoringRules(command, { normalized: stackWith(PROBE) as never })); |
| 801 | + expect(errors.filter((f) => f.rule === USER_FIELD_FOREIGN_REFERENCE)).toEqual([]); |
| 802 | + expect(advisories.filter((f) => f.rule === USER_FIELD_FOREIGN_REFERENCE)).toHaveLength(1); |
| 803 | + }); |
| 804 | + } |
714 | 805 | }); |
715 | 806 |
|
716 | 807 | /** |
|
0 commit comments