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