Files
qipai/backend/tests/inventory-route.test.mjs
T

209 lines
7.6 KiB
JavaScript

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.');