Files
qipai/backend/tests/mysql-migration-roundtrip.test.mjs
T
2026-06-20 14:20:37 +08:00

1244 lines
51 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 { UserManagementRepository } from '../dist/auth/user-management-repository.js';
import { StoreRoomRepository, StoreRoomError } from '../dist/stores/store-room-repository.js';
import { ContentRepository, ContentError } from '../dist/content/content-repository.js';
import { StoreDiscoveryRepository } from '../dist/stores/store-discovery-repository.js';
import { StoreAccessRepository, StoreAccessError } from '../dist/stores/access-repository.js';
import { PricingRepository, PricingError } from '../dist/orders/pricing-repository.js';
import {
OrderStateError, OrderStateRepository
} from '../dist/orders/order-state-repository.js';
import {
OrderManagementError, OrderManagementRepository
} from '../dist/orders/order-management-repository.js';
import {
OrderShareError, OrderShareRepository
} from '../dist/orders/order-share-repository.js';
import {
executeMigrationPlan,
loadMigrationPlan,
splitSqlStatements
} from '../dist/db/migration-runner.js';
const expectedTables = [
'qipai_advertisements',
'qipai_async_tasks',
'qipai_audit_logs',
'qipai_auth_sessions',
'qipai_devices',
'qipai_holiday_calendar',
'qipai_legacy_table_mappings',
'qipai_media_assets',
'qipai_members',
'qipai_order_adjustments',
'qipai_order_price_snapshots',
'qipai_order_shares',
'qipai_order_status_history',
'qipai_order_user_access',
'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_room_reservations',
'qipai_rooms',
'qipai_scene_codes',
'qipai_scene_scan_events',
'qipai_schema_migrations',
'qipai_store_business_hours',
'qipai_store_decorations',
'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', '2026061808', '2026061809',
'2026061810', '2026061811', '2026062012', '2026062013', '2026062014']
);
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', city: '上海市', district: '黄浦区',
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'
]);
}
async function assertStoreDiscovery(pool, context) {
const [secondStore] = await pool.query(
`INSERT INTO qipai_stores
(tenant_id, name, address, city, district, longitude, latitude, business_status, sort_order)
VALUES (?, 'M03C Far Store', 'Sanitized address B', '上海市', '浦东新区',
121.6000000, 31.3000000, 'OPEN', 2)`,
[context.tenantId]
);
await pool.query(
`INSERT INTO qipai_store_business_hours
(tenant_id, store_id, weekday, open_minute, close_minute, is_closed)
VALUES (?, ?, 4, 0, 0, 0)`,
[context.tenantId, secondStore.insertId]
);
const repository = new StoreDiscoveryRepository(pool);
const stores = await repository.findStores({
tenantId: context.tenantId,
city: '上海市',
latitude: 31.2304,
longitude: 121.4737,
now: new Date('2026-06-18T04:00:00.000Z')
});
assert.equal(stores[0].name, 'M03A Store');
assert.equal(stores[0].distanceMeters, 0);
assert.ok(stores[1].distanceMeters > stores[0].distanceMeters);
assert.equal(stores.every((store) => store.city === '上海市'), true);
assert.equal((await repository.findStores({
tenantId: context.tenantId, city: '不存在的城市'
})).length, 0);
}
async function assertSceneAndWifiAccess(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 [customerRows] = await pool.query(
`SELECT u.id FROM qipai_users u
INNER JOIN qipai_user_identities i
ON i.tenant_id = u.tenant_id AND i.user_id = u.id
WHERE u.tenant_id = ? AND i.openid = 'm02b-openid-a' LIMIT 1`,
[context.tenantId]
);
const [targetRows] = await pool.query(
`SELECT s.id AS storeId, r.id AS roomId
FROM qipai_stores s
INNER JOIN qipai_rooms r ON r.tenant_id = s.tenant_id AND r.store_id = s.id
WHERE s.tenant_id = ? AND s.name = 'M03A Store' LIMIT 1`,
[context.tenantId]
);
const adminId = String(adminRows[0].id);
const customerId = String(customerRows[0].id);
const storeId = String(targetRows[0].storeId);
const roomId = String(targetRows[0].roomId);
const rbac = new RbacRepository(pool);
const adminAccess = await rbac.getAccessProfile(context.tenantId, adminId);
const customerAccess = await rbac.getAccessProfile(context.tenantId, customerId);
const actor = {
tenantId: context.tenantId, userId: adminId, access: adminAccess,
traceId: 'm03d-live-test', ip: '127.0.0.1', userAgent: 'M03-D live test'
};
const repository = new StoreAccessRepository(pool);
const first = await repository.regenerateScene(actor, {
targetType: 'ROOM', storeId, roomId
});
const firstResolved = await repository.resolveScene({
code: first.code, sourceType: 'QRCODE', traceId: 'm03d-scan-1',
ip: '127.0.0.1', userAgent: 'M03-D scan'
});
assert.equal(firstResolved.roomId, roomId);
assert.deepEqual(firstResolved.permissions, []);
const second = await repository.regenerateScene(actor, {
targetType: 'ROOM', storeId, roomId
});
assert.equal(second.generation, 2);
await assert.rejects(
() => repository.resolveScene({
code: first.code, sourceType: 'NFC', traceId: 'm03d-old-code',
ip: '127.0.0.1', userAgent: 'M03-D old code'
}),
(error) => error instanceof StoreAccessError && error.code === 'SCENE_CODE_INVALID'
);
await repository.resolveScene({
code: second.code, sourceType: 'NFC', traceId: 'm03d-scan-2',
ip: '127.0.0.1', userAgent: 'M03-D NFC'
});
assert.equal((await repository.sceneStats(actor, storeId))[0].scanCount, 1);
await assert.rejects(
() => repository.getWifi({
tenantId: context.tenantId, userId: customerId, access: customerAccess, storeId,
traceId: 'm03d-wifi-denied', ip: '127.0.0.1', userAgent: 'M03-D denied'
}),
(error) => error instanceof StoreAccessError && error.code === 'WIFI_ACCESS_FORBIDDEN'
);
const [orderResult] = await pool.query(
`INSERT INTO qipai_orders
(tenant_id, store_id, room_id, order_no, status, start_at, end_at)
VALUES (?, ?, ?, 'M03D-WIFI-ORDER', 'IN_USE',
DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 10 MINUTE),
DATE_ADD(UTC_TIMESTAMP(3), INTERVAL 50 MINUTE))`,
[context.tenantId, storeId, roomId]
);
await pool.query(
`INSERT INTO qipai_order_user_access (tenant_id, order_id, user_id)
VALUES (?, ?, ?)`,
[context.tenantId, orderResult.insertId, customerId]
);
const wifi = await repository.getWifi({
tenantId: context.tenantId, userId: customerId, access: customerAccess, storeId,
traceId: 'm03d-wifi-allowed', ip: '127.0.0.1', userAgent: 'M03-D allowed'
});
assert.equal(wifi.ssid, 'M03A-WIFI');
assert.equal(wifi.password, 'sanitized-password');
const [auditRows] = await pool.query(
`SELECT CAST(metadata AS CHAR) AS metadata
FROM qipai_audit_logs
WHERE tenant_id = ? AND trace_id = 'm03d-wifi-allowed'`,
[context.tenantId]
);
assert.equal(auditRows.length, 1);
assert.match(auditRows[0].metadata, /M03A-WIFI/);
assert.doesNotMatch(auditRows[0].metadata, /sanitized-password/);
}
async function assertPricingAndReservations(pool, context) {
const [customerRows] = await pool.query(
`SELECT u.id FROM qipai_users u
INNER JOIN qipai_user_identities i
ON i.tenant_id = u.tenant_id AND i.user_id = u.id
WHERE u.tenant_id = ? AND i.openid = 'm02b-openid-a' LIMIT 1`,
[context.tenantId]
);
const [targetRows] = await pool.query(
`SELECT s.id AS storeId, r.id AS roomId
FROM qipai_stores s
INNER JOIN qipai_rooms r ON r.tenant_id = s.tenant_id AND r.store_id = s.id
WHERE s.tenant_id = ? AND s.name = 'M03A Store' LIMIT 1`,
[context.tenantId]
);
const customerId = String(customerRows[0].id);
const roomId = String(targetRows[0].roomId);
await pool.query(
`UPDATE qipai_rooms SET base_price_cents = 1200, weekday_price_cents = 1000,
holiday_price_cents = 1800, overnight_price_cents = 5000,
full_day_price_cents = 9000, minimum_spend_cents = 2500,
deposit_cents = 500, minimum_minutes = 60, max_advance_days = 30,
configuration_status = 'ENABLED', operational_status = 'AVAILABLE'
WHERE tenant_id = ? AND id = ?`,
[context.tenantId, roomId]
);
const startAt = new Date(Date.now() + 5 * 86400000);
startAt.setUTCHours(2, 0, 0, 0);
const endAt = new Date(startAt.getTime() + 2 * 3600000);
const holidayDate = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit'
}).format(startAt);
await pool.query(
`INSERT INTO qipai_holiday_calendar (tenant_id, holiday_date, name)
VALUES (?, ?, 'M04-A Test Holiday')`,
[context.tenantId, holidayDate]
);
const repository = new PricingRepository(pool);
const quote = await repository.quote({
tenantId: context.tenantId,
roomId,
startAt,
endAt,
pricingMode: 'HOURLY',
adjustment: { discountCents: 300, packageCreditCents: 200 }
});
assert.equal(quote.rules.priceSource, 'holiday');
assert.equal(quote.subtotalCents, 3600);
assert.equal(quote.totalCents, 3600);
const attempts = await Promise.allSettled([
repository.reserve({
tenantId: context.tenantId, userId: customerId, roomId,
startAt, endAt, pricingMode: 'HOURLY'
}),
repository.reserve({
tenantId: context.tenantId, userId: customerId, roomId,
startAt, endAt, pricingMode: 'HOURLY'
})
]);
assert.equal(attempts.filter((item) => item.status === 'fulfilled').length, 1);
assert.equal(attempts.filter((item) =>
item.status === 'rejected'
&& item.reason instanceof PricingError
&& item.reason.code === 'TIME_SLOT_CONFLICT'
).length, 1);
const first = attempts.find((item) => item.status === 'fulfilled').value;
const [snapshotBefore] = await pool.query(
`SELECT total_cents AS totalCents, rules
FROM qipai_order_price_snapshots WHERE tenant_id = ? AND order_id = ?`,
[context.tenantId, first.orderId]
);
await pool.query(
`UPDATE qipai_rooms SET holiday_price_cents = 9900
WHERE tenant_id = ? AND id = ?`,
[context.tenantId, roomId]
);
const [snapshotAfter] = await pool.query(
`SELECT total_cents AS totalCents, rules
FROM qipai_order_price_snapshots WHERE tenant_id = ? AND order_id = ?`,
[context.tenantId, first.orderId]
);
assert.deepEqual(snapshotAfter, snapshotBefore);
await pool.query(
`UPDATE qipai_room_reservations SET expires_at = DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 1 SECOND)
WHERE tenant_id = ? AND order_id = ?`,
[context.tenantId, first.orderId]
);
assert.equal((await repository.releaseExpired(context.tenantId, roomId)).released, 1);
const replacement = await repository.reserve({
tenantId: context.tenantId, userId: customerId, roomId,
startAt, endAt, pricingMode: 'FULL_DAY'
});
assert.equal(replacement.quote.unitPriceCents, 9000);
}
async function assertOrderStateMachine(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 [customerRows] = await pool.query(
`SELECT u.id FROM qipai_users u
INNER JOIN qipai_user_identities i
ON i.tenant_id = u.tenant_id AND i.user_id = u.id
WHERE u.tenant_id = ? AND i.openid = 'm02b-openid-a' LIMIT 1`,
[context.tenantId]
);
const [targetRows] = await pool.query(
`SELECT r.id AS roomId
FROM qipai_rooms r
INNER JOIN qipai_stores s ON s.id = r.store_id AND s.tenant_id = r.tenant_id
WHERE r.tenant_id = ? AND s.name = 'M03A Store' LIMIT 1`,
[context.tenantId]
);
const adminId = String(adminRows[0].id);
const customerId = String(customerRows[0].id);
const roomId = String(targetRows[0].roomId);
const startAt = new Date(Date.now() + 15 * 86400000);
startAt.setUTCHours(2, 0, 0, 0);
const endAt = new Date(startAt.getTime() + 2 * 3600000);
const order = await new PricingRepository(pool).reserve({
tenantId: context.tenantId, userId: customerId, roomId,
startAt, endAt, pricingMode: 'HOURLY'
});
const access = await new RbacRepository(pool).getAccessProfile(context.tenantId, adminId);
const repository = new OrderStateRepository(pool);
const actor = (traceId) => ({
tenantId: context.tenantId, userId: adminId, actorType: 'USER',
source: 'ADMIN', traceId, ip: '127.0.0.1',
userAgent: 'M04-B live state test', access
});
const paid = await repository.transition(
actor('m04b-paid'), order.orderId, 'CONFIRM_PAYMENT', 'payment accepted'
);
assert.equal(paid.status, 'PAID');
assert.equal(paid.statusVersion, 2);
const duplicate = await repository.transition(
actor('m04b-paid'), order.orderId, 'CONFIRM_PAYMENT', 'duplicate callback'
);
assert.equal(duplicate.idempotent, true);
await repository.transition(actor('m04b-reserved'), order.orderId, 'RESERVE');
await repository.transition(actor('m04b-start'), order.orderId, 'START');
await repository.transition(actor('m04b-finish'), order.orderId, 'FINISH');
const closed = await repository.transition(actor('m04b-close'), order.orderId, 'CLOSE');
assert.equal(closed.status, 'CLOSED');
assert.equal(closed.statusVersion, 6);
await assert.rejects(
() => repository.transition(actor('m04b-invalid'), order.orderId, 'START'),
(error) => error instanceof OrderStateError
&& error.code === 'ORDER_TRANSITION_NOT_ALLOWED'
);
const history = await repository.history(context.tenantId, customerId, order.orderId, {
roles: ['CUSTOMER'], capabilities: ['order.self.read'], storeIds: []
});
assert.deepEqual(history.map((item) => item.toStatus), [
'PENDING_PAYMENT', 'PAID', 'RESERVED', 'IN_PROGRESS', 'FINISHED', 'CLOSED'
]);
const [stateRows] = await pool.query(
`SELECT o.status, o.status_version AS statusVersion,
r.status AS reservationStatus, a.revoked_at AS revokedAt
FROM qipai_orders o
INNER JOIN qipai_room_reservations r
ON r.tenant_id = o.tenant_id AND r.order_id = o.id
INNER JOIN qipai_order_user_access a
ON a.tenant_id = o.tenant_id AND a.order_id = o.id AND a.user_id = ?
WHERE o.tenant_id = ? AND o.id = ?`,
[customerId, context.tenantId, order.orderId]
);
assert.equal(stateRows[0].status, 'CLOSED');
assert.equal(stateRows[0].statusVersion, 6);
assert.equal(stateRows[0].reservationStatus, 'RELEASED');
assert.ok(stateRows[0].revokedAt);
const [auditRows] = await pool.query(
`SELECT action FROM qipai_audit_logs
WHERE tenant_id = ? AND trace_id LIKE 'm04b-%' ORDER BY id`,
[context.tenantId]
);
assert.deepEqual(auditRows.map((row) => row.action), [
'ORDER_STATUS_CHANGED', 'ORDER_STATUS_CHANGED', 'ORDER_STATUS_CHANGED',
'ORDER_STATUS_CHANGED', 'ORDER_STATUS_CHANGED'
]);
}
async function assertOrderAdjustments(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 [customerRows] = await pool.query(
`SELECT u.id FROM qipai_users u
INNER JOIN qipai_user_identities i
ON i.tenant_id = u.tenant_id AND i.user_id = u.id
WHERE u.tenant_id = ? AND i.openid = 'm02b-openid-a' LIMIT 1`,
[context.tenantId]
);
const [targetRows] = await pool.query(
`SELECT s.id AS storeId, r.id AS roomId
FROM qipai_stores s
INNER JOIN qipai_rooms r ON r.tenant_id = s.tenant_id AND r.store_id = s.id
WHERE s.tenant_id = ? AND s.name = 'M03A Store' LIMIT 1`,
[context.tenantId]
);
const adminId = String(adminRows[0].id);
const customerId = String(customerRows[0].id);
const storeId = String(targetRows[0].storeId);
const roomId = String(targetRows[0].roomId);
const [newRoom] = await pool.query(
`INSERT INTO qipai_rooms
(tenant_id, store_id, name, room_no, base_price_cents,
configuration_status, operational_status, minimum_minutes, max_advance_days)
VALUES (?, ?, 'M04C Target Room', 'C02', 1800, 'ENABLED', 'AVAILABLE', 60, 30)`,
[context.tenantId, storeId]
);
const targetRoomId = String(newRoom.insertId);
await pool.query(
`UPDATE qipai_stores
SET cancellation_cutoff_minutes = 20160, cancellation_fee_bps = 2500
WHERE tenant_id = ? AND id = ?`,
[context.tenantId, storeId]
);
const startAt = new Date(Date.now() + 12 * 86400000);
startAt.setUTCHours(2, 0, 0, 0);
const endAt = new Date(startAt.getTime() + 2 * 3600000);
const pricing = new PricingRepository(pool);
const created = await pricing.reserve({
tenantId: context.tenantId, userId: customerId, roomId,
startAt, endAt, pricingMode: 'HOURLY'
});
const access = await new RbacRepository(pool).getAccessProfile(context.tenantId, adminId);
const state = new OrderStateRepository(pool);
await state.transition({
tenantId: context.tenantId, userId: adminId, actorType: 'USER',
source: 'ADMIN', traceId: 'm04c-order-paid', ip: '127.0.0.1',
userAgent: 'M04-C live test', access
}, created.orderId, 'CONFIRM_PAYMENT');
const repository = new OrderManagementRepository(pool);
const actor = (traceId) => ({
tenantId: context.tenantId, userId: adminId, actorType: 'USER',
source: 'ADMIN', traceId, ip: '127.0.0.1',
userAgent: 'M04-C live test', access
});
const blockedEnd = new Date(endAt.getTime() + 2 * 3600000);
const [blockingOrder] = await pool.query(
`INSERT INTO qipai_orders
(tenant_id, store_id, room_id, order_no, status, start_at, end_at)
VALUES (?, ?, ?, 'M04C-RENEW-BLOCK', 'PAID', ?, ?)`,
[context.tenantId, storeId, roomId, endAt, blockedEnd]
);
await pool.query(
`INSERT INTO qipai_room_reservations
(tenant_id, order_id, room_id, starts_at, ends_at, status, expires_at)
VALUES (?, ?, ?, ?, ?, 'CONSUMED', ?)`,
[context.tenantId, blockingOrder.insertId, roomId, endAt, blockedEnd, blockedEnd]
);
await assert.rejects(
() => repository.renew(actor('m04c-renew-conflict'), created.orderId, {
endAt: blockedEnd, pricingPolicy: 'LOCKED', reason: 'conflict rehearsal'
}),
(error) => error instanceof OrderManagementError && error.code === 'TIME_SLOT_CONFLICT'
);
const [unchanged] = await pool.query(
`SELECT end_at AS endAt FROM qipai_orders WHERE id = ?`,
[created.orderId]
);
assert.equal(new Date(unchanged[0].endAt).getTime(), endAt.getTime());
await pool.query(`DELETE FROM qipai_room_reservations WHERE order_id = ?`, [blockingOrder.insertId]);
await pool.query(`DELETE FROM qipai_orders WHERE id = ?`, [blockingOrder.insertId]);
const renewed = await repository.renew(actor('m04c-renew'), created.orderId, {
endAt: blockedEnd, pricingPolicy: 'LOCKED', reason: 'approved extension'
});
assert.equal(renewed.amountDeltaCents, created.quote.unitPriceCents * 2);
const duplicate = await repository.renew(actor('m04c-renew'), created.orderId, {
endAt: new Date(blockedEnd.getTime() + 3600000),
pricingPolicy: 'CURRENT', reason: 'duplicate request'
});
assert.equal(duplicate.idempotent, true);
const [targetBlockOrder] = await pool.query(
`INSERT INTO qipai_orders
(tenant_id, store_id, room_id, order_no, status, start_at, end_at)
VALUES (?, ?, ?, 'M04C-ROOM-BLOCK', 'PAID', ?, ?)`,
[context.tenantId, storeId, targetRoomId, startAt, blockedEnd]
);
await pool.query(
`INSERT INTO qipai_room_reservations
(tenant_id, order_id, room_id, starts_at, ends_at, status, expires_at)
VALUES (?, ?, ?, ?, ?, 'CONSUMED', ?)`,
[context.tenantId, targetBlockOrder.insertId, targetRoomId, startAt, blockedEnd, blockedEnd]
);
await assert.rejects(
() => repository.changeRoom(actor('m04c-room-conflict'), created.orderId, {
roomId: targetRoomId, reason: 'rollback rehearsal'
}),
(error) => error instanceof OrderManagementError && error.code === 'TIME_SLOT_CONFLICT'
);
const [stillOldRoom] = await pool.query(
`SELECT o.room_id AS orderRoomId, r.room_id AS reservationRoomId
FROM qipai_orders o INNER JOIN qipai_room_reservations r ON r.order_id = o.id
WHERE o.id = ?`,
[created.orderId]
);
assert.equal(String(stillOldRoom[0].orderRoomId), roomId);
assert.equal(String(stillOldRoom[0].reservationRoomId), roomId);
await pool.query(`DELETE FROM qipai_room_reservations WHERE order_id = ?`, [targetBlockOrder.insertId]);
await pool.query(`DELETE FROM qipai_orders WHERE id = ?`, [targetBlockOrder.insertId]);
const changed = await repository.changeRoom(actor('m04c-room-change'), created.orderId, {
roomId: targetRoomId, reason: 'customer requested target room'
});
assert.equal(changed.adjustmentType, 'CHANGE_ROOM');
const adjustedEnd = new Date(blockedEnd.getTime() + 3600000);
const adjusted = await repository.adjustTime(actor('m04c-time-adjust'), created.orderId, {
endAt: adjustedEnd, reason: 'manager granted one hour'
});
assert.equal(adjusted.adjustmentType, 'ADJUST_TIME');
await repository.note(actor('m04c-note'), created.orderId, 'sanitized operator note');
const cancellation = await repository.cancellationQuote(
context.tenantId, customerId, created.orderId
);
assert.equal(cancellation.allowed, true);
assert.ok(cancellation.feeCents > 0);
await assert.rejects(
() => pricing.reserve({
tenantId: context.tenantId, userId: customerId, roomId: targetRoomId,
startAt: new Date(adjustedEnd.getTime() + 86400000),
endAt: new Date(adjustedEnd.getTime() + 90000000),
pricingMode: 'HOURLY', allowedStoreIds: [String(Number(storeId) + 999)]
}),
(error) => error instanceof PricingError && error.code === 'STORE_SCOPE_FORBIDDEN'
);
const [history] = await pool.query(
`SELECT adjustment_type AS adjustmentType, amount_delta_cents AS amountDeltaCents
FROM qipai_order_adjustments
WHERE tenant_id = ? AND order_id = ? ORDER BY id`,
[context.tenantId, created.orderId]
);
assert.deepEqual(history.map((row) => row.adjustmentType), [
'RENEW', 'CHANGE_ROOM', 'ADJUST_TIME', 'NOTE'
]);
}
async function assertOrderShares(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 [customerRows] = await pool.query(
`SELECT u.id FROM qipai_users u
INNER JOIN qipai_user_identities i
ON i.tenant_id = u.tenant_id AND i.user_id = u.id
WHERE u.tenant_id = ? AND i.openid = 'm02b-openid-a' LIMIT 1`,
[context.tenantId]
);
const [roomRows] = await pool.query(
`SELECT id FROM qipai_rooms
WHERE tenant_id = ? AND name = 'M04C Target Room' LIMIT 1`,
[context.tenantId]
);
const adminId = String(adminRows[0].id);
const customerId = String(customerRows[0].id);
const roomId = String(roomRows[0].id);
const access = await new RbacRepository(pool).getAccessProfile(context.tenantId, adminId);
const startAt = new Date(Date.now() + 20 * 86400000);
startAt.setUTCHours(2, 0, 0, 0);
const endAt = new Date(startAt.getTime() + 2 * 3600000);
const order = await new PricingRepository(pool).reserve({
tenantId: context.tenantId, userId: customerId, roomId,
startAt, endAt, pricingMode: 'HOURLY'
});
const state = new OrderStateRepository(pool);
await state.transition({
tenantId: context.tenantId, userId: adminId, actorType: 'USER',
source: 'ADMIN', traceId: 'm04d-order-paid', ip: '127.0.0.1',
userAgent: 'M04-D live test', access
}, order.orderId, 'CONFIRM_PAYMENT');
const repository = new OrderShareRepository(pool);
const base = {
tenantId: context.tenantId, userId: customerId, orderId: order.orderId,
access: { roles: ['CUSTOMER'], capabilities: ['order.self.read'], storeIds: [] },
ip: '127.0.0.1', userAgent: 'M04-D live test'
};
const defaultShare = await repository.create({
...base, traceId: 'm04d-share-default'
});
assert.deepEqual(defaultShare.permissions.sort(), ['OPEN_DOOR', 'VIEW_ROOM']);
const [stored] = await pool.query(
`SELECT token_hash AS tokenHash, token_prefix AS tokenPrefix,
CAST(metadata AS CHAR) AS metadata
FROM qipai_order_shares s
LEFT JOIN qipai_audit_logs a
ON a.tenant_id = s.tenant_id AND a.resource_id = s.order_id
AND a.trace_id = 'm04d-share-default'
WHERE s.id = ?`,
[defaultShare.shareId]
);
assert.notEqual(stored[0].tokenHash, defaultShare.token);
assert.equal(stored[0].tokenHash.length, 64);
assert.equal(stored[0].tokenPrefix, defaultShare.token.slice(0, 10));
assert.doesNotMatch(stored[0].metadata, new RegExp(defaultShare.token));
const viewed = await repository.resolve(defaultShare.token, 'VIEW_ROOM', {
traceId: 'm04d-share-view', ip: '127.0.0.1', userAgent: 'M04-D recipient'
});
assert.equal(viewed.order.roomId, roomId);
assert.equal('phone' in viewed.order, false);
assert.equal('payment' in viewed.order, false);
await repository.resolve(defaultShare.token, 'OPEN_DOOR', {
traceId: 'm04d-share-door', ip: '127.0.0.1', userAgent: 'M04-D recipient'
});
await assert.rejects(
() => repository.resolve(defaultShare.token, 'RENEW', {
traceId: 'm04d-share-renew-denied', ip: '127.0.0.1', userAgent: 'M04-D recipient'
}),
(error) => error instanceof OrderShareError
&& error.code === 'ORDER_SHARE_PERMISSION_DENIED'
);
const renewShare = await repository.create({
...base, permissions: ['RENEW'], ttlMinutes: 5, traceId: 'm04d-share-renew'
});
const renewed = await repository.resolve(renewShare.token, 'RENEW', {
traceId: 'm04d-share-renew-used', ip: '127.0.0.1', userAgent: 'M04-D recipient'
});
assert.equal(renewed.grantedPermission, 'RENEW');
assert.equal(renewed.order.roomId, undefined);
await repository.revoke({
...base, shareId: renewShare.shareId, traceId: 'm04d-share-revoke'
});
await assert.rejects(
() => repository.resolve(renewShare.token, 'RENEW', {
traceId: 'm04d-share-revoked-use', ip: '127.0.0.1', userAgent: 'M04-D recipient'
}),
(error) => error instanceof OrderShareError && error.code === 'ORDER_SHARE_INVALID'
);
const expiredShare = await repository.create({
...base, traceId: 'm04d-share-expiry'
});
await pool.query(
`UPDATE qipai_order_shares
SET expires_at = DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 1 SECOND) WHERE id = ?`,
[expiredShare.shareId]
);
await assert.rejects(
() => repository.resolve(expiredShare.token, 'VIEW_ROOM', {
traceId: 'm04d-share-expired-use', ip: '127.0.0.1', userAgent: 'M04-D recipient'
}),
(error) => error instanceof OrderShareError && error.code === 'ORDER_SHARE_INVALID'
);
const terminalShare = await repository.create({
...base, traceId: 'm04d-share-terminal'
});
await state.transition({
tenantId: context.tenantId, userId: adminId, actorType: 'USER',
source: 'ADMIN', traceId: 'm04d-order-cancel', ip: '127.0.0.1',
userAgent: 'M04-D live test', access
}, order.orderId, 'CANCEL');
await assert.rejects(
() => repository.resolve(terminalShare.token, 'OPEN_DOOR', {
traceId: 'm04d-share-terminal-use', ip: '127.0.0.1', userAgent: 'M04-D recipient'
}),
(error) => error instanceof OrderShareError && error.code === 'ORDER_SHARE_INACTIVE'
);
}
async function assertContentManagement(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 [storeRows] = await pool.query(
`SELECT id FROM qipai_stores WHERE tenant_id = ? AND name = 'M03A Store' LIMIT 1`,
[context.tenantId]
);
const adminId = String(adminRows[0].id);
const storeId = String(storeRows[0].id);
const rbac = new RbacRepository(pool);
const access = await rbac.getAccessProfile(context.tenantId, adminId);
const actor = {
tenantId: context.tenantId, userId: adminId, access,
traceId: 'm03b-live-test', ip: '127.0.0.1', userAgent: 'M03-B live test'
};
const repository = new ContentRepository(pool);
const asset = await repository.registerAsset(actor, storeId, {
storagePath: `tenants/${context.tenantId}/stores/${storeId}/sanitized.webp`,
publicUrl: `https://api.txyundm.cn/uploads/tenants/${context.tenantId}/stores/${storeId}/sanitized.webp`,
mimeType: 'image/webp', byteSize: 1024, width: 1200, height: 600,
checksumSha256: 'a'.repeat(64)
});
const draft1 = await repository.saveDecoration(actor, {
storeId, templateCode: 'classic', schemaVersion: 1,
content: { components: [{ type: 'HERO', props: { assetId: asset.assetId } }] }
});
const draft2 = await repository.saveDecoration(actor, {
storeId, templateCode: 'modern', schemaVersion: 1,
content: { components: [{ type: 'ROOM_LIST', props: {} }] }
});
assert.equal(draft1.version, 1);
assert.equal(draft2.version, 2);
await repository.publishDecoration(actor, draft1.decorationId, storeId);
await repository.publishDecoration(actor, draft2.decorationId, storeId);
const [decorationRows] = await pool.query(
`SELECT version, status FROM qipai_store_decorations
WHERE tenant_id = ? AND store_id = ? ORDER BY version`,
[context.tenantId, storeId]
);
assert.deepEqual(decorationRows, [
{ version: 1, status: 'ARCHIVED' },
{ version: 2, status: 'PUBLISHED' }
]);
const ad = await repository.saveAdvertisement(actor, {
scopeType: 'STORE', storeId, title: 'Store banner', imageAssetId: asset.assetId,
targetType: 'PAGE', targetValue: '/pages/index/index',
startsAt: null, endsAt: null, status: 'ACTIVE', sortOrder: 1
});
assert.match(ad.advertisementId, /^[1-9]\d*$/);
assert.equal((await repository.listAdvertisements(actor))[0].scopeType, 'STORE');
await assert.rejects(
() => repository.saveAdvertisement(actor, {
scopeType: 'PLATFORM', title: 'forbidden', imageAssetId: asset.assetId,
targetType: 'NONE', targetValue: '', status: 'DRAFT', sortOrder: 0
}),
(error) => error instanceof ContentError && error.code === 'PLATFORM_AD_FORBIDDEN'
);
}
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' },
{ version: '2026061808', name: 'm03b_decoration_ads_media' },
{ version: '2026061809', name: 'm03c_store_discovery' },
{ version: '2026061810', name: 'm03d_scene_wifi_access' },
{ version: '2026061811', name: 'm04a_pricing_reservations' },
{ version: '2026062012', name: 'm04b_order_state_machine' },
{ version: '2026062013', name: 'm04c_order_adjustments' },
{ version: '2026062014', name: 'm04d_order_shares' }
]);
await assertTaskDurability(pool);
const loginContext = await assertPlatformTenantIsolation(pool);
await assertRevocableAuthSession(pool, loginContext);
await assertUserManagement(pool, loginContext);
await assertStoreRoomDomain(pool, loginContext);
await assertContentManagement(pool, loginContext);
await assertStoreDiscovery(pool, loginContext);
await assertSceneAndWifiAccess(pool, loginContext);
await assertPricingAndReservations(pool, loginContext);
await assertOrderStateMachine(pool, loginContext);
await assertOrderAdjustments(pool, loginContext);
await assertOrderShares(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 through M04-D 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' },
{ version: '2026061808', name: 'm03b_decoration_ads_media' },
{ version: '2026061809', name: 'm03c_store_discovery' },
{ version: '2026061810', name: 'm03d_scene_wifi_access' },
{ version: '2026061811', name: 'm04a_pricing_reservations' },
{ version: '2026062012', name: 'm04b_order_state_machine' },
{ version: '2026062013', name: 'm04c_order_adjustments' },
{ version: '2026062014', name: 'm04d_order_shares' }
]);
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',
'tenant-isolated media asset',
'versioned decoration publish and archive',
'store advertisement delivery scope',
'platform advertisement rejection',
'city fallback store filtering',
'server-side distance sorting',
'empty manual city result',
'scene regeneration revokes old code',
'QR and NFC navigation without permissions',
'scene scan statistics',
'Wi-Fi denied without active order',
'Wi-Fi allowed by active order grant',
'Wi-Fi audit excludes password',
'holiday and minimum-spend pricing',
'immutable order price snapshot',
'concurrent room hold conflict',
'expired hold release',
'controlled order actions',
'idempotent transition trace',
'complete order status history',
'reservation and access release on close',
'renewal conflict leaves original end time unchanged',
'idempotent renewal adjustment',
'room-change conflict transaction rollback',
'room price difference and manager time adjustment',
'configured cancellation fee quote',
'store-scoped on-behalf booking rejection',
'share token stored as SHA-256 only',
'default view and door permissions',
'renew permission denied by default',
'explicit renew permission without room disclosure',
'share revocation and expiry',
'terminal order invalidates share'
]
}, null, 2));
} finally {
await closeMySqlPool(pool);
}