Skip to content

Commit 0abd4f9

Browse files
fix(app-shell): the External Datasource panel unwraps the { success, data } envelope its routes answer (objectui#11628) (#11640)
Fixes #11628 Clause-②: no ## What changed `packages/app-shell/src/views/metadata-admin/external/api.ts` read the External Datasource routes as if they answered bare payloads. Every `/datasources/:name/external/*` route answers through the shared `sendOk`, so the payload sits under `data`. The client read `tables`, `draft`, `catalog` and `{ ok, results }` from the top of the body. As a result the panel listed no remote tables (`tables ?? []` turned the miss into an empty list), showed no catalog timestamp, crashed Validation on `results.length`, and the import dialog could not be reached. - A new `readExternalData` unwraps `data` once, for all four `/external/*` calls. Each caller then reads its own payload key from `data`. A 2xx body without the `{ success: true, data }` envelope is now an error that names the route, instead of being read as an empty answer. - The same reader's failure arm called `String(body.error)` on the ADR-0112 nested envelope, so every refusal read `[object Object]`. The 503 arm compared that same string with `external_service_unavailable`, a code the server no longer sends, so the "federation not enabled" hint could never show. Refusals now go through app-shell's existing `readEnvelopeFailureText`. The 503 is now recognised by the envelope's own `code`, `SERVICE_UNAVAILABLE`. This is the PM's mechanism assumption 2: measured below, it was the same envelope in the same function, so it is fixed in this PR. - `importObjectDraft` keeps its own reader, because it calls the `/meta` door, not an `/external/*` route. That door's success body is the save result, with no `data`. Its refusals come in two dialects, both measured: nested `{ error: { code, message } }` from the capability gate, and flat `{ error: 'SENTENCE', code }` from spec validation. The old reader handled only the flat one, and printed a non-admin's 403 as `[object Object]`. It now reads both. - `api.test.ts` uses the measured wire shapes. It covers all five calls, the 503 arm on each of the four routes, and both `/meta` refusal dialects. - No panel component changed. With the client fixed, the four consumers (`SchemaBrowser`, `ExternalDatasourcePanel`, `ValidationPanel`, `ImportObjectDialog`) render correctly as they are. ## Wire shapes, measured live (objectstack `main` at `0a348031`, stock showcase, `--fresh`) | Call | Route | Success body on the wire | Refusal body on the wire | Old client read | |---|---|---|---|---| | `listRemoteTables` | `GET /external/tables` | `{success:true,data:{tables:[customers,orders]}}` | `{success:false,error:{code,message}}` (401 `UNAUTHENTICATED`, 403 `PERMISSION_DENIED`) | `body.tables`, missing, so `[]` | | `generateObjectDraft` | `POST /external/tables/:remote/draft` | `{success:true,data:{draft:{...}}}` | 400 `EXTERNAL_DATASOURCE_ERROR` nested | `body.draft`, so `undefined` | | `refreshCatalog` | `POST /external/refresh-catalog` | `{success:true,data:{catalog:{snapshotAt,...}}}` | 400 `EXTERNAL_DATASOURCE_ERROR` nested | `body.catalog`, so `undefined` | | `validateDatasource` | `POST /external/validate` | `{success:true,data:{ok:true,results:[2 rows]}}` | 403 `PERMISSION_DENIED` nested | the whole envelope as `{ok,results}`, so `results` was `undefined` | | `importObjectDraft` | `PUT /meta/object/:name` | `{success:true,version,seq,state,message}` (no `data`, not read) | 403 `{error:{code:'FORBIDDEN',message}}` nested; 422 `INVALID_METADATA` and 400 `VALIDATION_ERROR` flat `{error:'SENTENCE',code}` | `String(body.error)`: right for flat, `[object Object]` for nested | The 503 `SERVICE_UNAVAILABLE` arm cannot be reached on the showcase, because it wires the `external-datasource` service. Its shape comes from the server's `unavailable` writer in `registerExternalDatasourceRoutes` (`sendError(res, 503, 'SERVICE_UNAVAILABLE', …)`) and is covered by the unit test. Live: NOT MEASURED. ## Live panel, before and after (same backend, console from this worktree) The console ran from this worktree under `vite`, proxied to the backend, at `/apps/setup/metadata/datasource/showcase_external`. "Before" is `api.ts` temporarily restored to the base `1c2e2c4` from the committed fix; restoring HEAD was confirmed by blob hash. Both runs blocked the dev-only `/api/v1/dev/metadata-events` stream; see Acceptance notes. | Step | Before (`1c2e2c4`) | After (this branch) | |---|---|---| | Tables tab | 0 rows, "No remote tables found (check the datasource's allowedSchemas)." | 2 rows: `customers` 7, `orders` 7 | | Refresh catalog | 200 with `data.catalog.snapshotAt`; no timestamp rendered | `snapshot 10/5/2026, 2:51:36 AM` rendered | | Import dialog, up to the draft read | unreachable (no row) | opens; `POST …/tables/customers/draft` 200; shows object name `customers` and the generated source | | Validation, Run validation | "Preview failed to render", `Cannot read properties of undefined (reading 'length')` | "All 2 objects match the remote schema." with `showcase_ext_customer` and `showcase_ext_order` | The real `api.ts` was also called inside the page against the same backend, as the admin and as the non-admin `auditor.demo@example.com` persona: | Call | Before | After | |---|---|---| | admin `generateObjectDraft(showcase_external, no_such_table)` | `Error: [object Object]` | `Remote table 'no_such_table' not found on datasource 'showcase_external'. (EXTERNAL_DATASOURCE_ERROR)` | | admin `refreshCatalog(no_such_ds)` | `Error: [object Object]` | `[ObjectQL] Datasource 'no_such_ds' has no registered driver to introspect. (EXTERNAL_DATASOURCE_ERROR)` | | auditor `listRemoteTables` / `validateDatasource` | `Error: [object Object]` | `Introspecting an external datasource requires the manage_platform_settings capability. (PERMISSION_DENIED)` (the server wraps the capability name in backticks; dropped here) | | auditor `importObjectDraft` | `Error: [object Object]` | `Saving a metadata item requires the manage_metadata capability. (FORBIDDEN)` | | admin `importObjectDraft` (name mismatch, flat 400) | the server's sentence | the same sentence (unchanged) | ## PM mechanism assumptions 1. Confirmed, and extended to the fifth call. The table above lists every call in the file and the shape each one read. 2. Confirmed as a misread, live. Every refusal on these routes printed `[object Object]`, and the 503 hint never fired. The fix is in this PR, in the same reader. 3. For refusals there was already a helper, and this PR reuses it: `readEnvelopeFailureText` (`packages/app-shell/src/utils/apiErrorEnvelope.ts`, same package, no new export). For success bodies app-shell has no shared unwrap helper. `@object-ui/data-objectstack`'s `unwrapDispatcherEnvelope` is module-private. It is also deliberately lenient (it returns the body as-is when there is no `data`), and that is the tolerance this card's defect hid behind. Reusing it would need a new package export, which the Clause-② fence rules out. So the strict unwrap lives in this module. 4. The backend ran from this worker's own objectstack worktree at `main` `0a348031`, built with `OS_SKIP_DTS=1` under the verify lock. The shared checkout was not touched. ## Tests All gates below ran on the merged head `4779053`: this branch plus one merge of `origin/main` at `22ddcd5`. Each exit code was written to a file as it ran. - **Unit tests for this module, red before and green after.** `pnpm exec vitest run packages/app-shell/src/views/metadata-admin/external/api.test.ts` - Fix committed (`790b3b1`), then `api.ts` restored to base `1c2e2c4`: `Tests 12 failed | 4 passed (16)`, exit 1. The failures were `expected [] to deeply equal [customers, orders]`, `Cannot read properties of undefined (reading 'name')`, `expected undefined to deeply equal` the catalog, the envelope returned as `{ok,results}`, `Error: [object Object]` where `ExternalServiceUnavailableError` was expected, and `'[object Object]'` in the message. - `git checkout HEAD -- api.ts` put the fix back; the blob hash matched HEAD and `git diff HEAD` was empty. Re-run: `Tests 16 passed (16)`, exit 0. - The four tests that pass on the old client are the `?schema` passthrough, the HTTP-status fallback, the `/meta` 2xx, and the flat `/meta` refusal. The old client already handled each of these. - **`pnpm exec vitest run packages/app-shell/ --maxWorkers=2`** (root form, under the verify lock): `Test Files 1000 passed | 1 skipped (1001)`, `Tests 9940 passed | 9 skipped (9949)`, exit 0. - **`pnpm --filter @object-ui/app-shell type-check`** (`tsc --noEmit && tsc -p tsconfig.test.json`; the second config includes `src/**/*.test.ts`), after `turbo run build --filter='@object-ui/app-shell^...'`: exit 0. - **`pnpm --filter @object-ui/app-shell lint`**: exit 0, `0 errors`. The warnings are pre-existing, and none is in `external/api.ts` or `api.test.ts`. - **Root checks whose subject this diff touches:** all exit 0. - `check:metadata-write-doors` reads `importObjectDraft` as a raw-PUT door and prints `OK 17 metadata write door(s) derived (3 raw PUT, 14 SDK) … 3 reach assertObjectMetadataWritable`. - `check:new-line-citations` prints `0 new citation(s)`. - The rest: `check:control-bytes`, `check:test-path-roots`, `check:vi-mock-specifiers`, `check:vi-mock-inherit`, `check:vi-mock-override-shape`, `check:changeset-claims`, `check:pending-changeset-literals`, `check:self-import`, `check:esm-specifiers`, `check:shell-escape-residue`, `check:unreferenced-sources`, `check:phantom-deps`, `check:unused-deps`, `check:comment-mask-corpus`, `scripts/check-changeset-presence.mjs` and `scripts/check-changeset-no-major.mjs`. - `node scripts/check-governed-queue-guard.mjs --test` on the three paths reports `NOT GOVERNED`. - The repo-wide `pnpm lint` and the full test farm are left to CI. ## Acceptance notes - **Dev-only reload after "Refresh catalog" (observation, not filed).** With the console under `vite` dev and the backend under `objectstack dev`, "Refresh catalog" writes the catalog snapshot. The backend then emits a `metadata-change` event on `/api/v1/dev/metadata-events`, and `apps/console`'s `MetadataHmrReloader` reloads the page about 0.6 s later. That wipes the panel state, timestamp included. The reloader is enabled by `import.meta.env.DEV`, so by its own default the published console (`/_console`) does not run it (read from source; not measured on `/_console`). The live measurement blocked that stream to see the panel as the published console behaves. Owner: none. - `listRemoteTables` no longer applies `?? []`. With the envelope checked first, that fallback could only hide a `data` without `tables`, and the server always sends `tables`. - objectui's import dialog saves through `PUT /meta/object/:name`, not the server's `POST /external/tables/:remote/import`. objectstack-ai/objectstack#21788 (the server-side twin) is about that POST route and stays open; this PR does not touch it. - Behaviour change in `importObjectDraft`: a 2xx no longer parses the body, because nothing reads it. A 2xx with an unparseable body used to reject. --- _Generated by [Claude Code](https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c4c506b commit 0abd4f9

3 files changed

Lines changed: 315 additions & 77 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
The External Datasource panel in Setup and Studio reads the `{ success, data }` envelope its routes answer (objectui#11628). On a federated datasource such as the showcase's `showcase_external`, the Tables tab listed no remote tables. "Refresh catalog" showed no snapshot time, and "Run validation" replaced the panel with a render error (`Cannot read properties of undefined (reading 'length')`). Because the list was empty, the import dialog could not be opened. The client read `tables`, `draft`, `catalog` and the validation verdict from the top of the body, but the server puts each one under `data`. The panel now lists the remote tables, shows the snapshot time after a refresh, renders the validation rows, and opens the import dialog on the generated draft.
6+
7+
Refusals on these routes now reach the user. Every refusal used to read `[object Object]`, because the client turned the ADR-0112 `{ error: { code, message } }` object into a string. That covered a missing capability, an unknown remote table, an unreachable datasource, and a capability refusal from "Import as Object". The panel now shows the server's message and code. The "federation is not enabled on this server" hint now appears when the server answers `503 SERVICE_UNAVAILABLE`. Before, it never did, because the client compared against a code the server no longer sends.
8+
9+
A successful response that is not the `{ success: true, data }` envelope is now an error that names the route. Before, the panel showed it as an empty list.
10+
11+
**Clause-②: no.** Nothing is added to or removed from the package entry. The changed module is not exported from it.

‎packages/app-shell/src/views/metadata-admin/external/api.test.ts‎

Lines changed: 196 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,15 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
44
import {
55
listRemoteTables,
66
generateObjectDraft,
7+
refreshCatalog,
78
validateDatasource,
89
importObjectDraft,
910
ExternalServiceUnavailableError,
1011
type ObjectDraft,
1112
} from './api';
1213

1314
/**
14-
* Build a minimal Response-like object the client's `jsonOrThrow` accepts.
15+
* Build a minimal Response-like object the client's readers accept.
1516
*
1617
* `headers` is real (objectui#4467): these calls go out through
1718
* `createAuthenticatedFetch`, which reads `set-auth-token` off every API
@@ -32,6 +33,22 @@ function jsonResponse(body: unknown, init: { status?: number; ok?: boolean } = {
3233
} as unknown as Response;
3334
}
3435

36+
/*
37+
* The WIRE shapes of the five `/datasources/:name/external/*` routes
38+
* (objectui#11628). Every body there is written by the shared `sendOk` /
39+
* `sendError` of `@objectstack/types` (`registerExternalDatasourceRoutes` in
40+
* `@objectstack/rest`): a success is `{ success: true, data }` with the
41+
* route's payload one level down, and a refusal is the ADR-0112 envelope
42+
* `{ success: false, error: { code, message } }`.
43+
*
44+
* These fixtures used to be the BARE payloads (`{ tables }`, `{ draft }`, a
45+
* string `error`), which is the shape the client read and the server never
46+
* sent — so this file stayed green while the live panel listed no tables,
47+
* showed no catalog timestamp and crashed on Validation.
48+
*/
49+
const ok = (data: unknown) => ({ success: true, data });
50+
const refusal = (code: string, message: string) => ({ success: false, error: { code, message } });
51+
3552
function stubFetch(impl: (url: string, init?: RequestInit) => Response | Promise<Response>) {
3653
const spy = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) =>
3754
impl(typeof input === 'string' ? input : String(input), init),
@@ -46,50 +63,154 @@ afterEach(() => {
4663
});
4764

4865
describe('external datasource api', () => {
49-
it('lists remote tables and unwraps the { tables } envelope', async () => {
66+
it('lists remote tables out of the { success, data: { tables } } envelope', async () => {
5067
const spy = stubFetch(() =>
51-
jsonResponse({ tables: [{ schema: 'public', name: 'orders', columnCount: 7 }] }),
68+
jsonResponse(
69+
ok({
70+
tables: [
71+
{ name: 'customers', columnCount: 7 },
72+
{ name: 'orders', columnCount: 7 },
73+
],
74+
}),
75+
),
5276
);
53-
const tables = await listRemoteTables('warehouse');
54-
expect(tables).toEqual([{ schema: 'public', name: 'orders', columnCount: 7 }]);
77+
const tables = await listRemoteTables('showcase_external');
78+
expect(tables).toEqual([
79+
{ name: 'customers', columnCount: 7 },
80+
{ name: 'orders', columnCount: 7 },
81+
]);
5582
const url = String(spy.mock.calls[0][0]);
56-
expect(url).toContain('/api/v1/datasources/warehouse/external/tables');
83+
expect(url).toContain('/api/v1/datasources/showcase_external/external/tables');
5784
});
5885

5986
it('passes the ?schema filter through', async () => {
60-
const spy = stubFetch(() => jsonResponse({ tables: [] }));
61-
await listRemoteTables('warehouse', { schema: 'analytics' });
87+
const spy = stubFetch(() => jsonResponse(ok({ tables: [] })));
88+
await expect(listRemoteTables('warehouse', { schema: 'analytics' })).resolves.toEqual([]);
6289
expect(String(spy.mock.calls[0][0])).toContain('tables?schema=analytics');
6390
});
6491

65-
it('maps 503 external_service_unavailable to a typed error', async () => {
66-
stubFetch(() => jsonResponse({ error: 'external_service_unavailable' }, { status: 503, ok: false }));
67-
await expect(listRemoteTables('warehouse')).rejects.toBeInstanceOf(
68-
ExternalServiceUnavailableError,
69-
);
70-
});
71-
72-
it('surfaces a generic server error message', async () => {
73-
stubFetch(() => jsonResponse({ error: 'boom' }, { status: 500, ok: false }));
74-
await expect(validateDatasource('warehouse')).rejects.toThrow('boom');
75-
});
76-
77-
it('POSTs draft generation with the remote schema option', async () => {
92+
it('POSTs draft generation with the remote schema option and reads data.draft', async () => {
7893
const spy = stubFetch(() =>
79-
jsonResponse({
80-
draft: { name: 'orders', datasource: 'warehouse', definition: {}, source: '', review: [] },
81-
}),
94+
jsonResponse(
95+
ok({
96+
draft: { name: 'orders', datasource: 'warehouse', definition: {}, source: '', review: [] },
97+
}),
98+
),
8299
);
83100
const draft = await generateObjectDraft('warehouse', 'orders', { remoteSchema: 'public' });
84101
expect(draft.name).toBe('orders');
102+
expect(draft.review).toEqual([]);
85103
const [url, init] = spy.mock.calls[0];
86104
expect(String(url)).toContain('/external/tables/orders/draft');
87105
expect(init?.method).toBe('POST');
88106
expect(JSON.parse(String(init?.body))).toEqual({ remoteSchema: 'public' });
89107
});
90108

109+
it('refreshes the catalog and reads data.catalog, snapshotAt included', async () => {
110+
const catalog = {
111+
name: 'showcase_external_catalog',
112+
datasource: 'showcase_external',
113+
snapshotAt: '2026-10-05T02:00:00.000Z',
114+
tables: [],
115+
};
116+
const spy = stubFetch(() => jsonResponse(ok({ catalog })));
117+
const got = await refreshCatalog('showcase_external');
118+
expect(got).toEqual(catalog);
119+
expect(got.snapshotAt).toBe('2026-10-05T02:00:00.000Z');
120+
const [url, init] = spy.mock.calls[0];
121+
expect(String(url)).toContain('/datasources/showcase_external/external/refresh-catalog');
122+
expect(init?.method).toBe('POST');
123+
});
124+
125+
it('validates and reads the { ok, results } verdict out of data', async () => {
126+
const results = [
127+
{ object: 'showcase_ext_customer', ok: true, diffs: [] },
128+
{ object: 'showcase_ext_order', ok: true, diffs: [] },
129+
];
130+
const spy = stubFetch(() => jsonResponse(ok({ ok: true, results })));
131+
const report = await validateDatasource('showcase_external');
132+
expect(report).toEqual({ ok: true, results });
133+
expect(report.results).toHaveLength(2);
134+
const [url, init] = spy.mock.calls[0];
135+
expect(String(url)).toContain('/datasources/showcase_external/external/validate');
136+
expect(init?.method).toBe('POST');
137+
});
138+
139+
it('refuses a 200 that is not the declared envelope instead of reading it as empty', async () => {
140+
// The bare payload this file used to mock. Read through the old client it
141+
// was "no tables"; it is not a body these routes send, so it is a loud
142+
// failure naming the route rather than an empty list.
143+
stubFetch(() => jsonResponse({ tables: [{ name: 'orders', columnCount: 7 }] }));
144+
const err = await listRemoteTables('warehouse').then(
145+
() => null,
146+
(e: unknown) => e,
147+
);
148+
expect(err).toBeInstanceOf(Error);
149+
expect(err).not.toBeInstanceOf(ExternalServiceUnavailableError);
150+
expect((err as Error).message).toContain('/external/tables');
151+
});
152+
153+
it.each([
154+
['listRemoteTables', () => listRemoteTables('warehouse')],
155+
['generateObjectDraft', () => generateObjectDraft('warehouse', 'orders')],
156+
['refreshCatalog', () => refreshCatalog('warehouse')],
157+
['validateDatasource', () => validateDatasource('warehouse')],
158+
])('%s maps 503 SERVICE_UNAVAILABLE to the typed error', async (_name, call) => {
159+
stubFetch(() =>
160+
jsonResponse(
161+
refusal('SERVICE_UNAVAILABLE', 'The external-datasource service is not available.'),
162+
{ status: 503 },
163+
),
164+
);
165+
await expect(call()).rejects.toBeInstanceOf(ExternalServiceUnavailableError);
166+
});
167+
168+
it('surfaces the refusal envelope message and code, never "[object Object]"', async () => {
169+
stubFetch(() =>
170+
jsonResponse(refusal('EXTERNAL_DATASOURCE_ERROR', 'no such schema: analytics'), {
171+
status: 400,
172+
}),
173+
);
174+
const err = await validateDatasource('warehouse').then(
175+
() => null,
176+
(e: unknown) => e,
177+
);
178+
expect(err).toBeInstanceOf(Error);
179+
expect(err).not.toBeInstanceOf(ExternalServiceUnavailableError);
180+
expect((err as Error).message).not.toContain('[object Object]');
181+
expect((err as Error).message).toContain('no such schema: analytics');
182+
expect((err as Error).message).toContain('EXTERNAL_DATASOURCE_ERROR');
183+
});
184+
185+
it('a 503 whose envelope names another code is not read as "federation not wired"', async () => {
186+
stubFetch(() => jsonResponse(refusal('INTERNAL_ERROR', 'Internal server error'), { status: 503 }));
187+
const err = await listRemoteTables('warehouse').then(
188+
() => null,
189+
(e: unknown) => e,
190+
);
191+
expect(err).toBeInstanceOf(Error);
192+
expect(err).not.toBeInstanceOf(ExternalServiceUnavailableError);
193+
expect((err as Error).message).toContain('INTERNAL_ERROR');
194+
});
195+
196+
it('falls back to the HTTP status when a failure carries no readable body', async () => {
197+
stubFetch(() => ({
198+
ok: false,
199+
status: 502,
200+
statusText: 'Bad Gateway',
201+
headers: new Headers(),
202+
json: async () => {
203+
throw new SyntaxError('Unexpected token <');
204+
},
205+
}) as unknown as Response);
206+
await expect(refreshCatalog('warehouse')).rejects.toThrow('502 Bad Gateway');
207+
});
208+
91209
it('imports a draft as an object via PUT /meta/object/:name', async () => {
92-
const spy = stubFetch(() => jsonResponse({ ok: true }));
210+
// The `/meta` door's own success body — the save result, no `data`.
211+
const spy = stubFetch(() =>
212+
jsonResponse({ success: true, version: 'v1', seq: 1, state: 'active', message: 'Saved' }),
213+
);
93214
const draft: ObjectDraft = {
94215
name: 'orders',
95216
datasource: 'warehouse',
@@ -103,4 +224,54 @@ describe('external datasource api', () => {
103224
expect(init?.method).toBe('PUT');
104225
expect(JSON.parse(String(init?.body))).toEqual({ name: 'orders', label: 'Orders' });
105226
});
227+
228+
/*
229+
* The `/meta` door answers a refused save in two dialects, both measured
230+
* live: the capability gate's nested `{ error: { code, message } }` and the
231+
* spec-validation flat `{ error: '<sentence>', code }`.
232+
*/
233+
const importDraft: ObjectDraft = {
234+
name: 'orders',
235+
datasource: 'warehouse',
236+
definition: { name: 'orders', label: 'Orders' },
237+
source: '',
238+
review: [],
239+
};
240+
241+
it('surfaces the /meta capability refusal (nested error) instead of "[object Object]"', async () => {
242+
stubFetch(() =>
243+
jsonResponse(
244+
{
245+
error: {
246+
code: 'FORBIDDEN',
247+
message: 'Saving a metadata item requires the `manage_metadata` capability.',
248+
},
249+
},
250+
{ status: 403 },
251+
),
252+
);
253+
const err = await importObjectDraft(importDraft).then(
254+
() => null,
255+
(e: unknown) => e,
256+
);
257+
expect(err).toBeInstanceOf(Error);
258+
expect((err as Error).message).not.toContain('[object Object]');
259+
expect((err as Error).message).toContain('manage_metadata');
260+
expect((err as Error).message).toContain('FORBIDDEN');
261+
});
262+
263+
it('surfaces the /meta spec-validation refusal (flat string error)', async () => {
264+
stubFetch(() =>
265+
jsonResponse(
266+
{
267+
error: 'object/orders failed spec validation: 1 issue — fields [invalid_type]',
268+
code: 'INVALID_METADATA',
269+
},
270+
{ status: 422 },
271+
),
272+
);
273+
await expect(importObjectDraft(importDraft)).rejects.toThrow(
274+
'object/orders failed spec validation',
275+
);
276+
});
106277
});

0 commit comments

Comments
 (0)