feat(M09-D1): 完成商品目录与库存流水底座
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import Fastify from 'fastify';
|
||||
import { signAccessToken } from '../dist/auth/jwt.js';
|
||||
import { InventoryError } from '../dist/inventory/inventory-service.js';
|
||||
import { registerInventoryRoutes } from '../dist/routes/inventory.js';
|
||||
|
||||
const secret = 'test-only-inventory-route-jwt-secret';
|
||||
const sessionId = '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e';
|
||||
const token = signAccessToken({
|
||||
sub: '21', sid: sessionId, tid: '7', aid: '9', rv: 1
|
||||
}, secret, 900);
|
||||
const headers = {
|
||||
authorization: `Bearer ${token}`,
|
||||
'x-trace-id': 'm09d1-inventory-route'
|
||||
};
|
||||
|
||||
const calls = [];
|
||||
let currentAccess = { roles: ['STAFF'], capabilities: [], storeIds: ['11'] };
|
||||
const mutationResult = (input, operation) => ({
|
||||
inventoryId: '101',
|
||||
ledgerId: '1001',
|
||||
storeId: input.storeId,
|
||||
skuId: input.skuId,
|
||||
operation,
|
||||
policyType: input.policyType ?? 'TRACKED',
|
||||
availableQuantity: input.availableQuantity ?? 8,
|
||||
lockedQuantity: 0,
|
||||
lossQuantity: input.lossQuantity ?? 1,
|
||||
lowStockThreshold: input.lowStockThreshold ?? 2,
|
||||
version: 2,
|
||||
idempotent: false
|
||||
});
|
||||
const record = (method, result) => async (...args) => {
|
||||
calls.push({ method, args });
|
||||
return typeof result === 'function' ? result(...args) : result;
|
||||
};
|
||||
const mutate = (operation) => record(operation.toLowerCase(), (_actor, input) => {
|
||||
if (input.storeId === '12') throw new InventoryError('INVENTORY_STORE_SCOPE_FORBIDDEN');
|
||||
if (input.requestId === 'insufficient') {
|
||||
throw new InventoryError('INVENTORY_INSUFFICIENT_AVAILABLE');
|
||||
}
|
||||
return mutationResult(input, operation);
|
||||
});
|
||||
const service = {
|
||||
listStocks: record('listStocks', (_actor, input) => ({
|
||||
items: [], total: 0, page: input.page, pageSize: input.pageSize
|
||||
})),
|
||||
listLedger: record('listLedger', (_actor, input) => ({
|
||||
items: [], total: 0, page: input.page, pageSize: input.pageSize
|
||||
})),
|
||||
configurePolicy: mutate('CONFIGURE'),
|
||||
inbound: mutate('INBOUND'),
|
||||
adjust: mutate('ADJUST'),
|
||||
stocktake: mutate('STOCKTAKE'),
|
||||
recordLoss: mutate('LOSS')
|
||||
};
|
||||
|
||||
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 registerInventoryRoutes(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: 'STAFF', status: 'ACTIVE',
|
||||
roleVersion: 1, nickname: '', avatarUrl: '', phone: ''
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
accessControl: {
|
||||
async getAccessProfile() { return currentAccess; }
|
||||
},
|
||||
jwtSecret: secret
|
||||
});
|
||||
|
||||
const unauthorized = await app.inject({
|
||||
method: 'GET', url: '/admin-api/inventory/stocks?storeId=11'
|
||||
});
|
||||
assert.equal(unauthorized.statusCode, 401);
|
||||
assert.equal(unauthorized.json().code, 'AUTH_SESSION_INVALID');
|
||||
|
||||
const forbidden = await app.inject({
|
||||
method: 'GET', url: '/admin-api/inventory/stocks?storeId=11', headers
|
||||
});
|
||||
assert.equal(forbidden.statusCode, 403);
|
||||
assert.equal(forbidden.json().code, 'INVENTORY_OPERATION_FORBIDDEN');
|
||||
|
||||
currentAccess = {
|
||||
roles: ['STAFF'], capabilities: ['inventory.read'], storeIds: ['11']
|
||||
};
|
||||
const listed = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin-api/inventory/stocks?storeId=11&page=2&pageSize=25&search=tea',
|
||||
headers
|
||||
});
|
||||
assert.equal(listed.statusCode, 200);
|
||||
assert.deepEqual(listed.json().data, { items: [], total: 0, page: 2, pageSize: 25 });
|
||||
const listCall = calls.find((call) => call.method === 'listStocks');
|
||||
assert.equal(listCall.args[0].tenantId, '7');
|
||||
assert.equal(listCall.args[0].traceId, 'm09d1-inventory-route');
|
||||
assert.deepEqual(listCall.args[1], {
|
||||
storeId: '11', page: 2, pageSize: 25, search: 'tea'
|
||||
});
|
||||
|
||||
const ledger = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin-api/inventory/stocks/101/ledger?storeId=11&page=1&pageSize=10',
|
||||
headers
|
||||
});
|
||||
assert.equal(ledger.statusCode, 200);
|
||||
const ledgerCall = calls.find((call) => call.method === 'listLedger');
|
||||
assert.equal(ledgerCall.args[1].inventoryId, '101');
|
||||
|
||||
const readOnlyWrite = await app.inject({
|
||||
method: 'POST', url: '/admin-api/inventory/stocks/5/inbound', headers,
|
||||
payload: { storeId: '11', requestId: 'inbound-1', reason: 'receive', quantity: 2 }
|
||||
});
|
||||
assert.equal(readOnlyWrite.statusCode, 403);
|
||||
|
||||
currentAccess = {
|
||||
roles: ['STORE_ADMIN'], capabilities: ['inventory.adjust'], storeIds: ['11']
|
||||
};
|
||||
const invalid = await app.inject({
|
||||
method: 'POST', url: '/admin-api/inventory/stocks/5/adjust', headers,
|
||||
payload: {
|
||||
storeId: '11', requestId: 'adjust-zero', reason: 'nothing',
|
||||
availableDelta: 0, lossDelta: 0
|
||||
}
|
||||
});
|
||||
assert.equal(invalid.statusCode, 400);
|
||||
assert.equal(invalid.json().code, 'INVALID_INVENTORY_REQUEST');
|
||||
|
||||
const requests = [
|
||||
['PUT', '/admin-api/inventory/stocks/5/policy', {
|
||||
storeId: '11', requestId: 'policy-1', reason: 'configure',
|
||||
expectedVersion: 0, policyType: 'TRACKED', lowStockThreshold: 2
|
||||
}, 'configure'],
|
||||
['POST', '/admin-api/inventory/stocks/5/inbound', {
|
||||
storeId: '11', requestId: 'inbound-1', reason: 'receive', quantity: 2
|
||||
}, 'inbound'],
|
||||
['POST', '/admin-api/inventory/stocks/5/adjust', {
|
||||
storeId: '11', requestId: 'adjust-1', reason: 'manual correction',
|
||||
availableDelta: -1, lossDelta: 1
|
||||
}, 'adjust'],
|
||||
['POST', '/admin-api/inventory/stocks/5/stocktake', {
|
||||
storeId: '11', requestId: 'stocktake-1', reason: 'count',
|
||||
expectedVersion: 1, availableQuantity: 8, lossQuantity: 1
|
||||
}, 'stocktake'],
|
||||
['POST', '/admin-api/inventory/stocks/5/loss', {
|
||||
storeId: '11', requestId: 'loss-1', reason: 'damaged', quantity: 1
|
||||
}, 'loss']
|
||||
];
|
||||
for (const [method, url, payload, calledMethod] of requests) {
|
||||
const response = await app.inject({ method, url, headers, payload });
|
||||
assert.equal(response.statusCode, 200, `${method} ${url}`);
|
||||
assert.equal(response.json().code, 0);
|
||||
const call = calls.findLast((item) => item.method === calledMethod);
|
||||
assert.equal(call.args[1].skuId, '5');
|
||||
assert.equal(call.args[1].storeId, '11');
|
||||
}
|
||||
|
||||
for (const [method, url, payload] of [
|
||||
['PUT', '/admin-api/inventory/stocks/5/policy', {
|
||||
storeId: '11', requestId: 'policy-no-version', reason: 'configure',
|
||||
policyType: 'TRACKED', lowStockThreshold: 2
|
||||
}],
|
||||
['POST', '/admin-api/inventory/stocks/5/stocktake', {
|
||||
storeId: '11', requestId: 'stocktake-no-version', reason: 'count',
|
||||
availableQuantity: 8, lossQuantity: 1
|
||||
}]
|
||||
]) {
|
||||
const response = await app.inject({ method, url, headers, payload });
|
||||
assert.equal(response.statusCode, 400, `${method} ${url} requires expectedVersion`);
|
||||
assert.equal(response.json().code, 'INVALID_INVENTORY_REQUEST');
|
||||
}
|
||||
|
||||
const insufficient = await app.inject({
|
||||
method: 'POST', url: '/admin-api/inventory/stocks/5/inbound', headers,
|
||||
payload: { storeId: '11', requestId: 'insufficient', reason: 'conflict', quantity: 2 }
|
||||
});
|
||||
assert.equal(insufficient.statusCode, 409);
|
||||
assert.equal(insufficient.json().code, 'INVENTORY_INSUFFICIENT_AVAILABLE');
|
||||
|
||||
const outOfScope = await app.inject({
|
||||
method: 'POST', url: '/admin-api/inventory/stocks/5/loss', headers,
|
||||
payload: { storeId: '12', requestId: 'loss-outside', reason: 'wrong store', quantity: 1 }
|
||||
});
|
||||
assert.equal(outOfScope.statusCode, 403);
|
||||
assert.equal(outOfScope.json().code, 'INVENTORY_STORE_SCOPE_FORBIDDEN');
|
||||
|
||||
const internalRoute = await app.inject({
|
||||
method: 'POST', url: '/admin-api/inventory/stocks/5/lock', headers,
|
||||
payload: { storeId: '11', requestId: 'lock-1', reason: 'must stay internal', quantity: 1 }
|
||||
});
|
||||
assert.equal(internalRoute.statusCode, 404);
|
||||
|
||||
await app.close();
|
||||
console.log('PASS: M09-D1 inventory routes enforce auth, inventory.adjust, validation, error mapping and HTTP boundaries.');
|
||||
Reference in New Issue
Block a user