import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { loadConfig } from '../dist/config.js'; import { closeMySqlPool, createMySqlPool } from '../dist/db/mysql.js'; import { LegacyReadRepository } from '../dist/db/legacy-read-repository.js'; import { TaskRepository } from '../dist/tasks/task-repository.js'; import { executeMigrationPlan, loadMigrationPlan, splitSqlStatements } from '../dist/db/migration-runner.js'; const expectedTables = [ 'qipai_async_tasks', 'qipai_audit_logs', 'qipai_devices', 'qipai_legacy_table_mappings', 'qipai_members', 'qipai_orders', 'qipai_outbox_events', 'qipai_payments', 'qipai_rooms', 'qipai_schema_migrations', 'qipai_stores', 'qipai_tenants' ]; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); async function readCoreTables(pool) { const placeholders = expectedTables.map(() => '?').join(', '); const [rows] = await pool.query( `SELECT table_name AS tableName FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name IN (${placeholders}) ORDER BY table_name`, expectedTables ); return rows.map((row) => row.tableName); } async function readMigrationVersions(pool) { const [rows] = await pool.query( `SELECT version, name FROM qipai_schema_migrations WHERE version IN (?, ?) ORDER BY version`, ['2026061601', '2026061802'] ); return rows; } async function loadLegacyFixture(pool) { const fixtureSql = await readFile( resolve(repoRoot, 'database/fixtures/2026061801_m01b_legacy_schema.sql'), 'utf8' ); const statements = splitSqlStatements(fixtureSql); for (const statement of statements) { await pool.query(statement); } return statements.length; } async function assertLegacyCompatibility(pool) { const repository = new LegacyReadRepository(pool); const stores = await repository.listStores({ tenantId: 1 }); const rooms = await repository.listRooms({ tenantId: 1, parentId: 101 }); const orders = await repository.listOrders({ tenantId: 1, parentId: 1001 }); const devices = await repository.listDevices({ tenantId: 1, parentId: 1001 }); assert.deepEqual(stores.map((record) => record.legacyId), [101]); assert.deepEqual(rooms.map((record) => record.legacyId), [1001, 1002]); assert.deepEqual(devices.map((record) => record.code), ['SANITIZED-DEVICE-001']); assert.equal(orders.length, 1); assert.equal(orders[0].code, 'LEGACY-SANITIZED-001'); assert.equal(orders[0].totalAmountCents, 2580); assert.equal(orders[0].paidAmountCents, 2000); assert.equal(orders[0].renewalAmountCents, 580); assert.equal(orders[0].groupAmountCents, 0); assert.equal(orders[0].refundAmountCents, null); } async function assertTaskDurability(pool) { await pool.query( `INSERT INTO qipai_tenants (code, name) VALUES ('M01C-TEST', 'M01C sanitized test tenant')` ); const firstRepository = new TaskRepository(pool); const firstEnqueue = await firstRepository.enqueue({ tenantId: '1', taskType: 'order.advance', idempotencyKey: 'order:501:paid', payload: { orderId: '501' }, maxAttempts: 3 }); const duplicateEnqueue = await firstRepository.enqueue({ tenantId: '1', taskType: 'order.advance', idempotencyKey: 'order:501:paid', payload: { orderId: '501' }, maxAttempts: 3 }); assert.equal(firstEnqueue.created, true); assert.equal(duplicateEnqueue.created, false); assert.equal(duplicateEnqueue.id, firstEnqueue.id); const restartedRepository = new TaskRepository(pool); const claimed = await restartedRepository.claimNext('worker-after-restart', 30_000); assert.equal(claimed?.id, firstEnqueue.id); assert.equal(claimed?.attempts, 1); assert.equal(await restartedRepository.complete(claimed.id, 'worker-after-restart'), true); const [rows] = await pool.query( `SELECT status, attempts FROM qipai_async_tasks WHERE id = ?`, [firstEnqueue.id] ); assert.deepEqual(rows, [{ status: 'SUCCEEDED', attempts: 1 }]); } const config = loadConfig(); assert.equal(config.mysql.passwordConfigured, true, 'Live migration test requires a temporary password.'); assert.match( config.mysql.database, /^qipai_m01c_test_[a-z0-9_]+$/, 'Live migration test refuses to use a non-temporary database.' ); const plans = { up: await loadMigrationPlan('up'), verify: await loadMigrationPlan('verify'), down: await loadMigrationPlan('down') }; const pool = createMySqlPool(config); try { const legacyFixtureStatements = await loadLegacyFixture(pool); await assertLegacyCompatibility(pool); console.log('PASS: sanitized legacy fixture is readable before migration.'); await executeMigrationPlan(pool, plans.up); await executeMigrationPlan(pool, plans.verify); assert.deepEqual(await readCoreTables(pool), expectedTables); assert.deepEqual(await readMigrationVersions(pool), [ { version: '2026061601', name: 'm01b_core_schema' }, { version: '2026061802', name: 'm01c_async_tasks' } ]); await assertTaskDurability(pool); await assertLegacyCompatibility(pool); console.log('PASS: first up, verify and durable task restart check completed.'); await executeMigrationPlan(pool, plans.down); assert.deepEqual(await readCoreTables(pool), []); await assertLegacyCompatibility(pool); console.log('PASS: down removed all M01-B/M01-C tables.'); await executeMigrationPlan(pool, plans.up); await executeMigrationPlan(pool, plans.verify); assert.deepEqual(await readCoreTables(pool), expectedTables); assert.deepEqual(await readMigrationVersions(pool), [ { version: '2026061601', name: 'm01b_core_schema' }, { version: '2026061802', name: 'm01c_async_tasks' } ]); await assertLegacyCompatibility(pool); console.log('PASS: second up and verify restored the schema.'); console.log(JSON.stringify({ mysqlHost: config.mysql.host, databaseClass: 'temporary', sequence: ['up', 'verify', 'down', 'up', 'verify'], checksums: { up: plans.up.checksum, verify: plans.verify.checksum, down: plans.down.checksum }, statementCounts: { up: plans.up.statements.length, verify: plans.verify.statements.length, down: plans.down.statements.length }, legacyFixtureStatements, legacyCompatibilityChecks: [ 'stores', 'rooms', 'orders', 'devices', 'tenant isolation', 'decimal cents' ] }, null, 2)); } finally { await closeMySqlPool(pool); }