feat(M09-D1): 完成商品目录与库存流水底座

This commit is contained in:
Codex
2026-08-11 03:51:18 +08:00
parent 2de796143f
commit 93af128002
37 changed files with 14683 additions and 58 deletions
+285
View File
@@ -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.');