@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/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +18 -0
- package/dist/index.cjs +173 -29
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +18 -4
- package/dist/index.d.ts +18 -4
- package/dist/index.js +174 -30
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/app-plugin.test.ts +167 -0
- package/src/app-plugin.ts +96 -4
- package/src/dispatcher-plugin.ts +33 -3
- package/src/http-dispatcher.test.ts +254 -0
- package/src/http-dispatcher.ts +85 -29
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@objectstack/runtime",
|
|
3
|
-
"version": "3.2.
|
|
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.
|
|
19
|
-
"@objectstack/rest": "3.2.
|
|
20
|
-
"@objectstack/spec": "3.2.
|
|
21
|
-
"@objectstack/types": "3.2.
|
|
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",
|
package/src/app-plugin.test.ts
CHANGED
|
@@ -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
|
-
//
|
|
63
|
-
|
|
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
|
}
|
package/src/dispatcher-plugin.ts
CHANGED
|
@@ -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
|
});
|