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); }