Files
qipai/backend/tests/mysql-migration-roundtrip.test.mjs
T

825 lines
32 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 {
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_price_snapshots',
'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']
);
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 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' }
]);
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 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-A 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' }
]);
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'
]
}, null, 2));
} finally {
await closeMySqlPool(pool);
}