feat(M09-D1): 完成商品目录与库存流水底座
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
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.');
|
||||
Reference in New Issue
Block a user