import assert from 'node:assert/strict'; import Fastify from 'fastify'; import { signAccessToken } from '../dist/auth/jwt.js'; import { ProductCatalogError } from '../dist/products/product-catalog-repository.js'; import { registerProductRoutes } from '../dist/routes/products.js'; const secret = 'test-only-product-route-jwt-secret-32'; const token = signAccessToken({ sub: '21', sid: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e', tid: '7', aid: '9', rv: 1 }, secret, 900); const calls = []; let currentAccess = { roles: ['TENANT_ADMIN'], capabilities: ['tenant.manage', 'product.catalog.read', 'product.catalog.write'], storeIds: [] }; const record = (method, result) => async (...args) => { calls.push({ method, args }); return typeof result === 'function' ? result(...args) : result; }; const repository = { listCategories: record('listCategories', []), createCategory: record('createCategory', { categoryId: '31', version: 1 }), updateCategory: record('updateCategory', { categoryId: '31', version: 2 }), archiveCategory: record('archiveCategory', { categoryId: '31', version: 3, archived: true }), listProducts: record('listProducts', []), createProduct: record('createProduct', { productId: '41', version: 1 }), updateProduct: record('updateProduct', { productId: '41', version: 2 }), archiveProduct: record('archiveProduct', { productId: '41', version: 3, archived: true }), listSkus: record('listSkus', []), createSku: record('createSku', { skuId: '51', productId: '41', version: 1 }), updateSku: record('updateSku', { skuId: '51', productId: '41', version: 2 }), archiveSku: record('archiveSku', { skuId: '51', productId: '41', version: 3, archived: true }), listListings: record('listListings', []), putListing: record('putListing', { listingId: '61', storeId: '11', productId: '41', version: 1 }), archiveListing: record('archiveListing', { listingId: '61', storeId: '11', productId: '41', version: 2, archived: true }), getStoreSettings: record('getStoreSettings', { storeId: '11', version: 0, hours: [] }), putStoreSettings: record('putStoreSettings', { settingsId: '71', storeId: '11', version: 1 }) }; 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 registerProductRoutes(app, { repository, jwtSecret: secret, authRepository: { async validateSession() { return { id: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e', tenantId: '7', platformAppId: '9', expiresAt: new Date(Date.now() + 60000), user: { id: '21', tenantId: '7', userType: 'STAFF', status: 'ACTIVE', roleVersion: 1, nickname: '', avatarUrl: '', phone: '' } }; } }, accessControl: { async getAccessProfile() { return currentAccess; } } }); const unauthorized = await app.inject({ method: 'GET', url: '/admin-api/products' }); assert.equal(unauthorized.statusCode, 401); assert.equal(unauthorized.json().code, 'AUTH_SESSION_INVALID'); currentAccess = { roles: ['STAFF'], capabilities: [], storeIds: ['11'] }; const forbidden = await app.inject({ method: 'GET', url: '/admin-api/products', headers: { authorization: `Bearer ${token}` } }); assert.equal(forbidden.statusCode, 403); assert.equal(forbidden.json().code, 'PRODUCT_READ_FORBIDDEN'); currentAccess = { roles: ['STAFF'], capabilities: ['product.catalog.read'], storeIds: ['11'] }; const globalCallsBeforeStaff = calls.length; const staffGlobalForbidden = await app.inject({ method: 'GET', url: '/admin-api/products', headers: { authorization: `Bearer ${token}` } }); assert.equal(staffGlobalForbidden.statusCode, 403); assert.equal(staffGlobalForbidden.json().code, 'PRODUCT_GLOBAL_CATALOG_READ_FORBIDDEN'); assert.equal(calls.length, globalCallsBeforeStaff, 'forbidden global reads must not reach repository'); assert.equal((await app.inject({ method: 'GET', url: '/admin-api/stores/11/product-listings?includeInactive=true', headers: { authorization: `Bearer ${token}` } })).statusCode, 200, 'store-scoped listing reads remain available to scoped staff'); currentAccess = { roles: ['STORE_ADMIN'], capabilities: ['product.catalog.read'], storeIds: [] }; const emptyStoreAdminForbidden = await app.inject({ method: 'GET', url: '/admin-api/products/41/skus', headers: { authorization: `Bearer ${token}` } }); assert.equal(emptyStoreAdminForbidden.statusCode, 403); assert.equal(emptyStoreAdminForbidden.json().code, 'PRODUCT_GLOBAL_CATALOG_READ_FORBIDDEN'); currentAccess = { roles: ['STORE_ADMIN'], capabilities: ['product.catalog.read'], storeIds: ['11'] }; assert.equal((await app.inject({ method: 'GET', url: '/admin-api/products', headers: { authorization: `Bearer ${token}` } })).statusCode, 200); assert.equal((await app.inject({ method: 'GET', url: '/admin-api/products/41/skus', headers: { authorization: `Bearer ${token}` } })).statusCode, 200); currentAccess = { roles: ['TENANT_ADMIN'], capabilities: ['tenant.manage', 'product.catalog.read', 'product.catalog.write'], storeIds: [] }; const authHeaders = { authorization: `Bearer ${token}`, 'x-trace-id': 'm09d1-product-route' }; const categoryPayload = { parentId: null, categoryCode: 'DRINKS', name: 'Drinks', description: '', imageUrl: '', status: 'ACTIVE', sortOrder: 1 }; assert.equal((await app.inject({ method: 'GET', url: '/admin-api/stores/11/product-categories?includeInactive=false', headers: authHeaders })).statusCode, 200); assert.equal((await app.inject({ method: 'POST', url: '/admin-api/stores/11/product-categories', headers: authHeaders, payload: categoryPayload })).statusCode, 201); assert.equal((await app.inject({ method: 'PUT', url: '/admin-api/stores/11/product-categories/31', headers: authHeaders, payload: { ...categoryPayload, expectedVersion: 1 } })).statusCode, 200); assert.equal((await app.inject({ method: 'DELETE', url: '/admin-api/stores/11/product-categories/31?expectedVersion=2', headers: authHeaders })).statusCode, 200); const productPayload = { productCode: 'TEA', name: 'Tea', unitName: 'bottle', description: '', coverUrl: '', images: [], deliveryEnabled: true, storageEnabled: true, status: 'ACTIVE', sortOrder: 1 }; assert.equal((await app.inject({ method: 'GET', url: '/admin-api/products?status=ACTIVE&search=Tea', headers: authHeaders })).statusCode, 200); assert.equal((await app.inject({ method: 'POST', url: '/admin-api/products', headers: authHeaders, payload: productPayload })).statusCode, 201); assert.equal((await app.inject({ method: 'PUT', url: '/admin-api/products/41', headers: authHeaders, payload: { ...productPayload, expectedVersion: 1 } })).statusCode, 200); assert.equal((await app.inject({ method: 'DELETE', url: '/admin-api/products/41?expectedVersion=2', headers: authHeaders })).statusCode, 200); const skuPayload = { skuCode: 'TEA-500', name: 'Tea 500ml', attributes: { size: '500ml' }, barcode: '690000000001', imageUrl: '', salePriceCents: 500, marketPriceCents: 600, costPriceCents: 200, defaultInventoryPolicy: 'TRACKED', status: 'ACTIVE' }; assert.equal((await app.inject({ method: 'GET', url: '/admin-api/products/41/skus?includeInactive=true', headers: authHeaders })).statusCode, 200); assert.equal((await app.inject({ method: 'POST', url: '/admin-api/products/41/skus', headers: authHeaders, payload: skuPayload })).statusCode, 201); assert.equal((await app.inject({ method: 'PUT', url: '/admin-api/products/41/skus/51', headers: authHeaders, payload: { ...skuPayload, expectedVersion: 1 } })).statusCode, 200); assert.equal((await app.inject({ method: 'DELETE', url: '/admin-api/products/41/skus/51?expectedVersion=2', headers: authHeaders })).statusCode, 200); assert.equal((await app.inject({ method: 'GET', url: '/admin-api/stores/11/product-listings?includeInactive=true', headers: authHeaders })).statusCode, 200); assert.equal((await app.inject({ method: 'PUT', url: '/admin-api/stores/11/product-listings/41', headers: authHeaders, payload: { expectedVersion: 0, categoryId: '31', status: 'ACTIVE', fulfillmentMode: 'BOTH', sortOrder: 1 } })).statusCode, 200); assert.equal((await app.inject({ method: 'DELETE', url: '/admin-api/stores/11/product-listings/41?expectedVersion=1', headers: authHeaders })).statusCode, 200); assert.equal((await app.inject({ method: 'GET', url: '/admin-api/stores/11/product-sales-settings', headers: authHeaders })).statusCode, 200); assert.equal((await app.inject({ method: 'PUT', url: '/admin-api/stores/11/product-sales-settings', headers: authHeaders, payload: { expectedVersion: 0, salesStatus: 'OPEN', manualPaused: true, manualPausedUntil: '2026-08-12T00:00:00.000Z', manualPauseReason: 'inventory count', hours: [{ weekday: 1, slotNo: 1, openMinute: 1320, closeMinute: 120, crossesMidnight: true }] } })).statusCode, 200); const createdSkuCall = calls.find((call) => call.method === 'createSku'); assert.equal(createdSkuCall.args[0].tenantId, '7'); assert.equal(createdSkuCall.args[0].traceId, 'm09d1-product-route'); assert.equal(createdSkuCall.args[2].defaultInventoryPolicy, 'TRACKED'); const settingsCall = calls.find((call) => call.method === 'putStoreSettings'); assert.equal(settingsCall.args[2], 0); assert.equal(settingsCall.args[3].hours[0].crossesMidnight, true); repository.updateProduct = async () => { throw new ProductCatalogError('PRODUCT_VERSION_CONFLICT'); }; const conflict = await app.inject({ method: 'PUT', url: '/admin-api/products/41', headers: authHeaders, payload: { ...productPayload, expectedVersion: 1 } }); assert.equal(conflict.statusCode, 409); assert.equal(conflict.json().code, 'PRODUCT_VERSION_CONFLICT'); const invalid = await app.inject({ method: 'PUT', url: '/admin-api/stores/11/product-sales-settings', headers: authHeaders, payload: { expectedVersion: 0, salesStatus: 'OPEN', manualPaused: true, manualPauseReason: '', hours: [] } }); assert.equal(invalid.statusCode, 400); assert.equal(invalid.json().code, 'INVALID_PRODUCT_REQUEST'); await app.close(); console.log('PASS: M09-D1 product routes cover global/store read boundaries, CRUD, CAS, auth and business settings.');