@@ -4,14 +4,15 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
44import {
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+
3552function 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
4865describe ( '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