301 lines
11 KiB
JavaScript
301 lines
11 KiB
JavaScript
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 = {
|
|
listStoreCatalog: record('listStoreCatalog', {
|
|
storeId: '11', salesOpen: true, categories: []
|
|
}),
|
|
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');
|
|
|
|
assert.equal((await app.inject({
|
|
method: 'GET', url: '/app-api/stores/11/product-catalog'
|
|
})).statusCode, 401);
|
|
const customerCatalog = await app.inject({
|
|
method: 'GET', url: '/app-api/stores/11/product-catalog',
|
|
headers: { authorization: `Bearer ${token}` }
|
|
});
|
|
assert.equal(customerCatalog.statusCode, 200);
|
|
assert.deepEqual(calls.find((call) => call.method === 'listStoreCatalog').args[0], {
|
|
tenantId: '7', storeId: '11'
|
|
});
|
|
|
|
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.');
|