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

1933 lines
78 KiB
JavaScript

import assert from 'node:assert/strict';
import { createHmac } from 'node:crypto';
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 {
PaymentError, PaymentRepository
} from '../dist/payments/payment-repository.js';
import { ProfitSharingService } from '../dist/payments/profit-sharing-service.js';
import { WechatPayClient } from '../dist/payments/wechat-pay-client.js';
import { ThirdPartyClient } from '../dist/third-party/third-party-client.js';
import { ThirdPartyService } from '../dist/third-party/third-party-service.js';
import { DeviceRepository } from '../dist/devices/device-repository.js';
import { IotMessageService } from '../dist/devices/iot-message-service.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_collection_accounts',
'qipai_device_alerts',
'qipai_device_channels',
'qipai_device_links',
'qipai_device_maintenance_records',
'qipai_device_status_snapshots',
'qipai_devices',
'qipai_direct_bookings',
'qipai_group_redemptions',
'qipai_group_vouchers',
'qipai_holiday_calendar',
'qipai_iot_commands',
'qipai_iot_dead_letters',
'qipai_iot_device_events',
'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_payment_attempts',
'qipai_payment_callbacks',
'qipai_payment_configs',
'qipai_payments',
'qipai_permissions',
'qipai_platform_apps',
'qipai_profit_share_policies',
'qipai_profit_share_receivers',
'qipai_profit_shares',
'qipai_reconciliation_runs',
'qipai_refunds',
'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_third_party_configs',
'qipai_third_party_mappings',
'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',
'2026062015', '2026062216', '2026062217', '2026062218', '2026062219',
'2026062220']
);
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 assertPaymentDomain(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 [roomRows] = await pool.query(
`SELECT store_id AS storeId, id AS roomId FROM qipai_rooms
WHERE tenant_id = ? AND name = 'M04C Target Room' LIMIT 1`,
[context.tenantId]
);
const customerId = String(customerRows[0].id);
const storeId = String(roomRows[0].storeId);
const roomId = String(roomRows[0].roomId);
const startAt = new Date(Date.now() + 25 * 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'
});
await pool.query(
`INSERT INTO qipai_payment_configs
(tenant_id, platform_app_id, store_id, provider, scope_key, credential_ref, settings)
VALUES
(NULL, ?, NULL, 'WECHAT', ?, 'env:WX_PLATFORM', JSON_OBJECT('level', 'app')),
(?, ?, NULL, 'WECHAT', ?, 'env:WX_TENANT', JSON_OBJECT('level', 'tenant')),
(?, ?, ?, 'WECHAT', ?, 'env:WX_STORE', JSON_OBJECT('level', 'store'))`,
[context.platformAppId, `app:${context.platformAppId}`,
context.tenantId, context.platformAppId,
`tenant:${context.tenantId}:app:${context.platformAppId}`,
context.tenantId, context.platformAppId, storeId,
`tenant:${context.tenantId}:app:${context.platformAppId}:store:${storeId}`]
);
const repository = new PaymentRepository(pool);
const resolved = await repository.resolveConfig(
pool, context.tenantId, context.platformAppId, storeId, 'WECHAT'
);
assert.equal(resolved.credentialRef, 'env:WX_STORE');
assert.equal(resolved.settings.level, 'store');
const created = await repository.createPayment({
tenantId: context.tenantId, platformAppId: context.platformAppId,
userId: customerId, orderId: order.orderId, provider: 'TEST',
clientRequestId: 'm05a-payment-request-1', testAdapterEnabled: true
});
assert.equal(created.amountCents, order.quote.totalCents);
const duplicateCreate = await repository.createPayment({
tenantId: context.tenantId, platformAppId: context.platformAppId,
userId: customerId, orderId: order.orderId, provider: 'TEST',
clientRequestId: 'm05a-payment-request-1', testAdapterEnabled: true
});
assert.equal(duplicateCreate.paymentId, created.paymentId);
assert.equal(duplicateCreate.idempotent, true);
await assert.rejects(
() => repository.createPayment({
tenantId: context.tenantId, platformAppId: context.platformAppId,
userId: customerId, orderId: order.orderId, provider: 'TEST',
clientRequestId: 'm05a-payment-disabled', testAdapterEnabled: false
}),
(error) => error instanceof PaymentError && error.code === 'TEST_PAYMENT_DISABLED'
);
const rejected = await repository.processTestCallback({
tenantId: context.tenantId, userId: customerId, paymentId: created.paymentId,
callbackId: 'm05a-callback-wrong-amount', amountCents: created.amountCents - 1,
testAdapterEnabled: true, traceId: 'm05a-wrong-amount'
});
assert.equal(rejected.status, 'REJECTED');
const [afterRejected] = await pool.query(
`SELECT o.status, o.paid_amount_cents AS paidAmountCents,
c.processing_status AS callbackStatus
FROM qipai_orders o
INNER JOIN qipai_payment_callbacks c ON c.payment_id = ?
WHERE o.id = ? AND c.callback_id = 'm05a-callback-wrong-amount'`,
[created.paymentId, order.orderId]
);
assert.equal(afterRejected[0].status, 'PENDING_PAYMENT');
assert.equal(afterRejected[0].paidAmountCents, 0);
assert.equal(afterRejected[0].callbackStatus, 'REJECTED');
const succeeded = await repository.processTestCallback({
tenantId: context.tenantId, userId: customerId, paymentId: created.paymentId,
callbackId: 'm05a-callback-success', amountCents: created.amountCents,
testAdapterEnabled: true, traceId: 'm05a-payment-success'
});
assert.equal(succeeded.status, 'SUCCEEDED');
const duplicateCallback = await repository.processTestCallback({
tenantId: context.tenantId, userId: customerId, paymentId: created.paymentId,
callbackId: 'm05a-callback-success', amountCents: created.amountCents,
testAdapterEnabled: true, traceId: 'm05a-payment-success-duplicate'
});
assert.equal(duplicateCallback.idempotent, true);
const [paidRows] = await pool.query(
`SELECT o.status, o.paid_amount_cents AS paidAmountCents,
p.status AS paymentStatus,
(SELECT COUNT(*) FROM qipai_order_status_history h
WHERE h.order_id = o.id AND h.to_status = 'PAID') AS paidHistoryCount,
(SELECT COUNT(*) FROM qipai_payment_attempts a
WHERE a.payment_id = p.id) AS attemptCount
FROM qipai_orders o
INNER JOIN qipai_payments p ON p.order_id = o.id
WHERE o.id = ? AND p.id = ?`,
[order.orderId, created.paymentId]
);
assert.equal(paidRows[0].status, 'PAID');
assert.equal(paidRows[0].paidAmountCents, created.amountCents);
assert.equal(paidRows[0].paymentStatus, 'SUCCEEDED');
assert.equal(Number(paidRows[0].paidHistoryCount), 1);
assert.equal(Number(paidRows[0].attemptCount), 1);
const [configRows] = await pool.query(
`SELECT credential_ref AS credentialRef, CAST(settings AS CHAR) AS settings
FROM qipai_payment_configs WHERE tenant_id = ?`,
[context.tenantId]
);
assert.equal(configRows.every((row) => row.credentialRef.startsWith('env:')), true);
assert.equal(configRows.some((row) => /secret|private.key/i.test(row.settings)), false);
}
async function assertThirdPartyDomain(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 [roomRows] = await pool.query(
`SELECT store_id AS storeId, id AS roomId FROM qipai_rooms
WHERE tenant_id = ? AND name = 'M04C Target Room' LIMIT 1`,
[context.tenantId]
);
const customerId = String(customerRows[0].id);
const storeId = String(roomRows[0].storeId);
const roomId = String(roomRows[0].roomId);
await pool.query(
`INSERT INTO qipai_third_party_configs
(tenant_id, store_id, provider, mode, credential_ref, settings)
VALUES (?, NULL, 'MEITUAN', 'MOCK', 'env:TP_TEST', JSON_OBJECT())`,
[context.tenantId]
);
const pricing = new PricingRepository(pool);
const service = new ThirdPartyService(
pool,
pricing,
new ThirdPartyClient({
async request() {
throw new Error('Live M05-C test must not call an external provider.');
}
}),
new Map([['TP_TEST', { webhookSecret: 'm05c-webhook-secret' }]])
);
const startAt = new Date(Date.now() + 27 * 86400000);
startAt.setUTCHours(2, 0, 0, 0);
const endAt = new Date(startAt.getTime() + 2 * 3600000);
const order = await pricing.reserve({
tenantId: context.tenantId,
userId: customerId,
roomId,
startAt,
endAt,
pricingMode: 'HOURLY'
});
const redeemed = await service.redeemVoucher({
tenantId: context.tenantId,
userId: customerId,
provider: 'MEITUAN',
voucherCode: 'M05C-SENSITIVE-VOUCHER-001',
orderId: order.orderId,
clientRequestId: 'm05c-redeem-request-001'
});
assert.equal(redeemed.status, 'SUCCEEDED');
const duplicate = await service.redeemVoucher({
tenantId: context.tenantId,
userId: customerId,
provider: 'MEITUAN',
voucherCode: 'M05C-SENSITIVE-VOUCHER-001',
orderId: order.orderId,
clientRequestId: 'm05c-redeem-request-001'
});
assert.equal(duplicate.idempotent, true);
const [voucherRows] = await pool.query(
`SELECT voucher_hash AS voucherHash, voucher_masked AS voucherMasked,
(SELECT COUNT(*) FROM qipai_group_redemptions r
WHERE r.voucher_id = v.id) AS redemptionCount
FROM qipai_group_vouchers v WHERE tenant_id = ?`,
[context.tenantId]
);
assert.match(voucherRows[0].voucherHash, /^[a-f0-9]{64}$/);
assert.equal(voucherRows[0].voucherMasked.includes('SENSITIVE'), false);
assert.equal(Number(voucherRows[0].redemptionCount), 1);
await pool.query(
`INSERT INTO qipai_third_party_mappings
(tenant_id, provider, resource_type, external_ref, local_resource_id)
VALUES (?, 'MEITUAN', 'STORE', 'external-store-001', ?),
(?, 'MEITUAN', 'ROOM', 'external-room-001', ?)`,
[context.tenantId, storeId, context.tenantId, roomId]
);
const bookingStart = new Date(Date.now() + 29 * 86400000);
bookingStart.setUTCHours(2, 0, 0, 0);
const bookingEnd = new Date(bookingStart.getTime() + 2 * 3600000);
const quote = await pricing.quote({
tenantId: context.tenantId,
roomId,
startAt: bookingStart,
endAt: bookingEnd,
pricingMode: 'HOURLY'
});
const payload = {
eventId: 'm05c-booking-event-001',
externalBookingNo: 'm05c-booking-001',
externalStoreRef: 'external-store-001',
externalRoomRef: 'external-room-001',
customerRef: 'private-customer-ref',
startsAt: bookingStart.toISOString(),
endsAt: bookingEnd.toISOString(),
amountCents: quote.totalCents
};
const rawBody = JSON.stringify(payload);
const signature = createHmac('sha256', 'm05c-webhook-secret')
.update(rawBody).digest('hex');
const received = await service.receiveDirectBooking({
tenantId: context.tenantId,
provider: 'MEITUAN',
signature,
rawBody,
eventId: payload.eventId,
externalBookingNo: payload.externalBookingNo,
externalStoreRef: payload.externalStoreRef,
externalRoomRef: payload.externalRoomRef,
customerRef: payload.customerRef,
startsAt: bookingStart,
endsAt: bookingEnd,
amountCents: payload.amountCents,
payload
});
assert.equal(received.status, 'READY_TO_CLAIM');
assert.equal((await service.receiveDirectBooking({
tenantId: context.tenantId,
provider: 'MEITUAN',
signature,
rawBody,
eventId: payload.eventId,
externalBookingNo: payload.externalBookingNo,
externalStoreRef: payload.externalStoreRef,
externalRoomRef: payload.externalRoomRef,
customerRef: payload.customerRef,
startsAt: bookingStart,
endsAt: bookingEnd,
amountCents: payload.amountCents,
payload
})).idempotent, true);
const claimed = await service.claimDirectBooking({
tenantId: context.tenantId,
userId: customerId,
bookingId: received.bookingId
});
const [claimedRows] = await pool.query(
`SELECT b.status, b.customer_ref_hash AS customerRefHash,
o.status AS orderStatus, o.paid_amount_cents AS paidAmountCents,
o.total_amount_cents AS totalAmountCents
FROM qipai_direct_bookings b
INNER JOIN qipai_orders o ON o.id = b.order_id AND o.tenant_id = b.tenant_id
WHERE b.id = ?`,
[received.bookingId]
);
assert.equal(claimedRows[0].status, 'CLAIMED');
assert.match(claimedRows[0].customerRefHash, /^[a-f0-9]{64}$/);
assert.equal(claimedRows[0].orderStatus, 'PAID');
assert.equal(claimedRows[0].paidAmountCents, claimedRows[0].totalAmountCents);
assert.match(claimed.orderId, /^[1-9]\d*$/);
const unmappedPayload = {
...payload,
eventId: 'm05c-booking-event-unmapped',
externalBookingNo: 'm05c-booking-unmapped',
externalRoomRef: 'missing-room'
};
const unmappedBody = JSON.stringify(unmappedPayload);
const unmapped = await service.receiveDirectBooking({
tenantId: context.tenantId,
provider: 'MEITUAN',
signature: createHmac('sha256', 'm05c-webhook-secret')
.update(unmappedBody).digest('hex'),
rawBody: unmappedBody,
eventId: unmappedPayload.eventId,
externalBookingNo: unmappedPayload.externalBookingNo,
externalStoreRef: unmappedPayload.externalStoreRef,
externalRoomRef: unmappedPayload.externalRoomRef,
customerRef: unmappedPayload.customerRef,
startsAt: bookingStart,
endsAt: bookingEnd,
amountCents: unmappedPayload.amountCents,
payload: unmappedPayload
});
assert.equal(unmapped.status, 'PENDING_MAPPING');
}
async function assertProfitSharingDomain(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 [paymentRows] = await pool.query(
`SELECT p.id, p.order_id AS orderId, p.store_id AS storeId,
p.amount_cents AS amountCents
FROM qipai_payments p
WHERE p.tenant_id = ? AND p.provider = 'GROUP_BUY'
AND p.status = 'SUCCEEDED' ORDER BY p.id DESC LIMIT 1`,
[context.tenantId]
);
const adminId = String(adminRows[0].id);
const payment = paymentRows[0];
await pool.query(
`INSERT INTO qipai_payments
(tenant_id, platform_app_id, order_id, store_id, payment_no,
channel, provider, client_request_id, status, amount_cents,
provider_payment_id, paid_at)
VALUES (?, ?, ?, ?, 'M05D-WECHAT-PAYMENT', 'WECHAT', 'WECHAT',
'm05d-wechat-payment', 'SUCCEEDED', ?, 'wx-m05d-transaction',
UTC_TIMESTAMP(3))`,
[context.tenantId, context.platformAppId, payment.orderId,
payment.storeId, payment.amountCents]
);
const [wechatPaymentRows] = await pool.query(
`SELECT id FROM qipai_payments
WHERE tenant_id = ? AND client_request_id = 'm05d-wechat-payment'`,
[context.tenantId]
);
const access = {
roles: ['TENANT_ADMIN'],
capabilities: ['tenant.manage'],
storeIds: []
};
const service = new ProfitSharingService(
pool,
new WechatPayClient({
async request() {
throw new Error('M05-D MySQL test uses the explicit mock adapter only.');
}
}),
new Map(),
true
);
await assert.rejects(
() => service.saveCollectionAccount({
tenantId: context.tenantId,
platformAppId: context.platformAppId,
actorId: adminId,
access,
storeId: String(payment.storeId),
merchantId: '1900000109',
credentialRef: 'env:WX_M05D',
authorizationStatus: 'PENDING',
profitSharingEnabled: true,
enabled: true
}),
(error) => error.code === 'PROFIT_SHARING_NOT_AUTHORIZED'
);
const account = await service.saveCollectionAccount({
tenantId: context.tenantId,
platformAppId: context.platformAppId,
actorId: adminId,
access,
storeId: String(payment.storeId),
merchantId: '1900000109',
credentialRef: 'env:WX_M05D',
authorizationStatus: 'AUTHORIZED',
profitSharingEnabled: true,
enabled: true
});
const receiverA = await service.saveReceiver({
tenantId: context.tenantId,
access,
collectionAccountId: account.accountId,
receiverType: 'MERCHANT_ID',
receiverAccount: 'receiver-private-account-a',
receiverCredentialRef: 'receiver:STORE_A',
relationType: 'PARTNER',
name: 'M05D Receiver A',
authorizationStatus: 'AUTHORIZED',
enabled: true
});
const receiverB = await service.saveReceiver({
tenantId: context.tenantId,
access,
collectionAccountId: account.accountId,
receiverType: 'MERCHANT_ID',
receiverAccount: 'receiver-private-account-b',
receiverCredentialRef: 'receiver:STORE_B',
relationType: 'PARTNER',
name: 'M05D Receiver B',
authorizationStatus: 'AUTHORIZED',
enabled: true
});
await service.savePolicy({
tenantId: context.tenantId,
access,
collectionAccountId: account.accountId,
storeId: String(payment.storeId),
receiverId: receiverA.receiverId,
percentageBps: 3000,
enabled: true
});
await service.savePolicy({
tenantId: context.tenantId,
access,
collectionAccountId: account.accountId,
storeId: String(payment.storeId),
receiverId: receiverB.receiverId,
percentageBps: 2000,
enabled: true
});
await assert.rejects(
() => service.savePolicy({
tenantId: context.tenantId,
access,
collectionAccountId: account.accountId,
storeId: String(payment.storeId),
receiverId: receiverB.receiverId,
percentageBps: 8000,
enabled: true
}),
(error) => error.code === 'PROFIT_SHARE_TOTAL_EXCEEDED'
);
const result = await service.execute({
tenantId: context.tenantId,
actorId: adminId,
access,
paymentId: String(wechatPaymentRows[0].id),
clientRequestId: 'm05d-profit-share-request',
mode: 'MOCK'
});
assert.equal(result.shares.length, 2);
assert.equal(result.shares.every((share) => share.status === 'SUCCEEDED'), true);
const duplicate = await service.execute({
tenantId: context.tenantId,
actorId: adminId,
access,
paymentId: String(wechatPaymentRows[0].id),
clientRequestId: 'm05d-profit-share-request',
mode: 'MOCK'
});
assert.equal(duplicate.idempotent, true);
assert.equal(duplicate.shares.length, 2);
const [shareRows] = await pool.query(
`SELECT ps.status, ps.percentage_bps AS percentageBps,
ps.amount_cents AS amountCents, ps.receiver_ref AS receiverMasked,
r.receiver_hash AS receiverHash, r.receiver_credential_ref AS receiverRef
FROM qipai_profit_shares ps
INNER JOIN qipai_profit_share_receivers r
ON r.tenant_id = ps.tenant_id AND r.id = ps.receiver_id
WHERE ps.tenant_id = ? AND ps.batch_request_id = ?
ORDER BY ps.id`,
[context.tenantId, 'm05d-profit-share-request']
);
assert.equal(shareRows.length, 2);
assert.deepEqual(shareRows.map((row) => row.percentageBps), [3000, 2000]);
assert.equal(shareRows.every((row) => row.status === 'SUCCEEDED'), true);
assert.equal(shareRows.every((row) => /^[a-f0-9]{64}$/.test(row.receiverHash)), true);
assert.equal(shareRows.some((row) => row.receiverMasked.includes('private')), false);
assert.equal(shareRows.every((row) => row.receiverRef.startsWith('receiver:')), true);
}
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'
);
}
async function assertDeviceTopology(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 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 rbac = new RbacRepository(pool);
const adminId = String(adminRows[0].id);
const access = await rbac.getAccessProfile(context.tenantId, adminId);
assert.ok(access.capabilities.includes('device.read'));
assert.ok(access.capabilities.includes('device.write'));
const storeId = String(storeRows[0].storeId);
const roomId = String(storeRows[0].roomId);
const actor = {
tenantId: context.tenantId, userId: adminId, access,
traceId: 'm06b-live-test', ip: '127.0.0.1', userAgent: 'M06-B live test'
};
const repository = new DeviceRepository(pool);
const controlBox = await repository.createAsset(actor, {
storeId, roomId, deviceId: 'M06B_BOX_001', imei: '860000000000001',
iccid: '89860000000000000001', deviceType: 'CONTROL_BOX',
model: 'JL-CONTROL', firmwareVersion: '1.0.0', signalStrength: 18,
capabilities: ['POWER', 'LOCK', 'TTS']
});
const socket = await repository.createAsset(actor, {
storeId, roomId, deviceId: 'M06B_SOCKET_001', imei: '860000000000002',
iccid: '89860000000000000002', deviceType: 'SMART_SOCKET',
model: 'JL-SOCKET', firmwareVersion: '1.0.0', signalStrength: 16,
capabilities: ['POWER', 'METERING']
});
const lock = await repository.createAsset(actor, {
storeId, roomId, deviceId: 'M06B_LOCK_001', imei: '',
iccid: null, deviceType: 'SUB_LOCK', model: '701C',
firmwareVersion: '1.0.0', capabilities: ['LOCK', 'CARD', 'PASSWORD']
});
await repository.bindChannel(actor, {
assetId: controlBox.assetId, storeId, roomId,
channelCode: 'SLOT1', purpose: 'ROOM_POWER'
});
await assert.rejects(
() => repository.bindChannel(actor, {
assetId: socket.assetId, storeId, roomId,
channelCode: 'MAIN', purpose: 'ROOM_POWER'
}),
(error) => error.code === 'DEVICE_CONTROL_TARGET_CONFLICT'
);
await repository.bindSubDevice(actor, {
parentAssetId: controlBox.assetId, childAssetId: lock.assetId,
storeId, roomId, subId: 'SUB-001', subtype: '701C'
});
await repository.recordStatus(actor, {
assetId: controlBox.assetId, storeId, onlineStatus: 'ONLINE',
signalStrength: 22, firmwareVersion: '1.0.1',
snapshot: { slot1: true, door: 'closed' }
});
await repository.addMaintenance(actor, {
assetId: controlBox.assetId, storeId, roomId,
recordType: 'INSPECTION', status: 'OPEN', description: 'M06-B inspection'
});
const assets = await repository.listAssets(actor, storeId);
const box = assets.find((item) => item.id === controlBox.assetId);
assert.deepEqual(box?.capabilities, ['LOCK', 'POWER', 'TTS']);
assert.equal(box?.status, 'ONLINE');
assert.equal(box?.maintenanceStatus, 'MAINTENANCE');
const [topologyRows] = await pool.query(
`SELECT c.purpose, l.sub_id AS subId, l.subtype
FROM qipai_device_channels c
INNER JOIN qipai_device_links l
ON l.tenant_id = c.tenant_id AND l.parent_device_id = c.device_id
WHERE c.tenant_id = ? AND c.device_id = ?`,
[context.tenantId, controlBox.assetId]
);
assert.deepEqual(topologyRows, [{
purpose: 'ROOM_POWER', subId: 'SUB-001', subtype: '701C'
}]);
}
async function assertIotMessages(pool, context) {
const [deviceRows] = await pool.query(
`SELECT id, store_id AS storeId, room_id AS roomId, device_id AS deviceId
FROM qipai_devices
WHERE tenant_id = ? AND device_id = 'M06B_BOX_001'`,
[context.tenantId]
);
const device = deviceRows[0];
const service = new IotMessageService(pool);
await service.createCommand({
tenantId: context.tenantId,
assetId: String(device.id),
storeId: String(device.storeId),
roomId: String(device.roomId),
commandId: '1782120000001',
commandType: 'ConctolPower',
payload: {
action: 'ConctolPower', id: '1782120000001', slot1: 'on'
},
traceId: 'm06c-live-test'
});
assert.equal(await service.markPublished(context.tenantId, '1782120000001'), true);
const payload = Buffer.from(JSON.stringify({
DeviceID: device.deviceId,
id: '1782120000001',
action: 'ConctolPower',
result: 'ok',
slot1: 'on',
timestamp: 1782120000
}));
await service.handle(`/devicesend/${device.deviceId}`, payload);
await service.handle(`/devicesend/${device.deviceId}`, payload);
const [commandRows] = await pool.query(
`SELECT status, failure_code AS failureCode
FROM qipai_iot_commands
WHERE tenant_id = ? AND command_id = '1782120000001'`,
[context.tenantId]
);
assert.deepEqual(commandRows, [{ status: 'ACKED', failureCode: '' }]);
const [eventRows] = await pool.query(
`SELECT receive_count AS receiveCount, processing_status AS processingStatus
FROM qipai_iot_device_events
WHERE tenant_id = ? AND command_id = '1782120000001'`,
[context.tenantId]
);
assert.deepEqual(eventRows, [{ receiveCount: 2, processingStatus: 'PROCESSED' }]);
await service.handle('/invalid/topic', Buffer.from('{bad-json'));
const [deadRows] = await pool.query(
`SELECT error_code AS errorCode, receive_count AS receiveCount
FROM qipai_iot_dead_letters WHERE topic = '/invalid/topic'`
);
assert.deepEqual(deadRows, [{ errorCode: 'MQTT_TOPIC_INVALID', receiveCount: 1 }]);
}
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' },
{ version: '2026062015', name: 'm05a_payment_domain' },
{ version: '2026062216', name: 'm05b_wechat_refunds' },
{ version: '2026062217', name: 'm05c_third_party' },
{ version: '2026062218', name: 'm05d_profit_sharing' },
{ version: '2026062219', name: 'm06b_device_topology' },
{ version: '2026062220', name: 'm06c_iot_messages' }
]);
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 assertPaymentDomain(pool, loginContext);
await assertThirdPartyDomain(pool, loginContext);
await assertProfitSharingDomain(pool, loginContext);
await assertDeviceTopology(pool, loginContext);
await assertIotMessages(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 M06-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' },
{ 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' },
{ version: '2026062015', name: 'm05a_payment_domain' },
{ version: '2026062216', name: 'm05b_wechat_refunds' },
{ version: '2026062217', name: 'm05c_third_party' },
{ version: '2026062218', name: 'm05d_profit_sharing' },
{ version: '2026062219', name: 'm06b_device_topology' },
{ version: '2026062220', name: 'm06c_iot_messages' }
]);
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',
'payment config store precedence',
'server-derived payment amount',
'idempotent payment creation',
'mismatched callback retained without accounting',
'duplicate success callback does not double account',
'test adapter explicit non-production gate',
'Wechat refund idempotency and callback indexes',
'Wechat reconciliation request history'
,
'group voucher hash-only storage and single redemption',
'third-party booking webhook idempotency',
'mapped booking claim creates a paid order',
'unmapped booking enters manual queue'
,
'collection account authorization gate',
'profit-share percentage total validation',
'receiver hash and masked storage',
'payment and receiver idempotent profit sharing'
,
'device asset identity and capabilities',
'control target conflict across control box and smart socket',
'Sub-1G parent-child topology',
'device status snapshots and maintenance state'
,
'13-digit IoT command state transition',
'QoS 1 duplicate event receive count',
'ACK correlation without duplicate side effects',
'invalid Topic dead-letter persistence'
]
}, null, 2));
} finally {
await closeMySqlPool(pool);
}