@objectstack/runtime 3.2.4 → 3.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@objectstack/runtime",
3
- "version": "3.2.4",
3
+ "version": "3.2.6",
4
4
  "license": "Apache-2.0",
5
5
  "description": "ObjectStack Core Runtime & Query Engine",
6
6
  "type": "module",
@@ -15,10 +15,10 @@
15
15
  },
16
16
  "dependencies": {
17
17
  "zod": "^4.3.6",
18
- "@objectstack/core": "3.2.4",
19
- "@objectstack/rest": "3.2.4",
20
- "@objectstack/spec": "3.2.4",
21
- "@objectstack/types": "3.2.4"
18
+ "@objectstack/core": "3.2.6",
19
+ "@objectstack/rest": "3.2.6",
20
+ "@objectstack/spec": "3.2.6",
21
+ "@objectstack/types": "3.2.6"
22
22
  },
23
23
  "devDependencies": {
24
24
  "typescript": "^5.0.0",
@@ -99,4 +99,171 @@ describe('AppPlugin', () => {
99
99
  expect.any(Object)
100
100
  );
101
101
  });
102
+
103
+ it('start should handle getService throwing for objectql', async () => {
104
+ const bundle = { id: 'com.test.throw' };
105
+ const plugin = new AppPlugin(bundle);
106
+
107
+ vi.mocked(mockContext.getService).mockImplementation(() => {
108
+ throw new Error("[Kernel] Service 'objectql' not found");
109
+ });
110
+
111
+ await plugin.start!(mockContext);
112
+
113
+ expect(mockContext.logger.warn).toHaveBeenCalledWith(
114
+ expect.stringContaining('ObjectQL engine service not found'),
115
+ expect.any(Object)
116
+ );
117
+ });
118
+
119
+ // ═══════════════════════════════════════════════════════════════
120
+ // i18n translation auto-loading
121
+ // ═══════════════════════════════════════════════════════════════
122
+
123
+ describe('i18n translation loading', () => {
124
+ let mockI18n: any;
125
+ let mockQL: any;
126
+
127
+ beforeEach(() => {
128
+ mockI18n = {
129
+ loadTranslations: vi.fn(),
130
+ setDefaultLocale: vi.fn(),
131
+ getLocales: vi.fn().mockReturnValue([]),
132
+ getDefaultLocale: vi.fn().mockReturnValue('en'),
133
+ };
134
+ mockQL = { registry: {} };
135
+
136
+ vi.mocked(mockContext.getService).mockImplementation((name: string) => {
137
+ if (name === 'objectql') return mockQL;
138
+ if (name === 'i18n') return mockI18n;
139
+ return undefined;
140
+ });
141
+ });
142
+
143
+ it('should auto-load translations from bundle into i18n service', async () => {
144
+ const bundle = {
145
+ id: 'com.test.i18n',
146
+ translations: [
147
+ {
148
+ en: { objects: { task: { label: 'Task' } } },
149
+ 'zh-CN': { objects: { task: { label: '任务' } } },
150
+ },
151
+ ],
152
+ };
153
+ const plugin = new AppPlugin(bundle);
154
+ await plugin.start!(mockContext);
155
+
156
+ expect(mockI18n.loadTranslations).toHaveBeenCalledWith('en', { objects: { task: { label: 'Task' } } });
157
+ expect(mockI18n.loadTranslations).toHaveBeenCalledWith('zh-CN', { objects: { task: { label: '任务' } } });
158
+ });
159
+
160
+ it('should set default locale from i18n config', async () => {
161
+ const bundle = {
162
+ id: 'com.test.locale',
163
+ i18n: { defaultLocale: 'zh-CN', supportedLocales: ['en', 'zh-CN'] },
164
+ translations: [{ en: { messages: { hello: 'Hello' } } }],
165
+ };
166
+ const plugin = new AppPlugin(bundle);
167
+ await plugin.start!(mockContext);
168
+
169
+ expect(mockI18n.setDefaultLocale).toHaveBeenCalledWith('zh-CN');
170
+ });
171
+
172
+ it('should skip translation loading when i18n service is not registered', async () => {
173
+ vi.mocked(mockContext.getService).mockImplementation((name: string) => {
174
+ if (name === 'objectql') return mockQL;
175
+ return undefined; // No i18n service
176
+ });
177
+
178
+ const bundle = {
179
+ id: 'com.test.noi18n',
180
+ translations: [{ en: { messages: { hello: 'Hello' } } }],
181
+ };
182
+ const plugin = new AppPlugin(bundle);
183
+ await plugin.start!(mockContext);
184
+
185
+ // Should log warning (translations exist but no i18n service) and not throw
186
+ expect(mockContext.logger.warn).toHaveBeenCalledWith(
187
+ expect.stringContaining('no i18n service is registered')
188
+ );
189
+ });
190
+
191
+ it('should skip translation loading when getService throws for i18n', async () => {
192
+ vi.mocked(mockContext.getService).mockImplementation((name: string) => {
193
+ if (name === 'objectql') return mockQL;
194
+ throw new Error("[Kernel] Service 'i18n' not found");
195
+ });
196
+
197
+ const bundle = {
198
+ id: 'com.test.i18nthrow',
199
+ translations: [{ en: { messages: { hello: 'Hello' } } }],
200
+ };
201
+ const plugin = new AppPlugin(bundle);
202
+ await plugin.start!(mockContext);
203
+
204
+ // Should log warning (translations exist but no i18n service) and not throw
205
+ expect(mockContext.logger.warn).toHaveBeenCalledWith(
206
+ expect.stringContaining('no i18n service is registered')
207
+ );
208
+ });
209
+
210
+ it('should handle bundle with no translations gracefully', async () => {
211
+ const bundle = { id: 'com.test.notrans' };
212
+ const plugin = new AppPlugin(bundle);
213
+ await plugin.start!(mockContext);
214
+
215
+ expect(mockI18n.loadTranslations).not.toHaveBeenCalled();
216
+ });
217
+
218
+ it('should load translations from nested manifest.translations', async () => {
219
+ const bundle = {
220
+ manifest: {
221
+ id: 'com.test.nested',
222
+ translations: [
223
+ { en: { messages: { save: 'Save' } } },
224
+ ],
225
+ },
226
+ };
227
+ const plugin = new AppPlugin(bundle);
228
+ await plugin.start!(mockContext);
229
+
230
+ expect(mockI18n.loadTranslations).toHaveBeenCalledWith('en', { messages: { save: 'Save' } });
231
+ });
232
+
233
+ it('should load multiple translation bundles', async () => {
234
+ const bundle = {
235
+ id: 'com.test.multi',
236
+ translations: [
237
+ { en: { objects: { task: { label: 'Task' } } } },
238
+ { en: { objects: { contact: { label: 'Contact' } } }, 'ja-JP': { objects: { contact: { label: '連絡先' } } } },
239
+ ],
240
+ };
241
+ const plugin = new AppPlugin(bundle);
242
+ await plugin.start!(mockContext);
243
+
244
+ expect(mockI18n.loadTranslations).toHaveBeenCalledTimes(3);
245
+ });
246
+
247
+ it('should handle errors in loadTranslations gracefully', async () => {
248
+ mockI18n.loadTranslations.mockImplementation((locale: string) => {
249
+ if (locale === 'zh-CN') throw new Error('Disk read failed');
250
+ });
251
+
252
+ const bundle = {
253
+ id: 'com.test.error',
254
+ translations: [
255
+ { en: { messages: { save: 'Save' } }, 'zh-CN': { messages: { save: '保存' } } },
256
+ ],
257
+ };
258
+ const plugin = new AppPlugin(bundle);
259
+ await plugin.start!(mockContext);
260
+
261
+ // en should still be loaded despite zh-CN failure
262
+ expect(mockI18n.loadTranslations).toHaveBeenCalledWith('en', { messages: { save: 'Save' } });
263
+ expect(mockContext.logger.warn).toHaveBeenCalledWith(
264
+ expect.stringContaining('Failed to load translations'),
265
+ expect.objectContaining({ locale: 'zh-CN' })
266
+ );
267
+ });
268
+ });
102
269
  });
package/src/app-plugin.ts CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { Plugin, PluginContext } from '@objectstack/core';
4
4
  import { SeedLoaderService } from './seed-loader.js';
5
- import type { IMetadataService } from '@objectstack/spec/contracts';
5
+ import type { IMetadataService, II18nService } from '@objectstack/spec/contracts';
6
6
 
7
7
  /**
8
8
  * AppPlugin
@@ -12,6 +12,7 @@ import type { IMetadataService } from '@objectstack/spec/contracts';
12
12
  * Responsibilities:
13
13
  * 1. Register App Manifest as a service (for ObjectQL discovery)
14
14
  * 2. Execute Runtime `onEnable` hook (for code logic)
15
+ * 3. Auto-load i18n translation bundles into the kernel's i18n service
15
16
  */
16
17
  export class AppPlugin implements Plugin {
17
18
  name: string;
@@ -59,9 +60,15 @@ export class AppPlugin implements Plugin {
59
60
 
60
61
  // Execute Runtime Step
61
62
  // Retrieve ObjectQL engine from services
62
- // We cast to any/ObjectQL because ctx.getService returns unknown
63
- const ql = ctx.getService('objectql') as any;
64
-
63
+ // ctx.getService throws when a service is not registered, so we
64
+ // must use try/catch instead of a null-check.
65
+ let ql: any;
66
+ try {
67
+ ql = ctx.getService('objectql');
68
+ } catch {
69
+ // Service not registered — handled below
70
+ }
71
+
65
72
  if (!ql) {
66
73
  ctx.logger.warn('ObjectQL engine service not found', {
67
74
  appName: this.name,
@@ -102,6 +109,11 @@ export class AppPlugin implements Plugin {
102
109
  ctx.logger.debug('No runtime.onEnable function found', { appId });
103
110
  }
104
111
 
112
+ // ── i18n Translation Loading ─────────────────────────────────────
113
+ // Auto-load translation bundles from the app config into the
114
+ // kernel's i18n service, so discovery and handlers stay consistent.
115
+ this.loadTranslations(ctx, appId);
116
+
105
117
  // Data Seeding
106
118
  // Collect seed data from multiple locations (top-level `data` preferred, `manifest.data` for backward compat)
107
119
  const seedDatasets: any[] = [];
@@ -185,4 +197,84 @@ export class AppPlugin implements Plugin {
185
197
  }
186
198
  }
187
199
  }
200
+
201
+ /**
202
+ * Auto-load i18n translation bundles from the app config into the
203
+ * kernel's i18n service. Handles both `translations` (array of
204
+ * TranslationBundle) and `i18n` config (default locale, etc.).
205
+ *
206
+ * Gracefully skips when the i18n service is not registered —
207
+ * this keeps AppPlugin resilient across server/dev/mock environments.
208
+ */
209
+ private loadTranslations(ctx: PluginContext, appId: string): void {
210
+ // ctx.getService throws when a service is not registered, so we
211
+ // must use try/catch to gracefully skip when no i18n plugin is loaded.
212
+ let i18nService: II18nService | undefined;
213
+ try {
214
+ i18nService = ctx.getService('i18n') as II18nService;
215
+ } catch {
216
+ // Service not registered — handled below
217
+ }
218
+
219
+ // Collect translation bundles early to determine if we have data
220
+ const bundles: Array<Record<string, unknown>> = [];
221
+ if (Array.isArray(this.bundle.translations)) {
222
+ bundles.push(...this.bundle.translations);
223
+ }
224
+ const manifest = this.bundle.manifest || this.bundle;
225
+ if (manifest && Array.isArray(manifest.translations) && manifest.translations !== this.bundle.translations) {
226
+ bundles.push(...manifest.translations);
227
+ }
228
+
229
+ if (!i18nService) {
230
+ if (bundles.length > 0) {
231
+ ctx.logger.warn(
232
+ `[i18n] App "${appId}" has ${bundles.length} translation bundle(s) but no i18n service is registered. ` +
233
+ 'Translations will not be served via REST API. ' +
234
+ 'Register I18nServicePlugin from @objectstack/service-i18n, or use DevPlugin ' +
235
+ 'which auto-detects translations and registers the i18n service automatically.'
236
+ );
237
+ } else {
238
+ ctx.logger.debug('[i18n] No i18n service registered; skipping translation loading', { appId });
239
+ }
240
+ return;
241
+ }
242
+
243
+ // Apply i18n config (default locale, etc.)
244
+ const i18nConfig = this.bundle.i18n || (this.bundle.manifest || this.bundle)?.i18n;
245
+ if (i18nConfig?.defaultLocale && typeof i18nService.setDefaultLocale === 'function') {
246
+ i18nService.setDefaultLocale(i18nConfig.defaultLocale);
247
+ ctx.logger.debug('[i18n] Set default locale', { appId, locale: i18nConfig.defaultLocale });
248
+ }
249
+
250
+ if (bundles.length === 0) {
251
+ return;
252
+ }
253
+
254
+ let loadedLocales = 0;
255
+ for (const bundle of bundles) {
256
+ // Each bundle is a TranslationBundle: Record<locale, TranslationData>
257
+ for (const [locale, data] of Object.entries(bundle)) {
258
+ if (data && typeof data === 'object') {
259
+ try {
260
+ i18nService.loadTranslations(locale, data as Record<string, unknown>);
261
+ loadedLocales++;
262
+ } catch (err: any) {
263
+ ctx.logger.warn('[i18n] Failed to load translations', { appId, locale, error: err.message });
264
+ }
265
+ }
266
+ }
267
+ }
268
+
269
+ // Emit diagnostic when the active i18n service is a fallback/stub
270
+ const svcAny = i18nService as unknown as Record<string, unknown>;
271
+ if (svcAny._fallback || svcAny._dev) {
272
+ ctx.logger.info(
273
+ `[i18n] Loaded ${loadedLocales} locale(s) into in-memory i18n fallback for "${appId}". ` +
274
+ 'For production, consider registering I18nServicePlugin from @objectstack/service-i18n.'
275
+ );
276
+ } else {
277
+ ctx.logger.info('[i18n] Loaded translation bundles', { appId, bundles: bundles.length, locales: loadedLocales });
278
+ }
279
+ }
188
280
  }
@@ -53,7 +53,7 @@ function errorResponse(err: any, res: any): void {
53
53
  * - /graphql (GraphQL)
54
54
  * - /analytics (BI queries)
55
55
  * - /packages (package management)
56
-
56
+ * - /i18n (internationalization — locales, translations, field labels)
57
57
  * - /storage (file storage)
58
58
  * - /automation (CRUD + triggers + runs)
59
59
  *
@@ -88,12 +88,12 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
88
88
 
89
89
  // ── Discovery (.well-known) ─────────────────────────────────
90
90
  server.get('/.well-known/objectstack', async (_req: any, res: any) => {
91
- res.json({ data: dispatcher.getDiscoveryInfo(prefix) });
91
+ res.json({ data: await dispatcher.getDiscoveryInfo(prefix) });
92
92
  });
93
93
 
94
94
  // ── Discovery (versioned API path) ──────────────────────────
95
95
  server.get(`${prefix}/discovery`, async (_req: any, res: any) => {
96
- res.json({ data: dispatcher.getDiscoveryInfo(prefix) });
96
+ res.json({ data: await dispatcher.getDiscoveryInfo(prefix) });
97
97
  });
98
98
 
99
99
  // ── Auth ────────────────────────────────────────────────────
@@ -237,6 +237,36 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
237
237
  }
238
238
  });
239
239
 
240
+ // ── i18n ────────────────────────────────────────────────────
241
+ // Bridges to HttpDispatcher.handleI18n() which resolves the i18n
242
+ // service from the kernel (either I18nServicePlugin or memory fallback).
243
+ server.get(`${prefix}/i18n/locales`, async (req: any, res: any) => {
244
+ try {
245
+ const result = await dispatcher.handleI18n('/locales', 'GET', req.query, { request: req });
246
+ sendResult(result, res);
247
+ } catch (err: any) {
248
+ errorResponse(err, res);
249
+ }
250
+ });
251
+
252
+ server.get(`${prefix}/i18n/translations/:locale`, async (req: any, res: any) => {
253
+ try {
254
+ const result = await dispatcher.handleI18n(`/translations/${req.params.locale}`, 'GET', req.query, { request: req });
255
+ sendResult(result, res);
256
+ } catch (err: any) {
257
+ errorResponse(err, res);
258
+ }
259
+ });
260
+
261
+ server.get(`${prefix}/i18n/labels/:object/:locale`, async (req: any, res: any) => {
262
+ try {
263
+ const result = await dispatcher.handleI18n(`/labels/${req.params.object}/${req.params.locale}`, 'GET', req.query, { request: req });
264
+ sendResult(result, res);
265
+ } catch (err: any) {
266
+ errorResponse(err, res);
267
+ }
268
+ });
269
+
240
270
  // ── Automation ──────────────────────────────────────────────
241
271
  server.get(`${prefix}/automation`, async (req: any, res: any) => {
242
272
  try {
@@ -953,5 +953,259 @@ describe('HttpDispatcher', () => {
953
953
  expect(result.handled).toBe(true);
954
954
  expect(result.response?.body?.data?.locales).toEqual(['en', 'zh-CN', 'ja']);
955
955
  });
956
+
957
+ it('should resolve locale via fallback (zh → zh-CN) for translations', async () => {
958
+ // Override mock to be locale-aware: only 'zh-CN' has data, 'zh' returns empty
959
+ mockI18nService.getTranslations = vi.fn().mockImplementation((locale: string) => {
960
+ if (locale === 'zh-CN') return { 'o.task.label': '任务' };
961
+ return {};
962
+ });
963
+
964
+ const result = await dispatcher.handleI18n('/translations/zh', 'GET', {}, { request: {} });
965
+ expect(result.handled).toBe(true);
966
+ expect(result.response?.status).toBe(200);
967
+ const data = result.response?.body?.data;
968
+ expect(data.locale).toBe('zh-CN');
969
+ expect(data.requestedLocale).toBe('zh');
970
+ expect(data.translations).toEqual({ 'o.task.label': '任务' });
971
+ });
972
+
973
+ it('should resolve locale via case-insensitive fallback (ZH-CN → zh-CN) for translations', async () => {
974
+ // Override mock to be locale-aware: 'ZH-CN' returns empty, 'zh-CN' has data
975
+ mockI18nService.getTranslations = vi.fn().mockImplementation((locale: string) => {
976
+ if (locale === 'zh-CN') return { 'o.task.label': '任务' };
977
+ return {};
978
+ });
979
+
980
+ const result = await dispatcher.handleI18n('/translations/ZH-CN', 'GET', {}, { request: {} });
981
+ expect(result.handled).toBe(true);
982
+ expect(result.response?.status).toBe(200);
983
+ const data = result.response?.body?.data;
984
+ expect(data.locale).toBe('zh-CN');
985
+ expect(data.translations).toEqual({ 'o.task.label': '任务' });
986
+ });
987
+ });
988
+
989
+ // ═══════════════════════════════════════════════════════════════
990
+ // Discovery ↔ Handler i18n consistency
991
+ // ═══════════════════════════════════════════════════════════════
992
+
993
+ describe('discovery-handler i18n consistency', () => {
994
+ it('should report i18n as available in discovery when service is registered', async () => {
995
+ const mockI18nService = {
996
+ getLocales: vi.fn().mockReturnValue(['en', 'zh-CN', 'ja']),
997
+ getTranslations: vi.fn().mockReturnValue({}),
998
+ getDefaultLocale: vi.fn().mockReturnValue('en'),
999
+ };
1000
+
1001
+ (kernel as any).getService = vi.fn().mockImplementation((name: string) => {
1002
+ if (name === 'i18n') return mockI18nService;
1003
+ return null;
1004
+ });
1005
+
1006
+ const info = await dispatcher.getDiscoveryInfo('/api/v1');
1007
+ expect(info.services.i18n.enabled).toBe(true);
1008
+ expect(info.services.i18n.status).toBe('available');
1009
+ expect(info.routes.i18n).toBe('/api/v1/i18n');
1010
+ expect(info.features.i18n).toBe(true);
1011
+ });
1012
+
1013
+ it('should report i18n as unavailable in discovery when service is not registered', async () => {
1014
+ (kernel as any).getService = vi.fn().mockResolvedValue(null);
1015
+ (kernel as any).services = new Map();
1016
+
1017
+ const info = await dispatcher.getDiscoveryInfo('/api/v1');
1018
+ expect(info.services.i18n.enabled).toBe(false);
1019
+ expect(info.services.i18n.status).toBe('unavailable');
1020
+ expect(info.routes.i18n).toBeUndefined();
1021
+ expect(info.features.i18n).toBe(false);
1022
+ });
1023
+
1024
+ it('should detect i18n via getServiceAsync (async factory) in discovery', async () => {
1025
+ const mockI18nService = {
1026
+ getLocales: vi.fn().mockReturnValue(['en', 'fr']),
1027
+ getTranslations: vi.fn().mockReturnValue({}),
1028
+ getDefaultLocale: vi.fn().mockReturnValue('fr'),
1029
+ };
1030
+
1031
+ // Service NOT in sync map, only accessible via async factory
1032
+ (kernel as any).services = new Map();
1033
+ (kernel as any).getServiceAsync = vi.fn().mockImplementation(async (name: string) => {
1034
+ if (name === 'i18n') return mockI18nService;
1035
+ return null;
1036
+ });
1037
+
1038
+ const info = await dispatcher.getDiscoveryInfo('/api/v1');
1039
+ expect(info.services.i18n.enabled).toBe(true);
1040
+ expect(info.services.i18n.status).toBe('available');
1041
+
1042
+ // Handler should also find it
1043
+ const result = await dispatcher.handleI18n('/locales', 'GET', {}, { request: {} });
1044
+ expect(result.handled).toBe(true);
1045
+ expect(result.response?.status).toBe(200);
1046
+ expect(result.response?.body?.data?.locales).toEqual(['en', 'fr']);
1047
+ });
1048
+
1049
+ it('should populate locale from actual i18n service', async () => {
1050
+ const mockI18nService = {
1051
+ getLocales: vi.fn().mockReturnValue(['en', 'zh-CN', 'ja']),
1052
+ getTranslations: vi.fn().mockReturnValue({}),
1053
+ getDefaultLocale: vi.fn().mockReturnValue('zh-CN'),
1054
+ };
1055
+
1056
+ (kernel as any).getService = vi.fn().mockImplementation((name: string) => {
1057
+ if (name === 'i18n') return mockI18nService;
1058
+ return null;
1059
+ });
1060
+
1061
+ const info = await dispatcher.getDiscoveryInfo('/api/v1');
1062
+ expect(info.locale.default).toBe('zh-CN');
1063
+ expect(info.locale.supported).toEqual(['en', 'zh-CN', 'ja']);
1064
+ });
1065
+
1066
+ it('should use default locale when i18n service is not available', async () => {
1067
+ (kernel as any).getService = vi.fn().mockResolvedValue(null);
1068
+ (kernel as any).services = new Map();
1069
+
1070
+ const info = await dispatcher.getDiscoveryInfo('/api/v1');
1071
+ expect(info.locale.default).toBe('en');
1072
+ expect(info.locale.supported).toEqual(['en']);
1073
+ expect(info.locale.timezone).toBe('UTC');
1074
+ });
1075
+
1076
+ it('should ensure discovery and dispatch are consistent for root path', async () => {
1077
+ const mockI18nService = {
1078
+ getLocales: vi.fn().mockReturnValue(['en']),
1079
+ getTranslations: vi.fn().mockReturnValue({}),
1080
+ getDefaultLocale: vi.fn().mockReturnValue('en'),
1081
+ };
1082
+
1083
+ (kernel as any).getService = vi.fn().mockImplementation((name: string) => {
1084
+ if (name === 'i18n') return mockI18nService;
1085
+ return null;
1086
+ });
1087
+
1088
+ // Dispatch to root should return the same discovery data
1089
+ const result = await dispatcher.dispatch('GET', '', undefined, {}, { request: {} });
1090
+ expect(result.handled).toBe(true);
1091
+ const data = result.response?.body?.data;
1092
+ expect(data.services.i18n.enabled).toBe(true);
1093
+ expect(data.locale.default).toBe('en');
1094
+ });
1095
+ });
1096
+
1097
+ // ═══════════════════════════════════════════════════════════════
1098
+ // i18n across server/dev/mock environments
1099
+ // ═══════════════════════════════════════════════════════════════
1100
+
1101
+ describe('i18n environment consistency', () => {
1102
+ it('should work with dev stub i18n service (in-memory translations)', async () => {
1103
+ // Simulate dev plugin i18n stub — Map-backed, all sync
1104
+ const translations = new Map<string, Record<string, unknown>>();
1105
+ let defaultLocale = 'en';
1106
+ const devI18nStub = {
1107
+ t: (key: string, locale: string) => {
1108
+ const t = translations.get(locale);
1109
+ return (t?.[key] as string) ?? key;
1110
+ },
1111
+ getTranslations: (locale: string) => translations.get(locale) ?? {},
1112
+ loadTranslations: (locale: string, data: Record<string, unknown>) => {
1113
+ translations.set(locale, { ...translations.get(locale), ...data });
1114
+ },
1115
+ getLocales: () => [...translations.keys()],
1116
+ getDefaultLocale: () => defaultLocale,
1117
+ setDefaultLocale: (locale: string) => { defaultLocale = locale; },
1118
+ };
1119
+
1120
+ // Load data like AppPlugin would
1121
+ devI18nStub.loadTranslations('en', { 'o.task.label': 'Task' });
1122
+ devI18nStub.loadTranslations('zh-CN', { 'o.task.label': '任务' });
1123
+
1124
+ (kernel as any).getService = vi.fn().mockImplementation((name: string) => {
1125
+ if (name === 'i18n') return devI18nStub;
1126
+ return null;
1127
+ });
1128
+
1129
+ // Discovery should reflect loaded locales
1130
+ const info = await dispatcher.getDiscoveryInfo('/api/v1');
1131
+ expect(info.services.i18n.enabled).toBe(true);
1132
+ expect(info.locale.supported).toEqual(['en', 'zh-CN']);
1133
+
1134
+ // Handler should serve translations
1135
+ const result = await dispatcher.handleI18n('/translations/zh-CN', 'GET', {}, { request: {} });
1136
+ expect(result.response?.status).toBe(200);
1137
+ expect(result.response?.body?.data?.translations['o.task.label']).toBe('任务');
1138
+ });
1139
+
1140
+ it('should handle MSW catch-all dispatch pattern for i18n', async () => {
1141
+ // MSW routes all requests through dispatcher.dispatch()
1142
+ const mockI18nService = {
1143
+ getLocales: vi.fn().mockReturnValue(['en', 'de']),
1144
+ getTranslations: vi.fn().mockReturnValue({ 'o.account.label': 'Konto' }),
1145
+ getDefaultLocale: vi.fn().mockReturnValue('de'),
1146
+ };
1147
+
1148
+ (kernel as any).getService = vi.fn().mockImplementation((name: string) => {
1149
+ if (name === 'i18n') return mockI18nService;
1150
+ return null;
1151
+ });
1152
+
1153
+ // MSW-style dispatch: full path stripped to relative
1154
+ const localesResult = await dispatcher.dispatch('GET', '/i18n/locales', undefined, {}, { request: {} });
1155
+ expect(localesResult.handled).toBe(true);
1156
+ expect(localesResult.response?.body?.data?.locales).toEqual(['en', 'de']);
1157
+
1158
+ const translationsResult = await dispatcher.dispatch('GET', '/i18n/translations/de', undefined, {}, { request: {} });
1159
+ expect(translationsResult.handled).toBe(true);
1160
+ expect(translationsResult.response?.body?.data?.translations['o.account.label']).toBe('Konto');
1161
+
1162
+ // Discovery and handler agree
1163
+ const discovery = await dispatcher.getDiscoveryInfo('/api/v1');
1164
+ expect(discovery.services.i18n.enabled).toBe(true);
1165
+ expect(discovery.locale.default).toBe('de');
1166
+ });
1167
+
1168
+ it('should return 501 consistently when i18n is unavailable in both discovery and handler', async () => {
1169
+ (kernel as any).getService = vi.fn().mockResolvedValue(null);
1170
+ (kernel as any).services = new Map();
1171
+
1172
+ // Discovery: unavailable
1173
+ const info = await dispatcher.getDiscoveryInfo('/api/v1');
1174
+ expect(info.services.i18n.enabled).toBe(false);
1175
+ expect(info.services.i18n.status).toBe('unavailable');
1176
+
1177
+ // Handler: 501
1178
+ const result = await dispatcher.handleI18n('/locales', 'GET', {}, { request: {} });
1179
+ expect(result.response?.status).toBe(501);
1180
+
1181
+ // Dispatch: also 501
1182
+ const dispatchResult = await dispatcher.dispatch('GET', '/i18n/locales', undefined, {}, { request: {} });
1183
+ expect(dispatchResult.response?.status).toBe(501);
1184
+ });
1185
+
1186
+ it('should handle context-based service resolution (mock kernel)', async () => {
1187
+ // Simulate a kernel that only provides i18n through context.getService
1188
+ const mockI18n = {
1189
+ getLocales: vi.fn().mockReturnValue(['en']),
1190
+ getTranslations: vi.fn().mockReturnValue({}),
1191
+ getDefaultLocale: vi.fn().mockReturnValue('en'),
1192
+ };
1193
+
1194
+ (kernel as any).services = new Map();
1195
+ (kernel as any).getService = undefined;
1196
+ (kernel as any).getServiceAsync = undefined;
1197
+ (kernel as any).context = {
1198
+ getService: vi.fn().mockImplementation((name: string) => {
1199
+ if (name === 'i18n') return mockI18n;
1200
+ return null;
1201
+ }),
1202
+ };
1203
+
1204
+ const info = await dispatcher.getDiscoveryInfo('/api/v1');
1205
+ expect(info.services.i18n.enabled).toBe(true);
1206
+
1207
+ const result = await dispatcher.handleI18n('/locales', 'GET', {}, { request: {} });
1208
+ expect(result.response?.status).toBe(200);
1209
+ });
956
1210
  });
957
1211
  });