diff --git a/backend/package.json b/backend/package.json index fa8128c..536889d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -15,6 +15,7 @@ "db:migrate:up": "npm run build && node dist/db/migrate-cli.js up", "db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify", "db:migrate:down": "npm run build && node dist/db/migrate-cli.js down", + "test:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs", "test": "npm run build && node tests/backend-contract.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs" }, "dependencies": { diff --git a/backend/tests/mysql-migration-roundtrip.test.mjs b/backend/tests/mysql-migration-roundtrip.test.mjs new file mode 100644 index 0000000..e7d2ab9 --- /dev/null +++ b/backend/tests/mysql-migration-roundtrip.test.mjs @@ -0,0 +1,100 @@ +import assert from 'node:assert/strict'; +import { loadConfig } from '../dist/config.js'; +import { closeMySqlPool, createMySqlPool } from '../dist/db/mysql.js'; +import { + executeMigrationPlan, + loadMigrationPlan +} from '../dist/db/migration-runner.js'; + +const expectedTables = [ + 'qipai_audit_logs', + 'qipai_devices', + 'qipai_legacy_table_mappings', + 'qipai_members', + 'qipai_orders', + 'qipai_payments', + 'qipai_rooms', + 'qipai_schema_migrations', + 'qipai_stores', + 'qipai_tenants' +]; + +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 readMigrationVersion(pool) { + const [rows] = await pool.query( + `SELECT version, name + FROM qipai_schema_migrations + WHERE version = ?`, + ['2026061601'] + ); + return rows; +} + +const config = loadConfig(); +assert.equal(config.mysql.passwordConfigured, true, 'Live migration test requires a temporary password.'); +assert.match( + config.mysql.database, + /^qipai_m01b_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 { + await executeMigrationPlan(pool, plans.up); + await executeMigrationPlan(pool, plans.verify); + assert.deepEqual(await readCoreTables(pool), expectedTables); + assert.deepEqual(await readMigrationVersion(pool), [{ + version: '2026061601', + name: 'm01b_core_schema' + }]); + console.log('PASS: first up and verify completed.'); + + await executeMigrationPlan(pool, plans.down); + assert.deepEqual(await readCoreTables(pool), []); + console.log('PASS: down removed all M01-B core tables.'); + + await executeMigrationPlan(pool, plans.up); + await executeMigrationPlan(pool, plans.verify); + assert.deepEqual(await readCoreTables(pool), expectedTables); + assert.deepEqual(await readMigrationVersion(pool), [{ + version: '2026061601', + name: 'm01b_core_schema' + }]); + 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 + } + }, null, 2)); +} finally { + await closeMySqlPool(pool); +} diff --git a/scripts/dev/windows/check-backend.ps1 b/scripts/dev/windows/check-backend.ps1 index b6505f3..381cef5 100644 --- a/scripts/dev/windows/check-backend.ps1 +++ b/scripts/dev/windows/check-backend.ps1 @@ -17,7 +17,9 @@ $requiredFiles = @( "backend/tests/migration-contract.test.mjs", "backend/tests/mysql-pool-contract.test.mjs", "backend/tests/migration-runner.test.mjs", + "backend/tests/mysql-migration-roundtrip.test.mjs", "backend/tests/legacy-read-repository.test.mjs", + "scripts/dev/wsl/mysql-migration-roundtrip.sh", "database/migrations/2026061601_m01b_core_schema.up.sql", "database/migrations/2026061601_m01b_core_schema.down.sql", "database/migrations/2026061601_m01b_core_schema.verify.sql", @@ -31,6 +33,11 @@ foreach ($path in $requiredFiles) { } } +$packageJson = Get-Content "backend/package.json" -Raw | ConvertFrom-Json +if (-not $packageJson.scripts.'test:mysql:migration') { + throw "backend/package.json is missing test:mysql:migration" +} + & npm --prefix backend test if ($LASTEXITCODE -ne 0) { throw "backend contract test failed" diff --git a/scripts/dev/windows/check-repo-completeness.ps1 b/scripts/dev/windows/check-repo-completeness.ps1 index 5f8dbbb..b11a30d 100644 --- a/scripts/dev/windows/check-repo-completeness.ps1 +++ b/scripts/dev/windows/check-repo-completeness.ps1 @@ -25,6 +25,7 @@ $requiredPaths = @( "backend/src/db/mysql.ts", "backend/tests/migration-contract.test.mjs", "backend/tests/mysql-pool-contract.test.mjs", + "backend/tests/mysql-migration-roundtrip.test.mjs", "database/migrations/2026061601_m01b_core_schema.up.sql", "database/migrations/2026061601_m01b_core_schema.down.sql", "database/migrations/2026061601_m01b_core_schema.verify.sql", @@ -46,6 +47,7 @@ $requiredPaths = @( "scripts/dev/wsl/prepare-test-copy.sh", "scripts/dev/wsl/verify-linux.sh", "scripts/dev/wsl/mqtt-smoke.sh", + "scripts/dev/wsl/mysql-migration-roundtrip.sh", "scripts/dev/wsl/check-gitea-ssh.sh", "scripts/dev/wsl/check-api-domain.sh", "scripts/dev/wsl/cleanup-test-copy.sh" diff --git a/scripts/dev/wsl/mysql-migration-roundtrip.sh b/scripts/dev/wsl/mysql-migration-roundtrip.sh new file mode 100644 index 0000000..ed7a259 --- /dev/null +++ b/scripts/dev/wsl/mysql-migration-roundtrip.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +case "${repo_root}" in + /mnt/*|/media/*) + echo "FAIL: run this test from a WSL-native copy, not a mounted Windows path." >&2 + exit 2 + ;; +esac + +cd "${repo_root}" + +for command in mysql mysqladmin node npm openssl; do + command -v "${command}" >/dev/null || { + echo "FAIL: required command is missing: ${command}" >&2 + exit 2 + } +done + +mysqladmin ping --silent >/dev/null || { + echo "FAIL: local MySQL is not responding." >&2 + exit 2 +} + +mysql_version="$(mysql --batch --skip-column-names -e 'SELECT VERSION()')" +mysql_major="${mysql_version%%.*}" +if [[ "${mysql_major}" != "8" ]]; then + echo "FAIL: MySQL 8 is required; actual version is ${mysql_version}." >&2 + exit 2 +fi + +mysql --batch --skip-column-names -e 'SELECT CURRENT_USER()' >/dev/null + +run_id="$(date -u +%Y%m%d%H%M%S)_$$_$(openssl rand -hex 3)" +database="qipai_m01b_test_${run_id}" +username="qmb_$$_$(openssl rand -hex 4)" +password="$(openssl rand -hex 24)" +created=0 + +cleanup() { + if [[ "${created}" == "1" ]]; then + mysql --batch --skip-column-names </dev/null +DROP DATABASE IF EXISTS \`${database}\`; +DROP USER IF EXISTS '${username}'@'127.0.0.1'; +SQL + fi +} +trap cleanup EXIT INT TERM + +create_temporary_resources() { + created=1 + mysql --batch --skip-column-names </dev/null +CREATE DATABASE \`${database}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE USER '${username}'@'127.0.0.1' IDENTIFIED BY '${password}'; +GRANT ALL PRIVILEGES ON \`${database}\`.* TO '${username}'@'127.0.0.1'; +SQL +} + +if [[ "${1:-}" == "--force-failure" ]]; then + create_temporary_resources + exit 23 +fi + +if [[ "${1:-}" == "--cleanup-probe" ]]; then + before_database_count="$(mysql --batch --skip-column-names -e "SELECT COUNT(*) FROM information_schema.SCHEMATA WHERE SCHEMA_NAME LIKE 'qipai_m01b_test_%'")" + before_user_count="$(mysql --batch --skip-column-names -e "SELECT COUNT(*) FROM mysql.user WHERE User LIKE 'qmb_%' AND Host = '127.0.0.1'")" + set +e + bash "${BASH_SOURCE[0]}" --force-failure + probe_status=$? + set -e + [[ "${probe_status}" == "23" ]] || { + echo "FAIL: cleanup probe did not produce the expected interrupted status." >&2 + exit 1 + } + after_database_count="$(mysql --batch --skip-column-names -e "SELECT COUNT(*) FROM information_schema.SCHEMATA WHERE SCHEMA_NAME LIKE 'qipai_m01b_test_%'")" + after_user_count="$(mysql --batch --skip-column-names -e "SELECT COUNT(*) FROM mysql.user WHERE User LIKE 'qmb_%' AND Host = '127.0.0.1'")" + if [[ "${before_database_count}" != "${after_database_count}" || "${before_user_count}" != "${after_user_count}" ]]; then + echo "FAIL: temporary MySQL resources survived an interrupted test." >&2 + exit 1 + fi + echo "PASS: interrupted-run cleanup removed the temporary database and account." + exit 0 +fi + +create_temporary_resources + +export NODE_ENV=test +export QIPAI_MYSQL_HOST=127.0.0.1 +export QIPAI_MYSQL_PORT=3306 +export QIPAI_MYSQL_DATABASE="${database}" +export QIPAI_MYSQL_USER="${username}" +export QIPAI_MYSQL_PASSWORD="${password}" +export QIPAI_MYSQL_CONNECTION_LIMIT=2 + +echo "INFO: MySQL ${mysql_version}; running M01-B migration roundtrip in a temporary database." +npm --prefix backend run test:mysql:migration +echo "PASS: M01-B live MySQL migration roundtrip completed."