|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#28858, ADR-0146 D10] An `extend` that `getObject` folds to a legacy |
| 5 | + * `<ns>__<name>` key never opens a second object entry. |
| 6 | + * |
| 7 | + * A vendor package still ships its Case under the legacy key `crm__case` |
| 8 | + * (the published `ObjectSchema` accepts the name), so `getObject('case')` |
| 9 | + * folds to it. A second package extends it as `extend: 'case'`. The |
| 10 | + * extension is filed under the key the object is registered under, its field |
| 11 | + * lands on the vendor's object, no owner-less `case` entry opens beside it, |
| 12 | + * and the spelling is named once at `warn`. Never a boot refusal (AGENTS.md |
| 13 | + * *Startup registry reads*). The `object_extension` item door still refuses |
| 14 | + * the folded spelling at save (#28853, pinned in `@objectstack/runtime`). |
| 15 | + */ |
| 16 | + |
| 17 | +import { describe, it, expect, vi, afterEach } from 'vitest'; |
| 18 | +import { SchemaRegistry } from './registry.js'; |
| 19 | +import { ObjectQL } from './engine.js'; |
| 20 | + |
| 21 | +const VENDOR = 'com.vendor.crm'; |
| 22 | +const EXT = 'com.acme.case-region'; |
| 23 | +const LEGACY_KEY = 'crm__case'; |
| 24 | +const SHORT = 'case'; |
| 25 | + |
| 26 | +const vendorCase = () => ({ |
| 27 | + name: LEGACY_KEY, |
| 28 | + label: 'Case', |
| 29 | + fields: { subject: { name: 'subject', type: 'text', label: 'Subject' } }, |
| 30 | +}); |
| 31 | + |
| 32 | +const region = { region: { name: 'region', type: 'text', label: 'Region' } }; |
| 33 | + |
| 34 | +function registry(): SchemaRegistry { |
| 35 | + const reg = new SchemaRegistry({ multiTenant: false, searchCompanion: false } as never); |
| 36 | + reg.logLevel = 'silent'; |
| 37 | + return reg; |
| 38 | +} |
| 39 | + |
| 40 | +const spellingLines = (warn: { mock: { calls: unknown[][] } }): string[] => |
| 41 | + warn.mock.calls.map((c) => String(c[0])).filter((l) => l.includes(`'${LEGACY_KEY}'`) && l.includes(EXT)); |
| 42 | + |
| 43 | +afterEach(() => vi.restoreAllMocks()); |
| 44 | + |
| 45 | +describe('#28858 — an extension whose `extend` folds to a legacy key lands on the object it folds to', () => { |
| 46 | + it('a manifest objectExtensions entry: the field is on the vendor\'s object, no second entry opens, named once', () => { |
| 47 | + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); |
| 48 | + const ql = new ObjectQL(); |
| 49 | + (ql.registry as SchemaRegistry).logLevel = 'silent'; |
| 50 | + ql.registerApp({ id: VENDOR, name: 'CRM', version: '1.0.0', objects: [vendorCase()] }); |
| 51 | + ql.registerApp({ |
| 52 | + id: EXT, name: 'Case region', version: '1.0.0', |
| 53 | + objectExtensions: [{ extend: SHORT, fields: region }], |
| 54 | + }); |
| 55 | + |
| 56 | + const reg = ql.registry as SchemaRegistry; |
| 57 | + expect(Object.keys((reg.getObject(LEGACY_KEY) as any).fields)).toEqual(expect.arrayContaining(['subject', 'region'])); |
| 58 | + expect(reg.getObjectContributors(SHORT)).toEqual([]); |
| 59 | + expect(reg.getObjectContributors(LEGACY_KEY).map((c) => [c.packageId, c.ownership])) |
| 60 | + .toEqual([[VENDOR, 'own'], [EXT, 'extend']]); |
| 61 | + expect(reg.getAllObjects().map((o) => o.name).filter((n) => n === SHORT || n === LEGACY_KEY)).toEqual([LEGACY_KEY]); |
| 62 | + // The short name still reads the one object, with the field. |
| 63 | + expect(Object.keys((reg.getObject(SHORT) as any).fields)).toContain('region'); |
| 64 | + |
| 65 | + // Re-reading and re-registering name it no more than once. |
| 66 | + reg.invalidateAll(); |
| 67 | + reg.getObject(LEGACY_KEY); |
| 68 | + ql.registerApp({ id: EXT, name: 'Case region', version: '1.0.0', objectExtensions: [{ extend: SHORT, fields: region }] }); |
| 69 | + const lines = spellingLines(warn); |
| 70 | + expect(lines).toHaveLength(1); |
| 71 | + expect(lines[0]).toContain(`'${SHORT}'`); |
| 72 | + }); |
| 73 | + |
| 74 | + it('registerObjectExtension (the stored row\'s verb) files under the resolved key and answers it', () => { |
| 75 | + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); |
| 76 | + const reg = registry(); |
| 77 | + reg.registerObject(vendorCase() as any, VENDOR, undefined, 'own'); |
| 78 | + |
| 79 | + const filed = reg.registerObjectExtension({ extend: SHORT, fields: region, _provenance: 'org' }, EXT); |
| 80 | + |
| 81 | + expect(filed).toBe(LEGACY_KEY); |
| 82 | + expect(reg.getObjectContributors(SHORT)).toEqual([]); |
| 83 | + expect(Object.keys((reg.getObject(LEGACY_KEY) as any).fields)).toContain('region'); |
| 84 | + expect(spellingLines(warn)).toHaveLength(1); |
| 85 | + // The withdrawal the row delete makes reaches the same entry. |
| 86 | + expect(reg.withdrawObjectExtension(SHORT, EXT)).toBe(true); |
| 87 | + expect(Object.keys((reg.getObject(LEGACY_KEY) as any).fields)).not.toContain('region'); |
| 88 | + }); |
| 89 | + |
| 90 | + it('control: an extension spelled with the registered key registers as before, with no warn', () => { |
| 91 | + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); |
| 92 | + const reg = registry(); |
| 93 | + reg.registerObject(vendorCase() as any, VENDOR, undefined, 'own'); |
| 94 | + |
| 95 | + expect(reg.registerObjectExtension({ extend: LEGACY_KEY, fields: region }, EXT)).toBe(LEGACY_KEY); |
| 96 | + expect(Object.keys((reg.getObject(LEGACY_KEY) as any).fields)).toContain('region'); |
| 97 | + expect(reg.getObjectContributors(SHORT)).toEqual([]); |
| 98 | + expect(spellingLines(warn)).toEqual([]); |
| 99 | + }); |
| 100 | + |
| 101 | + it('control: an object registered under the exact name keeps its extensions, even beside a legacy key that shares the short name', () => { |
| 102 | + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); |
| 103 | + const reg = registry(); |
| 104 | + reg.registerObject(vendorCase() as any, VENDOR, undefined, 'own'); |
| 105 | + reg.registerObject({ name: SHORT, label: 'Support case', fields: { title: { name: 'title', type: 'text', label: 'Title' } } } as any, 'com.other.support', undefined, 'own'); |
| 106 | + |
| 107 | + expect(reg.registerObjectExtension({ extend: SHORT, fields: region }, EXT)).toBe(SHORT); |
| 108 | + expect(reg.getObjectContributors(SHORT).map((c) => c.ownership)).toEqual(['own', 'extend']); |
| 109 | + expect(Object.keys((reg.getObject(LEGACY_KEY) as any).fields)).not.toContain('region'); |
| 110 | + expect(spellingLines(warn)).toEqual([]); |
| 111 | + }); |
| 112 | +}); |
0 commit comments