|
|
|
@@ -21,6 +21,9 @@ import { PricingRepository, PricingError } from '../dist/orders/pricing-reposito
|
|
|
|
|
import {
|
|
|
|
|
OrderStateError, OrderStateRepository
|
|
|
|
|
} from '../dist/orders/order-state-repository.js';
|
|
|
|
|
import {
|
|
|
|
|
OrderManagementError, OrderManagementRepository
|
|
|
|
|
} from '../dist/orders/order-management-repository.js';
|
|
|
|
|
import {
|
|
|
|
|
executeMigrationPlan,
|
|
|
|
|
loadMigrationPlan,
|
|
|
|
@@ -37,6 +40,7 @@ const expectedTables = [
|
|
|
|
|
'qipai_legacy_table_mappings',
|
|
|
|
|
'qipai_media_assets',
|
|
|
|
|
'qipai_members',
|
|
|
|
|
'qipai_order_adjustments',
|
|
|
|
|
'qipai_order_price_snapshots',
|
|
|
|
|
'qipai_order_status_history',
|
|
|
|
|
'qipai_order_user_access',
|
|
|
|
@@ -85,11 +89,11 @@ async function readMigrationVersions(pool) {
|
|
|
|
|
const [rows] = await pool.query(
|
|
|
|
|
`SELECT version, name
|
|
|
|
|
FROM qipai_schema_migrations
|
|
|
|
|
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
|
|
|
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
|
|
|
ORDER BY version`,
|
|
|
|
|
['2026061601', '2026061802', '2026061803', '2026061804',
|
|
|
|
|
'2026061805', '2026061806', '2026061807', '2026061808', '2026061809',
|
|
|
|
|
'2026061810', '2026061811', '2026062012']
|
|
|
|
|
'2026061810', '2026061811', '2026062012', '2026062013']
|
|
|
|
|
);
|
|
|
|
|
return rows;
|
|
|
|
|
}
|
|
|
|
@@ -723,6 +727,169 @@ async function assertOrderStateMachine(pool, context) {
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 assertContentManagement(pool, context) {
|
|
|
|
|
const [adminRows] = await pool.query(
|
|
|
|
|
`SELECT u.id FROM qipai_users u
|
|
|
|
@@ -822,7 +989,8 @@ try {
|
|
|
|
|
{ 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: '2026062012', name: 'm04b_order_state_machine' },
|
|
|
|
|
{ version: '2026062013', name: 'm04c_order_adjustments' }
|
|
|
|
|
]);
|
|
|
|
|
await assertTaskDurability(pool);
|
|
|
|
|
const loginContext = await assertPlatformTenantIsolation(pool);
|
|
|
|
@@ -834,13 +1002,14 @@ try {
|
|
|
|
|
await assertSceneAndWifiAccess(pool, loginContext);
|
|
|
|
|
await assertPricingAndReservations(pool, loginContext);
|
|
|
|
|
await assertOrderStateMachine(pool, loginContext);
|
|
|
|
|
await assertOrderAdjustments(pool, loginContext);
|
|
|
|
|
await assertLegacyCompatibility(pool);
|
|
|
|
|
console.log('PASS: first up, verify, tenant isolation and revocable auth checks completed.');
|
|
|
|
|
|
|
|
|
|
await executeMigrationPlan(pool, plans.down);
|
|
|
|
|
assert.deepEqual(await readCoreTables(pool), []);
|
|
|
|
|
await assertLegacyCompatibility(pool);
|
|
|
|
|
console.log('PASS: down removed all M01-B through M04-B tables.');
|
|
|
|
|
console.log('PASS: down removed all M01-B through M04-C tables.');
|
|
|
|
|
|
|
|
|
|
await executeMigrationPlan(pool, plans.up);
|
|
|
|
|
await executeMigrationPlan(pool, plans.verify);
|
|
|
|
@@ -857,7 +1026,8 @@ try {
|
|
|
|
|
{ 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: '2026062012', name: 'm04b_order_state_machine' },
|
|
|
|
|
{ version: '2026062013', name: 'm04c_order_adjustments' }
|
|
|
|
|
]);
|
|
|
|
|
await assertLegacyCompatibility(pool);
|
|
|
|
|
console.log('PASS: second up and verify restored the schema.');
|
|
|
|
@@ -919,7 +1089,13 @@ try {
|
|
|
|
|
'controlled order actions',
|
|
|
|
|
'idempotent transition trace',
|
|
|
|
|
'complete order status history',
|
|
|
|
|
'reservation and access release on close'
|
|
|
|
|
'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'
|
|
|
|
|
]
|
|
|
|
|
}, null, 2));
|
|
|
|
|
} finally {
|
|
|
|
|