Files
neta/scripts/i18n-phase9-hardening.mjs

296 lines
13 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
const root = process.cwd();
const workRoot = path.join(root, ".data", `i18n-phase9-hardening-${Date.now()}`);
const emptyDataDir = path.join(workRoot, "empty");
const productionLikeDataDir = path.join(workRoot, "production-like");
const restoreDir = path.join(workRoot, "restore");
const failedRestoreDir = path.join(workRoot, "failed-restore");
fs.mkdirSync(workRoot, { recursive: true });
execFileSync(process.execPath, ["scripts/i18n-phase9-boundary.mjs"], {
cwd: root,
stdio: "inherit",
});
runEmptyMigrationRehearsal(emptyDataDir);
runProductionLikeMigrationRehearsal(productionLikeDataDir);
runBackupRestoreAndFailureRehearsal(productionLikeDataDir, restoreDir, failedRestoreDir);
assertSupabaseImportCoverage();
assertStandaloneCatalogPresence();
console.log("I18n phase 9 hardening passed: migration, backup/restore, import coverage and release boundary verified.");
function runEmptyMigrationRehearsal(dataDir) {
const env = envFor(dataDir);
execFileSync(process.execPath, ["scripts/migrate.mjs"], { cwd: root, env, stdio: "inherit" });
execFileSync(process.execPath, ["scripts/migrate.mjs"], { cwd: root, env, stdio: "inherit" });
const db = new Database(path.join(dataDir, "neta.db"));
try {
assert.equal(countRows(db, "instance_locales", "code in ('tr', 'en') and status = 'active'"), 2);
assert.equal(single(db, "select default_locale from instance_i18n_settings where key = 'default'"), "tr");
assert.equal(countRows(db, "content_translations"), 0);
assert.deepEqual(db.pragma("foreign_key_check"), []);
} finally {
db.close();
}
}
function runProductionLikeMigrationRehearsal(dataDir) {
fs.mkdirSync(dataDir, { recursive: true });
const oldMigrationsDir = createPartialMigrationsDir(7);
const dbPath = path.join(dataDir, "neta.db");
const db = new Database(dbPath);
try {
applyPragmas(db);
migrate(drizzle({ client: db }), { migrationsFolder: oldMigrationsDir });
seedProductionLikeTurkishData(db);
} finally {
db.close();
}
const env = envFor(dataDir);
execFileSync(process.execPath, ["scripts/migrate.mjs"], { cwd: root, env, stdio: "inherit" });
assertBackfilledTurkishData(dbPath);
const before = snapshotBackfillCounts(dbPath);
execFileSync(process.execPath, ["scripts/migrate.mjs"], { cwd: root, env, stdio: "inherit" });
assert.deepEqual(snapshotBackfillCounts(dbPath), before, "Backfill must be idempotent on repeated migrate");
}
function runBackupRestoreAndFailureRehearsal(sourceDataDir, targetRestoreDir, failedTargetDir) {
const sourceDbPath = path.join(sourceDataDir, "neta.db");
const db = new Database(sourceDbPath);
try {
db.prepare(`
insert into instance_locales (code, name, native_name, status, fallback_locale, text_direction, built_in, sort_order)
values ('fr', 'French', 'Français', 'active', 'en', 'ltr', 0, 30)
on conflict(code) do update set status = excluded.status
`).run();
db.prepare(`
insert into content_translations (entity_type, entity_id, field, locale, value)
values ('project', 'prod-project', 'name', 'fr', 'Site de marque')
on conflict(entity_type, entity_id, field, locale) do update set value = excluded.value
`).run();
} finally {
db.close();
}
execFileSync(process.execPath, ["scripts/backup.mjs", "--retention-count", "2"], {
cwd: root,
env: envFor(sourceDataDir),
stdio: "inherit",
});
const backupDir = latestBackupDir(path.join(sourceDataDir, "backups"));
execFileSync(process.execPath, ["scripts/restore.mjs", "--from", backupDir, "--target", targetRestoreDir], {
cwd: root,
env: process.env,
stdio: "inherit",
});
const restored = new Database(path.join(targetRestoreDir, "neta.db"), { readonly: true });
try {
assert.equal(single(restored, "select status from instance_locales where code = 'fr'"), "active");
assert.equal(
single(restored, "select value from content_translations where entity_type = 'project' and entity_id = 'prod-project' and field = 'name' and locale = 'fr'"),
"Site de marque",
);
assert.deepEqual(restored.pragma("foreign_key_check"), []);
} finally {
restored.close();
}
fs.mkdirSync(failedTargetDir, { recursive: true });
fs.copyFileSync(path.join(targetRestoreDir, "neta.db"), path.join(failedTargetDir, "neta.db"));
const tamperedBackup = path.join(workRoot, "tampered-backup");
fs.cpSync(backupDir, tamperedBackup, { recursive: true });
fs.appendFileSync(path.join(tamperedBackup, "neta.db"), "tamper");
assert.throws(
() => execFileSync(
process.execPath,
["scripts/restore.mjs", "--from", tamperedBackup, "--target", failedTargetDir, "--force"],
{ cwd: root, env: process.env, stdio: "pipe" },
),
/Command failed/,
);
const failedTarget = new Database(path.join(failedTargetDir, "neta.db"), { readonly: true });
try {
assert.equal(single(failedTarget, "select status from instance_locales where code = 'fr'"), "active");
} finally {
failedTarget.close();
}
}
function assertSupabaseImportCoverage() {
const importSmoke = fs.readFileSync(path.join(root, "scripts", "phase8-import-smoke.mjs"), "utf8");
for (const marker of [
"runImport([\"--dry-run\"])",
"runImport([\"--allow-existing\"])",
"invalid-enum",
"invalid-foreign-key",
"unsafe-storage",
"rollback",
]) {
assert.ok(importSmoke.includes(marker), `Supabase import smoke must cover ${marker}`);
}
}
function assertStandaloneCatalogPresence() {
const standaloneDir = path.join(root, ".next", "standalone");
if (!fs.existsSync(standaloneDir)) return;
const serverDir = path.join(standaloneDir, ".next", "server");
const chunkText = listFiles(serverDir, ["."])
.filter((file) => file.endsWith(".js"))
.map((file) => fs.readFileSync(file, "utf8"))
.join("\n");
for (const marker of [
"auth.marketing.headline",
"Türkçe",
"English",
"instance.localization",
]) {
assert.ok(chunkText.includes(marker), `Standalone server bundle must include marker: ${marker}`);
}
}
function createPartialMigrationsDir(maxIndex) {
const source = path.join(root, "server", "db", "migrations");
const destination = path.join(workRoot, `migrations-0-${maxIndex}`);
fs.mkdirSync(path.join(destination, "meta"), { recursive: true });
for (const entry of fs.readdirSync(source)) {
const match = /^(\d{4})_.*\.sql$/.exec(entry);
if (match && Number(match[1]) <= maxIndex) {
fs.copyFileSync(path.join(source, entry), path.join(destination, entry));
}
}
for (const entry of fs.readdirSync(path.join(source, "meta"))) {
const match = /^(\d{4})_snapshot\.json$/.exec(entry);
if (match && Number(match[1]) <= maxIndex) {
fs.copyFileSync(path.join(source, "meta", entry), path.join(destination, "meta", entry));
}
}
const journal = JSON.parse(fs.readFileSync(path.join(source, "meta", "_journal.json"), "utf8"));
journal.entries = journal.entries.filter((entry) => entry.idx <= maxIndex);
fs.writeFileSync(path.join(destination, "meta", "_journal.json"), `${JSON.stringify(journal, null, 2)}\n`);
return destination;
}
function seedProductionLikeTurkishData(db) {
const now = Date.now();
db.prepare("insert into user (id, name, email, email_verified, created_at, updated_at) values (?, ?, ?, 1, ?, ?)")
.run("prod-owner", "Prod Owner", "prod-owner@example.com", now, now);
db.prepare("insert into app_profiles (auth_user_id, email, display_name, role, disabled, created_at, updated_at) values (?, ?, ?, 'freelancer', 0, ?, ?)")
.run("prod-owner", "prod-owner@example.com", "Prod Owner", now, now);
db.prepare("insert into user_preferences (owner_user_id, language, created_at, updated_at) values (?, 'tr', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)")
.run("prod-owner");
db.prepare("insert into clients (id, owner_user_id, name, email, status, pipeline_stage, created_at, updated_at) values (?, ?, ?, ?, 'active', 'won', ?, ?)")
.run("prod-client", "prod-owner", "Türkçe Müşteri", "client@example.com", now, now);
db.prepare(`
insert into projects (id, owner_user_id, client_id, name, description, status, currency, progress, progress_type, revision_quota, cover_image_alt, created_at, updated_at)
values (?, ?, ?, ?, ?, 'active', 'TRY', 25, 'manual', 2, ?, ?, ?)
`).run("prod-project", "prod-owner", "prod-client", "Marka Sitesi", "Türkçe proje açıklaması", "Kapak görseli", now, now);
db.prepare(`
insert into tasks (id, owner_user_id, client_id, project_id, title, description, status, priority, created_at, updated_at)
values (?, ?, ?, ?, ?, ?, 'todo', 'medium', ?, ?)
`).run("prod-task", "prod-owner", "prod-client", "prod-project", "Hero alanını hazırla", "Başlık ve CTA metnini düzenle", now, now);
db.prepare(`
insert into project_planning_sections (id, owner_user_id, project_id, category, title, content, metadata, sort_order, created_at, updated_at)
values (?, ?, ?, 'overview', ?, ?, '{}', 1, ?, ?)
`).run("prod-section", "prod-owner", "prod-project", "Genel Bakış", "Türkçe plan içeriği", now, now);
db.prepare(`
insert into instance_branding (id, owner_user_id, application_name, short_name, primary_color, accent_color, default_color_mode, radius_scale, organization_name, portal_welcome_text, portal_footer_text, updated_by_user_id, created_at, updated_at)
values ('default', ?, 'Neta', 'Neta', '#C81E1E', '#E6EDF5', 'system', 'default', 'Prod Workspace', ?, ?, ?, ?, ?)
`).run("prod-owner", "Portala hoş geldiniz", "Tüm hakları saklıdır", "prod-owner", now, now);
}
function assertBackfilledTurkishData(dbPath) {
const db = new Database(dbPath);
try {
assert.equal(single(db, "select value from content_translations where entity_type = 'project' and entity_id = 'prod-project' and field = 'name' and locale = 'tr'"), "Marka Sitesi");
assert.equal(single(db, "select value from content_translations where entity_type = 'project' and entity_id = 'prod-project' and field = 'description' and locale = 'tr'"), "Türkçe proje açıklaması");
assert.equal(single(db, "select value from content_translations where entity_type = 'task' and entity_id = 'prod-task' and field = 'title' and locale = 'tr'"), "Hero alanını hazırla");
assert.equal(single(db, "select value from content_translations where entity_type = 'planning_section' and entity_id = 'prod-section' and field = 'title' and locale = 'tr'"), "Genel Bakış");
assert.equal(single(db, "select value from content_translations where entity_type = 'branding' and entity_id = 'default' and field = 'portalWelcome' and locale = 'tr'"), "Portala hoş geldiniz");
assert.equal(single(db, "select default_locale from instance_i18n_settings where key = 'default'"), "tr");
assert.equal(countRows(db, "instance_locales", "code in ('tr', 'en') and status = 'active'"), 2);
assert.deepEqual(db.pragma("foreign_key_check"), []);
} finally {
db.close();
}
}
function snapshotBackfillCounts(dbPath) {
const db = new Database(dbPath);
try {
return {
translations: countRows(db, "content_translations"),
locales: countRows(db, "instance_locales"),
settings: countRows(db, "instance_i18n_settings"),
};
} finally {
db.close();
}
}
function latestBackupDir(backupsDir) {
return 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)))[0];
}
function envFor(dataDir) {
return {
...process.env,
DATA_DIR: dataDir,
DATABASE_PATH: path.join(dataDir, "neta.db"),
};
}
function countRows(db, table, where = "1 = 1") {
return db.prepare(`select count(*) as value from ${table} where ${where}`).get().value;
}
function single(db, sql) {
const row = db.prepare(sql).get();
assert.ok(row, `Expected row for query: ${sql}`);
return Object.values(row)[0];
}
function applyPragmas(db) {
db.pragma("foreign_keys = ON");
db.pragma("journal_mode = WAL");
db.pragma("synchronous = NORMAL");
db.pragma("busy_timeout = 5000");
}
function listFiles(baseDir, roots) {
const files = [];
for (const rootName of roots) {
const start = path.join(baseDir, rootName);
if (!fs.existsSync(start)) continue;
walk(start, files);
}
return files;
}
function walk(current, files) {
const stat = fs.lstatSync(current);
if (stat.isSymbolicLink()) return;
if (stat.isDirectory()) {
for (const entry of fs.readdirSync(current)) walk(path.join(current, entry), files);
return;
}
if (stat.isFile()) files.push(current);
}