feat: add language settings management
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import Database from "better-sqlite3";
|
||||
|
||||
const dataDir = path.join(process.cwd(), ".data", `i18n-phase1-smoke-${Date.now()}`);
|
||||
const databasePath = path.join(dataDir, "neta.db");
|
||||
const restoreDir = `${dataDir}-restore`;
|
||||
const env = { ...process.env, DATA_DIR: dataDir, DATABASE_PATH: databasePath };
|
||||
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
|
||||
execFileSync(process.execPath, ["scripts/migrate.mjs"], {
|
||||
cwd: process.cwd(),
|
||||
env,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
assertMigratedDatabase(databasePath, "tr");
|
||||
|
||||
execFileSync("./node_modules/.bin/tsc", ["-p", "tsconfig.i18n-phase1-smoke.json"], {
|
||||
cwd: process.cwd(),
|
||||
stdio: "inherit",
|
||||
});
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[path.join(".next", "i18n-phase1-smoke-dist", "scripts", "i18n-phase1-smoke.js"), databasePath],
|
||||
{ cwd: process.cwd(), stdio: "inherit" },
|
||||
);
|
||||
|
||||
execFileSync(process.execPath, ["scripts/backup.mjs", "--retention-count", "1"], {
|
||||
cwd: process.cwd(),
|
||||
env,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
const backupDir = latestBackupDir(path.join(dataDir, "backups"));
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
["scripts/restore.mjs", "--from", backupDir, "--target", restoreDir],
|
||||
{ cwd: process.cwd(), env: process.env, stdio: "inherit" },
|
||||
);
|
||||
|
||||
assertMigratedDatabase(path.join(restoreDir, "neta.db"), "fr");
|
||||
assertRestoredI18nData(path.join(restoreDir, "neta.db"));
|
||||
|
||||
console.log("I18n phase 1 migration, service and backup/restore smoke passed.");
|
||||
|
||||
function assertMigratedDatabase(dbPath, expectedDefaultLocale) {
|
||||
const sqlite = new Database(dbPath);
|
||||
try {
|
||||
for (const tableName of [
|
||||
"instance_locales",
|
||||
"instance_i18n_settings",
|
||||
"instance_ui_translations",
|
||||
"content_translations",
|
||||
]) {
|
||||
assert.equal(
|
||||
sqlite.prepare("select count(*) as value from sqlite_master where type = 'table' and name = ?").get(tableName).value,
|
||||
1,
|
||||
`${tableName} must exist`,
|
||||
);
|
||||
}
|
||||
|
||||
const builtInLocales = sqlite
|
||||
.prepare("select code, status, fallback_locale as fallbackLocale, built_in as builtIn from instance_locales where code in ('tr', 'en') order by sort_order")
|
||||
.all();
|
||||
assert.deepEqual(
|
||||
builtInLocales,
|
||||
[
|
||||
{ code: "tr", status: "active", fallbackLocale: null, builtIn: 1 },
|
||||
{ code: "en", status: "active", fallbackLocale: "tr", builtIn: 1 },
|
||||
],
|
||||
"Built-in locales must be seeded",
|
||||
);
|
||||
assert.deepEqual(
|
||||
sqlite.prepare("select key, default_locale as defaultLocale, catalog_version as catalogVersion from instance_i18n_settings").all(),
|
||||
[{ key: "default", defaultLocale: expectedDefaultLocale, catalogVersion: 1 }],
|
||||
"Default i18n settings must be seeded",
|
||||
);
|
||||
|
||||
const clientColumns = sqlite.prepare("pragma table_info(clients)").all().map((column) => column.name);
|
||||
assert.equal(clientColumns.includes("portal_locale"), true, "clients.portal_locale must exist");
|
||||
const invitationColumns = sqlite.prepare("pragma table_info(portal_invitations)").all().map((column) => column.name);
|
||||
assert.equal(invitationColumns.includes("locale"), true, "portal_invitations.locale must exist");
|
||||
|
||||
sqlite.prepare(
|
||||
"insert into user (id, name, email, email_verified, created_at, updated_at) values (?, ?, ?, ?, ?, ?)",
|
||||
).run("locale-preference-user", "Locale Preference User", "locale-preference@example.com", 1, Date.now(), Date.now());
|
||||
sqlite.prepare("insert into user_preferences (owner_user_id, language) values (?, ?)").run("locale-preference-user", "fr");
|
||||
sqlite.prepare("delete from user_preferences where owner_user_id = ?").run("locale-preference-user");
|
||||
sqlite.prepare("delete from user where id = ?").run("locale-preference-user");
|
||||
} finally {
|
||||
sqlite.close();
|
||||
}
|
||||
}
|
||||
|
||||
function assertRestoredI18nData(dbPath) {
|
||||
const sqlite = new Database(dbPath);
|
||||
try {
|
||||
assert.equal(
|
||||
sqlite.prepare("select default_locale as value from instance_i18n_settings where key = 'default'").get().value,
|
||||
"fr",
|
||||
"Restored backup must include updated i18n settings",
|
||||
);
|
||||
assert.equal(
|
||||
sqlite.prepare("select value from content_translations where locale = 'fr' and entity_id = 'i18n-project'").get().value,
|
||||
"Projet multilingue",
|
||||
"Restored backup must include content translations",
|
||||
);
|
||||
} finally {
|
||||
sqlite.close();
|
||||
}
|
||||
}
|
||||
|
||||
function latestBackupDir(backupsDir) {
|
||||
const entries = fs
|
||||
.readdirSync(backupsDir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => path.join(backupsDir, entry.name))
|
||||
.sort((left, right) => path.basename(right).localeCompare(path.basename(left)));
|
||||
assert.ok(entries[0], "Backup directory must contain a backup");
|
||||
return entries[0];
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import assert from "node:assert/strict";
|
||||
import Database from "better-sqlite3";
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import * as schema from "../server/db/schema";
|
||||
import type { DomainActor } from "../server/domain/actor";
|
||||
import { DomainError } from "../server/domain/errors";
|
||||
import { I18nService } from "../server/i18n/service";
|
||||
|
||||
const databasePath = process.argv[2];
|
||||
assert.ok(databasePath, "Database path is required");
|
||||
|
||||
const sqlite = new Database(databasePath);
|
||||
sqlite.pragma("foreign_keys = ON");
|
||||
const db = drizzle({ client: sqlite, schema });
|
||||
|
||||
const owner: DomainActor = {
|
||||
authUserId: "i18n-owner",
|
||||
role: "freelancer",
|
||||
clientId: null,
|
||||
disabled: false,
|
||||
};
|
||||
const client: DomainActor = {
|
||||
authUserId: "i18n-client-user",
|
||||
role: "client",
|
||||
clientId: "i18n-client",
|
||||
disabled: false,
|
||||
};
|
||||
const disabledOwner: DomainActor = {
|
||||
...owner,
|
||||
authUserId: "i18n-disabled-owner",
|
||||
disabled: true,
|
||||
};
|
||||
|
||||
try {
|
||||
for (const actor of [owner, client, disabledOwner]) {
|
||||
db.insert(schema.user).values({
|
||||
id: actor.authUserId,
|
||||
name: actor.authUserId,
|
||||
email: `${actor.authUserId}@example.com`,
|
||||
emailVerified: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}).run();
|
||||
}
|
||||
|
||||
const service = new I18nService(db);
|
||||
const locales = service.listLocales(owner);
|
||||
assert.deepEqual(
|
||||
locales.map((locale) => ({
|
||||
code: locale.code,
|
||||
status: locale.status,
|
||||
builtIn: locale.builtIn,
|
||||
fallbackLocale: locale.fallbackLocale,
|
||||
})),
|
||||
[
|
||||
{ code: "tr", status: "active", builtIn: true, fallbackLocale: null },
|
||||
{ code: "en", status: "active", builtIn: true, fallbackLocale: "tr" },
|
||||
],
|
||||
);
|
||||
assert.deepEqual(service.getSettings(owner), { defaultLocale: "tr", catalogVersion: 1 });
|
||||
|
||||
assertDomainError(() => service.listLocales(client), "FORBIDDEN");
|
||||
assertDomainError(() => service.createLocale(disabledOwner, { code: "fr", name: "French" }), "FORBIDDEN");
|
||||
assertDomainError(() => service.createLocale(owner, { code: "english", name: "English" }), "VALIDATION_ERROR");
|
||||
assertDomainError(() => service.createLocale(owner, { code: "en", name: "English" }), "CONFLICT");
|
||||
assertDomainError(
|
||||
() => service.createLocale(owner, { code: "de", name: "German", fallbackLocale: "de" }),
|
||||
"VALIDATION_ERROR",
|
||||
);
|
||||
assertDomainError(
|
||||
() => service.createLocale(owner, { code: "pt", name: "Portuguese", fallbackLocale: "zz" }),
|
||||
"VALIDATION_ERROR",
|
||||
);
|
||||
|
||||
const french = service.createLocale(owner, {
|
||||
code: "fr",
|
||||
name: "French",
|
||||
nativeName: "Français",
|
||||
fallbackLocale: "en",
|
||||
});
|
||||
assert.equal(french.status, "draft");
|
||||
assert.equal(french.fallbackLocale, "en");
|
||||
|
||||
service.createLocale(owner, { code: "es", name: "Spanish", fallbackLocale: "fr" });
|
||||
assertDomainError(() => service.updateLocale(owner, "fr", { fallbackLocale: "es" }), "VALIDATION_ERROR");
|
||||
assertDomainError(() => service.setDefaultLocale(owner, "fr"), "VALIDATION_ERROR");
|
||||
|
||||
const activeFrench = service.updateLocale(owner, "fr", { status: "active" });
|
||||
assert.equal(activeFrench.status, "active");
|
||||
assert.equal(service.setDefaultLocale(owner, "fr").defaultLocale, "fr");
|
||||
assertDomainError(() => service.archiveLocale(owner, "fr"), "CONFLICT");
|
||||
assertDomainError(() => service.archiveLocale(owner, "en"), "CONFLICT");
|
||||
|
||||
service.createLocale(owner, {
|
||||
code: "it",
|
||||
name: "Italian",
|
||||
nativeName: "Italiano",
|
||||
status: "active",
|
||||
fallbackLocale: "en",
|
||||
});
|
||||
db.insert(schema.clients).values({
|
||||
id: "i18n-client",
|
||||
ownerUserId: owner.authUserId,
|
||||
name: "I18n Client",
|
||||
authUserId: client.authUserId,
|
||||
portalLocale: "it",
|
||||
}).run();
|
||||
assertDomainError(() => service.archiveLocale(owner, "it"), "CONFLICT");
|
||||
|
||||
db.insert(schema.contentTranslations).values({
|
||||
entityType: "project",
|
||||
entityId: "i18n-project",
|
||||
field: "name",
|
||||
locale: "fr",
|
||||
value: "Projet multilingue",
|
||||
}).run();
|
||||
|
||||
const persisted = db.select().from(schema.instanceLocales).all();
|
||||
assert.equal(persisted.some((locale) => locale.code === "fr" && locale.status === "active"), true);
|
||||
assert.equal(
|
||||
db.select().from(schema.contentTranslations).all()[0]?.value,
|
||||
"Projet multilingue",
|
||||
);
|
||||
|
||||
console.log("I18n phase 1 service smoke passed.");
|
||||
} finally {
|
||||
sqlite.close();
|
||||
}
|
||||
|
||||
function assertDomainError(run: () => unknown, code: DomainError["code"]): void {
|
||||
assert.throws(run, (error) => error instanceof DomainError && error.code === code);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const dataDir = path.join(process.cwd(), ".data", `i18n-phase2-smoke-${Date.now()}`);
|
||||
const databasePath = path.join(dataDir, "neta.db");
|
||||
const env = { ...process.env, DATA_DIR: dataDir, DATABASE_PATH: databasePath };
|
||||
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
|
||||
execFileSync(process.execPath, ["scripts/migrate.mjs"], {
|
||||
cwd: process.cwd(),
|
||||
env,
|
||||
stdio: "inherit",
|
||||
});
|
||||
execFileSync("./node_modules/.bin/tsc", ["-p", "tsconfig.i18n-phase2-smoke.json"], {
|
||||
cwd: process.cwd(),
|
||||
stdio: "inherit",
|
||||
});
|
||||
const serverOnlyStubDir = path.join(process.cwd(), ".next", "i18n-phase2-smoke-dist", "node_modules", "server-only");
|
||||
fs.mkdirSync(serverOnlyStubDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(serverOnlyStubDir, "index.js"), "\n");
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[path.join(".next", "i18n-phase2-smoke-dist", "scripts", "i18n-phase2-smoke.js")],
|
||||
{ cwd: process.cwd(), env, stdio: "inherit" },
|
||||
);
|
||||
@@ -0,0 +1,93 @@
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
compareCatalogKeys,
|
||||
createTranslatorFromMessages,
|
||||
formatDate,
|
||||
formatMoney,
|
||||
I18N_NAMESPACES,
|
||||
} from "../lib/i18n";
|
||||
import { enCatalog } from "../locales/en";
|
||||
import { trCatalog } from "../locales/tr";
|
||||
import { getSqliteConnection } from "../server/db/client";
|
||||
import type { DomainActor } from "../server/domain/actor";
|
||||
import { DomainError } from "../server/domain/errors";
|
||||
import { I18nService } from "../server/i18n/service";
|
||||
import { directionForLocale } from "../server/i18n/locale";
|
||||
import { createTranslator } from "../server/i18n/translator";
|
||||
|
||||
const owner: DomainActor = {
|
||||
authUserId: "i18n-phase2-owner",
|
||||
role: "freelancer",
|
||||
clientId: null,
|
||||
disabled: false,
|
||||
};
|
||||
const client: DomainActor = {
|
||||
authUserId: "i18n-phase2-client",
|
||||
role: "client",
|
||||
clientId: "i18n-phase2-client",
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
const parity = compareCatalogKeys(trCatalog, enCatalog, I18N_NAMESPACES);
|
||||
assert.deepEqual(parity, { missingInLeft: [], missingInRight: [] }, "TR/EN catalog keys must match");
|
||||
|
||||
const clientTranslator = createTranslatorFromMessages("en", {
|
||||
"common.greeting": "Hello {name}",
|
||||
"common.items": "{count, plural, one {# item} other {# items}}",
|
||||
});
|
||||
assert.equal(clientTranslator.t("common.greeting", { name: "Neta" }), "Hello Neta");
|
||||
assert.equal(clientTranslator.t("common.items", { count: 1 }), "1 item");
|
||||
assert.equal(clientTranslator.t("common.items", { count: 3 }), "3 items");
|
||||
assert.equal(directionForLocale("ar-XB"), "rtl");
|
||||
assert.match(formatDate("2026-07-19", "en"), /July/);
|
||||
assert.match(formatMoney(12345, "USD", "en"), /\$123\.45/);
|
||||
|
||||
const service = new I18nService(getSqliteConnection().db);
|
||||
service.listLocales(owner);
|
||||
|
||||
const tr = createTranslator("tr", ["common"]);
|
||||
const en = createTranslator("en", ["common"]);
|
||||
assert.equal(tr.t("common.actions.save"), "Kaydet");
|
||||
assert.equal(en.t("common.actions.save"), "Save");
|
||||
|
||||
service.createLocale(owner, {
|
||||
code: "fr",
|
||||
name: "French",
|
||||
nativeName: "Français",
|
||||
fallbackLocale: "en",
|
||||
});
|
||||
service.upsertUiTranslation(owner, {
|
||||
locale: "fr",
|
||||
namespace: "common",
|
||||
key: "actions.save",
|
||||
value: "Enregistrer",
|
||||
});
|
||||
assertDomainError(
|
||||
() => service.upsertUiTranslation(client, {
|
||||
locale: "fr",
|
||||
namespace: "common",
|
||||
key: "actions.cancel",
|
||||
value: "Annuler",
|
||||
}),
|
||||
"FORBIDDEN",
|
||||
);
|
||||
|
||||
const fr = createTranslator("fr", ["common"]);
|
||||
assert.equal(fr.t("common.actions.save"), "Enregistrer");
|
||||
assert.equal(fr.t("common.actions.cancel"), "Cancel");
|
||||
assert.equal(fr.t("common.missing.key"), "common.missing.key");
|
||||
|
||||
service.upsertUiTranslation(owner, {
|
||||
locale: "fr",
|
||||
namespace: "common",
|
||||
key: "actions.cancel",
|
||||
value: "Annuler",
|
||||
});
|
||||
const frAfterBump = createTranslator("fr", ["common"]);
|
||||
assert.equal(frAfterBump.t("common.actions.cancel"), "Annuler");
|
||||
|
||||
console.log("I18n phase 2 runtime smoke passed.");
|
||||
|
||||
function assertDomainError(run: () => unknown, code: DomainError["code"]): void {
|
||||
assert.throws(run, (error) => error instanceof DomainError && error.code === code);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const dataDir = path.join(process.cwd(), ".data", `i18n-phase3-smoke-${Date.now()}`);
|
||||
const databasePath = path.join(dataDir, "neta.db");
|
||||
const env = { ...process.env, DATA_DIR: dataDir, DATABASE_PATH: databasePath };
|
||||
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
|
||||
execFileSync(process.execPath, ["scripts/migrate.mjs"], {
|
||||
cwd: process.cwd(),
|
||||
env,
|
||||
stdio: "inherit",
|
||||
});
|
||||
execFileSync("./node_modules/.bin/tsc", ["-p", "tsconfig.i18n-phase3-smoke.json"], {
|
||||
cwd: process.cwd(),
|
||||
stdio: "inherit",
|
||||
});
|
||||
const serverOnlyStubDir = path.join(process.cwd(), ".next", "i18n-phase3-smoke-dist", "node_modules", "server-only");
|
||||
fs.mkdirSync(serverOnlyStubDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(serverOnlyStubDir, "index.js"), "\n");
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[path.join(".next", "i18n-phase3-smoke-dist", "scripts", "i18n-phase3-smoke.js")],
|
||||
{ cwd: process.cwd(), env, stdio: "inherit" },
|
||||
);
|
||||
@@ -0,0 +1,72 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { getSqliteConnection } from "../server/db/client";
|
||||
import type { DomainActor } from "../server/domain/actor";
|
||||
import { DomainError } from "../server/domain/errors";
|
||||
import { I18nService } from "../server/i18n/service";
|
||||
|
||||
const owner: DomainActor = {
|
||||
authUserId: "phase3-owner",
|
||||
role: "freelancer",
|
||||
clientId: null,
|
||||
disabled: false,
|
||||
};
|
||||
const client: DomainActor = {
|
||||
authUserId: "phase3-client-user",
|
||||
role: "client",
|
||||
clientId: "phase3-client",
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
const service = new I18nService(getSqliteConnection().db);
|
||||
service.listLocales(owner);
|
||||
|
||||
assertDomainError(() => service.listLocales(client), "FORBIDDEN");
|
||||
|
||||
const fr = service.createLocale(owner, {
|
||||
code: "fr",
|
||||
name: "French",
|
||||
nativeName: "Français",
|
||||
fallbackLocale: "en",
|
||||
});
|
||||
assert.equal(fr.status, "draft");
|
||||
|
||||
const beforeCompletion = service.getCompletion(owner).find((item) => item.locale === "fr");
|
||||
assert.ok(beforeCompletion);
|
||||
assert.ok(beforeCompletion.missingKeys.includes("navigation.items.projects"));
|
||||
|
||||
service.upsertUiTranslation(owner, {
|
||||
locale: "fr",
|
||||
namespace: "navigation",
|
||||
key: "items.projects",
|
||||
value: "Projets",
|
||||
});
|
||||
|
||||
const exported = service.exportPackage(owner);
|
||||
assert.equal(exported.format, "neta-i18n");
|
||||
assert.equal(exported.translations.some((row) => row.locale === "fr" && row.value === "Projets"), true);
|
||||
|
||||
service.resetUiTranslation(owner, {
|
||||
locale: "fr",
|
||||
namespace: "navigation",
|
||||
key: "items.projects",
|
||||
});
|
||||
assert.equal(
|
||||
service.listUiTranslations(owner).some((row) => row.locale === "fr" && row.value === "Projets"),
|
||||
false,
|
||||
);
|
||||
|
||||
service.importPackage(owner, exported);
|
||||
assert.equal(
|
||||
service.listUiTranslations(owner).some((row) => row.locale === "fr" && row.value === "Projets"),
|
||||
true,
|
||||
);
|
||||
|
||||
service.updateLocale(owner, "fr", { status: "active" });
|
||||
assert.equal(service.setDefaultLocale(owner, "fr").defaultLocale, "fr");
|
||||
assertDomainError(() => service.archiveLocale(owner, "fr"), "CONFLICT");
|
||||
|
||||
console.log("I18n phase 3 settings smoke passed.");
|
||||
|
||||
function assertDomainError(run: () => unknown, code: DomainError["code"]): void {
|
||||
assert.throws(run, (error) => error instanceof DomainError && error.code === code);
|
||||
}
|
||||
Reference in New Issue
Block a user