Files
qipai/backend/tests/product-order-service.test.mjs
T

280 lines
12 KiB
JavaScript

import assert from 'node:assert/strict';
import {
ProductOrderError,
ProductOrderService,
productOrderFingerprint,
resolveProductOrderTransition
} from '../dist/products/product-order-service.js';
const fixedNow = new Date('2026-08-11T04:00:00.000Z');
const actor = {
tenantId: '7',
userId: '21',
access: { roles: ['CUSTOMER'], capabilities: [], storeIds: [] },
source: 'CUSTOMER',
traceId: 'm09d2-product-order-test',
ip: '127.0.0.1',
userAgent: 'product-order-test'
};
const createInput = {
storeId: '11',
requestId: 'create-order-1',
fulfillmentMode: 'SELF_SERVICE',
roomOrderId: null,
note: '少冰',
items: [{ skuId: '5', quantity: 2, note: '分开放' }]
};
const fingerprint = productOrderFingerprint(createInput);
assert.equal(
productOrderFingerprint({
...createInput,
items: [
{ skuId: '6', quantity: 1, note: '' },
{ skuId: '5', quantity: 2, note: '分开放' }
]
}),
productOrderFingerprint({
...createInput,
items: [
{ skuId: '5', quantity: 2, note: '分开放' },
{ skuId: '6', quantity: 1, note: '' }
]
}),
'semantic item ordering must not change the create fingerprint'
);
assert.notEqual(
fingerprint,
productOrderFingerprint({ ...createInput, note: '常温' }),
'meaningful input changes must change the create fingerprint'
);
assert.equal(resolveProductOrderTransition('PAID', 'DELIVERY', 'ACCEPT'), 'ACCEPTED');
assert.equal(
resolveProductOrderTransition('ACCEPTED', 'DELIVERY', 'START_DELIVERY'),
'DELIVERING'
);
assert.equal(
resolveProductOrderTransition('ACCEPTED', 'SELF_SERVICE', 'MARK_READY'),
'READY_FOR_SELF_SERVICE'
);
assert.equal(resolveProductOrderTransition('DELIVERING', 'DELIVERY', 'COMPLETE'), 'COMPLETED');
assert.throws(
() => resolveProductOrderTransition('ACCEPTED', 'SELF_SERVICE', 'START_DELIVERY'),
(error) => error instanceof ProductOrderError
&& error.code === 'PRODUCT_ORDER_TRANSITION_NOT_ALLOWED'
);
class ScriptedConnection {
constructor(steps) {
this.steps = [...steps];
this.calls = [];
this.committed = false;
this.rolledBack = false;
this.released = false;
}
async beginTransaction() {}
async commit() { this.committed = true; }
async rollback() { this.rolledBack = true; }
release() { this.released = true; }
async execute(sql, params = []) {
this.calls.push({ sql, params });
const step = this.steps.shift();
assert.ok(step, `Unexpected SQL: ${sql}`);
assert.match(sql, step.match);
if (step.check) step.check(params, sql);
return step.result;
}
}
class ScriptedPool {
constructor(directSteps, connections) {
this.directSteps = [...directSteps];
this.connections = [...connections];
}
async execute(sql, params = []) {
const step = this.directSteps.shift();
assert.ok(step, `Unexpected direct SQL: ${sql}`);
assert.match(sql, step.match);
if (step.check) step.check(params, sql);
return step.result;
}
async getConnection() {
const connection = this.connections.shift();
assert.ok(connection, 'Unexpected transaction');
return connection;
}
}
const baseOrder = {
id: '101', tenantId: '7', storeId: '11', memberId: '21', roomOrderId: null,
orderNo: 'PG202608110001', clientRequestId: createInput.requestId,
requestFingerprint: fingerprint, inventoryBusinessId: 'PRODUCT_ORDER:PG202608110001',
inventoryRequestId: 'po.lock.abc', fulfillmentMode: 'SELF_SERVICE',
status: 'PENDING_PAYMENT', itemCount: 1, totalQuantity: 2,
totalAmountCents: 500, paidAmountCents: 0, refundedAmountCents: 0,
orderNote: '少冰', inventoryStatus: 'LOCKED',
expiresAt: new Date('2026-08-11T04:15:00.000Z'),
paidAt: null, acceptedAt: null, deliveringAt: null, completedAt: null,
cancelledAt: null, createdSource: 'MEMBER', createdBy: null,
version: 1, createdAt: fixedNow, updatedAt: fixedNow
};
const itemView = [{
id: '201', lineNo: 1, productId: '3', skuId: '5', productCode: 'TEA',
productName: '茶饮', skuCode: 'TEA-L', skuName: '大杯', unitName: '杯',
attributes: null, unitPriceCents: 250, quantity: 2, subtotalCents: 500,
note: '分开放'
}];
const detailSteps = (order = baseOrder) => [
{ match: /FROM qipai_product_orders o[\s\S]*o\.member_id = \?/, result: [[order], []] },
{ match: /FROM qipai_product_order_items[\s\S]*ORDER BY line_no/, result: [itemView, []] },
{ match: /FROM qipai_product_payments[\s\S]*ORDER BY id DESC/, result: [[], []] },
{ match: /FROM qipai_product_refunds[\s\S]*ORDER BY id DESC/, result: [[], []] },
{ match: /FROM qipai_product_order_events[\s\S]*ORDER BY version_after/, result: [[{
id: '301', fromStatus: null, toStatus: order.status, action: 'CREATE',
actorType: 'MEMBER', actorId: null, versionAfter: order.version,
reason: '', createdAt: fixedNow
}], []] }
];
const createConnection = new ScriptedConnection([
{ match: /FROM qipai_product_orders o[\s\S]*client_request_id = \?[\s\S]*FOR UPDATE/, result: [[], []] },
{ match: /FROM qipai_stores s[\s\S]*FOR SHARE/, result: [[{
id: null, timezone: 'Asia/Shanghai', businessStatus: 'OPEN', salesStatus: null,
manualPausedAt: null, manualPausedUntil: null
}], []] },
{ match: /FROM qipai_product_skus s[\s\S]*FOR SHARE/, result: [[{
skuId: '5', productId: '3', skuCode: 'TEA-L', skuName: '大杯',
attributesSnapshot: null, unitPriceCents: 250, productCode: 'TEA',
productName: '茶饮', unitName: '杯', deliveryEnabled: 1,
fulfillmentMode: 'BOTH', salesStartAt: null, salesEndAt: null
}], []] },
{
match: /INSERT INTO qipai_product_orders/,
result: [{ insertId: 101, affectedRows: 1 }, []],
check(params) {
assert.equal(params[0], '7');
assert.equal(params[1], '11');
assert.equal(params[2], '21');
assert.equal(params[5], createInput.requestId);
assert.equal(params[6], fingerprint);
assert.equal(params[12], 500);
}
},
{ match: /INSERT INTO qipai_product_order_items/, result: [{ affectedRows: 1 }, []] },
{ match: /INSERT INTO qipai_product_order_events/, result: [{ affectedRows: 1 }, []] },
{ match: /INSERT INTO qipai_audit_logs/, result: [{ affectedRows: 1 }, []] },
...detailSteps()
]);
const createPool = new ScriptedPool([
{ match: /FROM qipai_product_orders o[\s\S]*client_request_id = \?/, result: [[], []] }
], [createConnection]);
let lockCall;
const created = await new ProductOrderService(createPool, {
async lockMany(input, connection) {
lockCall = { input, connection };
return { idempotent: false };
},
async releaseMany() { throw new Error('not expected'); },
async deductMany() { throw new Error('not expected'); },
async returnMany() { throw new Error('not expected'); }
}, { now: () => fixedNow }).create(actor, createInput);
assert.equal(created.id, '101');
assert.equal(created.totalAmountCents, 500);
assert.equal(created.items[0].skuId, '5');
assert.equal(lockCall.input.businessType, 'PRODUCT_ORDER');
assert.deepEqual(lockCall.input.items, [{ skuId: '5', quantity: 2 }]);
assert.equal(lockCall.connection, createConnection);
assert.equal(createConnection.committed, true);
assert.equal(createConnection.rolledBack, false);
assert.equal(createConnection.released, true);
assert.equal(createConnection.steps.length, 0);
const noSqlPool = new ScriptedPool([], []);
await assert.rejects(
() => new ProductOrderService(noSqlPool, {}).create(actor, {
...createInput,
items: [
{ skuId: '5', quantity: 1, note: '' },
{ skuId: '5', quantity: 1, note: 'duplicate' }
]
}),
(error) => error instanceof ProductOrderError && error.code === 'PRODUCT_ORDER_SKU_DUPLICATE'
);
const pendingPayment = {
id: '401', tenantId: '7', storeId: '11', orderId: '101',
paymentNo: 'PP202608110001', clientRequestId: 'payment-create-1',
requestFingerprint: 'a'.repeat(64), provider: 'TEST', channel: 'TEST',
status: 'PENDING', amountCents: 500, refundedAmountCents: 0,
expiresAt: baseOrder.expiresAt, paidAt: null, version: 1
};
const paymentConnection = new ScriptedConnection([
{ match: /FROM qipai_product_payment_callbacks[\s\S]*FOR UPDATE/, result: [[], []] },
{ match: /FROM qipai_product_payments WHERE[\s\S]*FOR UPDATE/, result: [[pendingPayment], []] },
{ match: /FROM qipai_product_orders o[\s\S]*FOR UPDATE/, result: [[baseOrder], []] },
{ match: /INSERT INTO qipai_product_payment_callbacks/, result: [{ insertId: 501, affectedRows: 1 }, []] },
{ match: /FROM qipai_product_order_items[\s\S]*GROUP BY sku_id/, result: [[{ skuId: '5', quantity: 2 }], []] },
{ match: /UPDATE qipai_product_payments[\s\S]*SUCCEEDED/, result: [{ affectedRows: 1 }, []] },
{ match: /UPDATE qipai_product_orders[\s\S]*status = 'PAID'/, result: [{ affectedRows: 1 }, []] },
{ match: /INSERT INTO qipai_product_order_events/, result: [{ affectedRows: 1 }, []] },
{ match: /UPDATE qipai_product_payment_callbacks/, result: [{ affectedRows: 1 }, []] }
]);
const paymentPool = new ScriptedPool([{
match: /o\.member_id AS memberId[\s\S]*FROM qipai_product_payments p/,
result: [[{ ...pendingPayment, memberId: '21' }], []]
}], [paymentConnection]);
let deductCall;
const paymentResult = await new ProductOrderService(paymentPool, {
async lockMany() { throw new Error('not expected'); },
async releaseMany() { throw new Error('not expected'); },
async deductMany(input, connection) { deductCall = { input, connection }; },
async returnMany() { throw new Error('not expected'); }
}, { now: () => fixedNow }).completeTestPaymentForCustomer(actor, '401', {
callbackId: 'test-payment-callback-1', amountCents: 500
});
assert.deepEqual(paymentResult, {
paymentId: '401', orderId: '101', status: 'SUCCEEDED', idempotent: false
});
assert.deepEqual(deductCall.input.items, [{ skuId: '5', quantity: 2 }]);
assert.equal(deductCall.input.businessId, baseOrder.inventoryBusinessId);
assert.equal(deductCall.connection, paymentConnection);
assert.equal(paymentConnection.committed, true);
assert.equal(paymentConnection.steps.length, 0);
const cancelledOrder = {
...baseOrder, status: 'CANCELLED', inventoryStatus: 'RELEASED',
cancelledAt: fixedNow, version: 2
};
const cancelConnection = new ScriptedConnection([
{ match: /FROM qipai_product_order_events[\s\S]*request_id = \?/, result: [[], []] },
{ match: /FROM qipai_product_orders o[\s\S]*o\.member_id = \?[\s\S]*FOR UPDATE/, result: [[baseOrder], []] },
{ match: /FROM qipai_product_order_items[\s\S]*GROUP BY sku_id/, result: [[{ skuId: '5', quantity: 2 }], []] },
{ match: /UPDATE qipai_product_orders[\s\S]*CANCELLED/, result: [{ affectedRows: 1 }, []] },
{ match: /UPDATE qipai_product_payments[\s\S]*CLOSED/, result: [{ affectedRows: 1 }, []] },
{ match: /INSERT INTO qipai_product_order_events/, result: [{ affectedRows: 1 }, []] },
{ match: /INSERT INTO qipai_audit_logs/, result: [{ affectedRows: 1 }, []] },
...detailSteps(cancelledOrder)
]);
let releaseCall;
const cancelled = await new ProductOrderService(new ScriptedPool([], [cancelConnection]), {
async lockMany() { throw new Error('not expected'); },
async releaseMany(input, connection) { releaseCall = { input, connection }; },
async deductMany() { throw new Error('not expected'); },
async returnMany() { throw new Error('not expected'); }
}, { now: () => fixedNow }).cancelForCustomer(actor, '101', {
requestId: 'cancel-order-1', reason: '顾客取消'
});
assert.equal(cancelled.status, 'CANCELLED');
assert.equal(cancelled.inventoryStatus, 'RELEASED');
assert.deepEqual(releaseCall.input.items, [{ skuId: '5', quantity: 2 }]);
assert.equal(releaseCall.connection, cancelConnection);
assert.equal(cancelConnection.committed, true);
assert.equal(cancelConnection.steps.length, 0);
console.log('PASS: M09-D2 product order snapshot, transition, payment deduct and cancellation release service contracts work.');