test(M01-B): 验证MySQL迁移往返
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 <<SQL >/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 <<SQL >/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."
|
||||
Reference in New Issue
Block a user