Skip to content

Commit 9fc3b6d

Browse files
committed
test(service-settings): pin the default language's served options and acceptance on a real boot
Claude-Session: https://claude.ai/code/session_01Utp6CZEhtHfQH5TLEVe3TF Co-authored-by: Claude <noreply@anthropic.com>
1 parent a9d1aa0 commit 9fc3b6d

2 files changed

Lines changed: 289 additions & 3 deletions

File tree

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Setup's 默认语言 (`localization.locale`) offers and accepts the languages
5+
* the deployment supports: the i18n service's locale list, the one the user's
6+
* language menu reads (`GET /api/v1/i18n/locales`), not four codes written
7+
* into the manifest.
8+
*
9+
* Pinned on a real `LiteKernel` boot of `SettingsServicePlugin`, through the
10+
* settings routes the Localization page reads and saves through, so the
11+
* wiring, the served manifest and the save's acceptance are one path. The
12+
* i18n service is a stand-in whose locale list the cases change at runtime,
13+
* the way an added translation item grows it.
14+
*/
15+
16+
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
17+
import { LiteKernel } from '@objectstack/core';
18+
import type { Plugin, PluginContext } from '@objectstack/core';
19+
import type { IHttpServer, IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts';
20+
import { SettingsServicePlugin } from './settings-service-plugin.js';
21+
import { SettingsService } from './settings-service.js';
22+
import { registerSettingsRoutes } from './settings-routes.js';
23+
import { localizationSettingsManifest } from './manifests/localization.manifest.js';
24+
import { localeSelfName, localizationLocaleOptionSource, notOfferedCopy } from './localization-locale-options.js';
25+
26+
/** An i18n service whose reported locale list each case sets. */
27+
class LocaleListI18nPlugin implements Plugin {
28+
name = 'test.locale-list-i18n';
29+
version = '0.0.0';
30+
locales: string[] = [];
31+
async init(ctx: PluginContext): Promise<void> {
32+
ctx.registerService('i18n', {
33+
loadTranslations: () => {},
34+
getLocales: () => [...this.locales],
35+
});
36+
}
37+
}
38+
39+
class MockHttp implements IHttpServer {
40+
routes = new Map<string, RouteHandler>();
41+
get(path: string, h: RouteHandler) { this.routes.set(`GET ${path}`, h); return this as any; }
42+
post(path: string, h: RouteHandler) { this.routes.set(`POST ${path}`, h); return this as any; }
43+
put(path: string, h: RouteHandler) { this.routes.set(`PUT ${path}`, h); return this as any; }
44+
delete(path: string, h: RouteHandler) { this.routes.set(`DELETE ${path}`, h); return this as any; }
45+
patch(path: string, h: RouteHandler) { this.routes.set(`PATCH ${path}`, h); return this as any; }
46+
use() { return this as any; }
47+
listen() { return Promise.resolve(); }
48+
close() { return Promise.resolve(); }
49+
getInstance() { return null; }
50+
}
51+
52+
async function call(
53+
http: MockHttp,
54+
route: string,
55+
opts: { body?: unknown; headers?: Record<string, string> } = {},
56+
): Promise<{ status: number; body: any }> {
57+
const state: { status: number; body?: any } = { status: 200 };
58+
const res = {
59+
json: vi.fn((data: unknown) => { state.body = data; }),
60+
send: vi.fn(),
61+
status: vi.fn((code: number) => { state.status = code; return res; }),
62+
header: vi.fn(() => res),
63+
} as unknown as IHttpResponse;
64+
const req = {
65+
params: { namespace: 'localization' },
66+
query: {},
67+
body: opts.body,
68+
headers: opts.headers ?? {},
69+
method: route.split(' ')[0],
70+
path: '/api/settings/localization',
71+
} as IHttpRequest;
72+
await http.routes.get(route)!(req, res);
73+
return { status: state.status, body: state.body };
74+
}
75+
76+
const admin = () => ({ enforced: true, permissions: ['setup.access', 'setup.write'], userId: 'usr_admin' });
77+
78+
async function boot(env: Record<string, string> = {}): Promise<{
79+
kernel: LiteKernel;
80+
i18n: LocaleListI18nPlugin;
81+
service: SettingsService;
82+
http: MockHttp;
83+
}> {
84+
const i18n = new LocaleListI18nPlugin();
85+
const kernel = new LiteKernel({ logger: { level: 'error' } as never });
86+
kernel.use(i18n);
87+
kernel.use(new SettingsServicePlugin({ registerRoutes: false, env }));
88+
await kernel.bootstrap();
89+
const service = kernel.getService<SettingsService>('settings');
90+
const http = new MockHttp();
91+
registerSettingsRoutes(http, service, { contextFromRequest: admin });
92+
return { kernel, i18n, service, http };
93+
}
94+
95+
const servedLocaleOptions = (body: any): Array<{ value: string; label: string }> =>
96+
body.data.manifest.specifiers.find((s: { key?: string }) => s.key === 'locale').options;
97+
98+
const getPage = (http: MockHttp, headers?: Record<string, string>) =>
99+
call(http, 'GET /api/settings/:namespace', { headers });
100+
const save = (http: MockHttp, body: unknown) => call(http, 'PUT /api/settings/:namespace', { body });
101+
102+
describe('localization.locale serves the deployment\'s locale list', () => {
103+
let booted: Awaited<ReturnType<typeof boot>>;
104+
beforeAll(async () => {
105+
booted = await boot();
106+
});
107+
afterAll(async () => {
108+
await booted.kernel.shutdown();
109+
});
110+
afterEach(async () => {
111+
await booted.service.setMany('localization', { locale: null });
112+
});
113+
114+
it('offers what the i18n service reports, each named in its own language', async () => {
115+
booted.i18n.locales = ['en', 'zh-CN', 'fr', 'vi-VN'];
116+
const page = await getPage(booted.http);
117+
expect(page.status).toBe(200);
118+
const options = servedLocaleOptions(page.body);
119+
// The manifest default `en-US` is in effect, a second spelling of `en`:
120+
// that option is offered under the stored spelling, so it shows chosen.
121+
expect(options.map((o) => o.value)).toEqual(['en-US', 'zh-CN', 'fr', 'vi-VN']);
122+
expect(options.slice(0, 3).map((o) => o.label)).toEqual(['English', '简体中文', 'Français']);
123+
expect(options[3].label).toBe(localeSelfName('vi-VN'));
124+
// The registered manifest is left as declared.
125+
const declared = (booted.service.getManifest('localization').specifiers as any[]).find((s) => s.key === 'locale');
126+
expect(declared.options.map((o: { value: string }) => o.value)).toEqual(['en-US', 'zh-CN', 'ja-JP', 'es-ES']);
127+
});
128+
129+
it('accepts a language the deployment supports that the declared four never named', async () => {
130+
booted.i18n.locales = ['en', 'zh-CN', 'fr-FR'];
131+
const saved = await save(booted.http, { locale: 'fr-FR' });
132+
expect(saved.status).toBe(200);
133+
expect(saved.body.data.values.locale.value).toBe('fr-FR');
134+
expect((await booted.service.get('localization', 'locale')).value).toBe('fr-FR');
135+
});
136+
137+
it('follows the list at runtime: a translation item added later is accepted without a reboot', async () => {
138+
booted.i18n.locales = ['en', 'zh-CN'];
139+
const before = await save(booted.http, { locale: 'vi-VN' });
140+
expect(before.status).toBe(400);
141+
booted.i18n.locales = ['en', 'zh-CN', 'vi-VN'];
142+
const after = await save(booted.http, { locale: 'vi-VN' });
143+
expect(after.status).toBe(200);
144+
expect((await booted.service.get('localization', 'locale')).value).toBe('vi-VN');
145+
});
146+
147+
it('refuses a language the deployment does not support, as any value outside a table is refused', async () => {
148+
booted.i18n.locales = ['en', 'zh-CN'];
149+
const refused = await save(booted.http, { locale: 'ja-JP' });
150+
expect(refused.status).toBe(400);
151+
expect(refused.body.error.code).toBe('SETTINGS_VALIDATION');
152+
expect(refused.body.error.details.fields).toEqual([
153+
expect.objectContaining({ field: 'locale', code: 'invalid_option', value: 'ja-JP', constraint: { allowed: 'en, zh-CN' } }),
154+
]);
155+
// Nothing was written.
156+
expect((await booted.service.get('localization', 'locale')).source).toBe('default');
157+
});
158+
159+
it('accepts the spelling stored values carry for an offered language (`en-US` where `en` is offered)', async () => {
160+
booted.i18n.locales = ['en', 'zh-Hans'];
161+
expect((await save(booted.http, { locale: 'zh-CN' })).status).toBe(200);
162+
expect((await save(booted.http, { locale: 'en-US' })).status).toBe(200);
163+
// A code the platform locale table does not alias is accepted only as spelled.
164+
booted.i18n.locales = ['en', 'fr'];
165+
expect((await save(booted.http, { locale: 'fr-FR' })).status).toBe(400);
166+
});
167+
168+
it('keeps a stored value the deployment no longer offers readable, and serves it named as such', async () => {
169+
booted.i18n.locales = ['en', 'zh-CN', 'ja-JP'];
170+
expect((await save(booted.http, { locale: 'ja-JP' })).status).toBe(200);
171+
booted.i18n.locales = ['en', 'zh-CN'];
172+
173+
const page = await getPage(booted.http, { 'accept-language': 'zh-CN' });
174+
expect(page.body.data.values.locale.value).toBe('ja-JP');
175+
expect(servedLocaleOptions(page.body)).toEqual([
176+
{ value: 'en', label: 'English' },
177+
{ value: 'zh-CN', label: '简体中文' },
178+
{ value: 'ja-JP', label: `日本語 (${notOfferedCopy('zh-CN')})` },
179+
]);
180+
// It keeps being the value in effect, and a save of another key passes it by.
181+
expect((await save(booted.http, { fiscal_year_start: 'april' })).status).toBe(200);
182+
expect((await booted.service.get('localization', 'locale')).value).toBe('ja-JP');
183+
// Written again, it is judged like any other value.
184+
expect((await save(booted.http, { locale: 'ja-JP' })).status).toBe(400);
185+
});
186+
187+
it('stands on the declared four when the i18n service reports no locale', async () => {
188+
booted.i18n.locales = [];
189+
const page = await getPage(booted.http);
190+
expect(servedLocaleOptions(page.body).map((o) => o.value)).toEqual(['en-US', 'zh-CN', 'ja-JP', 'es-ES']);
191+
expect((await save(booted.http, { locale: 'fr-FR' })).status).toBe(400);
192+
expect((await save(booted.http, { locale: 'es-ES' })).status).toBe(200);
193+
});
194+
});
195+
196+
describe('an OS_LOCALIZATION_LOCALE override is judged against the same list, when it is read', () => {
197+
it('is in force for a language the deployment supports, and not reported at boot', async () => {
198+
const booted = await boot({ OS_LOCALIZATION_LOCALE: 'vi-VN' });
199+
try {
200+
booted.i18n.locales = ['en', 'vi-VN'];
201+
const resolved = await booted.service.get('localization', 'locale');
202+
expect(resolved.value).toBe('vi-VN');
203+
expect(resolved.source).toBe('env');
204+
booted.i18n.locales = ['en'];
205+
// No longer offered: the override is not in force, as any rejected one.
206+
expect((await booted.service.get('localization', 'locale')).source).not.toBe('env');
207+
} finally {
208+
await booted.kernel.shutdown();
209+
}
210+
});
211+
});
212+
213+
describe('the registration-time audit of OS_* overrides', () => {
214+
it('leaves a sourced key to be judged when read, and still judges a key with only a declared table', () => {
215+
const error = vi.fn();
216+
const svc = new SettingsService({
217+
env: { OS_LOCALIZATION_LOCALE: 'vi-VN', OS_LOCALIZATION_FISCAL_YEAR_START: 'smarch' },
218+
logger: { warn: vi.fn(), error } as never,
219+
});
220+
// The i18n service is not up yet at this point of a boot.
221+
svc.registerOptionSource('localization', 'locale', localizationLocaleOptionSource(() => undefined));
222+
svc.registerManifest(localizationSettingsManifest);
223+
const reported = error.mock.calls.map((c) => String(c[0]));
224+
expect(reported.some((line) => line.includes('OS_LOCALIZATION_LOCALE'))).toBe(false);
225+
expect(reported.some((line) => line.includes('OS_LOCALIZATION_FISCAL_YEAR_START'))).toBe(true);
226+
});
227+
});
228+
229+
describe('a service with no option source, or a failing one', () => {
230+
it('judges the declared table', async () => {
231+
const svc = new SettingsService({ env: {} });
232+
svc.registerManifest(localizationSettingsManifest);
233+
await expect(svc.setMany('localization', { locale: 'fr-FR' })).rejects.toMatchObject({ code: 'SETTINGS_VALIDATION' });
234+
await svc.setMany('localization', { locale: 'ja-JP' });
235+
expect((await svc.get('localization', 'locale')).value).toBe('ja-JP');
236+
});
237+
238+
it('falls back to the declared table when the source throws, and says so once', async () => {
239+
const warn = vi.fn();
240+
const svc = new SettingsService({ env: {}, logger: { warn, error: vi.fn() } as never });
241+
svc.registerOptionSource('localization', 'locale', localizationLocaleOptionSource(() => {
242+
throw new Error('i18n exploded');
243+
}));
244+
svc.registerManifest(localizationSettingsManifest);
245+
await svc.setMany('localization', { locale: 'ja-JP' });
246+
await expect(svc.setMany('localization', { locale: 'fr-FR' })).rejects.toMatchObject({ code: 'SETTINGS_VALIDATION' });
247+
expect(warn).toHaveBeenCalledTimes(1);
248+
expect(String(warn.mock.calls[0][0])).toContain("'localization.locale'");
249+
});
250+
});
251+
252+
describe('localeSelfName: a locale named in its own language, else its code', () => {
253+
it('takes the platform locale table\'s name for the language a code means', () => {
254+
expect(localeSelfName('en')).toBe('English');
255+
expect(localeSelfName('en-US')).toBe('English');
256+
expect(localeSelfName('zh-TW')).toBe('繁體中文');
257+
expect(localeSelfName('ja-JP')).toBe('日本語');
258+
});
259+
260+
it('answers the code for a tag nothing names, a malformed tag, and a runtime without Intl.DisplayNames', () => {
261+
expect(localeSelfName('xx-YY')).toBe('xx-YY');
262+
expect(localeSelfName('not a tag')).toBe('not a tag');
263+
const original = Intl.DisplayNames;
264+
try {
265+
(Intl as { DisplayNames?: unknown }).DisplayNames = undefined;
266+
expect(localeSelfName('vi-VN')).toBe('vi-VN');
267+
} finally {
268+
(Intl as { DisplayNames?: unknown }).DisplayNames = original;
269+
}
270+
});
271+
272+
it('names a language off the table through Intl.DisplayNames where the runtime\'s ICU data has it', () => {
273+
// Read from the runtime rather than written down: ICU builds differ, and
274+
// the pin is that the name comes from it, capitalized, or is the code.
275+
let expected = 'vi-VN';
276+
try {
277+
const name = new Intl.DisplayNames(['vi-VN'], { type: 'language', fallback: 'none' }).of('vi-VN');
278+
if (name && name !== 'vi-VN') expected = name.charAt(0).toLocaleUpperCase('vi-VN') + name.slice(1);
279+
} catch {
280+
// keep the code
281+
}
282+
expect(localeSelfName('vi-VN')).toBe(expected);
283+
});
284+
});

‎packages/services/service-settings/src/localization-locale-options.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,15 +57,17 @@ const TABLE_LABEL = new Map<string, string>(PLATFORM_LOCALES.map((row) => [row.t
5757
* the language the code means, else `Intl.DisplayNames` in the code itself,
5858
* capitalized as the table's names are, else the code. Every failure answers
5959
* the code, which is still an honest, selectable name: `Intl.DisplayNames`
60-
* may be absent, throws `RangeError` on a malformed tag, and answers the code
61-
* back for a language its ICU data does not name.
60+
* may be absent, throws `RangeError` on a malformed tag, and names no language
61+
* its ICU data does not have.
6262
*/
6363
export function localeSelfName(code: string): string {
6464
const tag = platformLocaleTagOf(code);
6565
const fromTable = tag ? TABLE_LABEL.get(tag) : undefined;
6666
if (fromTable) return fromTable;
6767
try {
68-
const name = new Intl.DisplayNames([code], { type: 'language' }).of(code);
68+
// `fallback: 'none'`: a language the ICU data does not name answers
69+
// `undefined`, not a made-up name built from the subtags (`Xx (YY)`).
70+
const name = new Intl.DisplayNames([code], { type: 'language', fallback: 'none' }).of(code);
6971
if (!name || name === code) return code;
7072
return name.charAt(0).toLocaleUpperCase(code) + name.slice(1);
7173
} catch {

0 commit comments

Comments
 (0)