Skip to content

Commit 4affd99

Browse files
committed
test(app-shell): pin the landing's customization line against stubbed served answers
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QsR2jRL4qcnwHDD4M1e4wx
1 parent e76abc5 commit 4affd99

1 file changed

Lines changed: 211 additions & 0 deletions

File tree

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* ADR-0146 D3 — each read-only base's Studio landing card says what this
5+
* deployment changed on it, "n customized, m switched off", from the server's
6+
* per-package customization read (`GET /api/v1/packages/:id/customizations`).
7+
*
8+
* The real `BuilderLanding` and the real reader (`packageCustomizations-io`)
9+
* run under a real router; only the package list and the wire are stubbed.
10+
* The stubbed answers are shaped by the route's declaration,
11+
* `GetPackageCustomizationsResponseSchema`, inside the dispatcher envelope.
12+
*/
13+
14+
import '@testing-library/jest-dom/vitest';
15+
import * as React from 'react';
16+
import { afterEach, describe, expect, it, vi } from 'vitest';
17+
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
18+
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
19+
import { t, tFormat } from '../metadata-admin/i18n';
20+
import { resetRuntimeConfigForTesting } from '../../runtime-config';
21+
22+
const packages = vi.hoisted(() => ({ list: [] as Array<Record<string, unknown>> }));
23+
24+
vi.mock('./packages-io', async (importOriginal) => {
25+
const mod = await importOriginal<typeof import('./packages-io')>();
26+
return { ...mod, fetchPackages: vi.fn(async () => packages.list) };
27+
});
28+
29+
// The create dialog is closed throughout; its form stack is not under test.
30+
vi.mock('../metadata-admin/PackageFormDialog', () => ({ PackageFormDialog: () => null }));
31+
32+
import { BuilderLanding } from './BuilderLanding';
33+
34+
const HOTCRM = { id: 'hotcrm', name: 'HotCRM', writable: false, namespace: 'hotcrm' };
35+
const HOTCRM_PAIR = { id: 'hotcrm.custom', name: 'HotCRM customizations', writable: true, namespace: 'hotcrm_custom' };
36+
const SHOWCASE = { id: 'com.example.showcase', name: 'Showcase', writable: false, namespace: 'showcase' };
37+
const WRITABLE = { id: 'com.acme.app', name: 'Acme', writable: true, namespace: 'acme' };
38+
/** A pair served read-only (code-shipped): still a pair, so still no line. */
39+
const READONLY_PAIR = { id: 'com.example.showcase.custom', name: 'Showcase pair', writable: false, namespace: 'showcase_custom' };
40+
41+
const CUSTOMIZATIONS_RE = /^\/api\/v1\/packages\/([^/]+)\/customizations$/;
42+
43+
/** A served answer for `id`: `overrides` overrides, `extensions` field members, `switchedOff` flows. */
44+
function served(id: string, overrides: number, extensions: number, switchedOff: number) {
45+
const data = {
46+
packageId: id,
47+
overrides: Array.from({ length: overrides }, (_, i) => ({ type: 'view', name: `view_${i}` })),
48+
extensions: Array.from({ length: extensions }, (_, i) => ({ object: 'account', kind: 'field' as const, name: `field_${i}` })),
49+
switchedOff: Array.from({ length: switchedOff }, (_, i) => ({ type: 'flow', name: `flow_${i}` })),
50+
counts: { overrides, extensions, switchedOff },
51+
};
52+
return { status: 200, body: { success: true, data } };
53+
}
54+
55+
type Answer = { status: number; body: unknown } | 'never';
56+
57+
/**
58+
* Stub the wire: each customization request is answered from `answers` by
59+
* base id; every request URL is recorded.
60+
*/
61+
function stubWire(answers: Record<string, Answer>) {
62+
const requested: string[] = [];
63+
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
64+
const url = String(input);
65+
requested.push(url);
66+
const m = CUSTOMIZATIONS_RE.exec(url);
67+
const answer = m ? answers[decodeURIComponent(m[1])] : undefined;
68+
if (answer === 'never') return new Promise<Response>(() => {});
69+
if (!answer) throw new Error(`unexpected request ${url}`);
70+
return {
71+
ok: answer.status >= 200 && answer.status < 300,
72+
status: answer.status,
73+
json: async () => answer.body,
74+
text: async () => JSON.stringify(answer.body),
75+
} as Response;
76+
});
77+
vi.stubGlobal('fetch', fetchMock);
78+
return { requested };
79+
}
80+
81+
const customizationRequests = (requested: string[]) => requested.filter((u) => CUSTOMIZATIONS_RE.test(u));
82+
83+
function LocationProbe() {
84+
const { pathname } = useLocation();
85+
return <div data-testid="location">{pathname}</div>;
86+
}
87+
88+
function renderLanding() {
89+
return render(
90+
<MemoryRouter initialEntries={['/studio']}>
91+
<LocationProbe />
92+
<Routes>
93+
<Route path="/studio" element={<BuilderLanding />} />
94+
<Route path="/studio/:packageId/:tab" element={<div data-testid="pillar-builder" />} />
95+
</Routes>
96+
</MemoryRouter>,
97+
);
98+
}
99+
100+
/** The card a package renders in: the element holding both its name and its links. */
101+
function cardOf(name: string): HTMLElement {
102+
const card = screen.getByText(name).closest('.rounded-lg.border');
103+
if (!(card instanceof HTMLElement)) throw new Error(`no card found for ${name}`);
104+
return card;
105+
}
106+
107+
const customizedText = (n: number) => tFormat('engine.studio.landing.customizations.customized', 'en-US', { customized: n });
108+
const switchedOffText = (n: number) => tFormat('engine.studio.landing.customizations.switchedOff', 'en-US', { switchedOff: n });
109+
110+
afterEach(() => {
111+
cleanup();
112+
packages.list = [];
113+
vi.unstubAllGlobals();
114+
resetRuntimeConfigForTesting();
115+
});
116+
117+
describe('Studio landing: a read-only base says "n customized, m switched off" (ADR-0146 D3)', () => {
118+
it('reads 3 overrides + 1 extension and 2 switched off as "4 customized, 2 switched off", linking the pair and the Automations pillar', async () => {
119+
packages.list = [HOTCRM_PAIR, HOTCRM];
120+
const { requested } = stubWire({ hotcrm: served('hotcrm', 3, 1, 2) });
121+
renderLanding();
122+
123+
const line = await within(await waitFor(() => cardOf('HotCRM'))).findByTestId('studio-landing-customizations');
124+
await waitFor(() => expect(line).toHaveAttribute('data-state', 'read'));
125+
expect(line).toHaveTextContent('4 customized, 2 switched off');
126+
127+
const pairLink = within(line).getByTestId('studio-landing-customizations-pair');
128+
expect(pairLink).toHaveTextContent(customizedText(4));
129+
expect(pairLink).toHaveAttribute('href', '/studio/hotcrm.custom/data');
130+
const offLink = within(line).getByTestId('studio-landing-customizations-switched-off');
131+
expect(offLink).toHaveTextContent(switchedOffText(2));
132+
expect(offLink).toHaveAttribute('href', '/studio/hotcrm/automations');
133+
134+
fireEvent.click(pairLink);
135+
await waitFor(() => expect(screen.getByTestId('location')).toHaveTextContent('/studio/hotcrm.custom/data'));
136+
// One read, for the base; never for its pair.
137+
expect(customizationRequests(requested)).toEqual(['/api/v1/packages/hotcrm/customizations']);
138+
});
139+
140+
it('reads a base with no pair as "0 customized, 0 switched off", with no pair link', async () => {
141+
packages.list = [SHOWCASE];
142+
stubWire({ 'com.example.showcase': served('com.example.showcase', 0, 0, 0) });
143+
renderLanding();
144+
145+
const line = await within(await waitFor(() => cardOf('Showcase'))).findByTestId('studio-landing-customizations');
146+
await waitFor(() => expect(line).toHaveAttribute('data-state', 'read'));
147+
expect(line).toHaveTextContent('0 customized, 0 switched off');
148+
expect(within(line).queryByTestId('studio-landing-customizations-pair')).not.toBeInTheDocument();
149+
expect(within(line).getByText(customizedText(0))).not.toHaveAttribute('href');
150+
expect(within(line).getByTestId('studio-landing-customizations-switched-off')).toHaveAttribute(
151+
'href',
152+
'/studio/com.example.showcase/automations',
153+
);
154+
});
155+
156+
it('shows no number on a failed read: a muted "couldn’t read" with the server’s sentence as its title', async () => {
157+
packages.list = [SHOWCASE];
158+
const sentence = 'Metadata service not available';
159+
stubWire({ 'com.example.showcase': { status: 503, body: { success: false, error: { message: sentence } } } });
160+
renderLanding();
161+
162+
const line = await within(await waitFor(() => cardOf('Showcase'))).findByTestId('studio-landing-customizations');
163+
await waitFor(() => expect(line).toHaveAttribute('data-state', 'failed'));
164+
expect(line).toHaveTextContent(t('engine.studio.landing.customizations.failed', 'en-US'));
165+
expect(line).toHaveAttribute('title', expect.stringContaining(sentence));
166+
expect(line.textContent).not.toMatch(/\d/);
167+
});
168+
169+
it('shows no number when the answer is one the route’s declaration refuses', async () => {
170+
packages.list = [SHOWCASE];
171+
// `counts` missing: off-contract, so no count is read out of it.
172+
stubWire({
173+
'com.example.showcase': { status: 200, body: { success: true, data: { packageId: 'com.example.showcase', overrides: [] } } },
174+
});
175+
renderLanding();
176+
177+
const line = await within(await waitFor(() => cardOf('Showcase'))).findByTestId('studio-landing-customizations');
178+
await waitFor(() => expect(line).toHaveAttribute('data-state', 'failed'));
179+
expect(line.textContent).not.toMatch(/\d/);
180+
});
181+
182+
it('draws no line and sends no request for a writable package or a pair', async () => {
183+
packages.list = [WRITABLE, HOTCRM_PAIR, READONLY_PAIR, SHOWCASE];
184+
const { requested } = stubWire({ 'com.example.showcase': served('com.example.showcase', 1, 0, 0) });
185+
renderLanding();
186+
187+
await screen.findByText('Showcase pair');
188+
await waitFor(() =>
189+
expect(within(cardOf('Showcase')).getByTestId('studio-landing-customizations')).toHaveAttribute('data-state', 'read'),
190+
);
191+
expect(within(cardOf('Acme')).queryByTestId('studio-landing-customizations')).not.toBeInTheDocument();
192+
expect(within(cardOf('HotCRM customizations')).queryByTestId('studio-landing-customizations')).not.toBeInTheDocument();
193+
expect(within(cardOf('Showcase pair')).queryByTestId('studio-landing-customizations')).not.toBeInTheDocument();
194+
expect(customizationRequests(requested)).toEqual(['/api/v1/packages/com.example.showcase/customizations']);
195+
});
196+
197+
it('paints the landing before the counts arrive, with no number on the line', async () => {
198+
packages.list = [HOTCRM, SHOWCASE];
199+
const { requested } = stubWire({ hotcrm: 'never', 'com.example.showcase': 'never' });
200+
renderLanding();
201+
202+
await screen.findByText('HotCRM');
203+
expect(screen.getByText('Showcase')).toBeInTheDocument();
204+
await waitFor(() => expect(customizationRequests(requested)).toHaveLength(2));
205+
for (const name of ['HotCRM', 'Showcase']) {
206+
const line = within(cardOf(name)).getByTestId('studio-landing-customizations');
207+
expect(line).toHaveAttribute('data-state', 'loading');
208+
expect(line.textContent).not.toMatch(/\d/);
209+
}
210+
});
211+
});

0 commit comments

Comments
 (0)