feat: expose mobile localization contracts
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const distDir = path.join(process.cwd(), ".next", "i18n-phase8-smoke-dist");
|
||||
|
||||
execFileSync(process.execPath, ["scripts/phase9-api-boundary.mjs"], {
|
||||
cwd: process.cwd(),
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
execFileSync("./node_modules/.bin/tsc", ["-p", "tsconfig.i18n-phase8-smoke.json"], {
|
||||
cwd: process.cwd(),
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
const serverOnlyStubDir = path.join(distDir, "node_modules", "server-only");
|
||||
fs.mkdirSync(serverOnlyStubDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(serverOnlyStubDir, "index.js"), "\n");
|
||||
|
||||
const aliasScopeDir = path.join(distDir, "node_modules", "@");
|
||||
fs.mkdirSync(aliasScopeDir, { recursive: true });
|
||||
const serverAlias = path.join(aliasScopeDir, "server");
|
||||
if (!fs.existsSync(serverAlias)) {
|
||||
fs.symlinkSync(path.join(distDir, "server"), serverAlias, "dir");
|
||||
}
|
||||
|
||||
execFileSync(process.execPath, [path.join(distDir, "scripts", "i18n-phase8-smoke.js")], {
|
||||
cwd: process.cwd(),
|
||||
stdio: "inherit",
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
buildLocalizationContract,
|
||||
negotiateLocale,
|
||||
parseAcceptLanguage,
|
||||
UNSUPPORTED_LOCALE_CODE,
|
||||
type ApiLocalizationMetadata,
|
||||
} from "../server/api/v1/localization";
|
||||
import {
|
||||
NETA_CAPABILITIES,
|
||||
type NetaLocalizedResponse,
|
||||
type NetaTranslationMutationShape,
|
||||
} from "../server/api/v1/contracts";
|
||||
import { DomainError } from "../server/domain/errors";
|
||||
|
||||
const metadata: ApiLocalizationMetadata = {
|
||||
defaultLocale: "tr",
|
||||
supportedLocales: [
|
||||
{
|
||||
code: "tr",
|
||||
name: "Turkish",
|
||||
nativeName: "Türkçe",
|
||||
status: "active",
|
||||
fallbackLocale: null,
|
||||
textDirection: "ltr",
|
||||
builtIn: true,
|
||||
},
|
||||
{
|
||||
code: "en",
|
||||
name: "English",
|
||||
nativeName: "English",
|
||||
status: "active",
|
||||
fallbackLocale: "tr",
|
||||
textDirection: "ltr",
|
||||
builtIn: true,
|
||||
},
|
||||
{
|
||||
code: "fr",
|
||||
name: "French",
|
||||
nativeName: "Français",
|
||||
status: "draft",
|
||||
fallbackLocale: "en",
|
||||
textDirection: "ltr",
|
||||
builtIn: false,
|
||||
},
|
||||
],
|
||||
fallbacks: {
|
||||
en: "tr",
|
||||
fr: "en",
|
||||
},
|
||||
catalogVersion: 7,
|
||||
};
|
||||
|
||||
assert.deepEqual(parseAcceptLanguage("fr-FR, en-US;q=0.9, tr;q=0.7"), ["fr-FR", "en-US", "tr"]);
|
||||
assert.deepEqual(parseAcceptLanguage("en;q=0.4, tr;q=0.9"), ["tr", "en"]);
|
||||
|
||||
const queryLocale = negotiateLocale({
|
||||
metadata,
|
||||
requestedLocale: "en",
|
||||
acceptLanguage: "tr;q=0.9",
|
||||
});
|
||||
assert.equal(queryLocale.locale, "en");
|
||||
assert.equal(queryLocale.source, "query");
|
||||
assert.deepEqual(queryLocale.fallbackChain, ["en", "tr"]);
|
||||
|
||||
const baseLanguageMatch = negotiateLocale({
|
||||
metadata,
|
||||
acceptLanguage: "en-US,en;q=0.9",
|
||||
});
|
||||
assert.equal(baseLanguageMatch.locale, "en");
|
||||
assert.equal(baseLanguageMatch.requestedLocale, "en-US");
|
||||
assert.equal(baseLanguageMatch.source, "accept-language");
|
||||
|
||||
assert.throws(
|
||||
() => negotiateLocale({ metadata, requestedLocale: "fr" }),
|
||||
(error) =>
|
||||
error instanceof DomainError &&
|
||||
error.code === UNSUPPORTED_LOCALE_CODE &&
|
||||
error.message === "Unsupported locale." &&
|
||||
error.details?.messageKey === "validation.unsupportedLocale" &&
|
||||
error.details?.requestedLocale === "fr",
|
||||
);
|
||||
|
||||
const contract = buildLocalizationContract(metadata);
|
||||
assert.equal(contract.negotiator.queryParam, "locale");
|
||||
assert.equal(contract.negotiator.header, "Accept-Language");
|
||||
assert.equal(contract.negotiator.unsupportedLocaleCode, UNSUPPORTED_LOCALE_CODE);
|
||||
assert.equal(contract.responseContract.localizedResourceField, "localized");
|
||||
assert.equal(contract.responseContract.translationsField, "translations");
|
||||
|
||||
assert.equal(
|
||||
NETA_CAPABILITIES.some((capability) => capability.id === "instance.localization" && capability.status === "available"),
|
||||
true,
|
||||
);
|
||||
|
||||
const localizedResponse: NetaLocalizedResponse<{ title: string }> = {
|
||||
resource: { title: "Marka sitesi" },
|
||||
localized: { title: "Brand website" },
|
||||
locale: "en",
|
||||
fallbackChain: ["en", "tr"],
|
||||
};
|
||||
assert.equal(localizedResponse.localized.title, "Brand website");
|
||||
|
||||
const mutationShape: NetaTranslationMutationShape = {
|
||||
tr: { title: "Marka sitesi" },
|
||||
en: { title: "Brand website", description: null },
|
||||
};
|
||||
assert.equal(mutationShape.en.title, "Brand website");
|
||||
|
||||
console.log("I18n phase 8 mobile API localization smoke passed.");
|
||||
@@ -18,16 +18,32 @@ for (const value of [
|
||||
'NETA_PROTOCOL = "neta"',
|
||||
"NETA_DISCOVERY_VERSION = 1",
|
||||
'NETA_API_VERSION = "1"',
|
||||
'"instance.localization"',
|
||||
'"auth.device-pairing"',
|
||||
'status: "planned"',
|
||||
"minimumSupportedVersion",
|
||||
"workspaceName",
|
||||
"metaTitle",
|
||||
"faviconUrl",
|
||||
"NetaLocalizedResponse",
|
||||
"NetaTranslationMutationShape",
|
||||
"ownerMutationTranslations",
|
||||
"absoluteUrl",
|
||||
]) {
|
||||
assert.ok(contracts.includes(value), `Missing API contract marker: ${value}`);
|
||||
}
|
||||
|
||||
const localization = read("server/api/v1/localization.ts");
|
||||
for (const value of [
|
||||
"parseAcceptLanguage",
|
||||
"negotiateLocale",
|
||||
"UNSUPPORTED_LOCALE",
|
||||
"Accept-Language",
|
||||
"exact-or-base-language",
|
||||
]) {
|
||||
assert.ok(localization.includes(value), `Missing localization contract marker: ${value}`);
|
||||
}
|
||||
|
||||
const instanceService = read("server/instance/service.ts");
|
||||
assert.doesNotMatch(
|
||||
instanceService,
|
||||
@@ -46,6 +62,39 @@ assert.match(
|
||||
/getUserPreferences/,
|
||||
"Authenticated mobile metadata must expose the persisted user color mode",
|
||||
);
|
||||
assert.match(
|
||||
read("app/api/v1/me/route.ts"),
|
||||
/resolvedLocale/,
|
||||
"Authenticated mobile metadata must expose resolved localization state",
|
||||
);
|
||||
assert.match(
|
||||
read("app/api/v1/me/route.ts"),
|
||||
/portalLocale/,
|
||||
"Authenticated mobile metadata must expose client portal locale when available",
|
||||
);
|
||||
|
||||
assert.match(
|
||||
read("app/api/v1/meta/route.ts"),
|
||||
/stale-while-revalidate=300/,
|
||||
"Meta endpoint must keep a public revalidation cache contract",
|
||||
);
|
||||
|
||||
const domainErrors = read("server/domain/errors.ts");
|
||||
assert.match(
|
||||
domainErrors,
|
||||
/UNSUPPORTED_LOCALE/,
|
||||
"Unsupported locale must have a stable API error code",
|
||||
);
|
||||
|
||||
for (const fixture of [
|
||||
"docs/self-hosted-redesign/i18n-phase-8-fixtures/api-v1-locale-tr.json",
|
||||
"docs/self-hosted-redesign/i18n-phase-8-fixtures/api-v1-locale-en.json",
|
||||
"docs/self-hosted-redesign/i18n-phase-8-fixtures/api-v1-locale-fr.json",
|
||||
]) {
|
||||
const parsed = JSON.parse(read(fixture));
|
||||
assert.ok(parsed.request, `Missing request fixture in ${fixture}`);
|
||||
assert.ok(parsed.expected, `Missing expected fixture in ${fixture}`);
|
||||
}
|
||||
|
||||
for (const route of requiredRoutes.slice(1)) {
|
||||
const content = read(route);
|
||||
@@ -66,6 +115,7 @@ for (const futureRoute of [
|
||||
const runtimeFiles = [
|
||||
...requiredRoutes,
|
||||
"server/api/v1/contracts.ts",
|
||||
"server/api/v1/localization.ts",
|
||||
"server/api/v1/responses.ts",
|
||||
"server/api/v1/runtime.ts",
|
||||
"server/instance/service.ts",
|
||||
|
||||
Reference in New Issue
Block a user