|
| 1 | +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * Error-envelope conformance for the autonomously-mounted `/api/v1/i18n/*` |
| 5 | + * routes (#3675) — the error-path twin of the success-path fix in #3636. |
| 6 | + * |
| 7 | + * #3636 aligned the SUCCESS bodies because those were the ones breaking |
| 8 | + * `ObjectStackClient.unwrapResponse`. The error bodies stayed a bare |
| 9 | + * `{ error: '<message>' }` while the dispatcher's `/i18n` domain — the OTHER |
| 10 | + * provider of these same three shapes — emitted |
| 11 | + * `{ success: false, error: { … } }` for the identical condition. Same SDK |
| 12 | + * method, two error shapes, decided by which plugin mounted the route. |
| 13 | + * |
| 14 | + * Both directions are guarded: every error branch is driven and parsed against |
| 15 | + * the real `BaseResponseSchema`, and the module source is scanned so a new |
| 16 | + * route cannot quietly reintroduce the bare shape. |
| 17 | + */ |
| 18 | + |
| 19 | +import { describe, it, expect, vi } from 'vitest'; |
| 20 | +import { readFileSync } from 'node:fs'; |
| 21 | +import { BaseResponseSchema } from '@objectstack/spec/api'; |
| 22 | +import type { IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts'; |
| 23 | +import { I18nServicePlugin } from './i18n-service-plugin'; |
| 24 | + |
| 25 | +const BASE = '/api/v1/i18n'; |
| 26 | + |
| 27 | +/** |
| 28 | + * Mount the plugin's routes through its REAL lifecycle and hand back the |
| 29 | + * handlers, so these assertions run against the routes the plugin actually |
| 30 | + * registers rather than a hand-copied table. |
| 31 | + */ |
| 32 | +async function mount(i18nOverrides: Record<string, unknown> = {}) { |
| 33 | + const routes = new Map<string, RouteHandler>(); |
| 34 | + const server = { |
| 35 | + get: (p: string, h: RouteHandler) => { routes.set(`GET:${p}`, h); }, |
| 36 | + post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), use: vi.fn(), |
| 37 | + listen: vi.fn().mockResolvedValue(undefined), |
| 38 | + close: vi.fn().mockResolvedValue(undefined), |
| 39 | + }; |
| 40 | + const hooks = new Map<string, Array<(...a: unknown[]) => Promise<void>>>(); |
| 41 | + const services = new Map<string, unknown>(); |
| 42 | + const ctx = { |
| 43 | + registerService: vi.fn((name: string, svc: unknown) => { services.set(name, svc); }), |
| 44 | + replaceService: vi.fn(), |
| 45 | + getService: vi.fn((name: string) => { |
| 46 | + if (name === 'http-server') return server; |
| 47 | + if (services.has(name)) return services.get(name); |
| 48 | + throw new Error(`Service '${name}' not found`); |
| 49 | + }), |
| 50 | + getServices: vi.fn(() => new Map()), |
| 51 | + getKernel: vi.fn(), |
| 52 | + hook: vi.fn((name: string, h: (...a: unknown[]) => Promise<void>) => { |
| 53 | + if (!hooks.has(name)) hooks.set(name, []); |
| 54 | + hooks.get(name)!.push(h); |
| 55 | + }), |
| 56 | + trigger: vi.fn(async (name: string, ...a: unknown[]) => { |
| 57 | + for (const h of hooks.get(name) ?? []) await h(...a); |
| 58 | + }), |
| 59 | + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, |
| 60 | + }; |
| 61 | + |
| 62 | + const plugin = new I18nServicePlugin(); |
| 63 | + await plugin.init!(ctx as never); |
| 64 | + // Sabotage the registered service where a test needs the `catch` arm — the |
| 65 | + // routes close over whatever `init` registered. |
| 66 | + const svc = services.get('i18n') as Record<string, unknown> | undefined; |
| 67 | + if (svc) Object.assign(svc, i18nOverrides); |
| 68 | + await plugin.start!(ctx as never); |
| 69 | + await ctx.trigger('kernel:ready'); |
| 70 | + return routes; |
| 71 | +} |
| 72 | + |
| 73 | +async function drive( |
| 74 | + routes: Map<string, RouteHandler>, |
| 75 | + path: string, |
| 76 | + req: Partial<IHttpRequest> = {}, |
| 77 | +): Promise<{ status: number; body: any }> { |
| 78 | + const handler = routes.get(`GET:${path}`); |
| 79 | + if (!handler) throw new Error(`no handler for GET ${path} (have: ${[...routes.keys()].join(', ')})`); |
| 80 | + const captured = { status: 200, body: undefined as any }; |
| 81 | + const res: any = { |
| 82 | + json(data: any) { captured.body = data; }, |
| 83 | + send() {}, |
| 84 | + status(code: number) { captured.status = code; return res; }, |
| 85 | + header() { return res; }, |
| 86 | + }; |
| 87 | + await handler( |
| 88 | + { params: {}, query: {}, body: undefined, headers: {}, method: 'GET', path, ...req } as IHttpRequest, |
| 89 | + res as IHttpResponse, |
| 90 | + ); |
| 91 | + return captured; |
| 92 | +} |
| 93 | + |
| 94 | +describe('i18n error envelope (#3675)', () => { |
| 95 | + const CASES: Array<{ |
| 96 | + name: string; |
| 97 | + status: number; |
| 98 | + code: string; |
| 99 | + run: () => Promise<{ status: number; body: any }>; |
| 100 | + }> = [ |
| 101 | + { |
| 102 | + name: 'translations without a locale', |
| 103 | + status: 400, |
| 104 | + code: 'INVALID_REQUEST', |
| 105 | + run: async () => drive(await mount(), `${BASE}/translations/:locale`, { params: {} }), |
| 106 | + }, |
| 107 | + { |
| 108 | + name: 'labels without an object or locale', |
| 109 | + status: 400, |
| 110 | + code: 'INVALID_REQUEST', |
| 111 | + run: async () => drive(await mount(), `${BASE}/labels/:object/:locale`, { params: {} }), |
| 112 | + }, |
| 113 | + { |
| 114 | + name: 'a throwing i18n service on /locales', |
| 115 | + status: 500, |
| 116 | + code: 'INTERNAL', |
| 117 | + run: async () => |
| 118 | + drive( |
| 119 | + await mount({ getLocales: () => { throw new Error('adapter exploded'); } }), |
| 120 | + `${BASE}/locales`, |
| 121 | + ), |
| 122 | + }, |
| 123 | + { |
| 124 | + name: 'a throwing i18n service on /translations/:locale', |
| 125 | + status: 500, |
| 126 | + code: 'INTERNAL', |
| 127 | + run: async () => |
| 128 | + drive( |
| 129 | + await mount({ getTranslations: () => { throw new Error('adapter exploded'); } }), |
| 130 | + `${BASE}/translations/:locale`, |
| 131 | + { params: { locale: 'en' } }, |
| 132 | + ), |
| 133 | + }, |
| 134 | + { |
| 135 | + name: 'a throwing i18n service on /labels/:object/:locale', |
| 136 | + status: 500, |
| 137 | + code: 'INTERNAL', |
| 138 | + run: async () => |
| 139 | + drive( |
| 140 | + await mount({ |
| 141 | + getFieldLabels: () => { throw new Error('adapter exploded'); }, |
| 142 | + getTranslations: () => { throw new Error('adapter exploded'); }, |
| 143 | + }), |
| 144 | + `${BASE}/labels/:object/:locale`, |
| 145 | + { params: { object: 'customer', locale: 'en' } }, |
| 146 | + ), |
| 147 | + }, |
| 148 | + ]; |
| 149 | + |
| 150 | + for (const c of CASES) { |
| 151 | + it(`${c.name} → ${c.status} ${c.code}, in the declared envelope`, async () => { |
| 152 | + const { status, body } = await c.run(); |
| 153 | + expect(status).toBe(c.status); |
| 154 | + |
| 155 | + const parsed = BaseResponseSchema.safeParse(body); |
| 156 | + expect(parsed.success, `body is not a BaseResponse: ${JSON.stringify(body)}`).toBe(true); |
| 157 | + |
| 158 | + expect(body.success).toBe(false); |
| 159 | + expect(body.error.code).toBe(c.code); |
| 160 | + expect(typeof body.error.message).toBe('string'); |
| 161 | + expect(body.error.message.length).toBeGreaterThan(0); |
| 162 | + |
| 163 | + // The pre-#3675 shape, explicitly dead. |
| 164 | + expect(typeof body.error).not.toBe('string'); |
| 165 | + }); |
| 166 | + } |
| 167 | + |
| 168 | + it('a 500 still carries a message when the thrown value has none', async () => { |
| 169 | + // `error.message` is REQUIRED by ApiErrorSchema, so passing a raw thrown |
| 170 | + // value straight through would emit an invalid body for anything that is |
| 171 | + // not an Error. |
| 172 | + const routes = await mount({ getLocales: () => { throw 'a bare string'; } }); |
| 173 | + const { status, body } = await drive(routes, `${BASE}/locales`); |
| 174 | + expect(status).toBe(500); |
| 175 | + expect(BaseResponseSchema.safeParse(body).success).toBe(true); |
| 176 | + expect(body.error.message).toBe('Internal error'); |
| 177 | + }); |
| 178 | + |
| 179 | + it('routes every error through `sendError` — no route may reintroduce the bare shape', () => { |
| 180 | + // Comments stripped first: this module's prose quotes both shapes, and a |
| 181 | + // doc comment is not a code path. |
| 182 | + const source = readFileSync(new URL('./i18n-service-plugin.ts', import.meta.url), 'utf8') |
| 183 | + .replace(/\/\*[\s\S]*?\*\//g, '') |
| 184 | + .replace(/\/\/[^\n]*/g, ''); |
| 185 | + |
| 186 | + const bare = [...source.matchAll(/res\s*\.\s*status\([^)]*\)\s*\.\s*json\(\s*\{\s*error/g)]; |
| 187 | + expect(bare, `bare error bodies found: ${bare.map((m) => m[0]).join(', ')}`).toHaveLength(0); |
| 188 | + |
| 189 | + // The envelope is built in exactly one place. |
| 190 | + expect([...source.matchAll(/success:\s*false/g)]).toHaveLength(1); |
| 191 | + }); |
| 192 | +}); |
0 commit comments