3384 lines
142 KiB
JavaScript
3384 lines
142 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 { FranchiseRepository, FranchiseError } from '../dist/franchise/franchise-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 { DeviceCommandService } from '../dist/devices/device-command-service.js';
|
|
import { DeviceControlService } from '../dist/devices/device-control-service.js';
|
|
import { MemberProfileService } from '../dist/wallets/member-profile-service.js';
|
|
import { BusinessStatisticsRepository } from '../dist/operations/business-statistics-repository.js';
|
|
import { SystemOperationsRepository } from '../dist/operations/system-operations-repository.js';
|
|
import { AdminAuthRepository, AdminAuthError, hashToken } from '../dist/auth/admin-auth-repository.js';
|
|
import { hashPassword } from '../dist/auth/password.js';
|
|
import {
|
|
CleaningTaskError, CleaningTaskRepository
|
|
} from '../dist/cleaning/cleaning-task-repository.js';
|
|
import {
|
|
executeMigrationPlan,
|
|
loadMigrationPlan,
|
|
splitSqlStatements
|
|
} from '../dist/db/migration-runner.js';
|
|
|
|
const expectedTables = [
|
|
'qipai_admin_credentials',
|
|
'qipai_advertisements',
|
|
'qipai_async_tasks',
|
|
'qipai_audit_logs',
|
|
'qipai_auth_sessions',
|
|
'qipai_cleaning_settlement_events',
|
|
'qipai_cleaning_settlement_reversals',
|
|
'qipai_cleaning_task_photos',
|
|
'qipai_cleaning_task_submissions',
|
|
'qipai_cleaning_templates',
|
|
'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_franchise_applications',
|
|
'qipai_franchise_follow_ups',
|
|
'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', '2026081002', '2026081003', '2026081004', '2026081005',
|
|
'2026081006']
|
|
);
|
|
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);
|
|
|
|
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 managedStartAt = new Date(startAt.getTime() + 24 * 3600000);
|
|
const managedEndAt = new Date(managedStartAt.getTime() + 2 * 3600000);
|
|
const managed = await repository.reserve({
|
|
tenantId: context.tenantId, userId: customerId, roomId,
|
|
startAt: managedStartAt, endAt: managedEndAt, pricingMode: 'HOURLY',
|
|
actor: {
|
|
userId: adminId, source: 'ADMIN', traceId: 'm08c-reserve-on-behalf',
|
|
ip: '127.0.0.1', userAgent: 'M08-C live manager booking test'
|
|
}
|
|
});
|
|
const [managedHistory] = await pool.query(
|
|
`SELECT actor_id AS actorId, source, CAST(metadata AS CHAR) AS metadata
|
|
FROM qipai_order_status_history WHERE tenant_id = ? AND order_id = ?`,
|
|
[context.tenantId, managed.orderId]
|
|
);
|
|
assert.equal(String(managedHistory[0].actorId), adminId);
|
|
assert.equal(managedHistory[0].source, 'ADMIN');
|
|
assert.equal(String(JSON.parse(managedHistory[0].metadata).targetUserId), customerId);
|
|
const [managedAudit] = await pool.query(
|
|
`SELECT actor_id AS actorId, action, CAST(metadata AS CHAR) AS metadata
|
|
FROM qipai_audit_logs
|
|
WHERE tenant_id = ? AND trace_id = 'm08c-reserve-on-behalf'`,
|
|
[context.tenantId]
|
|
);
|
|
assert.equal(String(managedAudit[0].actorId), adminId);
|
|
assert.equal(managedAudit[0].action, 'ORDER_CREATED_ON_BEHALF');
|
|
assert.equal(String(JSON.parse(managedAudit[0].metadata).targetUserId), customerId);
|
|
|
|
const scopedMembers = await new MemberProfileService(pool).listMembers({
|
|
actor: {
|
|
tenantId: context.tenantId,
|
|
userId: adminId,
|
|
access: {
|
|
roles: ['STORE_ADMIN'],
|
|
capabilities: ['user.read'],
|
|
storeIds: [String(targetRows[0].storeId)]
|
|
}
|
|
},
|
|
page: 1,
|
|
pageSize: 20,
|
|
status: 'ACTIVE',
|
|
search: customerId
|
|
});
|
|
assert.equal(scopedMembers.total, 1);
|
|
assert.equal(scopedMembers.items[0].memberId, customerId);
|
|
assert.ok(scopedMembers.items[0].orders.orderCount >= 1);
|
|
|
|
const [foreignUser] = await pool.query(
|
|
`INSERT INTO qipai_users (tenant_id, user_type, status)
|
|
VALUES (?, 'CUSTOMER', 'ACTIVE')`,
|
|
[String(Number(context.tenantId) + 1)]
|
|
);
|
|
await assert.rejects(
|
|
() => repository.reserve({
|
|
tenantId: context.tenantId, userId: String(foreignUser.insertId), roomId,
|
|
startAt: new Date(managedStartAt.getTime() + 24 * 3600000),
|
|
endAt: new Date(managedEndAt.getTime() + 24 * 3600000),
|
|
pricingMode: 'HOURLY'
|
|
}),
|
|
(error) => error instanceof PricingError && error.code === 'USER_NOT_FOUND'
|
|
);
|
|
}
|
|
|
|
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);
|
|
|
|
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 adminAccess = await new RbacRepository(pool).getAccessProfile(context.tenantId, adminId);
|
|
const managerStartAt = new Date(endAt.getTime() + 3600000);
|
|
const managerEndAt = new Date(managerStartAt.getTime() + 2 * 3600000);
|
|
const managerOrder = await pricing.reserve({
|
|
tenantId: context.tenantId,
|
|
userId: customerId,
|
|
roomId,
|
|
startAt: managerStartAt,
|
|
endAt: managerEndAt,
|
|
pricingMode: 'HOURLY'
|
|
});
|
|
const managerRedeemed = await service.redeemVoucherAsManager({
|
|
tenantId: context.tenantId,
|
|
actorId: adminId,
|
|
access: adminAccess,
|
|
provider: 'MEITUAN',
|
|
voucherCode: 'M08C-MANAGER-VOUCHER-002',
|
|
orderId: managerOrder.orderId,
|
|
clientRequestId: 'm08c-manager-redeem-002'
|
|
});
|
|
assert.equal(managerRedeemed.status, 'SUCCEEDED');
|
|
const managerRecords = await service.listRecords({
|
|
tenantId: context.tenantId,
|
|
access: adminAccess,
|
|
provider: 'MEITUAN',
|
|
status: 'SUCCEEDED',
|
|
storeId
|
|
});
|
|
const managerRecord = managerRecords.redemptions.find(
|
|
(item) => String(item.id) === managerRedeemed.redemptionId
|
|
);
|
|
assert.equal(String(managerRecord.actorId), adminId);
|
|
assert.equal(managerRecord.voucherMasked.includes('MANAGER'), false);
|
|
const statistics = await new BusinessStatisticsRepository(pool).overview({
|
|
tenantId: context.tenantId,
|
|
userId: adminId,
|
|
access: adminAccess,
|
|
traceId: 'm08c-business-statistics',
|
|
ip: '127.0.0.1',
|
|
userAgent: 'M08-C MySQL test'
|
|
}, {
|
|
storeId,
|
|
from: new Date(Date.now() - 86400000),
|
|
to: new Date(Date.now() + 2 * 86400000)
|
|
});
|
|
assert.ok(statistics.summary.orderTotal >= 2);
|
|
assert.ok(statistics.summary.collectedAmountCents > 0);
|
|
assert.ok(statistics.summary.voucherSucceeded >= 2);
|
|
assert.ok(statistics.paymentChannels.some((item) => item.channel === 'GROUP_BUY'));
|
|
|
|
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 duplicateAsset = await repository.registerAsset(actor, storeId, {
|
|
storagePath: `tenants/${context.tenantId}/stores/${storeId}/duplicate.webp`,
|
|
publicUrl: `https://api.txyundm.cn/uploads/tenants/${context.tenantId}/stores/${storeId}/duplicate.webp`,
|
|
mimeType: 'image/webp', byteSize: 1024, width: 1200, height: 600,
|
|
checksumSha256: 'a'.repeat(64)
|
|
});
|
|
assert.equal(duplicateAsset.assetId, asset.assetId);
|
|
const globalAsset = await repository.registerAsset(actor, undefined, {
|
|
storagePath: `tenants/${context.tenantId}/global/sanitized.webp`,
|
|
publicUrl: `https://api.txyundm.cn/uploads/tenants/${context.tenantId}/global/sanitized.webp`,
|
|
mimeType: 'image/webp', byteSize: 1024, width: 1200, height: 600,
|
|
checksumSha256: 'a'.repeat(64)
|
|
});
|
|
assert.notEqual(globalAsset.assetId, asset.assetId);
|
|
assert.deepEqual(
|
|
(await repository.listAssets(actor, storeId)).map((item) => item.id).sort(),
|
|
[asset.assetId, globalAsset.assetId].sort()
|
|
);
|
|
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' }
|
|
]);
|
|
assert.equal((await repository.listDecorations(actor, storeId)).length, 2);
|
|
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*$/);
|
|
await repository.updateAdvertisement(actor, ad.advertisementId, {
|
|
scopeType: 'STORE', storeId, title: 'Updated store banner', imageAssetId: asset.assetId,
|
|
targetType: 'NONE', targetValue: '', startsAt: null, endsAt: null,
|
|
status: 'INACTIVE', sortOrder: 2
|
|
});
|
|
const savedAd = (await repository.listAdvertisements(actor))[0];
|
|
assert.equal(savedAd.scopeType, 'STORE');
|
|
assert.equal(savedAd.title, 'Updated store banner');
|
|
assert.equal(savedAd.imageAssetId, asset.assetId);
|
|
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 assertFranchiseManagement(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 access = await new RbacRepository(pool).getAccessProfile(context.tenantId, adminId);
|
|
const actor = { tenantId: context.tenantId, userId: adminId, access,
|
|
traceId: 'm08d-franchise-live', ip: '127.0.0.1', userAgent: 'M08-D franchise live test' };
|
|
const repository = new FranchiseRepository(pool);
|
|
const input = { tenantId: context.tenantId, city: '上海市', contactName: '测试联系人',
|
|
contactPhone: '13800138000', message: '计划开设两家门店', source: 'MINIAPP',
|
|
clientRequestId: 'm08d-franchise-idempotent', traceId: actor.traceId,
|
|
ip: actor.ip, userAgent: actor.userAgent };
|
|
const first = await repository.submitApplication(input);
|
|
const duplicate = await repository.submitApplication(input);
|
|
assert.equal(first.idempotent, false);
|
|
assert.equal(duplicate.idempotent, true);
|
|
assert.equal(duplicate.applicationId, first.applicationId);
|
|
assert.equal((await repository.listApplications({ tenantId: context.tenantId,
|
|
page: 1, pageSize: 20, status: 'NEW' })).total, 1);
|
|
await assert.rejects(
|
|
() => repository.assignApplication(actor, context.tenantId, first.applicationId, '999999999'),
|
|
(error) => error instanceof FranchiseError && error.code === 'FRANCHISE_ASSIGNEE_INVALID'
|
|
);
|
|
await repository.assignApplication(actor, context.tenantId, first.applicationId, adminId);
|
|
await repository.addFollowUp(actor, context.tenantId, first.applicationId, {
|
|
followUpType: 'CALL', note: '已完成首次电话沟通', status: 'CONTACTED', nextFollowUpAt: null
|
|
});
|
|
await assert.rejects(() => repository.addFollowUp(actor, context.tenantId, first.applicationId, {
|
|
followUpType: 'NOTE', note: '跳过资格确认', status: 'CONVERTED'
|
|
}), (error) => error instanceof FranchiseError && error.code === 'FRANCHISE_STATUS_TRANSITION_INVALID');
|
|
const detail = await repository.getApplication(context.tenantId, first.applicationId);
|
|
assert.equal(detail.application.status, 'CONTACTED');
|
|
assert.equal(detail.followUps.length, 2);
|
|
const [auditRows] = await pool.query(
|
|
`SELECT CAST(metadata AS CHAR) AS metadata FROM qipai_audit_logs
|
|
WHERE tenant_id = ? AND resource_type = 'FRANCHISE_APPLICATION'`, [context.tenantId]
|
|
);
|
|
assert.equal(auditRows.some((row) => row.metadata.includes('13800138000')), false);
|
|
}
|
|
|
|
async function assertSystemOperations(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 access = await new RbacRepository(pool).getAccessProfile(context.tenantId, adminId);
|
|
const actor = { tenantId: context.tenantId, userId: adminId, access,
|
|
traceId: 'm08d-system-live', ip: '172.18.20.42', userAgent: 'M08-D system live test' };
|
|
await pool.execute(
|
|
`INSERT INTO qipai_audit_logs
|
|
(tenant_id, actor_type, actor_id, action, resource_type, resource_id,
|
|
trace_id, ip, user_agent, metadata) VALUES (?, 'USER', ?, 'M08D_REDACTION_PROBE',
|
|
'TENANT', ?, ?, ?, ?, ?)`,
|
|
[context.tenantId, adminId, context.tenantId, actor.traceId, actor.ip, actor.userAgent,
|
|
JSON.stringify({ orderId: 'safe-order', phone: '13800138000', nested: { apiKey: 'secret', safe: 'visible' } })]
|
|
);
|
|
const repository = new SystemOperationsRepository(pool);
|
|
const logs = await repository.listAuditLogs({
|
|
tenantId: context.tenantId, page: 1, pageSize: 20, action: 'M08D_REDACTION_PROBE'
|
|
});
|
|
assert.equal(logs.total, 1);
|
|
assert.equal(logs.items[0].tenantId, context.tenantId);
|
|
assert.equal(logs.items[0].ip, '172.18.***.***');
|
|
assert.equal(logs.items[0].metadata.phone, '[REDACTED]');
|
|
assert.equal(logs.items[0].metadata.nested.apiKey, '[REDACTED]');
|
|
assert.equal(logs.items[0].metadata.nested.safe, 'visible');
|
|
const overview = await repository.getSystemOverview(context.tenantId);
|
|
assert.equal(overview.tenant.id, context.tenantId);
|
|
assert.equal(overview.latestMigration.version, '2026081006');
|
|
assert.ok(overview.counts.userCount > 0);
|
|
await repository.updateTenant(actor, context.tenantId, {
|
|
name: overview.tenant.name, timezone: overview.tenant.timezone
|
|
});
|
|
const updatedLogs = await repository.listAuditLogs({
|
|
tenantId: context.tenantId, page: 1, pageSize: 20, action: 'TENANT_SYSTEM_CONFIG_UPDATED'
|
|
});
|
|
assert.equal(updatedLogs.total, 1);
|
|
assert.equal(updatedLogs.items[0].actorId, adminId);
|
|
}
|
|
|
|
async function assertAdminPasswordAuth(pool, context) {
|
|
const [tenantRows] = await pool.query('SELECT code FROM qipai_tenants WHERE id = ?', [context.tenantId]);
|
|
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 userId = String(adminRows[0].id);
|
|
const access = await new RbacRepository(pool).getAccessProfile(context.tenantId, userId);
|
|
const actor = { tenantId: context.tenantId, userId, access,
|
|
traceId: 'm08d-admin-auth-live', ip: '127.0.0.1', userAgent: 'M08-D admin auth live test' };
|
|
const repository = new AdminAuthRepository(pool);
|
|
const passwordHash = await hashPassword('LiveAdminPassword!2026');
|
|
await repository.setCredential(actor, context.tenantId, {
|
|
userId, loginName: 'Live.Admin', passwordHash
|
|
});
|
|
const [credentialRows] = await pool.query(
|
|
'SELECT login_name AS loginName, password_hash AS passwordHash FROM qipai_admin_credentials WHERE tenant_id = ? AND user_id = ?',
|
|
[context.tenantId, userId]
|
|
);
|
|
assert.equal(credentialRows[0].loginName, 'live.admin');
|
|
assert.notEqual(credentialRows[0].passwordHash, 'LiveAdminPassword!2026');
|
|
await assert.rejects(() => repository.loginWithPassword({ tenantCode: tenantRows[0].code,
|
|
loginName: 'live.admin', ['password']: 'WrongPassword!2026', sessionId: '11000000-0000-4000-8000-000000000001',
|
|
refreshTokenHash: hashToken('unused'), expiresAt: new Date(Date.now() + 60000),
|
|
ip: actor.ip, userAgent: actor.userAgent, traceId: 'm08d-admin-login-failed' }),
|
|
(error) => error instanceof AdminAuthError && error.code === 'ADMIN_LOGIN_INVALID');
|
|
const refreshToken = '11000000-0000-4000-8000-000000000002.live-refresh-token';
|
|
const session = await repository.loginWithPassword({ tenantCode: tenantRows[0].code,
|
|
loginName: 'LIVE.ADMIN', ['password']: 'LiveAdminPassword!2026',
|
|
sessionId: '11000000-0000-4000-8000-000000000002', refreshTokenHash: hashToken(refreshToken),
|
|
expiresAt: new Date(Date.now() + 60000), ip: actor.ip, userAgent: actor.userAgent,
|
|
traceId: 'm08d-admin-login-success' });
|
|
assert.equal(session.user.id, userId);
|
|
const nextRefreshToken = '11000000-0000-4000-8000-000000000002.next-refresh-token';
|
|
const refreshed = await repository.rotateRefreshToken({ sessionId: session.id,
|
|
currentHash: hashToken(refreshToken), nextHash: hashToken(nextRefreshToken),
|
|
ip: actor.ip, userAgent: actor.userAgent });
|
|
assert.equal(refreshed.user.id, userId);
|
|
await assert.rejects(() => repository.rotateRefreshToken({ sessionId: session.id,
|
|
currentHash: hashToken(refreshToken), nextHash: hashToken('reused'),
|
|
ip: actor.ip, userAgent: actor.userAgent }),
|
|
(error) => error instanceof AdminAuthError && error.code === 'ADMIN_REFRESH_INVALID');
|
|
const [auditRows] = await pool.query(
|
|
`SELECT action, CAST(metadata AS CHAR) AS metadata FROM qipai_audit_logs
|
|
WHERE tenant_id = ? AND action IN ('ADMIN_CREDENTIAL_UPDATED', 'ADMIN_LOGIN_FAILED', 'ADMIN_LOGIN_SUCCEEDED')`,
|
|
[context.tenantId]
|
|
);
|
|
assert.ok(auditRows.some((row) => row.action === 'ADMIN_LOGIN_SUCCEEDED'));
|
|
assert.equal(auditRows.some((row) => row.metadata.includes('LiveAdminPassword!2026')), false);
|
|
}
|
|
|
|
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-16A', firmwareVersion: '1.0.0', signalStrength: 16,
|
|
capabilities: ['POWER', 'METERING', 'RATED_16A']
|
|
});
|
|
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' }
|
|
});
|
|
const published = [];
|
|
const control = new DeviceControlService(
|
|
pool,
|
|
new DeviceCommandService(new IotMessageService(pool), {
|
|
async publishDeviceCommand(deviceId, payload) {
|
|
published.push({ deviceId, payload: JSON.parse(payload) });
|
|
}
|
|
})
|
|
);
|
|
const doorResult = await control.controlDoor({
|
|
tenantId: context.tenantId,
|
|
storeId,
|
|
roomId,
|
|
traceId: 'm08c-manager-door',
|
|
actorType: 'ADMIN',
|
|
actorId: adminId,
|
|
ip: '127.0.0.1',
|
|
userAgent: 'M08-C manager miniapp',
|
|
access
|
|
}, { order: 'open', holdopen: 0, delayTime: 4 });
|
|
assert.equal(doorResult.status, 'PUBLISHED');
|
|
assert.equal(published[0].deviceId, 'M06B_BOX_001');
|
|
assert.equal(published[0].payload.action, 'Crldoor');
|
|
const [commandAuditRows] = await pool.query(
|
|
`SELECT actor_type AS actorType, actor_id AS actorId, action,
|
|
JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.commandType')) AS commandType,
|
|
JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.roomId')) AS roomId
|
|
FROM qipai_audit_logs
|
|
WHERE tenant_id = ? AND trace_id = 'm08c-manager-door'`,
|
|
[context.tenantId]
|
|
);
|
|
assert.deepEqual(commandAuditRows, [{
|
|
actorType: 'ADMIN',
|
|
actorId: Number(adminId),
|
|
action: 'DEVICE_COMMAND_REQUESTED',
|
|
commandType: 'Crldoor',
|
|
roomId
|
|
}]);
|
|
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 }]);
|
|
|
|
await service.handle(`/devicesend/${device.deviceId}`, Buffer.from(JSON.stringify({
|
|
DeviceID: device.deviceId,
|
|
event: 'record',
|
|
type: 'card',
|
|
state: 'open',
|
|
content: 'PRIVATE-CARD-001',
|
|
battery: 9,
|
|
timestamp: 1782120001
|
|
})));
|
|
const [recordRows] = await pool.query(
|
|
`SELECT JSON_UNQUOTE(JSON_EXTRACT(raw_payload, '$.content')) AS content,
|
|
JSON_UNQUOTE(JSON_EXTRACT(raw_payload, '$.contentHash')) AS contentHash
|
|
FROM qipai_iot_device_events
|
|
WHERE tenant_id = ? AND event_type = 'record'`,
|
|
[context.tenantId]
|
|
);
|
|
assert.equal(recordRows[0].content, null);
|
|
assert.match(recordRows[0].contentHash, /^[a-f0-9]{64}$/);
|
|
const [alertRows] = await pool.query(
|
|
`SELECT alert_type AS alertType, severity
|
|
FROM qipai_device_alerts
|
|
WHERE tenant_id = ? AND device_id = ? AND status = 'OPEN'`,
|
|
[context.tenantId, device.id]
|
|
);
|
|
assert.deepEqual(alertRows, [{ alertType: 'LOW_BATTERY', severity: 'HIGH' }]);
|
|
|
|
await service.createCommand({
|
|
tenantId: context.tenantId,
|
|
assetId: String(device.id),
|
|
storeId: String(device.storeId),
|
|
roomId: String(device.roomId),
|
|
commandId: '1782120000002',
|
|
commandType: 'AddDevice',
|
|
payload: { action: 'AddDevice', time: 60, id: '1782120000002' },
|
|
traceId: 'm06d-pair-test'
|
|
});
|
|
await service.markPublished(context.tenantId, '1782120000002');
|
|
await service.handle(`/devicesend/${device.deviceId}`, Buffer.from(JSON.stringify({
|
|
DeviceID: device.deviceId,
|
|
id: '1782120000002',
|
|
action: 'AddDevice',
|
|
result: 'ok',
|
|
timestamp: 1782120002
|
|
})));
|
|
const [pairPendingRows] = await pool.query(
|
|
`SELECT status FROM qipai_iot_commands
|
|
WHERE tenant_id = ? AND command_id = '1782120000002'`,
|
|
[context.tenantId]
|
|
);
|
|
assert.deepEqual(pairPendingRows, [{ status: 'PUBLISHED' }]);
|
|
await service.handle(`/devicesend/${device.deviceId}`, Buffer.from(JSON.stringify({
|
|
DeviceID: device.deviceId,
|
|
id: '1782120000002',
|
|
action: 'AddDevice',
|
|
result: 'ok',
|
|
subID: 'SUB-AUTO-001',
|
|
subtype: '14',
|
|
timestamp: 1782120002
|
|
})));
|
|
const [linkRows] = await pool.query(
|
|
`SELECT l.sub_id AS subId, l.subtype, d.model
|
|
FROM qipai_device_links l
|
|
INNER JOIN qipai_devices d
|
|
ON d.tenant_id = l.tenant_id AND d.id = l.child_device_id
|
|
WHERE l.tenant_id = ? AND l.parent_device_id = ? AND l.sub_id = 'SUB-AUTO-001'`,
|
|
[context.tenantId, device.id]
|
|
);
|
|
assert.deepEqual(linkRows, [{ subId: 'SUB-AUTO-001', subtype: '14', model: '701C' }]);
|
|
|
|
for (const [commandId, result, failureCode] of [
|
|
['1782120000003', 'busy', 'DEVICE_BUSY'],
|
|
['1782120000004', 'unconfirm', 'DEVICE_UNCONFIRM'],
|
|
['1782120000005', 'fail', 'DEVICE_FAIL']
|
|
]) {
|
|
await service.createCommand({
|
|
tenantId: context.tenantId,
|
|
assetId: String(device.id),
|
|
storeId: String(device.storeId),
|
|
roomId: String(device.roomId),
|
|
commandId,
|
|
commandType: 'task',
|
|
payload: { action: 'task', id: commandId, minute: 30, type: 1 },
|
|
traceId: `m06c-task-${result}`
|
|
});
|
|
await service.markPublished(context.tenantId, commandId);
|
|
await service.handle(`/devicesend/${device.deviceId}`, Buffer.from(JSON.stringify({
|
|
DeviceID: device.deviceId, id: commandId, action: 'task', result
|
|
})));
|
|
const [failedRows] = await pool.query(
|
|
`SELECT status, failure_code AS failureCode
|
|
FROM qipai_iot_commands WHERE tenant_id = ? AND command_id = ?`,
|
|
[context.tenantId, commandId]
|
|
);
|
|
assert.deepEqual(failedRows, [{ status: 'FAILED', failureCode }]);
|
|
}
|
|
|
|
await service.createCommand({
|
|
tenantId: context.tenantId,
|
|
assetId: String(device.id),
|
|
storeId: String(device.storeId),
|
|
roomId: String(device.roomId),
|
|
commandId: '1782120000006',
|
|
commandType: 'task',
|
|
payload: { action: 'task', id: '1782120000006', minute: 30, type: 1 },
|
|
traceId: 'm06c-unknown-result'
|
|
});
|
|
await service.markPublished(context.tenantId, '1782120000006');
|
|
await service.handle(`/devicesend/${device.deviceId}`, Buffer.from(JSON.stringify({
|
|
DeviceID: device.deviceId,
|
|
id: '1782120000006',
|
|
action: 'task',
|
|
result: 'vendor-new-code'
|
|
})));
|
|
const [unknownCommandRows] = await pool.query(
|
|
`SELECT status, failure_code AS failureCode
|
|
FROM qipai_iot_commands
|
|
WHERE tenant_id = ? AND command_id = '1782120000006'`,
|
|
[context.tenantId]
|
|
);
|
|
assert.deepEqual(unknownCommandRows, [{
|
|
status: 'FAILED', failureCode: 'UNKNOWN_VENDOR_RESULT'
|
|
}]);
|
|
const [unknownEventRows] = await pool.query(
|
|
`SELECT processing_status AS processingStatus,
|
|
JSON_UNQUOTE(JSON_EXTRACT(raw_payload, '$.result')) AS rawResult,
|
|
JSON_UNQUOTE(JSON_EXTRACT(normalized_payload, '$.result')) AS normalizedResult
|
|
FROM qipai_iot_device_events
|
|
WHERE tenant_id = ? AND command_id = '1782120000006'`,
|
|
[context.tenantId]
|
|
);
|
|
assert.deepEqual(unknownEventRows, [{
|
|
processingStatus: 'PROTOCOL_ERROR',
|
|
rawResult: 'vendor-new-code',
|
|
normalizedResult: 'UNKNOWN_VENDOR_RESULT'
|
|
}]);
|
|
const [unknownDeadRows] = await pool.query(
|
|
`SELECT error_code AS errorCode
|
|
FROM qipai_iot_dead_letters
|
|
WHERE tenant_id = ? AND topic = ? AND error_code = 'UNKNOWN_VENDOR_RESULT'`,
|
|
[context.tenantId, `/devicesend/${device.deviceId}`]
|
|
);
|
|
assert.deepEqual(unknownDeadRows, [{ errorCode: 'UNKNOWN_VENDOR_RESULT' }]);
|
|
|
|
await service.createCommand({
|
|
tenantId: context.tenantId,
|
|
assetId: String(device.id),
|
|
storeId: String(device.storeId),
|
|
roomId: String(device.roomId),
|
|
commandId: '1782120000007',
|
|
commandType: 'CtrlDevice',
|
|
payload: {
|
|
action: 'CtrlDevice', id: '1782120000007', subtype: '14', subID: 'SUB-AUTO-001',
|
|
order: 'setkey', value: '123456'
|
|
},
|
|
traceId: 'm06c-credential-redaction'
|
|
});
|
|
const [credentialRows] = await pool.query(
|
|
`SELECT JSON_UNQUOTE(JSON_EXTRACT(request_payload, '$.value')) AS value,
|
|
JSON_UNQUOTE(JSON_EXTRACT(request_payload, '$.valueHash')) AS valueHash
|
|
FROM qipai_iot_commands
|
|
WHERE tenant_id = ? AND command_id = '1782120000007'`,
|
|
[context.tenantId]
|
|
);
|
|
assert.equal(credentialRows[0].value, null);
|
|
assert.match(credentialRows[0].valueHash, /^[a-f0-9]{64}$/);
|
|
|
|
const [commandCountRows] = await pool.query(
|
|
`SELECT COUNT(*) AS total FROM qipai_iot_commands WHERE tenant_id = ?`,
|
|
[context.tenantId]
|
|
);
|
|
const commandCountBeforeEvents = Number(commandCountRows[0].total);
|
|
for (const [event, timestamp] of [['magstate', 1782120010], ['taskfinish', 1782120011]]) {
|
|
await service.handle(`/devicesend/${device.deviceId}`, Buffer.from(JSON.stringify({
|
|
DeviceID: device.deviceId, event, state: 'close', timestamp
|
|
})));
|
|
}
|
|
const [commandCountAfterRows] = await pool.query(
|
|
`SELECT COUNT(*) AS total FROM qipai_iot_commands WHERE tenant_id = ?`,
|
|
[context.tenantId]
|
|
);
|
|
assert.equal(Number(commandCountAfterRows[0].total), commandCountBeforeEvents);
|
|
const [nonAckRows] = await pool.query(
|
|
`SELECT event_type AS eventType, command_id AS commandId
|
|
FROM qipai_iot_device_events
|
|
WHERE tenant_id = ? AND event_type IN ('magstate', 'taskfinish')
|
|
ORDER BY event_type`,
|
|
[context.tenantId]
|
|
);
|
|
assert.deepEqual(nonAckRows, [
|
|
{ eventType: 'magstate', commandId: null },
|
|
{ eventType: 'taskfinish', commandId: null }
|
|
]);
|
|
|
|
await service.handle(`/devicewill/${device.deviceId}`, Buffer.from('close'));
|
|
const [offlineRows] = await pool.query(
|
|
`SELECT status, JSON_UNQUOTE(JSON_EXTRACT(state_snapshot, '$.will')) AS willValue
|
|
FROM qipai_devices WHERE tenant_id = ? AND id = ?`,
|
|
[context.tenantId, device.id]
|
|
);
|
|
assert.deepEqual(offlineRows, [{ status: 'OFFLINE', willValue: 'close' }]);
|
|
const [willDeadRows] = await pool.query(
|
|
`SELECT COUNT(*) AS total FROM qipai_iot_dead_letters
|
|
WHERE tenant_id = ? AND topic = ? AND error_code = 'MQTT_PAYLOAD_INVALID'`,
|
|
[context.tenantId, `/devicewill/${device.deviceId}`]
|
|
);
|
|
assert.equal(Number(willDeadRows[0].total), 0);
|
|
|
|
const [socketRows] = 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_SOCKET_001'`,
|
|
[context.tenantId]
|
|
);
|
|
const socket = socketRows[0];
|
|
assert.ok(socket, 'M06-E-R1 requires the smart socket fixture.');
|
|
await service.handle(`/devicesend/${socket.deviceId}`, Buffer.from(JSON.stringify({
|
|
DeviceID: socket.deviceId,
|
|
read: 'workInfo',
|
|
slotNum: 1,
|
|
onOff: 'on',
|
|
vol: '0220.5000',
|
|
cur: '01.250',
|
|
pow: '00275.000',
|
|
temp: '45.50',
|
|
energy: '0012.3400',
|
|
timestamp: 1782120020
|
|
})));
|
|
const [workInfoRows] = await pool.query(
|
|
`SELECT JSON_UNQUOTE(JSON_EXTRACT(normalized_payload, '$.payload.voltage')) AS voltage,
|
|
JSON_UNQUOTE(JSON_EXTRACT(normalized_payload, '$.payload.current')) AS currentValue,
|
|
JSON_UNQUOTE(JSON_EXTRACT(normalized_payload, '$.payload.power')) AS powerValue,
|
|
JSON_UNQUOTE(JSON_EXTRACT(normalized_payload, '$.payload.temperature')) AS temperature,
|
|
JSON_UNQUOTE(JSON_EXTRACT(normalized_payload, '$.payload.energy')) AS energy,
|
|
JSON_UNQUOTE(JSON_EXTRACT(normalized_payload, '$.payload.switchOn')) AS switchOn
|
|
FROM qipai_iot_device_events
|
|
WHERE tenant_id = ? AND device_id = ? AND event_type = 'workInfo'`,
|
|
[context.tenantId, socket.id]
|
|
);
|
|
assert.deepEqual(workInfoRows, [{
|
|
voltage: '220.5', currentValue: '1.25', powerValue: '275',
|
|
temperature: '45.5', energy: '12.34', switchOn: 'true'
|
|
}]);
|
|
|
|
const socketResultFixtures = [
|
|
['1782120000008', 'off', 'update', 'DEVICE_UPDATE'],
|
|
['1782120000009', 'off', 'paraerror', 'DEVICE_PARAERROR'],
|
|
['1782120000010', 'localtask', 'error', 'DEVICE_ERROR']
|
|
];
|
|
for (const [commandId, action, result, failureCode] of socketResultFixtures) {
|
|
await service.createCommand({
|
|
tenantId: context.tenantId,
|
|
assetId: String(socket.id),
|
|
storeId: String(socket.storeId),
|
|
roomId: String(socket.roomId),
|
|
commandId,
|
|
commandType: action,
|
|
payload: { action, id: commandId, slotNum: 1 },
|
|
traceId: `m06e-${result}`
|
|
});
|
|
await service.markPublished(context.tenantId, commandId);
|
|
await service.handle(`/devicesend/${socket.deviceId}`, Buffer.from(JSON.stringify({
|
|
DeviceID: socket.deviceId, id: commandId, action, result
|
|
})));
|
|
const [resultRows] = await pool.query(
|
|
`SELECT status, failure_code AS failureCode
|
|
FROM qipai_iot_commands WHERE tenant_id = ? AND command_id = ?`,
|
|
[context.tenantId, commandId]
|
|
);
|
|
assert.deepEqual(resultRows, [{ status: 'FAILED', failureCode }]);
|
|
}
|
|
|
|
for (let closeReason = 1; closeReason <= 7; closeReason += 1) {
|
|
await service.handle(`/devicesend/${socket.deviceId}`, Buffer.from(JSON.stringify({
|
|
DeviceID: socket.deviceId,
|
|
event: 'special',
|
|
slotNum: 1,
|
|
state: 'off',
|
|
closeReason,
|
|
energy: `${closeReason}.0`,
|
|
timestamp: 1782120030 + closeReason
|
|
})));
|
|
}
|
|
const [socketAlertRows] = await pool.query(
|
|
`SELECT alert_type AS alertType
|
|
FROM qipai_device_alerts
|
|
WHERE tenant_id = ? AND device_id = ?
|
|
AND alert_type IN ('SOCKET_PULL_OUT_STOP', 'SOCKET_CHARGE_FULL_STOP',
|
|
'SOCKET_OVERPOWER', 'SOCKET_OVERCURRENT', 'SOCKET_OVERTEMPERATURE')
|
|
ORDER BY alert_type`,
|
|
[context.tenantId, socket.id]
|
|
);
|
|
assert.deepEqual(socketAlertRows, [
|
|
{ alertType: 'SOCKET_CHARGE_FULL_STOP' },
|
|
{ alertType: 'SOCKET_OVERCURRENT' },
|
|
{ alertType: 'SOCKET_OVERPOWER' },
|
|
{ alertType: 'SOCKET_OVERTEMPERATURE' },
|
|
{ alertType: 'SOCKET_PULL_OUT_STOP' }
|
|
]);
|
|
|
|
await service.handle(`/devicesend/${socket.deviceId}`, Buffer.from(JSON.stringify({
|
|
DeviceID: socket.deviceId,
|
|
read: 'workInfo',
|
|
temp: '45',
|
|
temperature: '46',
|
|
timestamp: 1782120040
|
|
})));
|
|
const [conflictRows] = await pool.query(
|
|
`SELECT processing_status AS processingStatus,
|
|
JSON_UNQUOTE(JSON_EXTRACT(normalized_payload, '$.payload.temperature')) AS temperature
|
|
FROM qipai_iot_device_events
|
|
WHERE tenant_id = ? AND device_id = ? AND payload_hash = SHA2(?, 256)`,
|
|
[context.tenantId, socket.id, JSON.stringify({
|
|
DeviceID: socket.deviceId,
|
|
read: 'workInfo', temp: '45', temperature: '46', timestamp: 1782120040
|
|
})]
|
|
);
|
|
assert.deepEqual(conflictRows, [{ processingStatus: 'PROTOCOL_ERROR', temperature: '45' }]);
|
|
const [conflictDeadRows] = await pool.query(
|
|
`SELECT error_code AS errorCode FROM qipai_iot_dead_letters
|
|
WHERE tenant_id = ? AND device_id = ? AND error_code = 'SOCKET_TEMPERATURE_CONFLICT'`,
|
|
[context.tenantId, socket.id]
|
|
);
|
|
assert.deepEqual(conflictDeadRows, [{ errorCode: 'SOCKET_TEMPERATURE_CONFLICT' }]);
|
|
|
|
await service.createCommand({
|
|
tenantId: context.tenantId,
|
|
assetId: String(socket.id),
|
|
storeId: String(socket.storeId),
|
|
roomId: String(socket.roomId),
|
|
commandId: '1782120000011',
|
|
commandType: 'setReturnKey',
|
|
payload: {
|
|
action: 'setReturnKey', id: '1782120000011',
|
|
old: 'old-return-secret', new: 'new-return-secret'
|
|
},
|
|
traceId: 'm06e-return-key-redaction'
|
|
});
|
|
const [returnKeyRows] = await pool.query(
|
|
`SELECT JSON_UNQUOTE(JSON_EXTRACT(request_payload, '$.old')) AS oldValue,
|
|
JSON_UNQUOTE(JSON_EXTRACT(request_payload, '$.new')) AS newValue,
|
|
JSON_UNQUOTE(JSON_EXTRACT(request_payload, '$.oldHash')) AS oldHash,
|
|
JSON_UNQUOTE(JSON_EXTRACT(request_payload, '$.newHash')) AS newHash
|
|
FROM qipai_iot_commands
|
|
WHERE tenant_id = ? AND command_id = '1782120000011'`,
|
|
[context.tenantId]
|
|
);
|
|
assert.equal(returnKeyRows[0].oldValue, null);
|
|
assert.equal(returnKeyRows[0].newValue, null);
|
|
assert.match(returnKeyRows[0].oldHash, /^[a-f0-9]{64}$/);
|
|
assert.match(returnKeyRows[0].newHash, /^[a-f0-9]{64}$/);
|
|
}
|
|
|
|
async function assertCleaningTaskTransactions(pool, context) {
|
|
const [scopeRows] = 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'
|
|
ORDER BY r.id LIMIT 1`,
|
|
[context.tenantId]
|
|
);
|
|
const [managerRows] = 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.tenant_id = ur.tenant_id AND r.id = ur.role_id
|
|
WHERE u.tenant_id = ? AND r.code = 'TENANT_ADMIN'
|
|
ORDER BY u.id LIMIT 1`,
|
|
[context.tenantId]
|
|
);
|
|
assert.ok(scopeRows[0], 'M09-A cleaning test requires the M03-A store and room.');
|
|
assert.ok(managerRows[0], 'M09-A cleaning test requires a tenant administrator.');
|
|
const storeId = String(scopeRows[0].storeId);
|
|
const roomId = String(scopeRows[0].roomId);
|
|
const managerId = String(managerRows[0].id);
|
|
const managerAccess = await new RbacRepository(pool).getAccessProfile(context.tenantId, managerId);
|
|
assert.ok(managerAccess.capabilities.includes('tenant.manage'));
|
|
|
|
const createCleaner = async (nickname, phone) => {
|
|
const [result] = await pool.query(
|
|
`INSERT INTO qipai_users (tenant_id, user_type, nickname, phone)
|
|
VALUES (?, 'STAFF', ?, ?)`,
|
|
[context.tenantId, nickname, phone]
|
|
);
|
|
const cleanerId = String(result.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 = 'CLEANER'`,
|
|
[context.tenantId, cleanerId, context.tenantId]
|
|
);
|
|
await pool.query(
|
|
`INSERT INTO qipai_user_store_scopes (tenant_id, user_id, store_id, scope_type)
|
|
VALUES (?, ?, ?, 'STAFF')`,
|
|
[context.tenantId, cleanerId, storeId]
|
|
);
|
|
return cleanerId;
|
|
};
|
|
const firstCleanerId = await createCleaner('M09A Cleaner One', '13900000901');
|
|
const secondCleanerId = await createCleaner('M09A Cleaner Two', '13900000902');
|
|
const cleanerAccess = {
|
|
roles: ['CLEANER'],
|
|
capabilities: ['cleaning.task.read', 'cleaning.task.write'],
|
|
storeIds: [storeId]
|
|
};
|
|
const cleanerActor = (userId, traceId) => ({
|
|
tenantId: context.tenantId,
|
|
userId,
|
|
access: cleanerAccess,
|
|
traceId
|
|
});
|
|
const managerActor = (traceId) => ({
|
|
tenantId: context.tenantId,
|
|
userId: managerId,
|
|
access: managerAccess,
|
|
traceId
|
|
});
|
|
const repository = new CleaningTaskRepository(pool);
|
|
let taskSequence = 0;
|
|
const insertTask = async ({
|
|
status = 'WAITING', cleanerUserId = null, rewardCents = 100,
|
|
photoUrls = [], rejectReason = '', claimedAt = null, startedAt = null,
|
|
submittedAt = null, completedAt = null, settledAt = null, cancelledAt = null,
|
|
updatedAt = new Date()
|
|
} = {}) => {
|
|
taskSequence += 1;
|
|
const [result] = await pool.query(
|
|
`INSERT INTO qipai_cleaning_tasks
|
|
(tenant_id, store_id, room_id, task_no, status, cleaner_user_id,
|
|
priority, reward_cents, requirement, photo_urls_json, reject_reason,
|
|
claimed_at, started_at, submitted_at, completed_at, settled_at, cancelled_at,
|
|
updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, 5, ?, 'M09-A transaction test', ?, ?,
|
|
?, ?, ?, ?, ?, ?, ?)`,
|
|
[
|
|
context.tenantId, storeId, roomId, `M09A-${Date.now()}-${taskSequence}`,
|
|
status, cleanerUserId, rewardCents, JSON.stringify(photoUrls), rejectReason,
|
|
claimedAt, startedAt, submittedAt, completedAt, settledAt, cancelledAt, updatedAt
|
|
]
|
|
);
|
|
return String(result.insertId);
|
|
};
|
|
const insertMember = async ({
|
|
taskId, userId, memberRole = 'LEAD', rewardCents = 0,
|
|
removedAt = null, settledAt = null
|
|
}) => {
|
|
await pool.query(
|
|
`INSERT INTO qipai_cleaning_task_members
|
|
(tenant_id, task_id, user_id, member_role, reward_cents, joined_at, removed_at, settled_at)
|
|
VALUES (?, ?, ?, ?, ?, UTC_TIMESTAMP(3), ?, ?)`,
|
|
[context.tenantId, taskId, userId, memberRole, rewardCents, removedAt, settledAt]
|
|
);
|
|
};
|
|
const recordPhoto = async (taskId, userId, suffix) => repository.recordPhotoUpload({
|
|
...cleanerActor(userId, `m09b-photo-${suffix}`),
|
|
taskId,
|
|
image: {
|
|
storagePath: `tenants/${context.tenantId}/stores/${storeId}/${suffix}.webp`,
|
|
publicUrl: `https://api.txyundm.cn/uploads/tenants/${context.tenantId}/stores/${storeId}/${suffix}.webp`,
|
|
mimeType: 'image/webp',
|
|
byteSize: 1024,
|
|
width: 1280,
|
|
height: 720,
|
|
checksumSha256: suffix.padEnd(64, '0').slice(0, 64)
|
|
}
|
|
});
|
|
|
|
const rollbackTaskId = await insertTask();
|
|
const rollbackTrace = `m09a-event-failure-${rollbackTaskId}`;
|
|
await pool.query(
|
|
`INSERT INTO qipai_cleaning_task_events
|
|
(tenant_id, task_id, from_status, to_status, action, actor_id, trace_id, note, metadata)
|
|
VALUES (?, ?, 'WAITING', 'WAITING', 'TEST_BLOCKER', ?, ?, '', JSON_OBJECT())`,
|
|
[context.tenantId, rollbackTaskId, firstCleanerId, rollbackTrace]
|
|
);
|
|
await assert.rejects(
|
|
() => repository.claim({
|
|
...cleanerActor(firstCleanerId, 'm09a-event-failure'), taskId: rollbackTaskId
|
|
}),
|
|
(error) => error?.code === 'ER_DUP_ENTRY'
|
|
);
|
|
const [rollbackRows] = await pool.query(
|
|
`SELECT t.status, t.cleaner_user_id AS cleanerUserId,
|
|
(SELECT COUNT(*) FROM qipai_cleaning_task_members m
|
|
WHERE m.tenant_id = t.tenant_id AND m.task_id = t.id) AS memberCount
|
|
FROM qipai_cleaning_tasks t WHERE t.tenant_id = ? AND t.id = ?`,
|
|
[context.tenantId, rollbackTaskId]
|
|
);
|
|
assert.equal(rollbackRows[0].status, 'WAITING');
|
|
assert.equal(rollbackRows[0].cleanerUserId, null);
|
|
assert.equal(Number(rollbackRows[0].memberCount), 0);
|
|
|
|
const concurrentTaskId = await insertTask();
|
|
const claimResults = await Promise.allSettled([
|
|
repository.claim({
|
|
...cleanerActor(firstCleanerId, 'm09a-concurrent-claim-one'), taskId: concurrentTaskId
|
|
}),
|
|
repository.claim({
|
|
...cleanerActor(secondCleanerId, 'm09a-concurrent-claim-two'), taskId: concurrentTaskId
|
|
})
|
|
]);
|
|
assert.equal(claimResults.filter((result) => result.status === 'fulfilled').length, 1);
|
|
const rejectedClaim = claimResults.find((result) => result.status === 'rejected');
|
|
assert.ok(rejectedClaim?.reason instanceof CleaningTaskError);
|
|
assert.equal(rejectedClaim.reason.code, 'CLEANING_TASK_NOT_CLAIMABLE');
|
|
const [claimedRows] = await pool.query(
|
|
`SELECT t.status, t.cleaner_user_id AS cleanerUserId,
|
|
(SELECT COUNT(*) FROM qipai_cleaning_task_events e
|
|
WHERE e.tenant_id = t.tenant_id AND e.task_id = t.id AND e.action = 'CLAIM') AS eventCount,
|
|
(SELECT COUNT(*) FROM qipai_cleaning_task_members m
|
|
WHERE m.tenant_id = t.tenant_id AND m.task_id = t.id
|
|
AND m.member_role = 'LEAD' AND m.removed_at IS NULL) AS leadCount
|
|
FROM qipai_cleaning_tasks t WHERE t.tenant_id = ? AND t.id = ?`,
|
|
[context.tenantId, concurrentTaskId]
|
|
);
|
|
assert.equal(claimedRows[0].status, 'CLAIMED');
|
|
assert.ok([firstCleanerId, secondCleanerId].includes(String(claimedRows[0].cleanerUserId)));
|
|
assert.equal(Number(claimedRows[0].eventCount), 1);
|
|
assert.equal(Number(claimedRows[0].leadCount), 1);
|
|
|
|
const lifecycleTaskId = await insertTask();
|
|
await repository.assign({
|
|
...managerActor('m09a-assign-waiting'), taskId: lifecycleTaskId,
|
|
cleanerUserId: firstCleanerId, note: 'WAITING assignment'
|
|
});
|
|
await repository.assign({
|
|
...managerActor('m09a-assign-claimed'), taskId: lifecycleTaskId,
|
|
cleanerUserId: secondCleanerId, note: 'CLAIMED reassignment'
|
|
});
|
|
const expiredAt = new Date(Date.now() - 3_600_000);
|
|
await pool.query(
|
|
`UPDATE qipai_cleaning_tasks
|
|
SET status = 'REJECTED', photo_urls_json = JSON_ARRAY('https://old.example/photo.jpg'),
|
|
reject_reason = 'old rejection', started_at = ?, submitted_at = ?, completed_at = ?,
|
|
settled_at = ?, cancelled_at = ?
|
|
WHERE tenant_id = ? AND id = ?`,
|
|
[expiredAt, expiredAt, expiredAt, expiredAt, expiredAt, context.tenantId, lifecycleTaskId]
|
|
);
|
|
await repository.assign({
|
|
...managerActor('m09a-assign-rejected'), taskId: lifecycleTaskId,
|
|
cleanerUserId: firstCleanerId, note: 'REJECTED reassignment'
|
|
});
|
|
const [resetRows] = await pool.query(
|
|
`SELECT status, cleaner_user_id AS cleanerUserId,
|
|
claimed_at IS NOT NULL AS claimedSet,
|
|
started_at IS NULL AS startedCleared,
|
|
submitted_at IS NULL AS submittedCleared,
|
|
completed_at IS NULL AS completedCleared,
|
|
settled_at IS NULL AS settledCleared,
|
|
cancelled_at IS NULL AS cancelledCleared,
|
|
JSON_LENGTH(photo_urls_json) AS photoCount, reject_reason AS rejectReason
|
|
FROM qipai_cleaning_tasks WHERE tenant_id = ? AND id = ?`,
|
|
[context.tenantId, lifecycleTaskId]
|
|
);
|
|
assert.deepEqual({
|
|
status: resetRows[0].status,
|
|
cleanerUserId: String(resetRows[0].cleanerUserId),
|
|
claimedSet: Number(resetRows[0].claimedSet),
|
|
startedCleared: Number(resetRows[0].startedCleared),
|
|
submittedCleared: Number(resetRows[0].submittedCleared),
|
|
completedCleared: Number(resetRows[0].completedCleared),
|
|
settledCleared: Number(resetRows[0].settledCleared),
|
|
cancelledCleared: Number(resetRows[0].cancelledCleared),
|
|
photoCount: Number(resetRows[0].photoCount),
|
|
rejectReason: resetRows[0].rejectReason
|
|
}, {
|
|
status: 'CLAIMED', cleanerUserId: firstCleanerId, claimedSet: 1,
|
|
startedCleared: 1, submittedCleared: 1, completedCleared: 1,
|
|
settledCleared: 1, cancelledCleared: 1, photoCount: 0, rejectReason: ''
|
|
});
|
|
const [reassignedMemberRows] = await pool.query(
|
|
`SELECT user_id AS userId, member_role AS memberRole, reward_cents AS rewardCents,
|
|
removed_at IS NOT NULL AS removed
|
|
FROM qipai_cleaning_task_members
|
|
WHERE tenant_id = ? AND task_id = ? ORDER BY user_id`,
|
|
[context.tenantId, lifecycleTaskId]
|
|
);
|
|
assert.deepEqual(reassignedMemberRows.map((row) => ({
|
|
userId: String(row.userId), memberRole: row.memberRole,
|
|
rewardCents: Number(row.rewardCents), removed: Number(row.removed)
|
|
})), [
|
|
{ userId: firstCleanerId, memberRole: 'LEAD', rewardCents: 100, removed: 0 },
|
|
{ userId: secondCleanerId, memberRole: 'LEAD', rewardCents: 0, removed: 1 }
|
|
].sort((left, right) => Number(left.userId) - Number(right.userId)));
|
|
|
|
await repository.start({
|
|
...cleanerActor(firstCleanerId, 'm09a-start'), taskId: lifecycleTaskId
|
|
});
|
|
const firstPhoto = await recordPhoto(lifecycleTaskId, firstCleanerId, 'm09b-first');
|
|
await repository.submit({
|
|
...cleanerActor(firstCleanerId, 'm09a-submit-first'), taskId: lifecycleTaskId,
|
|
photoUrls: [firstPhoto.publicUrl], note: 'first submit'
|
|
});
|
|
await repository.reject({
|
|
...managerActor('m09a-reject'), taskId: lifecycleTaskId, reason: 'needs rework'
|
|
});
|
|
const [rejectedRows] = await pool.query(
|
|
`SELECT status, reject_reason AS rejectReason
|
|
FROM qipai_cleaning_tasks WHERE tenant_id = ? AND id = ?`,
|
|
[context.tenantId, lifecycleTaskId]
|
|
);
|
|
assert.deepEqual(rejectedRows, [{ status: 'REJECTED', rejectReason: 'needs rework' }]);
|
|
await repository.rework({
|
|
...cleanerActor(firstCleanerId, 'm09a-rework'), taskId: lifecycleTaskId
|
|
});
|
|
const secondPhoto = await recordPhoto(lifecycleTaskId, firstCleanerId, 'm09b-second');
|
|
await repository.submit({
|
|
...cleanerActor(firstCleanerId, 'm09a-submit-second'), taskId: lifecycleTaskId,
|
|
photoUrls: [secondPhoto.publicUrl], note: 'second submit'
|
|
});
|
|
const completeInput = {
|
|
...managerActor('m09a-complete'), taskId: lifecycleTaskId, note: 'accepted'
|
|
};
|
|
await repository.complete(completeInput);
|
|
await assert.rejects(
|
|
() => repository.complete(completeInput),
|
|
(error) => error instanceof CleaningTaskError
|
|
&& error.code === 'CLEANING_TASK_STATUS_CONFLICT'
|
|
);
|
|
const [lifecycleRows] = await pool.query(
|
|
`SELECT status, completed_at IS NOT NULL AS completedSet
|
|
FROM qipai_cleaning_tasks WHERE tenant_id = ? AND id = ?`,
|
|
[context.tenantId, lifecycleTaskId]
|
|
);
|
|
assert.deepEqual({
|
|
status: lifecycleRows[0].status,
|
|
completedSet: Number(lifecycleRows[0].completedSet)
|
|
}, { status: 'COMPLETED', completedSet: 1 });
|
|
const [submissionRows] = await pool.query(
|
|
`SELECT revision, status, JSON_UNQUOTE(JSON_EXTRACT(photo_urls_json, '$[0]')) AS photoUrl,
|
|
reject_reason AS rejectReason
|
|
FROM qipai_cleaning_task_submissions
|
|
WHERE tenant_id = ? AND task_id = ? ORDER BY revision`,
|
|
[context.tenantId, lifecycleTaskId]
|
|
);
|
|
assert.deepEqual(submissionRows.map((row) => ({
|
|
revision: Number(row.revision), status: row.status,
|
|
photoUrl: row.photoUrl, rejectReason: row.rejectReason
|
|
})), [
|
|
{ revision: 2, status: 'REJECTED', photoUrl: firstPhoto.publicUrl, rejectReason: 'needs rework' },
|
|
{ revision: 3, status: 'ACCEPTED', photoUrl: secondPhoto.publicUrl, rejectReason: '' }
|
|
]);
|
|
const [photoRows] = await pool.query(
|
|
`SELECT public_url AS publicUrl, status, attached_revision AS attachedRevision
|
|
FROM qipai_cleaning_task_photos
|
|
WHERE tenant_id = ? AND task_id = ? ORDER BY attached_revision`,
|
|
[context.tenantId, lifecycleTaskId]
|
|
);
|
|
assert.deepEqual(photoRows.map((row) => ({
|
|
publicUrl: row.publicUrl, status: row.status, revision: Number(row.attachedRevision)
|
|
})), [
|
|
{ publicUrl: firstPhoto.publicUrl, status: 'ATTACHED', revision: 2 },
|
|
{ publicUrl: secondPhoto.publicUrl, status: 'ATTACHED', revision: 3 }
|
|
]);
|
|
const submissionHistory = await repository.listSubmissions({
|
|
...managerActor('m09b-submission-history'), taskId: lifecycleTaskId
|
|
});
|
|
assert.deepEqual(submissionHistory.map((submission) => ({
|
|
revision: submission.revision,
|
|
status: submission.status,
|
|
photoUrl: submission.photoUrls[0],
|
|
rejectReason: submission.rejectReason
|
|
})), [
|
|
{ revision: 3, status: 'ACCEPTED', photoUrl: secondPhoto.publicUrl, rejectReason: '' },
|
|
{ revision: 2, status: 'REJECTED', photoUrl: firstPhoto.publicUrl, rejectReason: 'needs rework' }
|
|
]);
|
|
|
|
const cleanupTaskId = await insertTask({
|
|
status: 'STARTED', cleanerUserId: firstCleanerId,
|
|
claimedAt: new Date(), startedAt: new Date()
|
|
});
|
|
const expiredPhoto = await recordPhoto(cleanupTaskId, firstCleanerId, 'm09b-expired');
|
|
await pool.query(
|
|
`UPDATE qipai_cleaning_task_photos SET retention_until = TIMESTAMPADD(DAY, -1, UTC_TIMESTAMP(3))
|
|
WHERE tenant_id = ? AND id = ?`,
|
|
[context.tenantId, expiredPhoto.id]
|
|
);
|
|
const expiredClaims = await repository.claimExpiredPhotoUploads({
|
|
...managerActor('m09b-photo-cleanup-claim'), limit: 10
|
|
});
|
|
assert.deepEqual(expiredClaims, [{ id: expiredPhoto.id, storagePath: expiredPhoto.storagePath }]);
|
|
assert.deepEqual(
|
|
await repository.markPhotoUploadsDeleted({
|
|
...managerActor('m09b-photo-cleanup-delete'), photoIds: [expiredPhoto.id]
|
|
}),
|
|
{ deleted: 1 }
|
|
);
|
|
const [deletedPhotoRows] = await pool.query(
|
|
`SELECT status, deleted_at IS NOT NULL AS deleted
|
|
FROM qipai_cleaning_task_photos WHERE tenant_id = ? AND id = ?`,
|
|
[context.tenantId, expiredPhoto.id]
|
|
);
|
|
assert.deepEqual(
|
|
{ status: deletedPhotoRows[0].status, deleted: Number(deletedPhotoRows[0].deleted) },
|
|
{ status: 'ORPHANED', deleted: 1 }
|
|
);
|
|
const [lifecycleEventRows] = await pool.query(
|
|
`SELECT action FROM qipai_cleaning_task_events
|
|
WHERE tenant_id = ? AND task_id = ? ORDER BY id`,
|
|
[context.tenantId, lifecycleTaskId]
|
|
);
|
|
assert.deepEqual(lifecycleEventRows.map((row) => row.action), [
|
|
'ASSIGN', 'ASSIGN', 'ASSIGN', 'START', 'SUBMIT', 'REJECT', 'REWORK', 'SUBMIT', 'COMPLETE'
|
|
]);
|
|
|
|
const staleBase = Date.now() - 24 * 3_600_000;
|
|
const lockedTaskId = await insertTask({
|
|
status: 'CLAIMED', cleanerUserId: firstCleanerId,
|
|
claimedAt: new Date(staleBase), updatedAt: new Date(staleBase)
|
|
});
|
|
const reclaimTaskId = await insertTask({
|
|
status: 'STARTED', cleanerUserId: secondCleanerId,
|
|
claimedAt: new Date(staleBase + 3_600_000), startedAt: new Date(staleBase + 3_600_000),
|
|
photoUrls: ['https://old.example/reclaim.jpg'], rejectReason: 'stale',
|
|
updatedAt: new Date(staleBase + 3_600_000)
|
|
});
|
|
const outsideBatchTaskId = await insertTask({
|
|
status: 'CLAIMED', cleanerUserId: firstCleanerId,
|
|
claimedAt: new Date(staleBase + 7_200_000), updatedAt: new Date(staleBase + 7_200_000)
|
|
});
|
|
const settledTaskId = await insertTask({
|
|
status: 'SETTLED', cleanerUserId: secondCleanerId,
|
|
claimedAt: new Date(staleBase - 3_600_000), completedAt: new Date(staleBase - 3_600_000),
|
|
settledAt: new Date(staleBase - 3_600_000), updatedAt: new Date(staleBase - 3_600_000)
|
|
});
|
|
await insertMember({ taskId: lockedTaskId, userId: firstCleanerId, rewardCents: 100 });
|
|
await insertMember({ taskId: reclaimTaskId, userId: secondCleanerId, rewardCents: 70 });
|
|
await insertMember({
|
|
taskId: reclaimTaskId, userId: firstCleanerId, memberRole: 'ASSIST', rewardCents: 30
|
|
});
|
|
await insertMember({ taskId: outsideBatchTaskId, userId: firstCleanerId, rewardCents: 100 });
|
|
await insertMember({
|
|
taskId: settledTaskId, userId: secondCleanerId, rewardCents: 100,
|
|
settledAt: new Date(staleBase)
|
|
});
|
|
|
|
const lockConnection = await pool.getConnection();
|
|
let reclaimed;
|
|
try {
|
|
await lockConnection.beginTransaction();
|
|
await lockConnection.query(
|
|
`SELECT id FROM qipai_cleaning_tasks
|
|
WHERE tenant_id = ? AND id = ? FOR UPDATE`,
|
|
[context.tenantId, lockedTaskId]
|
|
);
|
|
reclaimed = await repository.reclaimTimeouts({
|
|
...managerActor('m09a-reclaim'), olderThanMinutes: 30, limit: 1
|
|
});
|
|
} finally {
|
|
await lockConnection.rollback();
|
|
lockConnection.release();
|
|
}
|
|
assert.deepEqual(reclaimed, { reclaimed: 1, taskIds: [reclaimTaskId] });
|
|
const [reclaimRows] = await pool.query(
|
|
`SELECT id, status, cleaner_user_id AS cleanerUserId,
|
|
JSON_LENGTH(photo_urls_json) AS photoCount, reject_reason AS rejectReason
|
|
FROM qipai_cleaning_tasks
|
|
WHERE tenant_id = ? AND id IN (?, ?, ?, ?) ORDER BY id`,
|
|
[context.tenantId, lockedTaskId, reclaimTaskId, outsideBatchTaskId, settledTaskId]
|
|
);
|
|
const reclaimById = new Map(reclaimRows.map((row) => [String(row.id), row]));
|
|
assert.equal(reclaimById.get(lockedTaskId).status, 'CLAIMED');
|
|
assert.equal(reclaimById.get(outsideBatchTaskId).status, 'CLAIMED');
|
|
assert.equal(reclaimById.get(settledTaskId).status, 'SETTLED');
|
|
assert.equal(reclaimById.get(reclaimTaskId).status, 'WAITING');
|
|
assert.equal(reclaimById.get(reclaimTaskId).cleanerUserId, null);
|
|
assert.equal(Number(reclaimById.get(reclaimTaskId).photoCount), 0);
|
|
assert.equal(reclaimById.get(reclaimTaskId).rejectReason, '');
|
|
const [reclaimedMembers] = await pool.query(
|
|
`SELECT reward_cents AS rewardCents, removed_at IS NOT NULL AS removed
|
|
FROM qipai_cleaning_task_members
|
|
WHERE tenant_id = ? AND task_id = ? ORDER BY user_id`,
|
|
[context.tenantId, reclaimTaskId]
|
|
);
|
|
assert.deepEqual(reclaimedMembers.map((row) => ({
|
|
rewardCents: Number(row.rewardCents), removed: Number(row.removed)
|
|
})), [
|
|
{ rewardCents: 0, removed: 1 }, { rewardCents: 0, removed: 1 }
|
|
]);
|
|
const [settledMemberRows] = await pool.query(
|
|
`SELECT reward_cents AS rewardCents, removed_at AS removedAt,
|
|
settled_at IS NOT NULL AS settled
|
|
FROM qipai_cleaning_task_members
|
|
WHERE tenant_id = ? AND task_id = ?`,
|
|
[context.tenantId, settledTaskId]
|
|
);
|
|
assert.equal(Number(settledMemberRows[0].rewardCents), 100);
|
|
assert.equal(settledMemberRows[0].removedAt, null);
|
|
assert.equal(Number(settledMemberRows[0].settled), 1);
|
|
|
|
const deniedExemptTaskId = await insertTask();
|
|
await pool.query(
|
|
`UPDATE qipai_cleaning_tasks SET exempt_policy = 'DISABLED'
|
|
WHERE tenant_id = ? AND id = ?`,
|
|
[context.tenantId, deniedExemptTaskId]
|
|
);
|
|
await assert.rejects(
|
|
() => repository.exempt({
|
|
...managerActor('m09b-exempt-denied'), taskId: deniedExemptTaskId, note: 'not allowed'
|
|
}),
|
|
(error) => error instanceof CleaningTaskError && error.code === 'CLEANING_EXEMPT_POLICY_DENIED'
|
|
);
|
|
|
|
const template = await repository.upsertTemplate({
|
|
...managerActor('m09b-template-v1'), scopeType: 'TENANT', name: 'M09-B tenant default',
|
|
requirement: '两张照片并在开始前决定免清洁', photoRequired: true,
|
|
minPhotoCount: 2, maxPhotoCount: 3, exemptPolicy: 'BEFORE_START', status: 'ACTIVE'
|
|
});
|
|
assert.equal(template.version, 1);
|
|
|
|
const [orderResult] = await pool.query(
|
|
`INSERT INTO qipai_orders
|
|
(tenant_id, store_id, room_id, order_no, status, start_at, end_at,
|
|
total_amount_cents, paid_amount_cents)
|
|
VALUES (?, ?, ?, ?, 'FINISHED', ?, ?, 1000, 1000)`,
|
|
[
|
|
context.tenantId, storeId, roomId, `M09A-FINISHED-${Date.now()}`,
|
|
new Date(Date.now() - 7_200_000), new Date(Date.now() - 3_600_000)
|
|
]
|
|
);
|
|
const finishedOrderId = String(orderResult.insertId);
|
|
const orderConnection = await pool.getConnection();
|
|
try {
|
|
await orderConnection.beginTransaction();
|
|
const creationInput = {
|
|
tenantId: context.tenantId,
|
|
orderId: finishedOrderId,
|
|
actorId: managerId,
|
|
traceId: 'm09a-finished-order'
|
|
};
|
|
await repository.createForFinishedOrder(orderConnection, creationInput);
|
|
await repository.createForFinishedOrder(orderConnection, creationInput);
|
|
await orderConnection.commit();
|
|
} catch (error) {
|
|
await orderConnection.rollback();
|
|
throw error;
|
|
} finally {
|
|
orderConnection.release();
|
|
}
|
|
const [generatedRows] = await pool.query(
|
|
`SELECT t.status, t.cleaning_template_id AS cleaningTemplateId,
|
|
t.cleaning_template_version AS cleaningTemplateVersion,
|
|
t.photo_required AS photoRequired, t.min_photo_count AS minPhotoCount,
|
|
t.max_photo_count AS maxPhotoCount, t.exempt_policy AS exemptPolicy,
|
|
(SELECT COUNT(*) FROM qipai_cleaning_task_events e
|
|
WHERE e.tenant_id = t.tenant_id AND e.task_id = t.id
|
|
AND e.action = 'AUTO_CREATE') AS eventCount
|
|
FROM qipai_cleaning_tasks t
|
|
WHERE t.tenant_id = ? AND t.order_id = ?`,
|
|
[context.tenantId, finishedOrderId]
|
|
);
|
|
assert.equal(generatedRows.length, 1);
|
|
assert.equal(generatedRows[0].status, 'WAITING');
|
|
assert.equal(String(generatedRows[0].cleaningTemplateId), template.id);
|
|
assert.equal(Number(generatedRows[0].cleaningTemplateVersion), 1);
|
|
assert.equal(Number(generatedRows[0].photoRequired), 1);
|
|
assert.equal(Number(generatedRows[0].minPhotoCount), 2);
|
|
assert.equal(Number(generatedRows[0].maxPhotoCount), 3);
|
|
assert.equal(generatedRows[0].exemptPolicy, 'BEFORE_START');
|
|
assert.equal(Number(generatedRows[0].eventCount), 1);
|
|
|
|
const updatedTemplate = await repository.upsertTemplate({
|
|
...managerActor('m09b-template-v2'), scopeType: 'TENANT', name: 'M09-B tenant default',
|
|
requirement: '新任务无需照片', photoRequired: false,
|
|
minPhotoCount: 0, maxPhotoCount: 2, exemptPolicy: 'DISABLED', status: 'ACTIVE'
|
|
});
|
|
assert.equal(updatedTemplate.version, 2);
|
|
const [snapshotRows] = await pool.query(
|
|
`SELECT cleaning_template_version AS version, min_photo_count AS minPhotoCount,
|
|
exempt_policy AS exemptPolicy
|
|
FROM qipai_cleaning_tasks WHERE tenant_id = ? AND order_id = ?`,
|
|
[context.tenantId, finishedOrderId]
|
|
);
|
|
assert.deepEqual({
|
|
version: Number(snapshotRows[0].version),
|
|
minPhotoCount: Number(snapshotRows[0].minPhotoCount),
|
|
exemptPolicy: snapshotRows[0].exemptPolicy
|
|
}, { version: 1, minPhotoCount: 2, exemptPolicy: 'BEFORE_START' });
|
|
|
|
const collaborativeTaskId = await insertTask({
|
|
status: 'COMPLETED', cleanerUserId: firstCleanerId, rewardCents: 100,
|
|
claimedAt: new Date(), completedAt: new Date()
|
|
});
|
|
await insertMember({
|
|
taskId: collaborativeTaskId, userId: firstCleanerId, memberRole: 'LEAD', rewardCents: 70
|
|
});
|
|
await insertMember({
|
|
taskId: collaborativeTaskId, userId: secondCleanerId, memberRole: 'ASSIST', rewardCents: 30
|
|
});
|
|
|
|
const concurrentSettlementResults = await Promise.allSettled([
|
|
repository.generateSettlement({
|
|
...managerActor('m09c-generate-one'), cleanerUserId: firstCleanerId, storeId
|
|
}),
|
|
repository.generateSettlement({
|
|
...managerActor('m09c-generate-two'), cleanerUserId: firstCleanerId, storeId
|
|
})
|
|
]);
|
|
assert.equal(
|
|
concurrentSettlementResults.filter((result) => result.status === 'fulfilled').length,
|
|
1
|
|
);
|
|
const rejectedSettlement = concurrentSettlementResults.find((result) => result.status === 'rejected');
|
|
assert.ok(rejectedSettlement?.reason instanceof CleaningTaskError);
|
|
assert.ok([
|
|
'CLEANING_SETTLEMENT_EMPTY', 'CLEANING_SETTLEMENT_CONCURRENTLY_CLAIMED'
|
|
].includes(rejectedSettlement.reason.code));
|
|
const leadDraft = concurrentSettlementResults.find((result) => result.status === 'fulfilled').value;
|
|
const assistDraft = await repository.generateSettlement({
|
|
...managerActor('m09c-generate-assist'), cleanerUserId: secondCleanerId, storeId
|
|
});
|
|
|
|
const [activeShareRows] = await pool.query(
|
|
`SELECT task_id AS taskId, cleaner_user_id AS cleanerUserId, COUNT(*) AS total
|
|
FROM qipai_cleaning_settlement_items
|
|
WHERE tenant_id = ? AND reversed_at IS NULL
|
|
AND task_id IN (?, ?)
|
|
GROUP BY task_id, cleaner_user_id`,
|
|
[context.tenantId, lifecycleTaskId, collaborativeTaskId]
|
|
);
|
|
assert.ok(activeShareRows.length >= 3);
|
|
assert.ok(activeShareRows.every((row) => Number(row.total) === 1));
|
|
const [draftTaskRows] = await pool.query(
|
|
`SELECT id, status, settled_at AS settledAt
|
|
FROM qipai_cleaning_tasks WHERE tenant_id = ? AND id IN (?, ?) ORDER BY id`,
|
|
[context.tenantId, lifecycleTaskId, collaborativeTaskId]
|
|
);
|
|
assert.ok(draftTaskRows.every((row) => row.status === 'COMPLETED' && row.settledAt === null));
|
|
const firstStatsInDraft = await repository.stats({
|
|
...cleanerActor(firstCleanerId, 'm09c-stats-draft')
|
|
});
|
|
assert.ok(firstStatsInDraft.pendingSettlementCents >= 170);
|
|
assert.equal(firstStatsInDraft.settledRewardCents, 0);
|
|
await assert.rejects(
|
|
() => repository.removeMember({
|
|
...managerActor('m09c-member-locked'), taskId: collaborativeTaskId,
|
|
cleanerUserId: secondCleanerId
|
|
}),
|
|
(error) => error instanceof CleaningTaskError
|
|
&& error.code === 'CLEANING_MEMBER_SETTLEMENT_LOCKED'
|
|
);
|
|
|
|
const cancelledLead = await repository.cancelSettlement({
|
|
...managerActor('m09c-cancel-draft'), settlementId: leadDraft.id,
|
|
reason: 'M09-C cancellation roundtrip'
|
|
});
|
|
assert.equal(cancelledLead.status, 'CANCELLED');
|
|
const cancelledDetail = await repository.getSettlementDetail({
|
|
...managerActor('m09c-cancel-detail'), settlementId: leadDraft.id
|
|
});
|
|
assert.equal(cancelledDetail.reversals.length, 1);
|
|
assert.equal(cancelledDetail.reversals[0].amountDeltaCents, -leadDraft.totalRewardCents);
|
|
assert.ok(cancelledDetail.items.every((item) => item.reversedAt instanceof Date));
|
|
assert.ok(cancelledDetail.events.some((event) => event.action === 'CANCEL'));
|
|
|
|
const retryLead = await repository.generateSettlement({
|
|
...managerActor('m09c-generate-retry'), cleanerUserId: firstCleanerId, storeId
|
|
});
|
|
await repository.confirmSettlement({
|
|
...managerActor('m09c-confirm-retry'), settlementId: retryLead.id
|
|
});
|
|
await repository.beginSettlementPayout({
|
|
...managerActor('m09c-payout-begin'), settlementId: retryLead.id,
|
|
payoutChannel: 'WECHAT_TRANSFER', payoutReference: `CLP${retryLead.id}`
|
|
});
|
|
await assert.rejects(
|
|
() => repository.cancelSettlement({
|
|
...managerActor('m09c-cancel-processing'), settlementId: retryLead.id,
|
|
reason: 'must not cancel uncertain transfer'
|
|
}),
|
|
(error) => error instanceof CleaningTaskError
|
|
&& error.code === 'CLEANING_SETTLEMENT_PAYOUT_PENDING'
|
|
);
|
|
await repository.recordSettlementPayoutFailure({
|
|
...managerActor('m09c-payout-explicit-fail'), settlementId: retryLead.id,
|
|
payoutChannel: 'WECHAT_TRANSFER', payoutReference: `CLP${retryLead.id}`,
|
|
error: 'PROVIDER_CONFIRMED_FAIL', providerConfirmed: true
|
|
});
|
|
await repository.cancelSettlement({
|
|
...managerActor('m09c-cancel-failed'), settlementId: retryLead.id,
|
|
reason: 'provider confirmed failure'
|
|
});
|
|
|
|
const paidLead = await repository.generateSettlement({
|
|
...managerActor('m09c-generate-paid'), cleanerUserId: firstCleanerId, storeId
|
|
});
|
|
await repository.confirmSettlement({
|
|
...managerActor('m09c-confirm-paid'), settlementId: paidLead.id
|
|
});
|
|
await repository.markSettlementPaid({
|
|
...managerActor('m09c-manual-paid-lead'), settlementId: paidLead.id,
|
|
payoutChannel: 'MANUAL', payoutReference: `MANUAL-${paidLead.id}`,
|
|
manualOverride: true
|
|
});
|
|
const [partiallyPaidRows] = await pool.query(
|
|
`SELECT status FROM qipai_cleaning_tasks WHERE tenant_id = ? AND id = ?`,
|
|
[context.tenantId, collaborativeTaskId]
|
|
);
|
|
assert.equal(partiallyPaidRows[0].status, 'COMPLETED');
|
|
|
|
await repository.confirmSettlement({
|
|
...managerActor('m09c-confirm-assist'), settlementId: assistDraft.id
|
|
});
|
|
await repository.markSettlementPaid({
|
|
...managerActor('m09c-manual-paid-assist'), settlementId: assistDraft.id,
|
|
payoutChannel: 'MANUAL', payoutReference: `MANUAL-${assistDraft.id}`,
|
|
manualOverride: true
|
|
});
|
|
const [fullyPaidRows] = await pool.query(
|
|
`SELECT status, settled_at IS NOT NULL AS settled
|
|
FROM qipai_cleaning_tasks WHERE tenant_id = ? AND id = ?`,
|
|
[context.tenantId, collaborativeTaskId]
|
|
);
|
|
assert.deepEqual(
|
|
{ status: fullyPaidRows[0].status, settled: Number(fullyPaidRows[0].settled) },
|
|
{ status: 'SETTLED', settled: 1 }
|
|
);
|
|
const firstStatsPaid = await repository.stats({
|
|
...cleanerActor(firstCleanerId, 'm09c-stats-paid')
|
|
});
|
|
assert.ok(firstStatsPaid.settledRewardCents >= 170);
|
|
|
|
const paidPage = await repository.listSettlements({
|
|
...managerActor('m09c-list-paid'), page: 1, pageSize: 50, status: 'PAID', storeId
|
|
});
|
|
const paidExport = await repository.exportSettlements({
|
|
...managerActor('m09c-export-paid'), status: 'PAID', storeId
|
|
});
|
|
assert.deepEqual(
|
|
paidExport.items.slice(0, paidPage.items.length).map((item) => item.id),
|
|
paidPage.items.map((item) => item.id)
|
|
);
|
|
assert.equal(paidExport.total, paidPage.total);
|
|
|
|
console.log(
|
|
'PASS: M09-A/M09-B/M09-C cleaning transactions, rules and settlement integrity are consistent.'
|
|
);
|
|
}
|
|
|
|
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' },
|
|
{ version: '2026081002', name: 'm08d_content_asset_scope' },
|
|
{ version: '2026081003', name: 'm08d_franchise_leads' },
|
|
{ version: '2026081004', name: 'm08d_admin_password_auth' },
|
|
{ version: '2026081005', name: 'm09b_cleaning_rules' },
|
|
{ version: '2026081006', name: 'm09c_cleaning_settlement_integrity' }
|
|
]);
|
|
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 assertFranchiseManagement(pool, loginContext);
|
|
await assertSystemOperations(pool, loginContext);
|
|
await assertAdminPasswordAuth(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 assertCleaningTaskTransactions(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 M09-C migration 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' },
|
|
{ version: '2026081002', name: 'm08d_content_asset_scope' },
|
|
{ version: '2026081003', name: 'm08d_franchise_leads' },
|
|
{ version: '2026081004', name: 'm08d_admin_password_auth' },
|
|
{ version: '2026081005', name: 'm09b_cleaning_rules' },
|
|
{ version: '2026081006', name: 'm09c_cleaning_settlement_integrity' }
|
|
]);
|
|
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',
|
|
'franchise application idempotency and phone-free audit metadata',
|
|
'franchise assignment and controlled follow-up status transition',
|
|
'tenant-scoped audit filtering with recursive sensitive metadata redaction',
|
|
'system overview and audited tenant configuration update',
|
|
'scrypt admin credentials and failed-login audit',
|
|
'rotating refresh token with reuse 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',
|
|
'manager on-behalf booking history and audit attribution',
|
|
'cross-tenant on-behalf target user rejection',
|
|
'store-scoped member discovery through historical owner orders',
|
|
'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',
|
|
'manager voucher redemption actor and store attribution',
|
|
'store-scoped business statistics from orders and successful payments',
|
|
'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'
|
|
,
|
|
'manager device command actor audit attribution'
|
|
,
|
|
'13-digit IoT command state transition',
|
|
'QoS 1 duplicate event receive count',
|
|
'ACK correlation without duplicate side effects',
|
|
'invalid Topic dead-letter persistence'
|
|
,
|
|
'door record credential hashing and masking',
|
|
'low-battery alert upsert',
|
|
'AddDevice first ACK waits for final subtype and subID',
|
|
'AddDevice final ACK creates 701C parent-child topology',
|
|
'task busy unconfirm and fail remain failed command states',
|
|
'unknown vendor result event dead-letter alert and command failure',
|
|
'CtrlDevice credential request hashing and masking',
|
|
'magstate and taskfinish remain events rather than ACKs',
|
|
'raw devicewill close marks the device offline without invalid-payload dead-letter',
|
|
'smart socket workInfo exact-decimal normalization',
|
|
'smart socket update paraerror and error command failures',
|
|
'special closeReason 3 through 7 alerts without 1 through 2 false alarms',
|
|
'conflicting temp and temperature protocol dead-letter',
|
|
'smart socket return-key request hashing and masking',
|
|
'concurrent cleaning claim has one winner',
|
|
'cleaning state and event transaction rollback',
|
|
'WAITING CLAIMED and REJECTED reassignment cleanup',
|
|
'idempotent cleaning completion trace',
|
|
'SKIP LOCKED cleaning timeout reclaim batch',
|
|
'settled cleaning member preservation',
|
|
'finished order creates one cleaning task',
|
|
'cleaning template snapshot remains stable after config version change',
|
|
'rejected and accepted cleaning photo revisions remain distinguishable',
|
|
'expired unattached cleaning photo retention cleanup',
|
|
'task-level cleaning exemption policy denial',
|
|
'concurrent cleaning settlement generation has one winner per member share',
|
|
'draft and confirmed rewards remain pending until paid',
|
|
'active settlement freezes member reward allocation',
|
|
'settlement reversal preserves history and releases the share',
|
|
'processing payout blocks cancellation until provider-confirmed failure',
|
|
'collaborative task settles only after every member share is paid',
|
|
'settlement page and CSV export reuse identical query ordering'
|
|
]
|
|
}, null, 2));
|
|
} finally {
|
|
await closeMySqlPool(pool);
|
|
}
|