From 554ca287c77f3f0c83ccd58f20ed2cf55c30b916 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 15:53:30 +0000 Subject: [PATCH 1/2] fix(service-storage): Setup storage test connection answers in the caller's language The storage/test settings action writes its message in the locale the settings action route hands the handler (Accept-Language, else ?locale): success, probe mismatch and incomplete S3 settings in en, zh-CN, ja-JP and es-ES; an adapter's own error text stays verbatim behind a localized lead-in outside English. Absent or unknown locale answers in English. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Utp6CaWLG2RrgKub5oRNjC --- .../30335-storage-test-message-locale.md | 10 + .../src/storage-service-plugin.ts | 20 +- .../service-storage/src/storage-test-copy.ts | 83 ++++++++ .../src/storage-test-locale.test.ts | 178 ++++++++++++++++++ 4 files changed, 285 insertions(+), 6 deletions(-) create mode 100644 .changeset/30335-storage-test-message-locale.md create mode 100644 packages/services/service-storage/src/storage-test-copy.ts create mode 100644 packages/services/service-storage/src/storage-test-locale.test.ts diff --git a/.changeset/30335-storage-test-message-locale.md b/.changeset/30335-storage-test-message-locale.md new file mode 100644 index 00000000000..d34e93f62dd --- /dev/null +++ b/.changeset/30335-storage-test-message-locale.md @@ -0,0 +1,10 @@ +--- +'@objectstack/service-storage': patch +--- + +fix(service-storage): Setup → Storage → Test connection answers in the caller's language + +Clause-②: no +Metadata-protocol: none + +The `storage/test` settings action writes its `message` in the language the settings action route hands its handler as `locale` (`Accept-Language`, else `?locale`), the same source the mail test notice reads. It covers the sentences the product writes: the success sentence (which still names the adapter), the refusal when the probe read back is not the one written, and the refusal for S3 settings missing a bucket or a region. An adapter's own error text stays verbatim: alone in English, as before; in another language behind a lead-in saying the connection test failed. Locales: `en`, `zh-CN`, `ja-JP`, `es-ES`; an absent locale, or one with no copy, is answered in English exactly as before. `ok`, `severity`, the probe and its cleanup, and the operator log lines are unchanged. diff --git a/packages/services/service-storage/src/storage-service-plugin.ts b/packages/services/service-storage/src/storage-service-plugin.ts index 38bfdf413d1..59063b6c259 100644 --- a/packages/services/service-storage/src/storage-service-plugin.ts +++ b/packages/services/service-storage/src/storage-service-plugin.ts @@ -87,6 +87,7 @@ import { mayActIrreversibly } from '@objectstack/platform-objects/system'; import { FILE_REFERENCES_MIGRATION_ID } from '@objectstack/spec/system'; import { inProcessSessionReadInput } from '@objectstack/types'; import { SwappableStorageService } from './swappable-storage-service.js'; +import { S3SettingsIncompleteError, storageTestCopy } from './storage-test-copy.js'; import { resolveStorageTarget, needsStorageSwap, @@ -286,7 +287,7 @@ export class StorageServicePlugin implements Plugin { const bucket = values.s3_bucket as string | undefined; const region = values.s3_region as string | undefined; if (!bucket || !region) { - throw new Error('StorageServicePlugin: S3 adapter requires s3_bucket and s3_region'); + throw new S3SettingsIncompleteError(); } const opts: S3StorageAdapterOptions = { bucket, @@ -814,7 +815,11 @@ export class StorageServicePlugin implements Plugin { // Register the live `storage/test` probe handler. if (typeof settings.registerAction === 'function' && this.storage) { const proxy = this.storage; - settings.registerAction('storage', 'test', async ({ values, payload }: any) => { + settings.registerAction('storage', 'test', async ({ values, payload, locale }: any) => { + // [#30335] The answer is the toast the administrator reads: written + // in the caller's display language, which the settings action + // route hands over as `locale` (English when none is given). + const copy = storageTestCopy(typeof locale === 'string' ? locale : undefined); // Merge the (possibly unsaved) form state posted as // `payload.values` over the persisted snapshot so an operator // can validate edits before hitting "Save". Matches the @@ -851,7 +856,10 @@ export class StorageServicePlugin implements Plugin { try { target = await this.buildAdapterFromValues(merged); } catch (err: any) { - return { ok: false, severity: 'error', message: err?.message ?? String(err) }; + const message = err instanceof S3SettingsIncompleteError + ? copy.s3SettingsIncomplete + : copy.failed(err?.message ?? String(err)); + return { ok: false, severity: 'error', message }; } } @@ -900,18 +908,18 @@ export class StorageServicePlugin implements Plugin { // unguarded delete, whose failure IS a probe failure the // operator must be told about, and no path deletes twice. await removeProbeObject(); - return { ok: false, severity: 'error', message: 'Probe download did not match upload.' }; + return { ok: false, severity: 'error', message: copy.downloadMismatch }; } await target.delete(probeKey); const adapter = String(merged.adapter ?? this.options.adapter ?? 'local'); return { ok: true, severity: 'info', - message: `Storage round-trip succeeded (adapter=${adapter}).`, + message: copy.succeeded(adapter), }; } catch (err: any) { await removeProbeObject(); - return { ok: false, severity: 'error', message: err?.message ?? String(err) }; + return { ok: false, severity: 'error', message: copy.failed(err?.message ?? String(err)) }; } }); ctx.logger.info('StorageServicePlugin: registered settings action storage/test'); diff --git a/packages/services/service-storage/src/storage-test-copy.ts b/packages/services/service-storage/src/storage-test-copy.ts new file mode 100644 index 00000000000..ac0dd9cccc7 --- /dev/null +++ b/packages/services/service-storage/src/storage-test-copy.ts @@ -0,0 +1,83 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#30335] The sentences Setup → Storage → 测试连接 (`storage/test`) answers + * with, one entry per locale this package's `translations/` serves. + * + * The console prints the action's `message` as the toast, bare, so the + * sentence is the administrator's whole answer. The settings action route + * hands the handler the caller's display language (`Accept-Language`, else + * `?locale`) as the input's `locale`; an absent locale, or one with no entry + * here, is answered in English, the sentence every caller got before. + * + * Only the sentences the product writes live here. An adapter's own error + * text (the S3 SDK's, the file system's) is passed through verbatim: in + * English on its own, as it always was; in another language behind a lead-in + * that says the connection test failed, so the reader knows what the + * untranslated part is. The operator log lines stay English. + */ + +import { resolveLocale } from '@objectstack/core'; + +/** The English refusal for S3 settings missing a bucket or a region. */ +export const S3_SETTINGS_INCOMPLETE_MESSAGE = + 'StorageServicePlugin: S3 adapter requires s3_bucket and s3_region'; + +/** + * Thrown when the `storage` settings name the S3 adapter without a bucket or a + * region. Its message is the English refusal the operator log has always + * carried; the `storage/test` answer reads the type to write the refusal in the + * caller's language instead. + */ +export class S3SettingsIncompleteError extends Error { + constructor() { + super(S3_SETTINGS_INCOMPLETE_MESSAGE); + this.name = 'S3SettingsIncompleteError'; + } +} + +export interface StorageTestCopy { + /** The probe object was written, read back identical and deleted. */ + succeeded(adapter: string): string; + /** The probe object read back is not the one written. */ + downloadMismatch: string; + /** The S3 adapter was chosen without a bucket or a region. */ + s3SettingsIncomplete: string; + /** The adapter refused; `reason` is its own text, verbatim. */ + failed(reason: string): string; +} + +export const STORAGE_TEST_COPY: Record<'en' | 'zh-CN' | 'ja-JP' | 'es-ES', StorageTestCopy> = { + en: { + succeeded: (adapter) => `Storage round-trip succeeded (adapter=${adapter}).`, + downloadMismatch: 'Probe download did not match upload.', + s3SettingsIncomplete: S3_SETTINGS_INCOMPLETE_MESSAGE, + failed: (reason) => reason, + }, + 'zh-CN': { + succeeded: (adapter) => `存储连接测试成功(适配器:${adapter})。`, + downloadMismatch: '探测文件下载后的内容与上传的不一致。', + s3SettingsIncomplete: 'S3 适配器需要填写存储桶(s3_bucket)和区域(s3_region)。', + failed: (reason) => `存储连接测试失败:${reason}`, + }, + 'ja-JP': { + succeeded: (adapter) => `ストレージの接続テストに成功しました(アダプター: ${adapter})。`, + downloadMismatch: 'プローブファイルのダウンロード内容がアップロードした内容と一致しません。', + s3SettingsIncomplete: 'S3 アダプターにはバケット(s3_bucket)とリージョン(s3_region)の指定が必要です。', + failed: (reason) => `ストレージの接続テストに失敗しました: ${reason}`, + }, + 'es-ES': { + succeeded: (adapter) => `La prueba de conexión del almacenamiento se ha completado correctamente (adaptador: ${adapter}).`, + downloadMismatch: 'El archivo de prueba descargado no coincide con el que se subió.', + s3SettingsIncomplete: 'El adaptador S3 necesita un bucket (s3_bucket) y una región (s3_region).', + failed: (reason) => `La prueba de conexión del almacenamiento ha fallado: ${reason}`, + }, +}; + +const COPY_LOCALES = Object.keys(STORAGE_TEST_COPY); + +/** The copy for a caller's locale: exact, then by language, else English. */ +export function storageTestCopy(locale: string | undefined): StorageTestCopy { + const picked = locale ? resolveLocale(locale, COPY_LOCALES) : undefined; + return STORAGE_TEST_COPY[(picked ?? 'en') as keyof typeof STORAGE_TEST_COPY]; +} diff --git a/packages/services/service-storage/src/storage-test-locale.test.ts b/packages/services/service-storage/src/storage-test-locale.test.ts new file mode 100644 index 00000000000..093fffebe85 --- /dev/null +++ b/packages/services/service-storage/src/storage-test-locale.test.ts @@ -0,0 +1,178 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#30335] Setup → Storage → 测试连接 answers in the caller's language. + * + * The console prints the `storage/test` action's `message` as the toast, bare. + * The settings action route hands the handler the caller's display language as + * the input's `locale` (`Accept-Language`, else `?locale`), the same source the + * mail test notice reads. These pins run the REAL handler the plugin registers, + * on a real local adapter, with that input member set or absent. + * + * `ok`, `severity`, the adapter name and the probe's cleanup are not the + * subject: each case asserts the first two are what they were, so a + * translation can never have moved a verdict. + */ + +import { describe, expect, it } from 'vitest'; +import { promises as fs } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { StorageServicePlugin } from './storage-service-plugin.js'; +import { LocalStorageAdapter } from './local-storage-adapter.js'; +import { storageTestCopy, STORAGE_TEST_COPY } from './storage-test-copy.js'; + +function makeCtx() { + const services = new Map(); + const hooks: Array<() => Promise | void> = []; + const ctx: any = { + logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }, + registerService: (name: string, svc: unknown) => { services.set(name, svc); }, + getService: (name: string): T => { + const s = services.get(name); + if (!s) throw new Error(`service '${name}' not registered`); + return s as T; + }, + hook: (event: string, fn: () => Promise | void) => { + if (event === 'kernel:ready') hooks.push(fn); + }, + _flushReady: async () => { for (const h of hooks) await h(); }, + }; + return ctx; +} + +/** A settings host that keeps the registered action so a test can run it. */ +function makeSettingsHost() { + const actions = new Map Promise>(); + return { + createClient: (_ns: string) => ({}), + getNamespace: async (_ns: string) => ({ values: {} }), + subscribe: (_ns: string, _fn: () => void) => {}, + registerAction: (ns: string, id: string, fn: (input: unknown) => Promise) => { + actions.set(`${ns}/${id}`, fn); + }, + run: async (input: Record) => { + const fn = actions.get('storage/test'); + if (!fn) throw new Error('storage/test was not registered'); + return await fn({ namespace: 'storage', actionId: 'test', ctx: {}, ...input }); + }, + }; +} + +async function bootPlugin() { + const root = await fs.mkdtemp(join(tmpdir(), 'oss-30335-')); + const plugin = new StorageServicePlugin({ adapter: 'local', local: { rootDir: root }, registerRoutes: false }); + const ctx = makeCtx(); + const settings = makeSettingsHost(); + ctx.registerService('settings', settings); + await plugin.init(ctx); + await plugin.start(ctx); + await ctx._flushReady(); + return { root, settings, plugin }; +} + +/** The local adapter with a download that answers bytes other than the ones uploaded. */ +function corruptDownloads(): () => void { + const original = LocalStorageAdapter.prototype.download; + LocalStorageAdapter.prototype.download = async function (this: LocalStorageAdapter, key: string) { + void key; + return Buffer.from('not the probe', 'utf-8'); + } as typeof original; + return () => { LocalStorageAdapter.prototype.download = original; }; +} + +describe('[#30335] storage/test answers in the caller\'s language', () => { + it('zh-CN: the success sentence is Chinese and names the adapter; the verdict is unchanged', async () => { + const { root, settings } = await bootPlugin(); + const result = await settings.run({ values: { adapter: 'local', local_root: root }, locale: 'zh-CN' }); + expect(result.ok).toBe(true); + expect(result.severity).toBe('info'); + expect(result.message).toBe('存储连接测试成功(适配器:local)。'); + expect(result.message).not.toContain('round-trip'); + }); + + it('en: the success sentence is the English one the console showed before', async () => { + const { root, settings } = await bootPlugin(); + const result = await settings.run({ values: { adapter: 'local', local_root: root }, locale: 'en-US' }); + expect(result.ok).toBe(true); + expect(result.severity).toBe('info'); + expect(result.message).toBe('Storage round-trip succeeded (adapter=local).'); + }); + + it('no locale, or one with no copy: English, exactly as an older settings host gets it', async () => { + const { root, settings } = await bootPlugin(); + const values = { adapter: 'local', local_root: root }; + for (const input of [{ values }, { values, locale: 'fr-FR' }, { values, locale: '' }]) { + const result = await settings.run(input); + expect(result.ok).toBe(true); + expect(result.severity).toBe('info'); + expect(result.message).toBe('Storage round-trip succeeded (adapter=local).'); + } + }); + + it('a language tag without the region still picks its copy (zh → zh-CN)', async () => { + const { root, settings } = await bootPlugin(); + const result = await settings.run({ values: { adapter: 'local', local_root: root }, locale: 'zh' }); + expect(result.message).toBe('存储连接测试成功(适配器:local)。'); + }); + + it('zh-CN: a download that does not match the upload is refused in Chinese', async () => { + const { root, settings } = await bootPlugin(); + const restore = corruptDownloads(); + try { + const result = await settings.run({ values: { adapter: 'local', local_root: root }, locale: 'zh-CN' }); + expect(result.ok).toBe(false); + expect(result.severity).toBe('error'); + expect(result.message).toBe('探测文件下载后的内容与上传的不一致。'); + } finally { + restore(); + } + // The probe object is still removed on this path (#13726). + const left = await fs.readdir(join(root, '__objectstack_probe__')).catch(() => []); + expect(left).toEqual([]); + }); + + it('zh-CN: incomplete S3 settings are refused in Chinese, naming the two fields', async () => { + const { settings } = await bootPlugin(); + const result = await settings.run({ values: { adapter: 's3', s3_bucket: '', s3_region: '' }, locale: 'zh-CN' }); + expect(result.ok).toBe(false); + expect(result.severity).toBe('error'); + expect(result.message).toBe('S3 适配器需要填写存储桶(s3_bucket)和区域(s3_region)。'); + }); + + it('no locale: incomplete S3 settings keep the English refusal', async () => { + const { settings } = await bootPlugin(); + const result = await settings.run({ values: { adapter: 's3', s3_bucket: '', s3_region: '' } }); + expect(result.ok).toBe(false); + expect(result.message).toBe('StorageServicePlugin: S3 adapter requires s3_bucket and s3_region'); + }); + + it('zh-CN: the adapter\'s own error text stays verbatim behind a Chinese lead-in', async () => { + const { settings } = await bootPlugin(); + // A root the process cannot create: a path under a regular FILE. + const file = join(await fs.mkdtemp(join(tmpdir(), 'oss-30335-file-')), 'not-a-dir'); + await fs.writeFile(file, 'x'); + const zh = await settings.run({ values: { adapter: 'local', local_root: join(file, 'root') }, locale: 'zh-CN' }); + const en = await settings.run({ values: { adapter: 'local', local_root: join(file, 'root') } }); + expect(zh.ok).toBe(false); + expect(zh.severity).toBe('error'); + expect(en.ok).toBe(false); + // English stays the adapter's sentence alone; Chinese is lead-in + that same sentence. + expect(en.message.length).toBeGreaterThan(0); + expect(zh.message).toBe(`存储连接测试失败:${en.message}`); + }); +}); + +describe('[#30335] the storage/test copy table', () => { + it('serves every locale the package\'s translations serve, each with every sentence', () => { + expect(Object.keys(STORAGE_TEST_COPY).sort()).toEqual(['en', 'es-ES', 'ja-JP', 'zh-CN']); + for (const locale of Object.keys(STORAGE_TEST_COPY)) { + const copy = storageTestCopy(locale); + expect(copy.succeeded('s3')).toContain('s3'); + expect(copy.downloadMismatch.length).toBeGreaterThan(0); + expect(copy.s3SettingsIncomplete).toContain('s3_bucket'); + expect(copy.s3SettingsIncomplete).toContain('s3_region'); + expect(copy.failed('EACCES')).toContain('EACCES'); + } + }); +}); From 700b4dcfe222f5463ebb793524465c7026ec75b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 16:48:55 +0000 Subject: [PATCH 2/2] fix(service-storage): storage test connection copy covers all eleven platform languages Adds zh-Hant, ar, fr, pt-BR, ru, de and ko to the storage/test copy table, reads the caller's code through platformLocaleTagOf (aliases and table tags) and then shorter subtags, and pins that every key carries every language. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Utp6CaWLG2RrgKub5oRNjC --- .../30335-storage-test-message-locale.md | 2 +- .../service-storage/src/storage-test-copy.ts | 92 +++++++++++++++++-- .../src/storage-test-locale.test.ts | 55 ++++++++--- 3 files changed, 130 insertions(+), 19 deletions(-) diff --git a/.changeset/30335-storage-test-message-locale.md b/.changeset/30335-storage-test-message-locale.md index d34e93f62dd..0953a201223 100644 --- a/.changeset/30335-storage-test-message-locale.md +++ b/.changeset/30335-storage-test-message-locale.md @@ -7,4 +7,4 @@ fix(service-storage): Setup → Storage → Test connection answers in the calle Clause-②: no Metadata-protocol: none -The `storage/test` settings action writes its `message` in the language the settings action route hands its handler as `locale` (`Accept-Language`, else `?locale`), the same source the mail test notice reads. It covers the sentences the product writes: the success sentence (which still names the adapter), the refusal when the probe read back is not the one written, and the refusal for S3 settings missing a bucket or a region. An adapter's own error text stays verbatim: alone in English, as before; in another language behind a lead-in saying the connection test failed. Locales: `en`, `zh-CN`, `ja-JP`, `es-ES`; an absent locale, or one with no copy, is answered in English exactly as before. `ok`, `severity`, the probe and its cleanup, and the operator log lines are unchanged. +The `storage/test` settings action writes its `message` in the language the settings action route hands its handler as `locale` (`Accept-Language`, else `?locale`), the same source the mail test notice reads. It covers the sentences the product writes: the success sentence (which still names the adapter), the refusal when the probe read back is not the one written, and the refusal for S3 settings missing a bucket or a region. An adapter's own error text stays verbatim: alone in English, as before; in another language behind a lead-in saying the connection test failed. Every language on the platform locale table is served (`en`, `zh-CN`, `zh-Hant`, `es-ES`, `ar`, `fr`, `pt-BR`, `ru`, `de`, `ja-JP`, `ko`), the caller's code read through `platformLocaleTagOf` and then shortened one subtag at a time; an absent locale, or one the table does not name, is answered in English exactly as before. `ok`, `severity`, the probe and its cleanup, and the operator log lines are unchanged. diff --git a/packages/services/service-storage/src/storage-test-copy.ts b/packages/services/service-storage/src/storage-test-copy.ts index ac0dd9cccc7..8d01131d3c5 100644 --- a/packages/services/service-storage/src/storage-test-copy.ts +++ b/packages/services/service-storage/src/storage-test-copy.ts @@ -2,7 +2,7 @@ /** * [#30335] The sentences Setup → Storage → 测试连接 (`storage/test`) answers - * with, one entry per locale this package's `translations/` serves. + * with, one entry per language on the platform locale table. * * The console prints the action's `message` as the toast, bare, so the * sentence is the administrator's whole answer. The settings action route @@ -10,6 +10,15 @@ * `?locale`) as the input's `locale`; an absent locale, or one with no entry * here, is answered in English, the sentence every caller got before. * + * The table carries every language on the platform locale table + * (`@objectstack/spec/system` `PLATFORM_LOCALES`). The four entries this package + * served first keep their keys (`zh-CN`, `ja-JP`, `es-ES`); the caller's code is + * read through the table's own reader, `platformLocaleTagOf`, so a stored + * alias (`zh-TW`, `pt`, `en-US`) and a table tag (`zh-Hans`, `ja`) both find + * their entry. A code the table does not name is shortened one subtag at a + * time (RFC 4647 lookup: `fr-CA` → `fr`, `zh-Hant-TW` → `zh-Hant`); nothing + * left ⇒ English. + * * Only the sentences the product writes live here. An adapter's own error * text (the S3 SDK's, the file system's) is passed through verbatim: in * English on its own, as it always was; in another language behind a lead-in @@ -17,7 +26,7 @@ * untranslated part is. The operator log lines stay English. */ -import { resolveLocale } from '@objectstack/core'; +import { platformLocaleTagOf, type PlatformLocaleTag } from '@objectstack/spec/system'; /** The English refusal for S3 settings missing a bucket or a region. */ export const S3_SETTINGS_INCOMPLETE_MESSAGE = @@ -47,7 +56,11 @@ export interface StorageTestCopy { failed(reason: string): string; } -export const STORAGE_TEST_COPY: Record<'en' | 'zh-CN' | 'ja-JP' | 'es-ES', StorageTestCopy> = { +/** The table's keys: the platform locale table's languages, the first four under their older codes. */ +export type StorageTestCopyLocale = + 'en' | 'zh-CN' | 'zh-Hant' | 'es-ES' | 'ar' | 'fr' | 'pt-BR' | 'ru' | 'de' | 'ja-JP' | 'ko'; + +export const STORAGE_TEST_COPY: Record = { en: { succeeded: (adapter) => `Storage round-trip succeeded (adapter=${adapter}).`, downloadMismatch: 'Probe download did not match upload.', @@ -60,24 +73,89 @@ export const STORAGE_TEST_COPY: Record<'en' | 'zh-CN' | 'ja-JP' | 'es-ES', Stora s3SettingsIncomplete: 'S3 适配器需要填写存储桶(s3_bucket)和区域(s3_region)。', failed: (reason) => `存储连接测试失败:${reason}`, }, + 'zh-Hant': { + succeeded: (adapter) => `儲存空間連線測試成功(轉接器:${adapter})。`, + downloadMismatch: '探測檔案下載後的內容與上傳的不一致。', + s3SettingsIncomplete: 'S3 轉接器需要填寫儲存貯體(s3_bucket)和區域(s3_region)。', + failed: (reason) => `儲存空間連線測試失敗:${reason}`, + }, 'ja-JP': { succeeded: (adapter) => `ストレージの接続テストに成功しました(アダプター: ${adapter})。`, downloadMismatch: 'プローブファイルのダウンロード内容がアップロードした内容と一致しません。', s3SettingsIncomplete: 'S3 アダプターにはバケット(s3_bucket)とリージョン(s3_region)の指定が必要です。', failed: (reason) => `ストレージの接続テストに失敗しました: ${reason}`, }, + ko: { + succeeded: (adapter) => `스토리지 연결 테스트에 성공했습니다(어댑터: ${adapter}).`, + downloadMismatch: '다운로드한 테스트 파일이 업로드한 파일과 일치하지 않습니다.', + s3SettingsIncomplete: 'S3 어댑터에는 버킷(s3_bucket)과 리전(s3_region)이 필요합니다.', + failed: (reason) => `스토리지 연결 테스트에 실패했습니다: ${reason}`, + }, 'es-ES': { succeeded: (adapter) => `La prueba de conexión del almacenamiento se ha completado correctamente (adaptador: ${adapter}).`, downloadMismatch: 'El archivo de prueba descargado no coincide con el que se subió.', s3SettingsIncomplete: 'El adaptador S3 necesita un bucket (s3_bucket) y una región (s3_region).', failed: (reason) => `La prueba de conexión del almacenamiento ha fallado: ${reason}`, }, + ar: { + succeeded: (adapter) => `نجح اختبار الاتصال بالتخزين (المحوّل: ${adapter}).`, + downloadMismatch: 'محتوى ملف الاختبار الذي تم تنزيله لا يطابق الملف الذي تم رفعه.', + s3SettingsIncomplete: 'يتطلب محوّل S3 تحديد الحاوية (s3_bucket) والمنطقة (s3_region).', + failed: (reason) => `فشل اختبار الاتصال بالتخزين: ${reason}`, + }, + fr: { + succeeded: (adapter) => `Le test de connexion au stockage a réussi (adaptateur : ${adapter}).`, + downloadMismatch: 'Le fichier de test téléchargé ne correspond pas à celui qui a été envoyé.', + s3SettingsIncomplete: 'L’adaptateur S3 nécessite un bucket (s3_bucket) et une région (s3_region).', + failed: (reason) => `Le test de connexion au stockage a échoué : ${reason}`, + }, + 'pt-BR': { + succeeded: (adapter) => `O teste de conexão com o armazenamento foi concluído com sucesso (adaptador: ${adapter}).`, + downloadMismatch: 'O arquivo de teste baixado não corresponde ao que foi enviado.', + s3SettingsIncomplete: 'O adaptador S3 exige um bucket (s3_bucket) e uma região (s3_region).', + failed: (reason) => `O teste de conexão com o armazenamento falhou: ${reason}`, + }, + ru: { + succeeded: (adapter) => `Проверка подключения к хранилищу прошла успешно (адаптер: ${adapter}).`, + downloadMismatch: 'Скачанный тестовый файл не совпадает с загруженным.', + s3SettingsIncomplete: 'Для адаптера S3 нужно указать бакет (s3_bucket) и регион (s3_region).', + failed: (reason) => `Проверка подключения к хранилищу не удалась: ${reason}`, + }, + de: { + succeeded: (adapter) => `Der Verbindungstest zum Speicher war erfolgreich (Adapter: ${adapter}).`, + downloadMismatch: 'Die heruntergeladene Testdatei stimmt nicht mit der hochgeladenen überein.', + s3SettingsIncomplete: 'Der S3-Adapter benötigt einen Bucket (s3_bucket) und eine Region (s3_region).', + failed: (reason) => `Der Verbindungstest zum Speicher ist fehlgeschlagen: ${reason}`, + }, +}; + +/** Each platform locale table tag → the key that serves it. */ +const KEY_BY_TABLE_TAG: Record = { + en: 'en', + 'zh-Hans': 'zh-CN', + 'zh-Hant': 'zh-Hant', + es: 'es-ES', + ar: 'ar', + fr: 'fr', + 'pt-BR': 'pt-BR', + ru: 'ru', + de: 'de', + ja: 'ja-JP', + ko: 'ko', }; -const COPY_LOCALES = Object.keys(STORAGE_TEST_COPY); +/** The key that serves a caller's code, or `undefined` (⇒ English). */ +export function storageTestCopyLocaleOf(locale: string | undefined): StorageTestCopyLocale | undefined { + if (!locale) return undefined; + const subtags = locale.split('-'); + for (let n = subtags.length; n > 0; n -= 1) { + const tag = platformLocaleTagOf(subtags.slice(0, n).join('-')); + if (tag) return KEY_BY_TABLE_TAG[tag]; + } + return undefined; +} -/** The copy for a caller's locale: exact, then by language, else English. */ +/** The copy for a caller's locale; English when the code names no language on the table. */ export function storageTestCopy(locale: string | undefined): StorageTestCopy { - const picked = locale ? resolveLocale(locale, COPY_LOCALES) : undefined; - return STORAGE_TEST_COPY[(picked ?? 'en') as keyof typeof STORAGE_TEST_COPY]; + return STORAGE_TEST_COPY[storageTestCopyLocaleOf(locale) ?? 'en']; } diff --git a/packages/services/service-storage/src/storage-test-locale.test.ts b/packages/services/service-storage/src/storage-test-locale.test.ts index 093fffebe85..5717bc7e994 100644 --- a/packages/services/service-storage/src/storage-test-locale.test.ts +++ b/packages/services/service-storage/src/storage-test-locale.test.ts @@ -20,7 +20,8 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { StorageServicePlugin } from './storage-service-plugin.js'; import { LocalStorageAdapter } from './local-storage-adapter.js'; -import { storageTestCopy, STORAGE_TEST_COPY } from './storage-test-copy.js'; +import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system'; +import { storageTestCopy, storageTestCopyLocaleOf, STORAGE_TEST_COPY } from './storage-test-copy.js'; function makeCtx() { const services = new Map(); @@ -102,7 +103,7 @@ describe('[#30335] storage/test answers in the caller\'s language', () => { it('no locale, or one with no copy: English, exactly as an older settings host gets it', async () => { const { root, settings } = await bootPlugin(); const values = { adapter: 'local', local_root: root }; - for (const input of [{ values }, { values, locale: 'fr-FR' }, { values, locale: '' }]) { + for (const input of [{ values }, { values, locale: 'tr-TR' }, { values, locale: '' }]) { const result = await settings.run(input); expect(result.ok).toBe(true); expect(result.severity).toBe('info'); @@ -164,15 +165,47 @@ describe('[#30335] storage/test answers in the caller\'s language', () => { }); describe('[#30335] the storage/test copy table', () => { - it('serves every locale the package\'s translations serve, each with every sentence', () => { - expect(Object.keys(STORAGE_TEST_COPY).sort()).toEqual(['en', 'es-ES', 'ja-JP', 'zh-CN']); - for (const locale of Object.keys(STORAGE_TEST_COPY)) { - const copy = storageTestCopy(locale); - expect(copy.succeeded('s3')).toContain('s3'); - expect(copy.downloadMismatch.length).toBeGreaterThan(0); - expect(copy.s3SettingsIncomplete).toContain('s3_bucket'); - expect(copy.s3SettingsIncomplete).toContain('s3_region'); - expect(copy.failed('EACCES')).toContain('EACCES'); + const KEYS = ['ar', 'de', 'en', 'es-ES', 'fr', 'ja-JP', 'ko', 'pt-BR', 'ru', 'zh-CN', 'zh-Hant']; + + it('carries all 11 languages of the platform locale table, each with every sentence', () => { + expect(Object.keys(STORAGE_TEST_COPY).sort()).toEqual(KEYS); + // Every table tag finds its own entry (none falls through to English). + expect(PLATFORM_LOCALE_TAGS).toHaveLength(11); + for (const tag of PLATFORM_LOCALE_TAGS) { + expect(storageTestCopyLocaleOf(tag), tag).toBeDefined(); + } + const en = STORAGE_TEST_COPY.en; + for (const key of KEYS) { + const copy = STORAGE_TEST_COPY[key as keyof typeof STORAGE_TEST_COPY]; + expect(Object.keys(copy).sort(), key).toEqual(['downloadMismatch', 'failed', 's3SettingsIncomplete', 'succeeded']); + // Placeholders survive verbatim; the two setting keys are named as written. + expect(copy.succeeded('ADAPTER_X'), key).toContain('ADAPTER_X'); + expect(copy.failed('REASON_Y'), key).toContain('REASON_Y'); + expect(copy.s3SettingsIncomplete, key).toContain('s3_bucket'); + expect(copy.s3SettingsIncomplete, key).toContain('s3_region'); + expect(copy.downloadMismatch.length, key).toBeGreaterThan(0); + if (key !== 'en') { + // A real translation, not the English sentence copied in. + expect(copy.succeeded('local'), key).not.toBe(en.succeeded('local')); + expect(copy.downloadMismatch, key).not.toBe(en.downloadMismatch); + expect(copy.s3SettingsIncomplete, key).not.toBe(en.s3SettingsIncomplete); + expect(copy.failed('x'), key).not.toBe(en.failed('x')); + } + } + }); + + it('reads a caller\'s code through the platform table: aliases, table tags, then shorter subtags', () => { + const cases: Array<[string | undefined, string | undefined]> = [ + ['zh-CN', 'zh-CN'], ['zh', 'zh-CN'], ['zh-Hans', 'zh-CN'], ['zh-Hans-CN', 'zh-CN'], + ['zh-TW', 'zh-Hant'], ['zh-HK', 'zh-Hant'], ['zh-Hant', 'zh-Hant'], ['zh-Hant-TW', 'zh-Hant'], + ['ja', 'ja-JP'], ['ja-JP', 'ja-JP'], ['es', 'es-ES'], ['es-MX', 'es-ES'], + ['pt', 'pt-BR'], ['pt-BR', 'pt-BR'], ['fr-FR', 'fr'], ['de-DE', 'de'], ['ko-KR', 'ko'], + ['ru', 'ru'], ['ar-SA', 'ar'], ['en-US', 'en'], ['EN-gb', 'en'], + ['xx-YY', undefined], ['', undefined], [undefined, undefined], + ]; + for (const [code, key] of cases) { + expect(storageTestCopyLocaleOf(code), String(code)).toBe(key); } + expect(storageTestCopy('xx-YY')).toBe(STORAGE_TEST_COPY.en); }); });