@@ -26,6 +26,22 @@ function mount(svc: unknown) {
2626 return server . getRawApp ( ) ;
2727}
2828
29+ /**
30+ * Mount with a service PER NAME, unlike `mount` above, which answers the same
31+ * object for every lookup.
32+ *
33+ * That difference is the point: this module dispatches to two services, and a
34+ * context that cannot tell them apart cannot show which one a route resolved.
35+ * It is what let #4225 sit here — the 503 named `datasource-admin` on all nine
36+ * routes, three of which resolve `external-datasource`, and no test could see it.
37+ */
38+ function mountServices ( services : Record < string , unknown > ) {
39+ const server = new HonoHttpServer ( 0 ) ;
40+ const ctx = { getService : vi . fn ( ( name : string ) => services [ name ] ) } as any ;
41+ registerDatasourceAdminRoutes ( server , ctx , '/api/v1' ) ;
42+ return server . getRawApp ( ) ;
43+ }
44+
2945describe ( 'registerDatasourceAdminRoutes (real HonoHttpServer)' , ( ) => {
3046 it ( 'GET /api/v1/datasources returns the service listing' , async ( ) => {
3147 const listDatasources = vi . fn ( ) . mockResolvedValue ( [
@@ -136,10 +152,19 @@ describe('registerDatasourceAdminRoutes (real HonoHttpServer)', () => {
136152 expect ( await res . json ( ) ) . toEqual ( { success : true , data : { datasource : { name : 'pg' , origin : 'runtime' } } } ) ;
137153 } ) ;
138154
139- it ( 'degrades to 503 when the datasource-admin service is not wired' , async ( ) => {
140- const app = mount ( undefined ) ;
155+ it ( 'degrades to 503 when the service registry THROWS, not just when it answers undefined' , async ( ) => {
156+ // The other arm of the resolver's try/catch. `getService` throwing on an
157+ // unregistered name is the shape a real `PluginContext` has; every mock in
158+ // this file returns `undefined` instead, so nothing else drives this branch.
159+ const server = new HonoHttpServer ( 0 ) ;
160+ const ctx = {
161+ getService : vi . fn ( ( ) => {
162+ throw new Error ( 'service "datasource-admin" is not registered' ) ;
163+ } ) ,
164+ } as any ;
165+ registerDatasourceAdminRoutes ( server , ctx , '/api/v1' ) ;
141166
142- const res = await app . fetch ( json ( '/api/v1/datasources' ) ) ;
167+ const res = await server . getRawApp ( ) . fetch ( json ( '/api/v1/datasources' ) ) ;
143168
144169 expect ( res . status ) . toBe ( 503 ) ;
145170 expect ( await res . json ( ) ) . toEqual ( {
@@ -151,6 +176,69 @@ describe('registerDatasourceAdminRoutes (real HonoHttpServer)', () => {
151176 } ) ;
152177 } ) ;
153178
179+ /**
180+ * #4225 — the 503 names the service the route ACTUALLY resolves.
181+ *
182+ * Every route below is listed with the service it dispatches to, so the table
183+ * is the module's service map as well as its test: a new route that resolves
184+ * one service and reports the other has to disagree with a row here.
185+ */
186+ const UNAVAILABLE : Array < { route : string ; service : string ; run : ( app : any ) => Promise < Response > } > = [
187+ { route : 'GET /datasources' , service : 'datasource-admin' , run : ( a ) => a . fetch ( json ( '/api/v1/datasources' ) ) } ,
188+ { route : 'GET /datasources/:name' , service : 'datasource-admin' , run : ( a ) => a . fetch ( json ( '/api/v1/datasources/pg' ) ) } ,
189+ { route : 'POST /datasources/test' , service : 'datasource-admin' , run : ( a ) => a . fetch ( json ( '/api/v1/datasources/test' , { method : 'POST' , body : '{}' } ) ) } ,
190+ { route : 'POST /datasources' , service : 'datasource-admin' , run : ( a ) => a . fetch ( json ( '/api/v1/datasources' , { method : 'POST' , body : '{}' } ) ) } ,
191+ { route : 'PATCH /datasources/:name' , service : 'datasource-admin' , run : ( a ) => a . fetch ( json ( '/api/v1/datasources/pg' , { method : 'PATCH' , body : '{}' } ) ) } ,
192+ { route : 'DELETE /datasources/:name' , service : 'datasource-admin' , run : ( a ) => a . fetch ( json ( '/api/v1/datasources/pg' , { method : 'DELETE' } ) ) } ,
193+ { route : 'GET /datasources/:name/remote-tables' , service : 'external-datasource' , run : ( a ) => a . fetch ( json ( '/api/v1/datasources/ext/remote-tables' ) ) } ,
194+ { route : 'POST /datasources/:name/test' , service : 'external-datasource' , run : ( a ) => a . fetch ( json ( '/api/v1/datasources/ext/test' , { method : 'POST' , body : '{}' } ) ) } ,
195+ { route : 'POST /datasources/:name/object-draft' , service : 'external-datasource' , run : ( a ) => a . fetch ( json ( '/api/v1/datasources/ext/object-draft' , { method : 'POST' , body : JSON . stringify ( { table : 'customers' } ) } ) ) } ,
196+ ] ;
197+
198+ for ( const c of UNAVAILABLE ) {
199+ it ( `${ c . route } degrades to 503 naming ${ c . service } (#4225)` , async ( ) => {
200+ const res = await c . run ( mountServices ( { } ) ) ;
201+ expect ( res . status ) . toBe ( 503 ) ;
202+ expect ( await res . json ( ) ) . toEqual ( {
203+ success : false ,
204+ error : {
205+ code : 'SERVICE_UNAVAILABLE' ,
206+ message : `The ${ c . service } service is not available.` ,
207+ } ,
208+ } ) ;
209+ } ) ;
210+ }
211+
212+ it ( 'a wired datasource-admin does not answer for an unwired external-datasource (#4225)' , async ( ) => {
213+ // The operator's actual situation: lifecycle works, federation does not. The
214+ // old message sent them to read the logs of the service that was running.
215+ const app = mountServices ( {
216+ 'datasource-admin' : {
217+ listDatasources : async ( ) => [ { name : 'ext' , origin : 'runtime' } ] ,
218+ getDatasource : async ( ) => ( { name : 'ext' , driver : 'sqlite' } ) ,
219+ testConnection : async ( ) => ( { ok : true } ) ,
220+ } ,
221+ // 'external-datasource' deliberately absent.
222+ } ) ;
223+
224+ expect ( ( await app . fetch ( json ( '/api/v1/datasources' ) ) ) . status ) . toBe ( 200 ) ;
225+
226+ const remote = await app . fetch ( json ( '/api/v1/datasources/ext/remote-tables' ) ) ;
227+ expect ( remote . status ) . toBe ( 503 ) ;
228+ expect ( ( ( await remote . json ( ) ) as any ) . error . message ) . toBe (
229+ 'The external-datasource service is not available.' ,
230+ ) ;
231+
232+ // `POST /:name/test` resolves `external-datasource` even though its unsaved-draft
233+ // sibling `POST /test` resolves `datasource-admin` — the wired admin service
234+ // above has a `testConnection`, and it must not answer for this route.
235+ const saved = await app . fetch ( json ( '/api/v1/datasources/ext/test' , { method : 'POST' , body : '{}' } ) ) ;
236+ expect ( saved . status ) . toBe ( 503 ) ;
237+ expect ( ( ( await saved . json ( ) ) as any ) . error . message ) . toBe (
238+ 'The external-datasource service is not available.' ,
239+ ) ;
240+ } ) ;
241+
154242 it ( 'surfaces lifecycle errors as 400 with the service message' , async ( ) => {
155243 const createDatasource = vi . fn ( ) . mockRejectedValue ( new Error ( 'duplicate name' ) ) ;
156244 const app = mount ( { createDatasource } ) ;
0 commit comments