feat(M09-D3): 完成商品寄存与安全取出闭环
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import Fastify from 'fastify';
|
||||
import { signAccessToken } from '../dist/auth/jwt.js';
|
||||
import { ProductStorageError } from '../dist/products/product-storage-service.js';
|
||||
import { registerProductStorageRoutes } from '../dist/routes/product-storages.js';
|
||||
|
||||
const secret = 'test-only-product-storage-route-jwt-secret';
|
||||
const sessionId = '80f21c7c-4c1d-4e62-bb82-301c6d51adea';
|
||||
const token = signAccessToken({
|
||||
sub: '21', sid: sessionId, tid: '7', aid: '9', rv: 1
|
||||
}, secret, 900);
|
||||
const headers = {
|
||||
authorization: `Bearer ${token}`,
|
||||
'x-trace-id': 'm09d3-product-storage-route'
|
||||
};
|
||||
let currentAccess = { roles: ['CUSTOMER'], capabilities: [], storeIds: [] };
|
||||
const calls = [];
|
||||
const record = (method, result) => async (...args) => {
|
||||
calls.push({ method, args });
|
||||
return typeof result === 'function' ? result(...args) : result;
|
||||
};
|
||||
const storage = {
|
||||
id: '301', storeId: '11', memberId: '21', storageNo: 'PS301',
|
||||
status: 'STORED', totalQuantity: 3, remainingQuantity: 3,
|
||||
claimCredentialVersion: 1, claimCredentialConsumed: false,
|
||||
items: [{ id: '401', skuId: '5', totalQuantity: 3, remainingQuantity: 3 }]
|
||||
};
|
||||
const service = {
|
||||
createFromOrder: record('createFromOrder', () => ({
|
||||
...storage, claimCredential: 'claim_credential_abcdefghijklmnopqrstuvwxyz012345'
|
||||
})),
|
||||
createManual: record('createManual', () => storage),
|
||||
listForCustomer: record('listForCustomer', (_actor, input) => ({
|
||||
items: [storage], total: 1, page: input.page, pageSize: input.pageSize
|
||||
})),
|
||||
getForCustomer: record('getForCustomer', (_actor, storageId) => {
|
||||
if (storageId === '999') throw new ProductStorageError('PRODUCT_STORAGE_NOT_FOUND');
|
||||
return storage;
|
||||
}),
|
||||
listForManagement: record('listForManagement', (_actor, input) => ({
|
||||
items: [storage], total: 1, page: input.page, pageSize: input.pageSize
|
||||
})),
|
||||
getForManagement: record('getForManagement', () => storage),
|
||||
retrieve: record('retrieve', () => ({
|
||||
...storage, status: 'PARTIALLY_RETRIEVED', remainingQuantity: 2,
|
||||
nextClaimCredential: 'next_claim_credential_abcdefghijklmnopqrstuvwxyz'
|
||||
})),
|
||||
rotateCredential: record('rotateCredential', () => ({
|
||||
...storage, claimCredentialVersion: 2,
|
||||
claimCredential: 'rotated_claim_credential_abcdefghijklmnopqrstuvwxyz'
|
||||
})),
|
||||
cancel: record('cancel', () => ({ ...storage, status: 'CANCELLED' })),
|
||||
expireDueForManagement: record('expireDueForManagement', () => ({
|
||||
expired: 1, storageIds: ['301']
|
||||
}))
|
||||
};
|
||||
|
||||
const app = Fastify({ logger: false });
|
||||
app.decorateRequest('traceId', '');
|
||||
app.addHook('onRequest', async (request, reply) => {
|
||||
request.traceId = request.headers['x-trace-id'] || request.id;
|
||||
reply.header('x-trace-id', request.traceId);
|
||||
});
|
||||
await registerProductStorageRoutes(app, {
|
||||
service,
|
||||
authRepository: {
|
||||
async validateSession() {
|
||||
return {
|
||||
id: sessionId, tenantId: '7', platformAppId: '9',
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
user: {
|
||||
id: '21', tenantId: '7', userType: 'CUSTOMER', status: 'ACTIVE',
|
||||
roleVersion: 1, nickname: '', avatarUrl: '', phone: ''
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
accessControl: { async getAccessProfile() { return currentAccess; } },
|
||||
jwtSecret: secret
|
||||
});
|
||||
|
||||
const unauthorized = await app.inject({ method: 'GET', url: '/app-api/product-storages' });
|
||||
assert.equal(unauthorized.statusCode, 401);
|
||||
|
||||
const invalidCreate = await app.inject({
|
||||
method: 'POST', url: '/app-api/product-storages', headers,
|
||||
payload: { requestId: 'storage-create-invalid', sourceOrderId: '101', expiresAt: 'bad-date' }
|
||||
});
|
||||
assert.equal(invalidCreate.statusCode, 400);
|
||||
assert.equal(invalidCreate.json().code, 'PRODUCT_STORAGE_INPUT_INVALID');
|
||||
|
||||
const created = await app.inject({
|
||||
method: 'POST', url: '/app-api/product-storages', headers,
|
||||
payload: {
|
||||
requestId: 'storage-create-1', sourceOrderId: '101',
|
||||
expiresAt: '2026-08-12T07:00:00.000Z',
|
||||
items: [{ orderItemId: '201', quantity: 3 }]
|
||||
}
|
||||
});
|
||||
assert.equal(created.statusCode, 201);
|
||||
assert.equal(created.json().data.id, '301');
|
||||
const createCall = calls.find((call) => call.method === 'createFromOrder');
|
||||
assert.equal(createCall.args[0].source, 'CUSTOMER');
|
||||
assert.ok(createCall.args[1].expiresAt instanceof Date);
|
||||
|
||||
const listed = await app.inject({
|
||||
method: 'GET', url: '/app-api/product-storages?status=STORED&page=2&pageSize=10', headers
|
||||
});
|
||||
assert.equal(listed.statusCode, 200);
|
||||
assert.equal(listed.json().data.page, 2);
|
||||
|
||||
const retrieved = await app.inject({
|
||||
method: 'POST', url: '/app-api/product-storages/301/retrieve', headers,
|
||||
payload: {
|
||||
requestId: 'storage-retrieve-1',
|
||||
claimCredential: 'claim_credential_abcdefghijklmnopqrstuvwxyz012345',
|
||||
items: [{ storageItemId: '401', quantity: 1 }]
|
||||
}
|
||||
});
|
||||
assert.equal(retrieved.statusCode, 200);
|
||||
assert.equal(retrieved.json().data.status, 'PARTIALLY_RETRIEVED');
|
||||
|
||||
const missing = await app.inject({
|
||||
method: 'GET', url: '/app-api/product-storages/999', headers
|
||||
});
|
||||
assert.equal(missing.statusCode, 404);
|
||||
|
||||
const managementForbidden = await app.inject({
|
||||
method: 'GET', url: '/admin-api/product-storages?storeId=11', headers
|
||||
});
|
||||
assert.equal(managementForbidden.statusCode, 403);
|
||||
|
||||
currentAccess = {
|
||||
roles: ['STAFF'], capabilities: ['goods.storage.read'], storeIds: ['11']
|
||||
};
|
||||
const managementList = await app.inject({
|
||||
method: 'GET', url: '/app-api/management/product-storages?storeId=11', headers
|
||||
});
|
||||
assert.equal(managementList.statusCode, 200);
|
||||
const managementCall = calls.find((call) => call.method === 'listForManagement');
|
||||
assert.equal(managementCall.args[0].source, 'MANAGEMENT');
|
||||
|
||||
const readOnlyCancel = await app.inject({
|
||||
method: 'POST', url: '/admin-api/product-storages/301/cancel', headers,
|
||||
payload: { requestId: 'storage-cancel-1', reason: '管理员取消' }
|
||||
});
|
||||
assert.equal(readOnlyCancel.statusCode, 403);
|
||||
|
||||
currentAccess = {
|
||||
roles: ['STAFF'],
|
||||
capabilities: ['goods.storage.read', 'goods.storage.manage'], storeIds: ['11']
|
||||
};
|
||||
const manual = await app.inject({
|
||||
method: 'POST', url: '/admin-api/product-storages', headers,
|
||||
payload: {
|
||||
sourceType: 'MANUAL', requestId: 'storage-manual-1', storeId: '11', memberId: '21',
|
||||
expiresAt: '2026-08-12T07:00:00.000Z', items: [{ skuId: '5', quantity: 2 }]
|
||||
}
|
||||
});
|
||||
assert.equal(manual.statusCode, 201);
|
||||
assert.ok(calls.some((call) => call.method === 'createManual'));
|
||||
|
||||
const expired = await app.inject({
|
||||
method: 'POST', url: '/admin-api/product-storages/expire-due', headers,
|
||||
payload: { storeId: '11', limit: 25 }
|
||||
});
|
||||
assert.equal(expired.statusCode, 200);
|
||||
assert.equal(expired.json().data.expired, 1);
|
||||
|
||||
await app.close();
|
||||
console.log('PASS: M09-D3 storage routes enforce ownership, credential input and management permissions.');
|
||||
Reference in New Issue
Block a user