fix: secure order workflow for lifecycle candidate
This commit is contained in:
170
test/orders-business-outcome.test.cjs
Normal file
170
test/orders-business-outcome.test.cjs
Normal file
@@ -0,0 +1,170 @@
|
||||
'use strict';
|
||||
|
||||
// Exercise the actual TypeScript service, with only its injected CRUD/Action
|
||||
// collaborators stubbed. No application bootstrap, network, database or dist
|
||||
// writes are needed; generated contracts and decorators remain the real ones.
|
||||
require('reflect-metadata');
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const ts = require('typescript');
|
||||
const { SaasBusinessReject, ModuleRuntimeError } = require('@cool-midway/module-runtime');
|
||||
const sourceRoot = path.resolve(__dirname, '../src');
|
||||
const previousTsLoader = require.extensions['.ts'];
|
||||
let OrdersService;
|
||||
try {
|
||||
require.extensions['.ts'] = (module, filename) => {
|
||||
assert.ok(filename.startsWith(sourceRoot + path.sep), 'only this service source tree is compiled in memory');
|
||||
const compiled = ts.transpileModule(fs.readFileSync(filename, 'utf8'), {
|
||||
fileName: filename,
|
||||
compilerOptions: { target: ts.ScriptTarget.ES2018, module: ts.ModuleKind.CommonJS,
|
||||
experimentalDecorators: true, emitDecoratorMetadata: true, esModuleInterop: true },
|
||||
});
|
||||
module._compile(compiled.outputText, filename);
|
||||
};
|
||||
({ OrdersService } = require('../src/service/orders.ts'));
|
||||
} finally {
|
||||
if (previousTsLoader) require.extensions['.ts'] = previousTsLoader;
|
||||
else delete require.extensions['.ts'];
|
||||
}
|
||||
|
||||
function fixture(overrides = {}) {
|
||||
const state = {
|
||||
goods: { id: '24', status: 1, price: '12.50', stock: 20 },
|
||||
order: { id: '27', order_no: 'UNIT-ORDER', goods_id: '24', quantity: 3, amount: '37.50', status: 'confirmed' },
|
||||
calls: [], ...overrides,
|
||||
};
|
||||
const service = new OrdersService();
|
||||
service.batchInfo = async input => { state.calls.push(['batchInfo', input]); return { list: state.goods ? [state.goods] : [] }; };
|
||||
service.reserveStock = async input => { state.calls.push(['reserveStock', input]); return { goodsId: input.goodsId, remainingStock: 17 }; };
|
||||
service.releaseStock = async input => { state.calls.push(['releaseStock', input]); return { goodsId: input.goodsId, currentStock: 20 }; };
|
||||
service.orders = {
|
||||
lock: async input => {state.calls.push(['lock',input]);return state.order;},
|
||||
info: async () => assert.fail('missing-record lookup must not invoke the nonnullable info contract'),
|
||||
list: async input => { state.calls.push(['list', input]); return state.order ? [state.order] : []; },
|
||||
add: async input => { state.calls.push(['add', input]); return { id: '27', ...input, ...state.addResult }; },
|
||||
update: async input => { state.calls.push(['update', input]); return { affected: 1 }; },
|
||||
};
|
||||
service.workflow = {start: async input => {state.calls.push(['workflow',input]);return {sagaId:'test-saga'};}};
|
||||
return { service, state };
|
||||
}
|
||||
const createInput = { orderNo: 'UNIT-ORDER', goodsId: 24, quantity: 3 };
|
||||
const business = code => error => error instanceof SaasBusinessReject && !(error instanceof ModuleRuntimeError) && error.code === code;
|
||||
const contract = error => error instanceof ModuleRuntimeError && !(error instanceof SaasBusinessReject) &&
|
||||
error.errorCode === 'MODULE_ACTION_INPUT_INVALID' && error.statusCode === 409;
|
||||
const noWrites = state => assert.ok(state.calls.every(([name]) => !['reserveStock', 'releaseStock', 'add', 'update'].includes(name)));
|
||||
|
||||
test('the existing locked list contract authorizes ID filtering and valid empty-array results', () => {
|
||||
const lock = JSON.parse(fs.readFileSync(path.join(sourceRoot, 'saas/generated/order/saas-schema.lock.json'), 'utf8'));
|
||||
const model = lock.models.find(item => item.tableCode === 'orders');
|
||||
assert.equal(model.fields.find(field => field.code === 'id').filterable, true);
|
||||
const list = lock.actions.find(item => item.manifestDescriptor.name === 'orders.list').manifestDescriptor;
|
||||
assert.equal(list.inputSchema.properties.where.properties.id.type, 'string');
|
||||
assert.equal(list.inputSchema.properties.take.minimum, 1);
|
||||
assert.equal(list.outputSchema.type, 'array');
|
||||
assert.ok(list.tables.some(table => table.tableCode === 'orders' && table.select === true && table.fields.includes('id')));
|
||||
});
|
||||
|
||||
test('unlisted goods is an explicit GOODS_UNAVAILABLE business rejection before any write', async () => {
|
||||
const f = fixture({ goods: { id: '24', status: 0, price: '12.50' } });
|
||||
await assert.rejects(f.service.createOrder(createInput), business('GOODS_UNAVAILABLE'));
|
||||
noWrites(f.state);
|
||||
});
|
||||
|
||||
test('missing goods is GOODS_NOT_FOUND in both order creation and details', async t => {
|
||||
for (const method of ['createOrder', 'detailWithGoods']) await t.test(method, async () => {
|
||||
const f = fixture({ goods: null });
|
||||
await assert.rejects(f.service[method](method === 'createOrder' ? createInput : { id: 27 }), business('GOODS_NOT_FOUND'));
|
||||
noWrites(f.state);
|
||||
});
|
||||
});
|
||||
|
||||
test('missing order is ORDER_NOT_FOUND in details and cancellation', async t => {
|
||||
for (const method of ['detailWithGoods', 'cancelOrder']) await t.test(method, async () => {
|
||||
const f = fixture({ order: null });
|
||||
await assert.rejects(f.service[method]({ id: 27 }), business('ORDER_NOT_FOUND'));
|
||||
assert.deepEqual(f.state.calls, method === 'cancelOrder' ? [['lock',{id:'27'}]] : [['list', { where: { id: '27' }, take: 1 }]]);
|
||||
noWrites(f.state);
|
||||
});
|
||||
});
|
||||
|
||||
test('malformed goods status and price remain contract errors, not business warnings', async t => {
|
||||
for (const [label, patch] of [['fractional status', { status: 1.2 }], ['invalid status', { status: 'bad' }],
|
||||
['invalid price', { price: '12.5oops' }], ['nonfinite price', { price: Infinity }]]) {
|
||||
await t.test(label, async () => {
|
||||
const f = fixture(); Object.assign(f.state.goods, patch);
|
||||
await assert.rejects(f.service.createOrder(createInput), contract);
|
||||
noWrites(f.state);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('unsafe stored goods IDs remain contract failures before a cross-module call', async t => {
|
||||
for (const id of ['0', '-1', '1.5', '9007199254740992', 'bad']) await t.test(id, async () => {
|
||||
const f = fixture(); f.state.order.goods_id = id;
|
||||
await assert.rejects(f.service.detailWithGoods({ id: 27 }), contract);
|
||||
assert.deepEqual(f.state.calls.map(([name]) => name), ['list']);
|
||||
});
|
||||
});
|
||||
|
||||
test('invalid generated order ID or amount stays a contract error after mocked CRUD', async t => {
|
||||
for (const [label, addResult] of [['unsafe order ID', { id: '9007199254740992' }], ['nonfinite amount', { amount: 'NaN' }]]) {
|
||||
await t.test(label, async () => {
|
||||
const f = fixture({ addResult });
|
||||
await assert.rejects(f.service.createOrder(createInput), contract);
|
||||
assert.deepEqual(f.state.calls.map(([name]) => name), ['batchInfo', 'add', 'workflow']);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('failure to persist workflow propagates to the enclosing transaction', async () => {
|
||||
const f = fixture();
|
||||
const downstream = new SaasBusinessReject('INSUFFICIENT_STOCK', '库存不足');
|
||||
f.service.workflow.start = async () => { throw downstream; };
|
||||
await assert.rejects(f.service.createOrder(createInput), error => error === downstream);
|
||||
assert.equal(f.state.calls.some(([name]) => name === 'reserveStock'), false);
|
||||
});
|
||||
|
||||
test('list contract and database failures propagate unchanged instead of becoming ORDER_NOT_FOUND', async t => {
|
||||
for (const error of [new ModuleRuntimeError('MODULE_ACTION_OUTPUT_INVALID', 'invalid list output', 500),
|
||||
new ModuleRuntimeError('MODULE_ACTION_FORBIDDEN', 'list access denied', 403), new Error('database unavailable')]) {
|
||||
await t.test(error.message, async () => {
|
||||
const f = fixture();
|
||||
f.service.orders.list = async () => { throw error; };
|
||||
await assert.rejects(f.service.detailWithGoods({ id: 27 }), observed => observed === error && !(observed instanceof SaasBusinessReject));
|
||||
noWrites(f.state);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('creation persists pending intent and starts durable workflow without a synchronous stock write', async () => {
|
||||
const f = fixture();
|
||||
assert.deepEqual(await f.service.createOrder(createInput), { id: 27, orderNo: 'UNIT-ORDER', amount: 37.5, status: 'pending_reservation' });
|
||||
assert.deepEqual(f.state.calls.map(([name]) => name), ['batchInfo', 'add', 'workflow']);
|
||||
const flow=f.state.calls[2][1];
|
||||
assert.equal(flow.type,'order.create@1');
|
||||
assert.deepEqual(flow.steps[0].input,{goodsId:24,quantity:3});
|
||||
assert.deepEqual(flow.outcomes.completed,{id:'27',status:'confirmed'});
|
||||
assert.deepEqual(flow.outcomes.failed,{id:'27',status:'rejected'});
|
||||
});
|
||||
|
||||
test('cancellation locks state and persists release workflow; terminal and pending repeats are inert', async () => {
|
||||
const f = fixture();
|
||||
assert.deepEqual(await f.service.cancelOrder({ id: 27 }), { id: 27, status: 'cancel_pending', restoredStock: 0 });
|
||||
assert.deepEqual(f.state.calls.map(([name]) => name), ['lock', 'update', 'workflow']);
|
||||
const repeated = fixture(); repeated.state.order.status = 'cancelled';
|
||||
assert.deepEqual(await repeated.service.cancelOrder({ id: 27 }), { id: 27, status: 'cancelled', restoredStock: 0 });
|
||||
assert.deepEqual(repeated.state.calls.map(([name]) => name), ['lock']);
|
||||
const pending=fixture(); pending.state.order.status='cancel_pending';
|
||||
assert.equal((await pending.service.cancelOrder({id:27})).status,'cancel_pending');
|
||||
assert.deepEqual(pending.state.calls.map(([name])=>name),['lock']);
|
||||
});
|
||||
|
||||
test('legacy or unconfirmed orders cannot manufacture a stock release', async () => {
|
||||
for (const status of ['created','pending_reservation','rejected','manual_review']) {
|
||||
const f=fixture();f.state.order.status=status;
|
||||
await assert.rejects(f.service.cancelOrder({id:27}),business('ORDER_STATE_CONFLICT'));
|
||||
noWrites(f.state);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user