Files
qipai/backend/tests/mysql-migration-roundtrip.test.mjs
T
2026-06-18 10:50:43 +08:00

341 lines
12 KiB
JavaScript

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 {
AmbiguousAppTenantError,
PlatformConfigRepository
} from '../dist/tenancy/platform-config-repository.js';
import { AuthRepository } from '../dist/auth/auth-repository.js';
import { RbacRepository } from '../dist/auth/rbac-repository.js';
import {
executeMigrationPlan,
loadMigrationPlan,
splitSqlStatements
} from '../dist/db/migration-runner.js';
const expectedTables = [
'qipai_async_tasks',
'qipai_audit_logs',
'qipai_auth_sessions',
'qipai_devices',
'qipai_legacy_table_mappings',
'qipai_members',
'qipai_orders',
'qipai_outbox_events',
'qipai_payments',
'qipai_permissions',
'qipai_platform_apps',
'qipai_role_permissions',
'qipai_roles',
'qipai_rooms',
'qipai_schema_migrations',
'qipai_stores',
'qipai_tenant_apps',
'qipai_tenant_configs',
'qipai_tenants',
'qipai_user_identities',
'qipai_user_roles',
'qipai_user_store_scopes',
'qipai_users'
];
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', '2026061803', '2026061804', '2026061805']
);
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 }]);
}
async function assertPlatformTenantIsolation(pool) {
const [tenantResult] = await pool.query(
`INSERT INTO qipai_tenants (code, name)
VALUES ('M02A-A', 'M02A tenant A'), ('M02A-B', 'M02A tenant B')`
);
const firstTenantId = Number(tenantResult.insertId);
const secondTenantId = firstTenantId + 1;
const [appResult] = await pool.query(
`INSERT INTO qipai_platform_apps (appid, name)
VALUES ('wx-m02a-shared', 'M02A shared app')`
);
const platformAppId = Number(appResult.insertId);
await pool.query(
`INSERT INTO qipai_tenant_apps
(tenant_id, platform_app_id, is_default)
VALUES (?, ?, 1), (?, ?, 0)`,
[firstTenantId, platformAppId, secondTenantId, platformAppId]
);
await pool.query(
`INSERT INTO qipai_tenant_configs
(tenant_id, platform_app_id, brand_name, theme_color)
VALUES (?, ?, 'Tenant A Brand', '#111111'),
(?, ?, 'Tenant B Brand', '#222222')`,
[firstTenantId, platformAppId, secondTenantId, platformAppId]
);
const repository = new PlatformConfigRepository(pool);
const tenantA = await repository.resolveBootstrap('wx-m02a-shared', String(firstTenantId));
const tenantB = await repository.resolveBootstrap('wx-m02a-shared', String(secondTenantId));
assert.equal(tenantA?.brand.name, 'Tenant A Brand');
assert.equal(tenantB?.brand.name, 'Tenant B Brand');
assert.equal(
await repository.resolveBootstrap('wx-m02a-shared', String(secondTenantId + 999)),
null
);
await assert.rejects(
() => repository.resolveBootstrap('wx-m02a-shared'),
AmbiguousAppTenantError
);
return {
tenantId: String(firstTenantId),
platformAppId: String(platformAppId),
appId: 'wx-m02a-shared'
};
}
async function assertRevocableAuthSession(pool, context) {
const repository = new AuthRepository(pool);
const resolved = await repository.resolveLoginContext(context.appId, context.tenantId);
assert.deepEqual(resolved, context);
const sessionId = '9c47fdb5-0c38-463a-858f-e1d85ce9b3fd';
const session = await repository.loginWithWechat({
context,
openid: 'm02b-openid-a',
unionid: 'm02b-unionid',
sessionId,
expiresAt: new Date(Date.now() + 60_000),
ip: '127.0.0.1',
userAgent: 'M02-B test'
});
assert.equal(session.user.userType, 'CUSTOMER');
const rbac = new RbacRepository(pool);
assert.deepEqual(await rbac.getAccessProfile(context.tenantId, session.user.id), {
roles: ['CUSTOMER'],
capabilities: ['order.self.read', 'profile.read'],
storeIds: []
});
const [storeResult] = await pool.query(
`INSERT INTO qipai_stores (tenant_id, name) VALUES (?, 'M02C Store')`,
[context.tenantId]
);
assert.equal(await rbac.grantStore({
tenantId: context.tenantId,
userId: session.user.id,
storeId: String(storeResult.insertId),
scopeType: 'STAFF'
}), true);
assert.equal(await rbac.grantStore({
tenantId: String(Number(context.tenantId) + 1),
userId: session.user.id,
storeId: String(storeResult.insertId),
scopeType: 'STAFF'
}), false);
assert.equal((await repository.loginWithWechat({
context,
openid: 'm02b-openid-a',
unionid: 'm02b-unionid',
sessionId: 'c07df18c-a90d-4fa6-bea2-640d9710c84e',
expiresAt: new Date(Date.now() + 60_000),
ip: '127.0.0.1',
userAgent: 'M02-B repeat login'
})).user.id, session.user.id);
assert.ok(await repository.validateSession(sessionId, context.tenantId, session.user.id));
assert.equal(await repository.revokeSession(sessionId), true);
assert.equal(await repository.validateSession(sessionId, context.tenantId, session.user.id), null);
const roleSessionId = 'd8eb245b-e513-401e-9046-f574447909ad';
await repository.loginWithWechat({
context,
openid: 'm02b-openid-a',
sessionId: roleSessionId,
expiresAt: new Date(Date.now() + 60_000),
ip: '127.0.0.1',
userAgent: 'M02-B role test'
});
await pool.query(
'UPDATE qipai_users SET role_version = role_version + 1 WHERE tenant_id = ? AND id = ?',
[context.tenantId, session.user.id]
);
assert.equal(await repository.validateSession(roleSessionId, context.tenantId, session.user.id), null);
}
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' },
{ version: '2026061803', name: 'm02a_tenant_apps' },
{ version: '2026061804', name: 'm02b_wechat_auth' },
{ version: '2026061805', name: 'm02c_rbac' }
]);
await assertTaskDurability(pool);
const loginContext = await assertPlatformTenantIsolation(pool);
await assertRevocableAuthSession(pool, loginContext);
await assertLegacyCompatibility(pool);
console.log('PASS: first up, verify, tenant isolation and revocable auth checks 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' },
{ version: '2026061803', name: 'm02a_tenant_apps' },
{ version: '2026061804', name: 'm02b_wechat_auth' },
{ version: '2026061805', name: 'm02c_rbac' }
]);
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',
'app-to-tenant binding',
'cross-tenant bootstrap rejection',
'openid identity reuse',
'session revocation',
'role-version invalidation',
'customer capabilities',
'cross-tenant store grant rejection'
]
}, null, 2));
} finally {
await closeMySqlPool(pool);
}