Skip to content
This repository was archived by the owner on Oct 26, 2026. It is now read-only.

Commit d761384

Browse files
committed
test(app-shell): pin the four remaining /layers reads' 404 as "never published" (objectui#11799)
The four GET /meta/:type/:name/layers reads objectui#11892 and objectui#11928 left open cannot be skipped with what their editors hold. The framework keeps its 404 for a name with no stored layer, the author included (objectstack-ai/objectstack#22397, answer (a)), and MetadataClient.layered() already resolves that 404 as an envelope with every layer null, so none of these reads raises an error for it. Pins, each over the real editor and a real MetadataClient on an in-memory server answering as the framework does: - the metadata edit page's first load of a draft-only item: no error, no lock banner, no reset/delete control, Layers sheet with no overlay; controls: a published item's layers are used, a 5xx shows the load error; - the embedded item editor: opening sends no read; controls: a published parent's save splices into its layers, a 5xx is the save's error; - the permission matrix's package-door save re-read of a "+ New" set: 404, saved over its own slice, no refusal; controls: a published set keeps other packages' rows, a 5xx still refuses; - the list-view panel, a view that exists nowhere: 404, no failure, the canvas shows the view the first edit creates; control: a 5xx is the load failure. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6694abe commit d761384

5 files changed

Lines changed: 431 additions & 19 deletions
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
---
3+
4+
No release: tests only. `@object-ui/app-shell` gains pins for the four `GET /meta/:type/:name/layers` reads objectui#11799 left open: the metadata edit page's first load, the embedded item editor's save read, the permission matrix's package-door save re-read, and the list-view panel's read of a view that exists nowhere yet. None of them can be skipped with what its editor holds. The framework keeps answering 404 for a name with no stored layer, its author included (objectstack-ai/objectstack#22397, answer (a)), and `MetadataClient.layered()` already resolves that 404 as an envelope with every layer null, so none of these reads raises an error for it. The pins hold that in place: a draft-only item opens with no error state, a published item still gets its layers, and a failed read (a 5xx) still shows its error. No executable code changed (objectui#11799).
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectui#11799 — the embedded item editor's one `GET …/layers` read.
5+
*
6+
* `EmbeddedItemEditor` edits an item that lives inside a parent body
7+
* (`object.fields.amount`). It reads the parent's layered view once, when the
8+
* author saves, to splice the item back into the parent's current body. It
9+
* holds only the item snapshot and the parent's name, so nothing in hand says
10+
* whether the parent was ever published. The framework answers `/layers` with
11+
* a 404 for a parent that exists only as a draft, and keeps that answer by
12+
* design (objectstack-ai/objectstack#22397, answer (a)).
13+
*
14+
* The REAL editor over a REAL `MetadataClient`, whose transport is an
15+
* in-memory server answering as the framework does. Requests are counted on
16+
* it.
17+
*
18+
* Pinned:
19+
* - opening an item of a draft-only parent sends no request at all, so it
20+
* cannot show an error: the read belongs to the save, not to the open;
21+
* - CONTROL: a published parent's save reads its layers once and splices the
22+
* item into the effective body it got;
23+
* - CONTROL: a failed `/layers` read (a 5xx) is the save's error, and nothing
24+
* is PUT.
25+
*
26+
* Not covered, said here so it does not read as covered: what a SAVE does for
27+
* a draft-only parent. `MetadataClient.layered()` resolves the 404 as an
28+
* envelope with every layer null, so the read itself raises nothing; the save
29+
* then splices the item into an empty body and PUTs the parent in publish
30+
* mode. That PUT is outside objectui#11799 (its claim excludes it), and it is
31+
* not pinned here, so this file does not hold that body in place.
32+
*/
33+
34+
import '@testing-library/jest-dom/vitest';
35+
import * as React from 'react';
36+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
37+
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
38+
import { MetadataClient } from '@object-ui/data-objectstack';
39+
40+
const FIELD_SCHEMA = {
41+
type: 'object',
42+
properties: {
43+
label: { type: 'string', title: 'Label' },
44+
description: { type: 'string', title: 'Description' },
45+
},
46+
} satisfies Record<string, unknown>;
47+
48+
const server = {
49+
active: new Map<string, Record<string, unknown>>(),
50+
drafts: new Map<string, Record<string, unknown>>(),
51+
requests: [] as Array<{ method: string; path: string; search: string; status: number }>,
52+
puts: [] as Array<{ search: string; body: Record<string, unknown> }>,
53+
/** Rows whose `/layers` read fails with a 5xx. */
54+
failLayers: new Set<string>(),
55+
};
56+
57+
const json = (status: number, body: unknown) =>
58+
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
59+
60+
async function serve(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
61+
const method = (init?.method ?? 'GET').toUpperCase();
62+
const url = new URL(String(input), 'http://console.test');
63+
const q = url.searchParams;
64+
let status = 200;
65+
let body: unknown = [];
66+
const meta = url.pathname.match(/^\/api\/v1\/meta\/(.+)$/);
67+
if (meta) {
68+
const seg = meta[1].split('/').map(decodeURIComponent);
69+
const key = `${seg[0]}/${seg[1]}`;
70+
if (seg[0] === '_drafts') {
71+
body = { drafts: [...server.drafts.keys()].map((k) => ({ type: k.split('/')[0], name: k.split('/')[1], packageId: null })) };
72+
} else if (seg.length === 3 && seg[2] === 'layers' && server.failLayers.has(key)) {
73+
[status, body] = [500, { error: { code: 'INTERNAL_ERROR', message: 'metadata store unavailable' } }];
74+
} else if (seg.length === 3 && seg[2] === 'layers') {
75+
const row = server.active.get(key);
76+
if (row) body = { code: null, overlay: row, overlayScope: 'env', effective: row };
77+
else [status, body] = [404, { error: { code: 'NOT_FOUND', message: 'absent' } }];
78+
} else if (seg.length === 2 && method === 'PUT') {
79+
const item = JSON.parse(String(init?.body ?? '{}')) as Record<string, unknown>;
80+
server.puts.push({ search: url.search, body: item });
81+
if (q.get('mode') === 'draft') server.drafts.set(key, item);
82+
else server.active.set(key, item);
83+
body = { type: seg[0], name: seg[1], state: q.get('mode') === 'draft' ? 'draft' : 'active' };
84+
} else if (seg.length === 2) {
85+
const draftRead = q.get('state') === 'draft';
86+
const row = (draftRead ? server.drafts : server.active).get(key);
87+
if (row) body = { type: seg[0], name: seg[1], item: draftRead ? { ...row, _draft: true } : row };
88+
else [status, body] = [404, { error: { code: draftRead ? 'NO_DRAFT' : 'NOT_FOUND', message: 'absent' } }];
89+
}
90+
}
91+
server.requests.push({ method, path: url.pathname, search: url.search, status });
92+
return json(status, body);
93+
}
94+
95+
const client = new MetadataClient({ baseUrl: '', fetch: vi.fn(serve) as unknown as typeof fetch });
96+
97+
vi.mock('./useMetadata', () => ({
98+
useMetadataClient: () => client,
99+
useMetadataTypes: () => ({
100+
loading: false,
101+
error: null,
102+
entries: [{ type: 'field', label: 'Field', allowOrgOverride: true, schema: FIELD_SCHEMA }],
103+
}),
104+
}));
105+
106+
import { EmbeddedItemEditor } from './EmbeddedItemEditor';
107+
108+
const ORDER = {
109+
name: 'sales_order',
110+
label: 'Sales Order',
111+
fields: { amount: { type: 'number', label: 'Amount' }, region: { type: 'text', label: 'Region' } },
112+
};
113+
114+
beforeEach(() => {
115+
server.active.clear();
116+
server.drafts.clear();
117+
server.requests = [];
118+
server.puts = [];
119+
server.failLayers.clear();
120+
});
121+
afterEach(cleanup);
122+
123+
async function settle() {
124+
await act(async () => {
125+
for (let i = 0; i < 20; i++) await new Promise((r) => setTimeout(r, 0));
126+
});
127+
}
128+
129+
function openAmountField() {
130+
render(
131+
<EmbeddedItemEditor
132+
parentType="object"
133+
parentName="sales_order"
134+
embeddedPath="fields"
135+
itemName="amount"
136+
editAs="field"
137+
initialRaw={{ type: 'number', label: 'Amount' }}
138+
/>,
139+
);
140+
}
141+
142+
const saveButton = () => screen.getByRole('button', { name: /save into object/i });
143+
const layersStatuses = () =>
144+
server.requests.filter((r) => r.method === 'GET' && r.path === '/api/v1/meta/object/sales_order/layers').map((r) => r.status);
145+
146+
describe('EmbeddedItemEditor — the parent\'s /layers read belongs to the save (objectui#11799)', () => {
147+
it('opening an item of a draft-only parent sends no request, and shows no error', async () => {
148+
server.drafts.set('object/sales_order', ORDER);
149+
openAmountField();
150+
await settle();
151+
152+
expect(screen.getByRole('textbox', { name: 'Label' })).toHaveValue('Amount');
153+
expect(server.requests).toEqual([]);
154+
expect(screen.queryByText(/metadata store unavailable|Validation failed|absent/)).toBeNull();
155+
});
156+
157+
it('CONTROL: a published parent\'s save reads its layers once and splices the item into the body it got', async () => {
158+
server.active.set('object/sales_order', ORDER);
159+
openAmountField();
160+
fireEvent.change(screen.getByRole('textbox', { name: 'Label' }), { target: { value: 'Order amount' } });
161+
fireEvent.click(saveButton());
162+
163+
expect(await screen.findByText('Saved.')).toBeInTheDocument();
164+
expect(layersStatuses()).toEqual([200]);
165+
expect(server.puts).toHaveLength(1);
166+
expect(server.puts[0].body).toEqual({
167+
...ORDER,
168+
fields: { ...ORDER.fields, amount: { type: 'number', label: 'Order amount' } },
169+
});
170+
});
171+
172+
it('CONTROL: a failed /layers read (a 5xx) is the save\'s error, and nothing is PUT', async () => {
173+
server.active.set('object/sales_order', ORDER);
174+
server.failLayers.add('object/sales_order');
175+
openAmountField();
176+
fireEvent.click(saveButton());
177+
178+
expect(await screen.findByText('metadata store unavailable')).toBeInTheDocument();
179+
await waitFor(() => expect(saveButton()).not.toBeDisabled());
180+
expect(layersStatuses()).toEqual([500]);
181+
expect(server.puts).toEqual([]);
182+
expect(screen.queryByText('Saved.')).toBeNull();
183+
});
184+
});

‎packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.layersUnsaved-11799.test.tsx‎

Lines changed: 86 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,17 +25,23 @@
2525
* - CONTROL: the published set list is read once, by the editor's own read
2626
* of it; the load sends no second one.
2727
*
28-
* Not covered, said here so it does not read as covered: the package door's
29-
* SAVE still re-reads `/layers` first, and gets the 404 for such a set. That
30-
* read decides what other packages' rows a save must keep, and the draft in
31-
* hand does not say the set was never published; the list read at open is not
32-
* re-asked there.
28+
* The package door's SAVE still re-reads `/layers` first, and gets the 404 for
29+
* such a set. That read decides what other packages' rows a save must keep
30+
* (objectui#9420: only a fresh read keeps that promise), and the draft in hand
31+
* does not say the set was never published; the list read at open is not
32+
* re-asked there. The framework keeps that 404 by design
33+
* (objectstack-ai/objectstack#22397, answer (a)), and `MetadataClient.layered()`
34+
* resolves it as an envelope with every layer null, so the save reads it as
35+
* "never published": the draft is saved over its own slice, with no refusal
36+
* and no error. Pinned in the second describe below, through the real client,
37+
* with two controls: a published set's re-read keeps the other packages' rows,
38+
* and a failed re-read (a 5xx) still refuses the save.
3339
*/
3440

3541
import '@testing-library/jest-dom/vitest';
3642
import * as React from 'react';
3743
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
38-
import { act, cleanup, render, screen } from '@testing-library/react';
44+
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
3945
import { MemoryRouter } from 'react-router-dom';
4046
import { MetadataClient } from '@object-ui/data-objectstack';
4147

@@ -45,6 +51,10 @@ const server = {
4551
active: new Map<string, Record<string, unknown>>(),
4652
drafts: new Map<string, Record<string, unknown>>(),
4753
requests: [] as Array<{ method: string; path: string; search: string; status: number }>,
54+
/** Bodies the editor PUT, with their query strings. */
55+
puts: [] as Array<{ search: string; body: Record<string, unknown> }>,
56+
/** Rows whose `/layers` read fails with a 5xx. */
57+
failLayers: new Set<string>(),
4858
};
4959

5060
const json = (status: number, body: unknown) =>
@@ -66,10 +76,18 @@ async function serve(input: RequestInfo | URL, init?: RequestInit): Promise<Resp
6676
body = { items: [{ name: 'a_account' }] };
6777
} else if (seg.length === 1) {
6878
body = { items: [...server.active.entries()].filter(([k]) => k.startsWith(`${seg[0]}/`)).map(([, v]) => v) };
79+
} else if (seg.length === 3 && seg[2] === 'layers' && server.failLayers.has(key)) {
80+
[status, body] = [500, { error: { code: 'INTERNAL_ERROR', message: 'metadata store unavailable' } }];
6981
} else if (seg.length === 3 && seg[2] === 'layers') {
7082
const row = server.active.get(key);
7183
if (row) body = { code: null, overlay: row, overlayScope: 'env', effective: row };
7284
else [status, body] = [404, { error: { code: 'NOT_FOUND', message: 'absent' } }];
85+
} else if (seg.length === 2 && method === 'PUT') {
86+
const item = JSON.parse(String(init?.body ?? '{}')) as Record<string, unknown>;
87+
server.puts.push({ search: url.search, body: item });
88+
if (q.get('mode') === 'draft') server.drafts.set(key, item);
89+
else server.active.set(key, item);
90+
body = { type: seg[0], name: seg[1], state: q.get('mode') === 'draft' ? 'draft' : 'active' };
7391
} else if (seg.length === 2) {
7492
const draftRead = q.get('state') === 'draft';
7593
const row = (draftRead ? server.drafts : server.active).get(key);
@@ -123,6 +141,8 @@ beforeEach(() => {
123141
server.active.clear();
124142
server.drafts.clear();
125143
server.requests = [];
144+
server.puts = [];
145+
server.failLayers.clear();
126146
});
127147
afterEach(cleanup);
128148

@@ -158,3 +178,63 @@ describe('PermissionMatrixEditPage — a never-published set is not asked for /l
158178
expect(granted('Create')).toHaveAttribute('aria-checked', 'true');
159179
});
160180
});
181+
182+
const layersStatuses = () =>
183+
server.requests.filter((r) => r.method === 'GET' && r.path === '/api/v1/meta/permission/sales_perms/layers').map((r) => r.status);
184+
185+
/** Distinctive slice of the package door's refusal when its re-read fails (`perm.save.rereadFailed`). */
186+
const REFUSAL = /could not be re-read/;
187+
188+
/**
189+
* The package door has no Save button: an edit autosaves to the package draft
190+
* after the shared autosave's pause (objectui#11787), so a wait on a save
191+
* allows for that pause.
192+
*/
193+
const AUTOSAVE = { timeout: 4000 };
194+
195+
describe('PermissionMatrixEditPage — the package door\'s SAVE reads a /layers 404 as "never published" (objectui#11799)', () => {
196+
it('a set "+ New" created: the save\'s one /layers answers 404, and the draft is saved over its own slice, with no refusal', async () => {
197+
server.drafts.set('permission/sales_perms', set('Sales (draft)', true));
198+
await openSet();
199+
expect(layersStatuses()).toEqual([]);
200+
201+
fireEvent.click(granted('Create'));
202+
await waitFor(() => expect(server.puts).toHaveLength(1), AUTOSAVE);
203+
204+
expect(layersStatuses()).toEqual([404]);
205+
expect(server.puts[0].search).toBe(`?mode=draft&package=${PKG}`);
206+
expect(server.puts[0].body.objects).toEqual({ a_account: { allowRead: true, allowCreate: true } });
207+
expect(screen.queryByText(REFUSAL)).toBeNull();
208+
});
209+
210+
it('CONTROL: a published set\'s save re-reads its layers and keeps the rows another package contributed', async () => {
211+
server.active.set('permission/sales_perms', {
212+
...set('Sales', true),
213+
objects: { a_account: { allowRead: true, allowCreate: false }, b_order: { allowRead: true } },
214+
});
215+
await openSet();
216+
expect(layersStatuses()).toEqual([200]);
217+
218+
fireEvent.click(granted('Create'));
219+
await waitFor(() => expect(server.puts).toHaveLength(1), AUTOSAVE);
220+
221+
expect(layersStatuses()).toEqual([200, 200]);
222+
expect(server.puts[0].body.objects).toEqual({
223+
a_account: { allowRead: true, allowCreate: true },
224+
b_order: { allowRead: true },
225+
});
226+
expect(screen.queryByText(REFUSAL)).toBeNull();
227+
});
228+
229+
it('CONTROL: a failed re-read (a 5xx) still refuses the save, for a "+ New" set too', async () => {
230+
server.drafts.set('permission/sales_perms', set('Sales (draft)', true));
231+
server.failLayers.add('permission/sales_perms');
232+
await openSet();
233+
234+
fireEvent.click(granted('Create'));
235+
expect(await screen.findByText(REFUSAL, undefined, AUTOSAVE)).toBeInTheDocument();
236+
237+
expect(layersStatuses()).toEqual([500]);
238+
expect(server.puts).toEqual([]);
239+
});
240+
});

0 commit comments

Comments
 (0)