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 { UserManagementRepository } from '../dist/auth/user-management-repository.js'; import { StoreRoomRepository, StoreRoomError } from '../dist/stores/store-room-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_room_categories', 'qipai_room_disabled_periods', 'qipai_rooms', 'qipai_schema_migrations', 'qipai_store_business_hours', 'qipai_stores', 'qipai_tenant_apps', 'qipai_tenant_configs', 'qipai_tenants', 'qipai_user_admin_profiles', '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', '2026061806', '2026061807'] ); 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); } async function assertUserManagement(pool, context) { const [adminResult] = await pool.query( `INSERT INTO qipai_users (tenant_id, user_type, nickname, phone) VALUES (?, 'STAFF', 'Tenant Admin', '13800000001')`, [context.tenantId] ); const adminId = String(adminResult.insertId); await pool.query( `INSERT INTO qipai_user_roles (tenant_id, user_id, role_id) SELECT ?, ?, id FROM qipai_roles WHERE tenant_id = ? AND code = 'TENANT_ADMIN'`, [context.tenantId, adminId, context.tenantId] ); const rbac = new RbacRepository(pool); const access = await rbac.getAccessProfile(context.tenantId, adminId); assert.ok(access.capabilities.includes('tenant.manage')); assert.ok(access.capabilities.includes('staff.manage')); const [storeResult] = await pool.query( `INSERT INTO qipai_stores (tenant_id, name) VALUES (?, 'M02D Staff Store')`, [context.tenantId] ); const repository = new UserManagementRepository(pool); const actor = { tenantId: context.tenantId, userId: adminId, access, traceId: 'm02d-live-test', ip: '127.0.0.1', userAgent: 'M02-D live test' }; const created = await repository.createStaff(actor, { nickname: 'Live Staff', phone: '13800000002', note: 'sanitized live test', roles: ['STAFF'], storeIds: [String(storeResult.insertId)] }); const sessionId = '7197e528-f727-4c85-a490-f5ec1721594c'; await pool.query( `INSERT INTO qipai_auth_sessions (id, tenant_id, platform_app_id, user_id, role_version, expires_at) SELECT ?, ?, ?, id, role_version, DATE_ADD(UTC_TIMESTAMP(3), INTERVAL 1 HOUR) FROM qipai_users WHERE tenant_id = ? AND id = ?`, [sessionId, context.tenantId, context.platformAppId, context.tenantId, created.userId] ); await repository.updateUser(actor, created.userId, { status: 'DISABLED', roles: ['STAFF'], storeIds: [String(storeResult.insertId)] }); const [sessionRows] = await pool.query( 'SELECT status, revoke_reason AS revokeReason FROM qipai_auth_sessions WHERE id = ?', [sessionId] ); assert.deepEqual(sessionRows, [{ status: 'REVOKED', revokeReason: 'ACCESS_CHANGED' }]); const [auditRows] = await pool.query( `SELECT action FROM qipai_audit_logs WHERE tenant_id = ? AND resource_type = 'USER' AND resource_id = ? ORDER BY id`, [context.tenantId, created.userId] ); assert.deepEqual(auditRows.map((row) => row.action), ['STAFF_CREATED', 'USER_UPDATED']); } async function assertStoreRoomDomain(pool, context) { const [adminRows] = await pool.query( `SELECT u.id FROM qipai_users u INNER JOIN qipai_user_roles ur ON ur.tenant_id = u.tenant_id AND ur.user_id = u.id INNER JOIN qipai_roles r ON r.id = ur.role_id AND r.tenant_id = ur.tenant_id WHERE u.tenant_id = ? AND r.code = 'TENANT_ADMIN' LIMIT 1`, [context.tenantId] ); const adminId = String(adminRows[0].id); const rbac = new RbacRepository(pool); const access = await rbac.getAccessProfile(context.tenantId, adminId); const repository = new StoreRoomRepository(pool); const actor = { tenantId: context.tenantId, userId: adminId, access, traceId: 'm03a-live-test', ip: '127.0.0.1', userAgent: 'M03-A live test' }; const store = await repository.createStore(actor, { name: 'M03A Store', address: 'Sanitized address', longitude: 121.4737, latitude: 31.2304, contactPhone: '13800000003', timezone: 'Asia/Shanghai', businessStatus: 'OPEN', wifiSsid: 'M03A-WIFI', wifiPassword: 'sanitized-password', notificationUrl: '', sortOrder: 1, businessHours: [ { weekday: 1, openMinute: 600, closeMinute: 1320, isClosed: false }, { weekday: 2, openMinute: 600, closeMinute: 1320, isClosed: false } ] }); const roomInput = { storeId: store.storeId, categoryName: '标准间', name: 'M03A Room', roomNo: 'A01', capacity: 4, basePriceCents: 3000, weekdayPriceCents: 2800, holidayPriceCents: 3500, overnightPriceCents: 12000, depositCents: 5000, minimumMinutes: 60, maxAdvanceStartMinutes: 30, maxAdvanceDays: 30, configurationStatus: 'ENABLED', operationalStatus: 'AVAILABLE', tags: ['麻将', '禁烟'], images: ['https://api.txyundm.cn/uploads/test-room.jpg'], sortOrder: 1 }; const room = await repository.createRoom(actor, roomInput); await repository.addDisabledPeriod(actor, room.roomId, { storeId: store.storeId, startsAt: new Date('2026-06-20T02:00:00.000Z'), endsAt: new Date('2026-06-20T04:00:00.000Z'), reason: 'maintenance rehearsal' }); const stores = await repository.listStores(actor); const rooms = await repository.listRooms(actor, store.storeId); assert.equal(stores.find((item) => item.id === store.storeId)?.timezone, 'Asia/Shanghai'); assert.equal(rooms.find((item) => item.id === room.roomId)?.holidayPriceCents, 3500); assert.deepEqual(rooms.find((item) => item.id === room.roomId)?.tags, ['麻将', '禁烟']); await assert.rejects( () => repository.listRooms({ ...actor, access: { roles: ['STORE_ADMIN'], capabilities: ['store.operation.read', 'store.operation.write'], storeIds: [String(Number(store.storeId) + 999)] } }, store.storeId), (error) => error instanceof StoreRoomError && error.code === 'STORE_SCOPE_FORBIDDEN' ); const [auditRows] = await pool.query( `SELECT action FROM qipai_audit_logs WHERE tenant_id = ? AND trace_id = 'm03a-live-test' ORDER BY id`, [context.tenantId] ); assert.deepEqual(auditRows.map((row) => row.action), [ 'STORE_CREATED', 'ROOM_CREATED', 'ROOM_DISABLED_PERIOD_CREATED' ]); } 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' }, { version: '2026061806', name: 'm02d_user_management' }, { version: '2026061807', name: 'm03a_store_room_domain' } ]); await assertTaskDurability(pool); const loginContext = await assertPlatformTenantIsolation(pool); await assertRevocableAuthSession(pool, loginContext); await assertUserManagement(pool, loginContext); await assertStoreRoomDomain(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' }, { version: '2026061806', name: 'm02d_user_management' }, { version: '2026061807', name: 'm03a_store_room_domain' } ]); 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', 'staff creation and store assignment', 'access-change session revocation', 'user-management audit log', 'store business hours and coordinates', 'room category and integer-cent pricing', 'configuration and operational status separation', 'room disabled period', 'cross-store management rejection' ] }, null, 2)); } finally { await closeMySqlPool(pool); }