209 lines
7.6 KiB
JavaScript
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.');
|