Skip to content

Commit dc65a34

Browse files
committed
test(studio): pin the All metadata area's customization marks, filter and counts
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QsR2jSKpVAotd33JtFuXkP
1 parent 862c27a commit dc65a34

2 files changed

Lines changed: 359 additions & 0 deletions

File tree

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#28568 — the All metadata area's lists and directory mark a
5+
* base's customizations (ADR-0146 D3, "On top, one surface"), from the
6+
* server's customization read alone (`GET /api/v1/packages/:id/customizations`).
7+
*
8+
* The fixture is a read-only base, HotCRM (`hotcrm`), and its paired
9+
* `hotcrm.custom` package carrying one overridden translation, one added email
10+
* template and one extension field. The real pages and the real reader run;
11+
* only `fetch` and the metadata client are stubbed. Pinned, each against a
12+
* control:
13+
*
14+
* - the translation list marks the served override "Customized (HotCRM)" and
15+
* the "only customizations" filter shows it alone;
16+
* - the email-template list holds the pair's item beside the base's, marked
17+
* "Added", and an override of ANOTHER member of a name marks no row;
18+
* - the directory's object tile counts the extension, and the header the base's total;
19+
* - a failed read draws no mark, no filter and no number, with the server's sentence;
20+
* - a base without a pair is unchanged, and Setup (no area) never reads;
21+
* - in the cross-package leg an item of a pair is labelled with its base.
22+
*/
23+
24+
import '@testing-library/jest-dom/vitest';
25+
import * as React from 'react';
26+
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
27+
import { render, screen, cleanup, within, fireEvent } from '@testing-library/react';
28+
import { MemoryRouter } from 'react-router-dom';
29+
// The switch column waits on `audienceAnchorsOf`'s dynamic import of this exact
30+
// specifier (see `ResourceListPage.environmentScope-7611.test.tsx`).
31+
import '@objectstack/spec/identity';
32+
import { MetadataAreaHostContext, type MetadataAreaHost } from './metadataAreaHost';
33+
34+
const BASE = 'hotcrm';
35+
const PAIR = 'hotcrm.custom';
36+
const PLAIN = 'com.example.crm';
37+
38+
const LISTS: Record<string, Record<string, unknown>[]> = {
39+
// A served override is served as the base's item, under the base's id.
40+
translation: [
41+
{ name: 'crm_en', label: 'CRM English', _packageId: BASE },
42+
{ name: 'crm_de', label: 'CRM German', _packageId: BASE },
43+
{ name: 'plain_en', label: 'Plain English', _packageId: PLAIN },
44+
],
45+
email_template: [
46+
{ name: 'welcome', label: 'Welcome', locale: 'en', _packageId: BASE },
47+
{ name: 'renewal_notice', label: 'Renewal notice', locale: 'en', _packageId: PAIR },
48+
],
49+
permission: [
50+
{ name: 'crm_rep', label: 'CRM Rep', _packageId: BASE },
51+
{ name: 'crm_rep_plus', label: 'CRM Rep Plus', _packageId: PAIR },
52+
],
53+
};
54+
55+
const CLIENT = {
56+
list: async (type: string) =>
57+
type === 'package' ? [{ manifest: { id: BASE, name: 'HotCRM', scope: 'installed' } }] : (LISTS[type] ?? []),
58+
};
59+
60+
const entry = (type: string, domain: string) => ({
61+
type,
62+
label: type,
63+
domain,
64+
allowEnvironmentOverlay: true,
65+
allowRuntimeCreate: true,
66+
});
67+
const ENTRIES = [
68+
entry('object', 'data'),
69+
entry('translation', 'ui'),
70+
entry('email_template', 'system'),
71+
entry('permission', 'security'),
72+
];
73+
74+
vi.mock('./useMetadata', async (importOriginal) => ({
75+
...(await importOriginal<Record<string, unknown>>()),
76+
useMetadataClient: () => CLIENT,
77+
useMetadataTypes: () => ({ loading: false, error: null, entries: ENTRIES }),
78+
useGlobalDiagnostics: () => ({
79+
loading: false,
80+
error: null,
81+
summary: { entries: [], total: 0, scannedTypes: 0, scannedItems: 0 },
82+
byType: {},
83+
warnByType: {},
84+
countsByType: { object: 3, translation: 2, email_template: 1 },
85+
lockedByType: {},
86+
packagesByType: Object.fromEntries(ENTRIES.map((e) => [e.type, [BASE, PLAIN]])),
87+
allPackages: [BASE, PLAIN],
88+
reload: () => {},
89+
}),
90+
}));
91+
vi.mock('./i18n', async (importOriginal) => ({
92+
...(await importOriginal<Record<string, unknown>>()),
93+
useMetadataLocale: () => 'en-US',
94+
}));
95+
vi.mock('../../hooks/useCanAuthorMetadata', async (importOriginal) => ({
96+
...(await importOriginal<Record<string, unknown>>()),
97+
useCanAuthorMetadata: () => true,
98+
}));
99+
100+
import { MetadataDirectoryPage } from './DirectoryPage';
101+
import { MetadataResourceListPage } from './ResourceListPage';
102+
import { registerDefaultMetadataSchemas } from './default-schemas';
103+
import { t, tFormat } from './i18n';
104+
105+
registerDefaultMetadataSchemas();
106+
107+
/** HotCRM's read: the overridden translation, an override of another member, the extension. */
108+
const HOTCRM_READ = {
109+
packageId: BASE,
110+
overrides: [
111+
{ type: 'email_template', name: 'welcome', member: 'zh-CN' },
112+
{ type: 'translation', name: 'crm_en', basedOn: '2.1.0' },
113+
],
114+
extensions: [{ object: 'crm_account', kind: 'field', name: 'tier__c' }],
115+
switchedOff: [],
116+
counts: { overrides: 2, extensions: 1, switchedOff: 0 },
117+
};
118+
const EMPTY_READ = { packageId: PLAIN, overrides: [], extensions: [], switchedOff: [], counts: { overrides: 0, extensions: 0, switchedOff: 0 } };
119+
120+
let customizationsFail = false;
121+
const fetchMock = vi.fn(async (url: string) => {
122+
const json = (status: number, body: unknown) =>
123+
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
124+
const u = String(url);
125+
if (u === '/api/v1/packages') {
126+
return json(200, { success: true, data: { packages: [{ manifest: { id: BASE, name: 'HotCRM', scope: 'installed' }, writable: false }] } });
127+
}
128+
const m = u.match(/^\/api\/v1\/packages\/([^/]+)\/customizations$/);
129+
if (m) {
130+
if (customizationsFail) {
131+
return json(503, { success: false, error: { code: 'SERVICE_UNAVAILABLE', message: 'The customization ledger is unavailable.' } });
132+
}
133+
return json(200, { success: true, data: decodeURIComponent(m[1]) === BASE ? HOTCRM_READ : EMPTY_READ });
134+
}
135+
if (u.startsWith('/api/v1/data/sys_metadata_activation')) {
136+
return json(200, { object: 'sys_metadata_activation', records: [], hasMore: false });
137+
}
138+
return json(404, { success: false });
139+
});
140+
141+
beforeEach(() => {
142+
customizationsFail = false;
143+
fetchMock.mockClear();
144+
vi.stubGlobal('fetch', fetchMock);
145+
});
146+
afterEach(() => {
147+
cleanup();
148+
vi.unstubAllGlobals();
149+
});
150+
151+
const area = (packageId: string | null): MetadataAreaHost => ({
152+
packageId,
153+
readOnly: packageId !== null,
154+
basePath: `/studio/${packageId ?? '~all'}/metadata`,
155+
});
156+
157+
function mountList(host: MetadataAreaHost | null, type: string, search: string) {
158+
const base = host?.basePath ?? '/apps/setup/metadata';
159+
const tree = (
160+
<MemoryRouter initialEntries={[`${base}/${type}${search}`]}>
161+
<MetadataResourceListPage type={type} />
162+
</MemoryRouter>
163+
);
164+
return render(host ? <MetadataAreaHostContext.Provider value={host}>{tree}</MetadataAreaHostContext.Provider> : tree);
165+
}
166+
167+
function mountDirectory(host: MetadataAreaHost) {
168+
return render(
169+
<MetadataAreaHostContext.Provider value={host}>
170+
<MemoryRouter initialEntries={[`${host.basePath}?package=${host.packageId}`]}>
171+
<MetadataDirectoryPage />
172+
</MemoryRouter>
173+
</MetadataAreaHostContext.Provider>,
174+
);
175+
}
176+
177+
const marksOf = (rowName: string) =>
178+
within(screen.getByTestId(`metadata-row-${rowName}`))
179+
.queryAllByTestId('customization-mark')
180+
.map((m) => `${m.getAttribute('data-kind')}:${m.textContent}`);
181+
const customizationReads = () =>
182+
fetchMock.mock.calls.filter(([u]) => String(u).endsWith('/customizations')).map(([u]) => String(u));
183+
184+
describe('the All metadata lists mark a base\'s customizations (objectstack#28568)', () => {
185+
it('the translation list marks the served override "Customized (HotCRM)", and the filter shows it alone', async () => {
186+
mountList(area(BASE), 'translation', `?package=${BASE}`);
187+
const only = await screen.findByTestId('metadata-only-customizations');
188+
expect(marksOf('crm_en')).toEqual([`customized:${tFormat('engine.customizationMark.customized', 'en-US', { base: 'HotCRM' })}`]);
189+
expect(marksOf('crm_en')).toEqual(['customized:Customized (HotCRM)']);
190+
// CONTROL: the base's item the read does not list carries no mark.
191+
expect(marksOf('crm_de')).toEqual([]);
192+
// Another package's row stays out of the base's list, as before.
193+
expect(screen.queryByTestId('metadata-row-plain_en')).toBeNull();
194+
195+
expect(only).toHaveTextContent(tFormat('engine.list.customizations.only', 'en-US', { marked: 1 }));
196+
fireEvent.click(only);
197+
expect(screen.getByTestId('metadata-row-crm_en')).toBeInTheDocument();
198+
expect(screen.queryByTestId('metadata-row-crm_de')).toBeNull();
199+
fireEvent.click(only);
200+
expect(screen.getByTestId('metadata-row-crm_de')).toBeInTheDocument();
201+
});
202+
203+
it('the email-template list holds the pair\'s item beside the base\'s, marked "Added"', async () => {
204+
mountList(area(BASE), 'email_template', `?package=${BASE}`);
205+
await screen.findByTestId('metadata-only-customizations');
206+
expect(marksOf('renewal_notice')).toEqual([`added:${t('engine.customizationMark.added', 'en-US')}`]);
207+
// The read overrides `welcome` in zh-CN only: the en member is the base's own.
208+
expect(marksOf('welcome')).toEqual([]);
209+
});
210+
211+
it('a failed read draws no mark and no filter, and says the server\'s sentence', async () => {
212+
customizationsFail = true;
213+
mountList(area(BASE), 'email_template', `?package=${BASE}`);
214+
const failed = await screen.findByTestId('metadata-customizations-failed');
215+
expect(failed).toHaveTextContent('The customization ledger is unavailable.');
216+
// The pair's item is still listed (the list read is its own), unmarked.
217+
expect(marksOf('renewal_notice')).toEqual([]);
218+
expect(screen.queryByTestId('metadata-only-customizations')).toBeNull();
219+
});
220+
221+
it('a base without a pair is unchanged: no mark, no filter', async () => {
222+
mountList(area(PLAIN), 'translation', `?package=${PLAIN}`);
223+
await screen.findByTestId('metadata-row-plain_en');
224+
await vi.waitFor(() => expect(customizationReads()).toEqual([`/api/v1/packages/${PLAIN}/customizations`]));
225+
// Let the read's answer render, then look.
226+
await new Promise((r) => setTimeout(r, 0));
227+
expect(marksOf('plain_en')).toEqual([]);
228+
expect(screen.queryByTestId('metadata-only-customizations')).toBeNull();
229+
expect(screen.queryByTestId('metadata-customizations-failed')).toBeNull();
230+
});
231+
232+
it('CONTROL: Setup (no area) reads no customizations and marks nothing', async () => {
233+
mountList(null, 'translation', `?package=${BASE}`);
234+
await screen.findByTestId('metadata-row-crm_en');
235+
expect(marksOf('crm_en')).toEqual([]);
236+
expect(customizationReads()).toEqual([]);
237+
});
238+
239+
it('the cross-package leg labels an item of a pair with its base', async () => {
240+
mountList(area(null), 'permission', '?scope=environment');
241+
await screen.findByTestId('metadata-row-crm_rep_plus');
242+
expect(marksOf('crm_rep_plus')).toEqual([
243+
`customizationOf:${tFormat('engine.customizationMark.customizationOf', 'en-US', { base: BASE })}`,
244+
]);
245+
// CONTROL: the base's own item names no base.
246+
expect(marksOf('crm_rep')).toEqual([]);
247+
expect(customizationReads()).toEqual([]);
248+
});
249+
});
250+
251+
describe('the All metadata directory counts a base\'s customizations (objectstack#28568)', () => {
252+
it('the object tile counts the extension, the translation tile its override, and the header the total', async () => {
253+
mountDirectory(area(BASE));
254+
const total = await screen.findByTestId('directory-customizations-total');
255+
expect(total).toHaveTextContent(
256+
tFormat('engine.directory.customizations.total', 'en-US', { customized: 3, base: 'HotCRM' }),
257+
);
258+
expect(screen.getByTestId('directory-tile-customized-object')).toHaveTextContent(
259+
tFormat('engine.directory.customizations.tile', 'en-US', { customized: 1 }),
260+
);
261+
expect(screen.getByTestId('directory-tile-customized-translation')).toHaveTextContent('1 customized');
262+
expect(screen.getByTestId('directory-tile-customized-email_template')).toHaveTextContent('1 customized');
263+
// CONTROL: a type with no served customization draws no number, not "0".
264+
expect(screen.queryByTestId('directory-tile-customized-permission')).toBeNull();
265+
});
266+
267+
it('a failed read draws no number, never "0", and says the server\'s sentence', async () => {
268+
customizationsFail = true;
269+
mountDirectory(area(BASE));
270+
expect(await screen.findByTestId('directory-customizations-failed')).toHaveTextContent(
271+
'The customization ledger is unavailable.',
272+
);
273+
expect(screen.queryByTestId('directory-customizations-total')).toBeNull();
274+
expect(screen.queryAllByTestId(/^directory-tile-customized-/)).toEqual([]);
275+
});
276+
});
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#28568 — which mark a row of a base's lists carries, and the
5+
* per-type numbers of the directory, from the served customization read only
6+
* (ADR-0146 D3; no diff, no `basedOn` verdict, D2/D6).
7+
*/
8+
9+
import { describe, it, expect } from 'vitest';
10+
import {
11+
customizationBaseOfRow,
12+
customizationCountsByType,
13+
customizationMarkOf,
14+
customizationTotal,
15+
indexCustomizations,
16+
} from './customizationMarks';
17+
import type { PackageCustomizations } from './packageCustomizations-io';
18+
19+
const READ: PackageCustomizations = {
20+
packageId: 'hotcrm',
21+
overrides: [
22+
{ type: 'translation', name: 'crm_en' },
23+
{ type: 'email_template', name: 'welcome', member: 'zh-CN' },
24+
{ type: 'object', name: 'crm_account' },
25+
],
26+
extensions: [
27+
{ object: 'crm_account', kind: 'field', name: 'tier__c' },
28+
{ object: 'crm_lead', kind: 'index', fields: ['email'] },
29+
],
30+
switchedOff: [{ type: 'flow', name: 'nightly_sync' }],
31+
counts: { overrides: 3, extensions: 2, switchedOff: 1 },
32+
};
33+
const index = indexCustomizations('hotcrm', READ)!;
34+
35+
describe('customizationMarkOf (objectstack#28568)', () => {
36+
it('a served override is "customized", whether served under the base or the pair', () => {
37+
expect(customizationMarkOf({ type: 'translation', name: 'crm_en', packageId: 'hotcrm' }, index)).toBe('customized');
38+
expect(customizationMarkOf({ type: 'translation', name: 'crm_en', packageId: 'hotcrm.custom' }, index)).toBe('customized');
39+
});
40+
41+
it('an override of a pair-identified type marks its own member only', () => {
42+
expect(customizationMarkOf({ type: 'email_template', name: 'welcome', member: 'zh-CN', packageId: 'hotcrm' }, index)).toBe('customized');
43+
expect(customizationMarkOf({ type: 'email_template', name: 'welcome', member: 'en', packageId: 'hotcrm' }, index)).toBeNull();
44+
expect(customizationMarkOf({ type: 'email_template', name: 'welcome', packageId: 'hotcrm' }, index)).toBeNull();
45+
});
46+
47+
it('any other item of the pair is "added"; the base\'s own items and other packages carry nothing', () => {
48+
expect(customizationMarkOf({ type: 'translation', name: 'crm_fr', packageId: 'hotcrm.custom' }, index)).toBe('added');
49+
expect(customizationMarkOf({ type: 'translation', name: 'crm_de', packageId: 'hotcrm' }, index)).toBeNull();
50+
// The type is part of the identity: a same-named item of another type is not the override.
51+
expect(customizationMarkOf({ type: 'view', name: 'crm_en', packageId: 'hotcrm' }, index)).toBeNull();
52+
expect(customizationMarkOf({ type: 'translation', name: 'crm_en', packageId: 'com.example.other' }, index)).toBeNull();
53+
expect(customizationMarkOf({ type: 'translation', name: 'crm_en' }, index)).toBeNull();
54+
});
55+
56+
it('a customization package has no pair to index', () => {
57+
expect(indexCustomizations('hotcrm.custom', { ...READ, packageId: 'hotcrm.custom' })).toBeNull();
58+
});
59+
});
60+
61+
describe('the directory\'s numbers (objectstack#28568)', () => {
62+
it('per type: the served overrides, and the extensions (members) on object', () => {
63+
expect(customizationCountsByType(READ)).toEqual({ translation: 1, email_template: 1, object: 3 });
64+
});
65+
66+
it('the total is the served overrides plus the served extensions, the landing\'s "customized"', () => {
67+
expect(customizationTotal(READ)).toBe(5);
68+
});
69+
70+
it('an empty read counts nothing', () => {
71+
const empty = { ...READ, overrides: [], extensions: [], counts: { overrides: 0, extensions: 0, switchedOff: 1 } };
72+
expect(customizationCountsByType(empty)).toEqual({});
73+
expect(customizationTotal(empty)).toBe(0);
74+
});
75+
});
76+
77+
describe('customizationBaseOfRow (objectstack#28568)', () => {
78+
it('names the base of a pair id, and nothing else', () => {
79+
expect(customizationBaseOfRow('hotcrm.custom')).toBe('hotcrm');
80+
expect(customizationBaseOfRow('hotcrm')).toBeNull();
81+
expect(customizationBaseOfRow(undefined)).toBeNull();
82+
});
83+
});

0 commit comments

Comments
 (0)