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: ['platform.manage'], storeIds: [] }; assert.equal((await app.inject({ method: 'GET', url: '/admin-api/inventory/stocks?storeId=11', headers })).statusCode, 200, 'platform.manage must authorize inventory reads'); assert.equal((await app.inject({ method: 'POST', url: '/admin-api/inventory/stocks/5/inbound', headers, payload: { storeId: '11', requestId: 'platform-inbound', reason: 'platform check', quantity: 1 } })).statusCode, 200, 'platform.manage must authorize inventory writes'); 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.findLast((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.');