@objectstack/service-i18n 4.0.3 → 4.0.5

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.
@@ -1,221 +0,0 @@
1
- // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
4
- import { FileI18nAdapter } from './file-i18n-adapter';
5
- import type { II18nService } from '@objectstack/spec/contracts';
6
- import * as fs from 'node:fs';
7
- import * as path from 'node:path';
8
- import * as os from 'node:os';
9
-
10
- describe('FileI18nAdapter', () => {
11
- it('should implement II18nService contract', () => {
12
- const i18n: II18nService = new FileI18nAdapter();
13
- expect(typeof i18n.t).toBe('function');
14
- expect(typeof i18n.getTranslations).toBe('function');
15
- expect(typeof i18n.loadTranslations).toBe('function');
16
- expect(typeof i18n.getLocales).toBe('function');
17
- expect(typeof i18n.getDefaultLocale).toBe('function');
18
- expect(typeof i18n.setDefaultLocale).toBe('function');
19
- });
20
-
21
- it('should default to "en" locale', () => {
22
- const i18n = new FileI18nAdapter();
23
- expect(i18n.getDefaultLocale()).toBe('en');
24
- });
25
-
26
- it('should use custom default locale', () => {
27
- const i18n = new FileI18nAdapter({ defaultLocale: 'zh-CN' });
28
- expect(i18n.getDefaultLocale()).toBe('zh-CN');
29
- });
30
-
31
- it('should set and get default locale', () => {
32
- const i18n = new FileI18nAdapter();
33
- i18n.setDefaultLocale('ja');
34
- expect(i18n.getDefaultLocale()).toBe('ja');
35
- });
36
-
37
- it('should return empty translations for unknown locale', () => {
38
- const i18n = new FileI18nAdapter();
39
- expect(i18n.getTranslations('fr')).toEqual({});
40
- });
41
-
42
- it('should return empty locales when no translations loaded', () => {
43
- const i18n = new FileI18nAdapter();
44
- expect(i18n.getLocales()).toEqual([]);
45
- });
46
-
47
- it('should load and retrieve translations', () => {
48
- const i18n = new FileI18nAdapter();
49
- i18n.loadTranslations('en', { greeting: 'Hello' });
50
- i18n.loadTranslations('zh-CN', { greeting: '你好' });
51
-
52
- expect(i18n.getLocales()).toContain('en');
53
- expect(i18n.getLocales()).toContain('zh-CN');
54
- expect(i18n.getTranslations('en')).toEqual({ greeting: 'Hello' });
55
- expect(i18n.getTranslations('zh-CN')).toEqual({ greeting: '你好' });
56
- });
57
-
58
- it('should merge translations when loading into existing locale', () => {
59
- const i18n = new FileI18nAdapter();
60
- i18n.loadTranslations('en', { greeting: 'Hello' });
61
- i18n.loadTranslations('en', { farewell: 'Goodbye' });
62
-
63
- expect(i18n.getTranslations('en')).toEqual({
64
- greeting: 'Hello',
65
- farewell: 'Goodbye',
66
- });
67
- });
68
-
69
- it('should translate a simple key', () => {
70
- const i18n = new FileI18nAdapter();
71
- i18n.loadTranslations('en', { greeting: 'Hello' });
72
-
73
- expect(i18n.t('greeting', 'en')).toBe('Hello');
74
- });
75
-
76
- it('should return key when translation is missing', () => {
77
- const i18n = new FileI18nAdapter();
78
- expect(i18n.t('missing.key', 'en')).toBe('missing.key');
79
- });
80
-
81
- it('should resolve nested dot-notation keys', () => {
82
- const i18n = new FileI18nAdapter();
83
- i18n.loadTranslations('en', {
84
- objects: {
85
- account: {
86
- label: 'Account',
87
- },
88
- },
89
- });
90
-
91
- expect(i18n.t('objects.account.label', 'en')).toBe('Account');
92
- });
93
-
94
- it('should interpolate parameters', () => {
95
- const i18n = new FileI18nAdapter();
96
- i18n.loadTranslations('en', { greeting: 'Hello, {{name}}!' });
97
-
98
- expect(i18n.t('greeting', 'en', { name: 'World' })).toBe('Hello, World!');
99
- });
100
-
101
- it('should keep placeholder when parameter is missing', () => {
102
- const i18n = new FileI18nAdapter();
103
- i18n.loadTranslations('en', { greeting: 'Hello, {{name}}!' });
104
-
105
- expect(i18n.t('greeting', 'en', {})).toBe('Hello, {{name}}!');
106
- });
107
-
108
- it('should fallback to fallback locale when key not found', () => {
109
- const i18n = new FileI18nAdapter({ fallbackLocale: 'en' });
110
- i18n.loadTranslations('en', { greeting: 'Hello' });
111
-
112
- expect(i18n.t('greeting', 'zh-CN')).toBe('Hello');
113
- });
114
-
115
- it('should not fallback when key exists in requested locale', () => {
116
- const i18n = new FileI18nAdapter({ fallbackLocale: 'en' });
117
- i18n.loadTranslations('en', { greeting: 'Hello' });
118
- i18n.loadTranslations('zh-CN', { greeting: '你好' });
119
-
120
- expect(i18n.t('greeting', 'zh-CN')).toBe('你好');
121
- });
122
-
123
- it('should return key when neither locale nor fallback has translation', () => {
124
- const i18n = new FileI18nAdapter({ fallbackLocale: 'en' });
125
- i18n.loadTranslations('en', { greeting: 'Hello' });
126
-
127
- expect(i18n.t('missing.key', 'zh-CN')).toBe('missing.key');
128
- });
129
-
130
- it('should deep-merge nested objects from multiple plugins', () => {
131
- const i18n = new FileI18nAdapter();
132
-
133
- // Plugin 1: CRM objects
134
- i18n.loadTranslations('en', {
135
- objects: {
136
- account: { label: 'Account', fields: { name: { label: 'Account Name' } } },
137
- contact: { label: 'Contact' },
138
- },
139
- apps: { crm: { label: 'CRM' } },
140
- });
141
-
142
- // Plugin 2: HR objects
143
- i18n.loadTranslations('en', {
144
- objects: {
145
- department: { label: 'Department', fields: { name: { label: 'Department Name' } } },
146
- employee: { label: 'Employee' },
147
- },
148
- apps: { hr: { label: 'HR' } },
149
- });
150
-
151
- const data = i18n.getTranslations('en');
152
-
153
- // Both plugins' objects must be preserved (not overwritten)
154
- expect(i18n.t('objects.account.label', 'en')).toBe('Account');
155
- expect(i18n.t('objects.contact.label', 'en')).toBe('Contact');
156
- expect(i18n.t('objects.department.label', 'en')).toBe('Department');
157
- expect(i18n.t('objects.employee.label', 'en')).toBe('Employee');
158
- expect(i18n.t('objects.account.fields.name.label', 'en')).toBe('Account Name');
159
- expect(i18n.t('objects.department.fields.name.label', 'en')).toBe('Department Name');
160
-
161
- // Both plugins' apps must be preserved
162
- expect((data as any).apps.crm.label).toBe('CRM');
163
- expect((data as any).apps.hr.label).toBe('HR');
164
- });
165
-
166
- describe('file-based loading', () => {
167
- let tmpDir: string;
168
-
169
- beforeEach(() => {
170
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'i18n-test-'));
171
- });
172
-
173
- afterEach(() => {
174
- fs.rmSync(tmpDir, { recursive: true, force: true });
175
- });
176
-
177
- it('should load translations from JSON files in a directory', () => {
178
- fs.writeFileSync(
179
- path.join(tmpDir, 'en.json'),
180
- JSON.stringify({ greeting: 'Hello', objects: { account: { label: 'Account' } } }),
181
- );
182
- fs.writeFileSync(
183
- path.join(tmpDir, 'zh-CN.json'),
184
- JSON.stringify({ greeting: '你好', objects: { account: { label: '客户' } } }),
185
- );
186
-
187
- const i18n = new FileI18nAdapter({ localesDir: tmpDir });
188
-
189
- expect(i18n.getLocales()).toContain('en');
190
- expect(i18n.getLocales()).toContain('zh-CN');
191
- expect(i18n.t('greeting', 'en')).toBe('Hello');
192
- expect(i18n.t('greeting', 'zh-CN')).toBe('你好');
193
- expect(i18n.t('objects.account.label', 'en')).toBe('Account');
194
- expect(i18n.t('objects.account.label', 'zh-CN')).toBe('客户');
195
- });
196
-
197
- it('should ignore non-JSON files in the directory', () => {
198
- fs.writeFileSync(path.join(tmpDir, 'en.json'), JSON.stringify({ greeting: 'Hello' }));
199
- fs.writeFileSync(path.join(tmpDir, 'notes.txt'), 'not a translation file');
200
-
201
- const i18n = new FileI18nAdapter({ localesDir: tmpDir });
202
-
203
- expect(i18n.getLocales()).toEqual(['en']);
204
- });
205
-
206
- it('should skip malformed JSON files gracefully', () => {
207
- fs.writeFileSync(path.join(tmpDir, 'en.json'), JSON.stringify({ greeting: 'Hello' }));
208
- fs.writeFileSync(path.join(tmpDir, 'bad.json'), '{invalid json');
209
-
210
- const i18n = new FileI18nAdapter({ localesDir: tmpDir });
211
-
212
- expect(i18n.getLocales()).toEqual(['en']);
213
- expect(i18n.t('greeting', 'en')).toBe('Hello');
214
- });
215
-
216
- it('should handle non-existent directory gracefully', () => {
217
- const i18n = new FileI18nAdapter({ localesDir: '/nonexistent/path' });
218
- expect(i18n.getLocales()).toEqual([]);
219
- });
220
- });
221
- });
@@ -1,193 +0,0 @@
1
- // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- import type { II18nService } from '@objectstack/spec/contracts';
4
- import * as fs from 'node:fs';
5
- import * as path from 'node:path';
6
-
7
- /**
8
- * Configuration options for FileI18nAdapter.
9
- */
10
- export interface FileI18nAdapterOptions {
11
- /** Default locale (e.g. 'en') */
12
- defaultLocale?: string;
13
- /** Directory containing locale files (JSON). Each file should be named `{locale}.json`. */
14
- localesDir?: string;
15
- /** Fallback locale when a key is not found in the requested locale */
16
- fallbackLocale?: string;
17
- }
18
-
19
- /**
20
- * Resolve a nested key in a translations object using dot notation.
21
- *
22
- * @param data - Translation data object
23
- * @param key - Dot-separated key (e.g. 'objects.account.label')
24
- * @returns The resolved string value, or undefined if not found
25
- */
26
- function resolveKey(data: Record<string, unknown>, key: string): string | undefined {
27
- const parts = key.split('.');
28
- let current: unknown = data;
29
- for (const part of parts) {
30
- if (current == null || typeof current !== 'object') return undefined;
31
- current = (current as Record<string, unknown>)[part];
32
- }
33
- return typeof current === 'string' ? current : undefined;
34
- }
35
-
36
- /**
37
- * Deep-merge two plain objects recursively.
38
- * Arrays and non-plain-object values from `source` overwrite those in `target`.
39
- */
40
- function deepMerge(
41
- target: Record<string, unknown>,
42
- source: Record<string, unknown>,
43
- ): Record<string, unknown> {
44
- const result: Record<string, unknown> = { ...target };
45
- for (const key of Object.keys(source)) {
46
- const tVal = target[key];
47
- const sVal = source[key];
48
- if (
49
- tVal && sVal
50
- && typeof tVal === 'object' && !Array.isArray(tVal)
51
- && typeof sVal === 'object' && !Array.isArray(sVal)
52
- ) {
53
- result[key] = deepMerge(
54
- tVal as Record<string, unknown>,
55
- sVal as Record<string, unknown>,
56
- );
57
- } else {
58
- result[key] = sVal;
59
- }
60
- }
61
- return result;
62
- }
63
-
64
- /**
65
- * Interpolate parameters into a translated string.
66
- * Replaces `{{paramName}}` with the corresponding value from params.
67
- *
68
- * @param template - Template string with `{{key}}` placeholders
69
- * @param params - Parameter map
70
- * @returns Interpolated string
71
- */
72
- function interpolate(template: string, params: Record<string, unknown>): string {
73
- return template.replace(/\{\{(\w+)\}\}/g, (_match, key: string) => {
74
- return params[key] != null ? String(params[key]) : `{{${key}}}`;
75
- });
76
- }
77
-
78
- /**
79
- * File-based I18n adapter implementing II18nService.
80
- *
81
- * Loads JSON translation files from a directory on disk.
82
- * Each file should be named `{locale}.json` and contain a flat or nested
83
- * key-value map of translations.
84
- *
85
- * Supports:
86
- * - Dot-notation key resolution (e.g. 'objects.account.label')
87
- * - Parameter interpolation via `{{paramName}}` syntax
88
- * - Fallback locale for missing translations
89
- * - Runtime translation loading via loadTranslations()
90
- *
91
- * Suitable for server-side rendering, CLI tools, and development environments.
92
- *
93
- * @example
94
- * ```ts
95
- * const i18n = new FileI18nAdapter({
96
- * defaultLocale: 'en',
97
- * localesDir: './i18n',
98
- * fallbackLocale: 'en',
99
- * });
100
- *
101
- * i18n.t('objects.account.label', 'zh-CN'); // '客户'
102
- * i18n.t('greeting', 'en', { name: 'World' }); // 'Hello, World!'
103
- * ```
104
- */
105
- export class FileI18nAdapter implements II18nService {
106
- private readonly translations = new Map<string, Record<string, unknown>>();
107
- private defaultLocale: string;
108
- private readonly fallbackLocale: string | undefined;
109
-
110
- constructor(options: FileI18nAdapterOptions = {}) {
111
- this.defaultLocale = options.defaultLocale ?? 'en';
112
- this.fallbackLocale = options.fallbackLocale;
113
-
114
- if (options.localesDir) {
115
- this.loadFromDirectory(options.localesDir);
116
- }
117
- }
118
-
119
- t(key: string, locale: string, params?: Record<string, unknown>): string {
120
- // Try requested locale
121
- let value = this.resolveFromLocale(key, locale);
122
-
123
- // Try fallback locale
124
- if (value === undefined && this.fallbackLocale && this.fallbackLocale !== locale) {
125
- value = this.resolveFromLocale(key, this.fallbackLocale);
126
- }
127
-
128
- // Return key if not found
129
- if (value === undefined) return key;
130
-
131
- // Interpolate parameters
132
- if (params && Object.keys(params).length > 0) {
133
- return interpolate(value, params);
134
- }
135
-
136
- return value;
137
- }
138
-
139
- getTranslations(locale: string): Record<string, unknown> {
140
- return this.translations.get(locale) ?? {};
141
- }
142
-
143
- loadTranslations(locale: string, translations: Record<string, unknown>): void {
144
- const existing = this.translations.get(locale);
145
- if (existing) {
146
- // Deep-merge so multiple plugins can contribute to the same nested keys
147
- // (e.g. each plugin adds its own objects under `objects.*`)
148
- this.translations.set(locale, deepMerge(existing, translations));
149
- } else {
150
- this.translations.set(locale, { ...translations });
151
- }
152
- }
153
-
154
- getLocales(): string[] {
155
- return Array.from(this.translations.keys());
156
- }
157
-
158
- getDefaultLocale(): string {
159
- return this.defaultLocale;
160
- }
161
-
162
- setDefaultLocale(locale: string): void {
163
- this.defaultLocale = locale;
164
- }
165
-
166
- /**
167
- * Load all JSON translation files from a directory.
168
- * Each file should be named `{locale}.json`.
169
- */
170
- private loadFromDirectory(dir: string): void {
171
- if (!fs.existsSync(dir)) return;
172
-
173
- const files = fs.readdirSync(dir);
174
- for (const file of files) {
175
- if (!file.endsWith('.json')) continue;
176
- const locale = file.replace(/\.json$/, '');
177
- const filePath = path.join(dir, file);
178
- try {
179
- const content = fs.readFileSync(filePath, 'utf-8');
180
- const data = JSON.parse(content) as Record<string, unknown>;
181
- this.translations.set(locale, data);
182
- } catch {
183
- // Skip files that can't be parsed
184
- }
185
- }
186
- }
187
-
188
- private resolveFromLocale(key: string, locale: string): string | undefined {
189
- const data = this.translations.get(locale);
190
- if (!data) return undefined;
191
- return resolveKey(data, key);
192
- }
193
- }
@@ -1,280 +0,0 @@
1
- // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- import { describe, it, expect, vi, beforeEach } from 'vitest';
4
- import { I18nServicePlugin } from './i18n-service-plugin';
5
- import type { IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts';
6
-
7
- // ---------------------------------------------------------------------------
8
- // Mocks
9
- // ---------------------------------------------------------------------------
10
-
11
- function createMockHttpServer() {
12
- const routes = new Map<string, RouteHandler>();
13
- return {
14
- get: vi.fn((path: string, handler: RouteHandler) => { routes.set(`GET:${path}`, handler); }),
15
- post: vi.fn(),
16
- put: vi.fn(),
17
- delete: vi.fn(),
18
- patch: vi.fn(),
19
- use: vi.fn(),
20
- listen: vi.fn().mockResolvedValue(undefined),
21
- close: vi.fn().mockResolvedValue(undefined),
22
- /** Test helper: retrieve a registered handler */
23
- _getHandler(method: string, path: string): RouteHandler | undefined {
24
- return routes.get(`${method}:${path}`);
25
- },
26
- };
27
- }
28
-
29
- function createMockPluginContext(services: Record<string, any> = {}) {
30
- const hooks = new Map<string, Array<(...args: any[]) => Promise<void>>>();
31
- return {
32
- registerService: vi.fn(),
33
- getService: vi.fn((name: string) => {
34
- if (services[name]) return services[name];
35
- throw new Error(`Service '${name}' not found`);
36
- }),
37
- getServices: vi.fn(() => new Map(Object.entries(services))),
38
- hook: vi.fn((name: string, handler: (...args: any[]) => Promise<void>) => {
39
- if (!hooks.has(name)) hooks.set(name, []);
40
- hooks.get(name)!.push(handler);
41
- }),
42
- trigger: vi.fn(async (name: string, ...args: any[]) => {
43
- const handlers = hooks.get(name) ?? [];
44
- for (const h of handlers) await h(...args);
45
- }),
46
- logger: {
47
- info: vi.fn(),
48
- warn: vi.fn(),
49
- error: vi.fn(),
50
- debug: vi.fn(),
51
- },
52
- getKernel: vi.fn(),
53
- replaceService: vi.fn(),
54
- };
55
- }
56
-
57
- function createMockReq(overrides: Partial<IHttpRequest> = {}): IHttpRequest {
58
- return {
59
- params: {},
60
- query: {},
61
- headers: {},
62
- method: 'GET',
63
- path: '/',
64
- ...overrides,
65
- };
66
- }
67
-
68
- function createMockRes(): IHttpResponse & { _data: any; _status: number } {
69
- const res: any = {
70
- _data: null,
71
- _status: 200,
72
- json(data: any) { res._data = data; },
73
- send(data: string) { res._data = data; },
74
- status(code: number) { res._status = code; return res; },
75
- header() { return res; },
76
- };
77
- return res;
78
- }
79
-
80
- // ---------------------------------------------------------------------------
81
- // Tests
82
- // ---------------------------------------------------------------------------
83
-
84
- describe('I18nServicePlugin', () => {
85
- let httpServer: ReturnType<typeof createMockHttpServer>;
86
- let ctx: ReturnType<typeof createMockPluginContext>;
87
-
88
- beforeEach(() => {
89
- httpServer = createMockHttpServer();
90
- ctx = createMockPluginContext({ 'http-server': httpServer });
91
- });
92
-
93
- // -- Service registration -------------------------------------------------
94
-
95
- describe('init', () => {
96
- it('should register i18n service during init', async () => {
97
- const plugin = new I18nServicePlugin();
98
- await plugin.init!(ctx as any);
99
-
100
- expect(ctx.registerService).toHaveBeenCalledWith('i18n', expect.any(Object));
101
- });
102
-
103
- it('should pass options to the FileI18nAdapter', async () => {
104
- const plugin = new I18nServicePlugin({ defaultLocale: 'zh-CN' });
105
- await plugin.init!(ctx as any);
106
-
107
- const registeredService = ctx.registerService.mock.calls[0][1];
108
- expect(registeredService.getDefaultLocale()).toBe('zh-CN');
109
- });
110
- });
111
-
112
- // -- Route self-registration ----------------------------------------------
113
-
114
- describe('route self-registration', () => {
115
- it('should register a kernel:ready hook during start', async () => {
116
- const plugin = new I18nServicePlugin();
117
- await plugin.init!(ctx as any);
118
- await plugin.start!(ctx as any);
119
-
120
- expect(ctx.hook).toHaveBeenCalledWith('kernel:ready', expect.any(Function));
121
- });
122
-
123
- it('should register i18n routes when http-server is available', async () => {
124
- const plugin = new I18nServicePlugin();
125
- await plugin.init!(ctx as any);
126
- await plugin.start!(ctx as any);
127
-
128
- // Simulate kernel:ready
129
- await ctx.trigger('kernel:ready');
130
-
131
- expect(httpServer.get).toHaveBeenCalledWith('/api/v1/i18n/locales', expect.any(Function));
132
- expect(httpServer.get).toHaveBeenCalledWith('/api/v1/i18n/translations/:locale', expect.any(Function));
133
- expect(httpServer.get).toHaveBeenCalledWith('/api/v1/i18n/labels/:object/:locale', expect.any(Function));
134
- });
135
-
136
- it('should respect custom basePath', async () => {
137
- const plugin = new I18nServicePlugin({ basePath: '/custom/i18n' });
138
- await plugin.init!(ctx as any);
139
- await plugin.start!(ctx as any);
140
-
141
- await ctx.trigger('kernel:ready');
142
-
143
- expect(httpServer.get).toHaveBeenCalledWith('/custom/i18n/locales', expect.any(Function));
144
- expect(httpServer.get).toHaveBeenCalledWith('/custom/i18n/translations/:locale', expect.any(Function));
145
- expect(httpServer.get).toHaveBeenCalledWith('/custom/i18n/labels/:object/:locale', expect.any(Function));
146
- });
147
-
148
- it('should skip route registration when registerRoutes is false', async () => {
149
- const plugin = new I18nServicePlugin({ registerRoutes: false });
150
- await plugin.init!(ctx as any);
151
- await plugin.start!(ctx as any);
152
-
153
- expect(ctx.hook).not.toHaveBeenCalled();
154
- });
155
-
156
- it('should gracefully skip routes when http-server is not available', async () => {
157
- const ctxNoHttp = createMockPluginContext({}); // no http-server
158
- const plugin = new I18nServicePlugin();
159
- await plugin.init!(ctxNoHttp as any);
160
- await plugin.start!(ctxNoHttp as any);
161
-
162
- await ctxNoHttp.trigger('kernel:ready');
163
-
164
- expect(ctxNoHttp.logger.warn).toHaveBeenCalledWith(
165
- expect.stringContaining('No HTTP server available'),
166
- );
167
- });
168
- });
169
-
170
- // -- Route handler behavior -----------------------------------------------
171
-
172
- describe('route handlers', () => {
173
- async function setupPlugin(options: ConstructorParameters<typeof I18nServicePlugin>[0] = {}) {
174
- const plugin = new I18nServicePlugin(options);
175
- await plugin.init!(ctx as any);
176
- // Load some translations after init so the service has data
177
- const i18n = ctx.registerService.mock.calls[0][1];
178
- i18n.loadTranslations('en', { greeting: 'Hello', 'o.account.fields.name': 'Account Name' });
179
- i18n.loadTranslations('zh-CN', { greeting: '你好', 'o.account.fields.name': '账户名称' });
180
- await plugin.start!(ctx as any);
181
- await ctx.trigger('kernel:ready');
182
- return { plugin, i18n };
183
- }
184
-
185
- it('GET /locales should return all available locales', async () => {
186
- await setupPlugin();
187
-
188
- const handler = httpServer._getHandler('GET', '/api/v1/i18n/locales')!;
189
- expect(handler).toBeDefined();
190
-
191
- const req = createMockReq();
192
- const res = createMockRes();
193
- await handler(req, res);
194
-
195
- expect(res._data).toEqual({
196
- data: {
197
- locales: [
198
- { code: 'en', label: 'en', isDefault: true },
199
- { code: 'zh-CN', label: 'zh-CN', isDefault: false },
200
- ],
201
- },
202
- });
203
- });
204
-
205
- it('GET /translations/:locale should return translations for the given locale', async () => {
206
- await setupPlugin();
207
-
208
- const handler = httpServer._getHandler('GET', '/api/v1/i18n/translations/:locale')!;
209
- expect(handler).toBeDefined();
210
-
211
- const req = createMockReq({ params: { locale: 'en' } });
212
- const res = createMockRes();
213
- await handler(req, res);
214
-
215
- expect(res._data).toEqual({
216
- data: {
217
- locale: 'en',
218
- translations: { greeting: 'Hello', 'o.account.fields.name': 'Account Name' },
219
- },
220
- });
221
- });
222
-
223
- it('GET /translations/:locale should return 400 when locale is missing', async () => {
224
- await setupPlugin();
225
-
226
- const handler = httpServer._getHandler('GET', '/api/v1/i18n/translations/:locale')!;
227
- const req = createMockReq({ params: {} });
228
- const res = createMockRes();
229
- await handler(req, res);
230
-
231
- expect(res._status).toBe(400);
232
- expect(res._data).toEqual({ error: 'Missing locale parameter' });
233
- });
234
-
235
- it('GET /labels/:object/:locale should derive field labels from translation bundle', async () => {
236
- await setupPlugin();
237
-
238
- const handler = httpServer._getHandler('GET', '/api/v1/i18n/labels/:object/:locale')!;
239
- expect(handler).toBeDefined();
240
-
241
- const req = createMockReq({ params: { object: 'account', locale: 'en' } });
242
- const res = createMockRes();
243
- await handler(req, res);
244
-
245
- expect(res._data).toEqual({
246
- data: {
247
- object: 'account',
248
- locale: 'en',
249
- labels: { name: 'Account Name' },
250
- },
251
- });
252
- });
253
-
254
- it('GET /labels/:object/:locale should return 400 when params are missing', async () => {
255
- await setupPlugin();
256
-
257
- const handler = httpServer._getHandler('GET', '/api/v1/i18n/labels/:object/:locale')!;
258
- const req = createMockReq({ params: {} });
259
- const res = createMockRes();
260
- await handler(req, res);
261
-
262
- expect(res._status).toBe(400);
263
- expect(res._data).toEqual({ error: 'Missing object or locale parameter' });
264
- });
265
- });
266
-
267
- // -- Plugin metadata -------------------------------------------------------
268
-
269
- describe('plugin metadata', () => {
270
- it('should have correct plugin name', () => {
271
- const plugin = new I18nServicePlugin();
272
- expect(plugin.name).toBe('com.objectstack.service.i18n');
273
- });
274
-
275
- it('should have version', () => {
276
- const plugin = new I18nServicePlugin();
277
- expect(plugin.version).toBe('1.0.0');
278
- });
279
- });
280
- });