feat(M09-D1): 完成商品目录与库存流水底座
This commit is contained in:
@@ -0,0 +1,681 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
ProductCatalogError,
|
||||
ProductCatalogRepository,
|
||||
isProductStoreOpenAt,
|
||||
validateBusinessHours
|
||||
} from '../dist/products/product-catalog-repository.js';
|
||||
|
||||
const tenantActor = {
|
||||
tenantId: '7',
|
||||
userId: '21',
|
||||
access: { roles: ['TENANT_ADMIN'], capabilities: ['tenant.manage'], storeIds: [] },
|
||||
traceId: 'product-catalog-test',
|
||||
ip: '127.0.0.1',
|
||||
userAgent: 'catalog-test'
|
||||
};
|
||||
const storeActor = {
|
||||
...tenantActor,
|
||||
userId: '22',
|
||||
access: {
|
||||
roles: ['STORE_ADMIN'],
|
||||
capabilities: ['product.catalog.read', 'product.catalog.write'],
|
||||
storeIds: ['11']
|
||||
}
|
||||
};
|
||||
|
||||
const mondayOvernight = [{
|
||||
weekday: 1,
|
||||
slotNo: 1,
|
||||
openMinute: 22 * 60,
|
||||
closeMinute: 2 * 60,
|
||||
crossesMidnight: true
|
||||
}];
|
||||
validateBusinessHours(mondayOvernight);
|
||||
const openSettings = {
|
||||
timezone: 'Asia/Shanghai',
|
||||
businessStatus: 'OPEN',
|
||||
salesStatus: 'OPEN',
|
||||
manualPausedAt: null,
|
||||
manualPausedUntil: null,
|
||||
hours: mondayOvernight
|
||||
};
|
||||
assert.equal(
|
||||
isProductStoreOpenAt(openSettings, new Date('2026-08-10T15:00:00.000Z')),
|
||||
true,
|
||||
'Monday 23:00 should be inside the Monday overnight window'
|
||||
);
|
||||
assert.equal(
|
||||
isProductStoreOpenAt(openSettings, new Date('2026-08-10T17:00:00.000Z')),
|
||||
true,
|
||||
'Tuesday 01:00 must be attributed to the previous-day overnight window'
|
||||
);
|
||||
assert.equal(
|
||||
isProductStoreOpenAt(openSettings, new Date('2026-08-10T19:00:00.000Z')),
|
||||
false
|
||||
);
|
||||
assert.equal(isProductStoreOpenAt({
|
||||
...openSettings,
|
||||
manualPausedAt: new Date('2026-08-10T14:00:00.000Z'),
|
||||
manualPausedUntil: null
|
||||
}, new Date('2026-08-10T15:00:00.000Z')), false, 'manual pause overrides hours');
|
||||
assert.throws(
|
||||
() => validateBusinessHours([
|
||||
{ weekday: 1, slotNo: 1, openMinute: 540, closeMinute: 720, crossesMidnight: false },
|
||||
{ weekday: 1, slotNo: 2, openMinute: 600, closeMinute: 780, crossesMidnight: false }
|
||||
]),
|
||||
(error) => error instanceof ProductCatalogError
|
||||
&& error.code === 'PRODUCT_BUSINESS_HOUR_OVERLAP'
|
||||
);
|
||||
|
||||
class ScriptedConnection {
|
||||
constructor(steps) {
|
||||
this.steps = [...steps];
|
||||
this.committed = false;
|
||||
this.rolledBack = false;
|
||||
this.released = false;
|
||||
this.calls = [];
|
||||
}
|
||||
|
||||
async beginTransaction() {}
|
||||
async commit() { this.committed = true; }
|
||||
async rollback() { this.rolledBack = true; }
|
||||
release() { this.released = true; }
|
||||
|
||||
async execute(sql, params = []) {
|
||||
this.calls.push({ sql, params });
|
||||
const step = this.steps.shift();
|
||||
assert.ok(step, `Unexpected SQL: ${sql}`);
|
||||
assert.match(sql, step.match);
|
||||
if (step.check) step.check(params, sql);
|
||||
if (step.error) throw step.error;
|
||||
return step.result;
|
||||
}
|
||||
}
|
||||
|
||||
function repositoryFor(connection) {
|
||||
return new ProductCatalogRepository({
|
||||
async getConnection() { return connection; },
|
||||
async execute(sql, params) { return connection.execute(sql, params); }
|
||||
});
|
||||
}
|
||||
|
||||
function repositoryForConnections(connections) {
|
||||
const pending = [...connections];
|
||||
return new ProductCatalogRepository({
|
||||
async getConnection() {
|
||||
const connection = pending.shift();
|
||||
assert.ok(connection, 'Unexpected transaction');
|
||||
return connection;
|
||||
},
|
||||
async execute() { throw new Error('This scenario must use transactional connections'); }
|
||||
});
|
||||
}
|
||||
|
||||
function categoryInput(parentId) {
|
||||
return {
|
||||
parentId,
|
||||
categoryCode: 'DRINKS',
|
||||
name: 'Cold drinks',
|
||||
description: '',
|
||||
imageUrl: '',
|
||||
status: 'ACTIVE',
|
||||
sortOrder: 1
|
||||
};
|
||||
}
|
||||
|
||||
const createConnection = new ScriptedConnection([
|
||||
{ match: /FROM qipai_stores/, result: [[{ id: '11' }], []] },
|
||||
{
|
||||
match: /INSERT INTO qipai_product_categories/,
|
||||
result: [{ insertId: 31, affectedRows: 1 }, []],
|
||||
check(params) {
|
||||
assert.equal(params[0], '7');
|
||||
assert.equal(params[1], '11');
|
||||
assert.equal(params[3], 'DRINKS');
|
||||
}
|
||||
},
|
||||
{
|
||||
match: /INSERT INTO qipai_audit_logs/,
|
||||
result: [{ affectedRows: 1 }, []],
|
||||
check(params) {
|
||||
assert.equal(params[2], 'PRODUCT_CATEGORY_CREATED');
|
||||
assert.equal(params[3], 'PRODUCT_CATEGORY');
|
||||
assert.equal(params[4], '31');
|
||||
assert.deepEqual(JSON.parse(params[8]), { storeId: '11', version: 1 });
|
||||
}
|
||||
}
|
||||
]);
|
||||
const created = await repositoryFor(createConnection).createCategory(storeActor, '11', {
|
||||
parentId: null,
|
||||
categoryCode: 'DRINKS',
|
||||
name: 'Drinks',
|
||||
description: '',
|
||||
imageUrl: '',
|
||||
status: 'ACTIVE',
|
||||
sortOrder: 1
|
||||
});
|
||||
assert.deepEqual(created, { categoryId: '31', version: 1 });
|
||||
assert.equal(createConnection.committed, true);
|
||||
assert.equal(createConnection.rolledBack, false);
|
||||
assert.equal(createConnection.released, true);
|
||||
|
||||
const casConnection = new ScriptedConnection([
|
||||
{ match: /FROM qipai_stores/, result: [[{ id: '11' }], []] },
|
||||
{ match: /UPDATE qipai_product_categories/, result: [{ affectedRows: 0 }, []] },
|
||||
{ match: /SELECT version FROM qipai_product_categories/, result: [[{ version: 3 }], []] }
|
||||
]);
|
||||
await assert.rejects(
|
||||
() => repositoryFor(casConnection).updateCategory(storeActor, '11', '31', 2, {
|
||||
parentId: null,
|
||||
categoryCode: 'DRINKS',
|
||||
name: 'Cold drinks',
|
||||
description: '',
|
||||
imageUrl: '',
|
||||
status: 'ACTIVE',
|
||||
sortOrder: 1
|
||||
}),
|
||||
(error) => error instanceof ProductCatalogError
|
||||
&& error.code === 'PRODUCT_CATEGORY_VERSION_CONFLICT'
|
||||
);
|
||||
assert.equal(casConnection.committed, false);
|
||||
assert.equal(casConnection.rolledBack, true);
|
||||
assert.equal(casConnection.released, true);
|
||||
|
||||
for (const scenario of [
|
||||
{
|
||||
name: 'two-node cycle',
|
||||
ancestors: [{ id: '32', parentId: '31' }]
|
||||
},
|
||||
{
|
||||
name: 'multi-node cycle',
|
||||
ancestors: [{ id: '32', parentId: '33' }, { id: '33', parentId: '31' }]
|
||||
}
|
||||
]) {
|
||||
const cycleConnection = new ScriptedConnection([
|
||||
{ match: /FROM qipai_stores/, result: [[{ id: '11' }], []] },
|
||||
...scenario.ancestors.map((ancestor, index) => ({
|
||||
match: /FROM qipai_product_categories[\s\S]*FOR UPDATE/,
|
||||
result: [[ancestor], []],
|
||||
check(params, sql) {
|
||||
assert.deepEqual(params, ['7', '11', index === 0 ? '32' : '33']);
|
||||
assert.match(sql, /deleted_at IS NULL/);
|
||||
}
|
||||
}))
|
||||
]);
|
||||
await assert.rejects(
|
||||
() => repositoryFor(cycleConnection).updateCategory(
|
||||
storeActor, '11', '31', 2, categoryInput('32')
|
||||
),
|
||||
(error) => error instanceof ProductCatalogError
|
||||
&& error.code === 'PRODUCT_CATEGORY_PARENT_CYCLE',
|
||||
scenario.name
|
||||
);
|
||||
assert.equal(cycleConnection.rolledBack, true, scenario.name);
|
||||
assert.equal(cycleConnection.steps.length, 0, scenario.name);
|
||||
}
|
||||
|
||||
const selfParentConnection = new ScriptedConnection([
|
||||
{ match: /FROM qipai_stores/, result: [[{ id: '11' }], []] }
|
||||
]);
|
||||
await assert.rejects(
|
||||
() => repositoryFor(selfParentConnection).updateCategory(
|
||||
storeActor, '11', '31', 2, categoryInput('31')
|
||||
),
|
||||
(error) => error instanceof ProductCatalogError
|
||||
&& error.code === 'PRODUCT_CATEGORY_PARENT_CYCLE'
|
||||
);
|
||||
assert.equal(selfParentConnection.rolledBack, true);
|
||||
|
||||
for (const invalidParent of ['cross-store parent', 'deleted parent']) {
|
||||
const invalidParentConnection = new ScriptedConnection([
|
||||
{ match: /FROM qipai_stores/, result: [[{ id: '11' }], []] },
|
||||
{
|
||||
match: /FROM qipai_product_categories[\s\S]*FOR UPDATE/,
|
||||
result: [[], []],
|
||||
check(params, sql) {
|
||||
assert.deepEqual(params, ['7', '11', '32']);
|
||||
assert.match(sql, /store_id = \?/);
|
||||
assert.match(sql, /deleted_at IS NULL/);
|
||||
}
|
||||
}
|
||||
]);
|
||||
await assert.rejects(
|
||||
() => repositoryFor(invalidParentConnection).updateCategory(
|
||||
storeActor, '11', '31', 2, categoryInput('32')
|
||||
),
|
||||
(error) => error instanceof ProductCatalogError
|
||||
&& error.code === 'PRODUCT_CATEGORY_PARENT_NOT_FOUND',
|
||||
invalidParent
|
||||
);
|
||||
assert.equal(invalidParentConnection.rolledBack, true, invalidParent);
|
||||
}
|
||||
|
||||
for (const [bucket, balances] of [
|
||||
['available', { availableQuantity: 1, lockedQuantity: 0, lossQuantity: 0 }],
|
||||
['locked', { availableQuantity: 0, lockedQuantity: 1, lossQuantity: 0 }],
|
||||
['loss', { availableQuantity: 0, lockedQuantity: 0, lossQuantity: 1 }]
|
||||
]) {
|
||||
const stockConnection = new ScriptedConnection([
|
||||
{
|
||||
match: /FROM qipai_products[\s\S]*FOR UPDATE/,
|
||||
result: [[{ id: '41', version: 4 }], []]
|
||||
},
|
||||
{
|
||||
match: /FROM qipai_product_skus[\s\S]*FOR UPDATE/,
|
||||
result: [[{ id: '51', productId: '41', version: 2 }], []],
|
||||
check(params, sql) {
|
||||
assert.deepEqual(params, ['7', '41', '51']);
|
||||
assert.match(sql, /product_id = \?/);
|
||||
assert.match(sql, /deleted_at IS NULL/);
|
||||
}
|
||||
},
|
||||
{
|
||||
match: /FROM qipai_product_inventory[\s\S]*FOR UPDATE/,
|
||||
result: [[{ id: '71', storeId: '11', skuId: '51', ...balances }], []],
|
||||
check(params, sql) {
|
||||
assert.deepEqual(params, ['7', '51']);
|
||||
assert.match(sql, /ORDER BY store_id, id[\s\S]*FOR UPDATE/);
|
||||
}
|
||||
}
|
||||
]);
|
||||
await assert.rejects(
|
||||
() => repositoryFor(stockConnection).archiveSku(tenantActor, '41', '51', 2),
|
||||
(error) => error instanceof ProductCatalogError
|
||||
&& error.code === 'PRODUCT_SKU_HAS_INVENTORY_STOCK',
|
||||
`${bucket} stock must block SKU archival`
|
||||
);
|
||||
assert.equal(stockConnection.rolledBack, true, bucket);
|
||||
assert.equal(stockConnection.steps.length, 0, bucket);
|
||||
}
|
||||
|
||||
const skuVersionConnection = new ScriptedConnection([
|
||||
{ match: /FROM qipai_products[\s\S]*FOR UPDATE/, result: [[{ id: '41', version: 4 }], []] },
|
||||
{
|
||||
match: /FROM qipai_product_skus[\s\S]*FOR UPDATE/,
|
||||
result: [[{ id: '51', productId: '41', version: 3 }], []]
|
||||
}
|
||||
]);
|
||||
await assert.rejects(
|
||||
() => repositoryFor(skuVersionConnection).archiveSku(tenantActor, '41', '51', 2),
|
||||
(error) => error instanceof ProductCatalogError
|
||||
&& error.code === 'PRODUCT_SKU_VERSION_CONFLICT'
|
||||
);
|
||||
assert.equal(skuVersionConnection.rolledBack, true);
|
||||
|
||||
const skuOwnershipConnection = new ScriptedConnection([
|
||||
{ match: /FROM qipai_products[\s\S]*FOR UPDATE/, result: [[{ id: '41', version: 4 }], []] },
|
||||
{
|
||||
match: /FROM qipai_product_skus[\s\S]*FOR UPDATE/,
|
||||
result: [[], []],
|
||||
check(params) { assert.deepEqual(params, ['7', '41', '51']); }
|
||||
}
|
||||
]);
|
||||
await assert.rejects(
|
||||
() => repositoryFor(skuOwnershipConnection).archiveSku(tenantActor, '41', '51', 2),
|
||||
(error) => error instanceof ProductCatalogError && error.code === 'PRODUCT_SKU_NOT_FOUND'
|
||||
);
|
||||
|
||||
const productStockConnection = new ScriptedConnection([
|
||||
{ match: /FROM qipai_products[\s\S]*FOR UPDATE/, result: [[{ id: '41', version: 2 }], []] },
|
||||
{
|
||||
match: /FROM qipai_product_skus[\s\S]*ORDER BY id[\s\S]*FOR UPDATE/,
|
||||
result: [[{ id: '51', productId: '41', version: 2 }], []]
|
||||
},
|
||||
{
|
||||
match: /INNER JOIN qipai_product_skus[\s\S]*FOR UPDATE/,
|
||||
result: [[{
|
||||
id: '71', storeId: '11', skuId: '51',
|
||||
availableQuantity: 0, lockedQuantity: 0, lossQuantity: 2
|
||||
}], []],
|
||||
check(params, sql) {
|
||||
assert.deepEqual(params, ['7', '41']);
|
||||
assert.match(sql, /ORDER BY s.id, i.store_id, i.id[\s\S]*FOR UPDATE/);
|
||||
}
|
||||
}
|
||||
]);
|
||||
await assert.rejects(
|
||||
() => repositoryFor(productStockConnection).archiveProduct(tenantActor, '41', 2),
|
||||
(error) => error instanceof ProductCatalogError
|
||||
&& error.code === 'PRODUCT_HAS_INVENTORY_STOCK'
|
||||
);
|
||||
assert.equal(productStockConnection.rolledBack, true);
|
||||
|
||||
const activeSkuConnection = new ScriptedConnection([
|
||||
{ match: /FROM qipai_products[\s\S]*FOR UPDATE/, result: [[{ id: '41', version: 2 }], []] },
|
||||
{
|
||||
match: /deleted_at AS deletedAt[\s\S]*FROM qipai_product_skus[\s\S]*FOR UPDATE/,
|
||||
result: [[{ id: '51', productId: '41', version: 2, deletedAt: null }], []]
|
||||
},
|
||||
{
|
||||
match: /INNER JOIN qipai_product_skus[\s\S]*FOR UPDATE/,
|
||||
result: [[{
|
||||
id: '71', storeId: '11', skuId: '51',
|
||||
availableQuantity: 0, lockedQuantity: 0, lossQuantity: 0
|
||||
}], []]
|
||||
}
|
||||
]);
|
||||
await assert.rejects(
|
||||
() => repositoryFor(activeSkuConnection).archiveProduct(tenantActor, '41', 2),
|
||||
(error) => error instanceof ProductCatalogError && error.code === 'PRODUCT_HAS_ACTIVE_SKUS'
|
||||
);
|
||||
assert.equal(activeSkuConnection.rolledBack, true);
|
||||
|
||||
const skuArchiveConnection = new ScriptedConnection([
|
||||
{ match: /FROM qipai_products[\s\S]*FOR UPDATE/, result: [[{ id: '41', version: 4 }], []] },
|
||||
{
|
||||
match: /FROM qipai_product_skus[\s\S]*FOR UPDATE/,
|
||||
result: [[{ id: '51', productId: '41', version: 2 }], []]
|
||||
},
|
||||
{
|
||||
match: /FROM qipai_product_inventory[\s\S]*FOR UPDATE/,
|
||||
result: [[{
|
||||
id: '71', storeId: '11', skuId: '51',
|
||||
availableQuantity: 0, lockedQuantity: 0, lossQuantity: 0
|
||||
}], []]
|
||||
},
|
||||
{ match: /UPDATE qipai_product_skus/, result: [{ affectedRows: 1 }, []] },
|
||||
{
|
||||
match: /INSERT INTO qipai_audit_logs/,
|
||||
result: [{ affectedRows: 1 }, []],
|
||||
check(params) {
|
||||
assert.equal(params[2], 'PRODUCT_SKU_ARCHIVED');
|
||||
assert.equal(params[4], '51');
|
||||
}
|
||||
}
|
||||
]);
|
||||
const productArchiveAfterSkuConnection = new ScriptedConnection([
|
||||
{ match: /FROM qipai_products[\s\S]*FOR UPDATE/, result: [[{ id: '41', version: 4 }], []] },
|
||||
{
|
||||
match: /FROM qipai_product_skus[\s\S]*ORDER BY id[\s\S]*FOR UPDATE/,
|
||||
result: [[{
|
||||
id: '51', productId: '41', version: 3, deletedAt: new Date('2026-08-11T00:00:00Z')
|
||||
}], []]
|
||||
},
|
||||
{
|
||||
match: /INNER JOIN qipai_product_skus[\s\S]*FOR UPDATE/,
|
||||
result: [[{
|
||||
id: '71', storeId: '11', skuId: '51',
|
||||
availableQuantity: 0, lockedQuantity: 0, lossQuantity: 0
|
||||
}], []]
|
||||
},
|
||||
{ match: /FROM qipai_product_store_listings/, result: [[{ total: 0 }], []] },
|
||||
{
|
||||
match: /UPDATE qipai_products/,
|
||||
result: [{ affectedRows: 1 }, []],
|
||||
check(params, sql) {
|
||||
assert.deepEqual(params, ['21', '7', '41', 4]);
|
||||
assert.match(sql, /deleted_at = UTC_TIMESTAMP/);
|
||||
}
|
||||
},
|
||||
{
|
||||
match: /INSERT INTO qipai_audit_logs/,
|
||||
result: [{ affectedRows: 1 }, []],
|
||||
check(params) { assert.equal(params[2], 'PRODUCT_ARCHIVED'); }
|
||||
}
|
||||
]);
|
||||
const reusedProductConnection = new ScriptedConnection([
|
||||
{
|
||||
match: /INSERT INTO qipai_products/,
|
||||
result: [{ insertId: 42, affectedRows: 1 }, []],
|
||||
check(params) { assert.equal(params[1], 'TEA'); }
|
||||
},
|
||||
{ match: /INSERT INTO qipai_audit_logs/, result: [{ affectedRows: 1 }, []] }
|
||||
]);
|
||||
const reusedSkuConnection = new ScriptedConnection([
|
||||
{ match: /FROM qipai_products[\s\S]*FOR UPDATE/, result: [[{ id: '42', version: 1 }], []] },
|
||||
{
|
||||
match: /INSERT INTO qipai_product_skus/,
|
||||
result: [{ insertId: 52, affectedRows: 1 }, []],
|
||||
check(params) {
|
||||
assert.equal(params[2], 'TEA-500');
|
||||
assert.equal(params[5], '690000000001');
|
||||
}
|
||||
},
|
||||
{ match: /INSERT INTO qipai_audit_logs/, result: [{ affectedRows: 1 }, []] }
|
||||
]);
|
||||
const archiveAndReuseRepository = repositoryForConnections([
|
||||
skuArchiveConnection,
|
||||
productArchiveAfterSkuConnection,
|
||||
reusedProductConnection,
|
||||
reusedSkuConnection
|
||||
]);
|
||||
assert.deepEqual(
|
||||
await archiveAndReuseRepository.archiveSku(tenantActor, '41', '51', 2),
|
||||
{ skuId: '51', productId: '41', version: 3, archived: true }
|
||||
);
|
||||
assert.deepEqual(
|
||||
await archiveAndReuseRepository.archiveProduct(tenantActor, '41', 4),
|
||||
{ productId: '41', version: 5, archived: true }
|
||||
);
|
||||
assert.deepEqual(
|
||||
await archiveAndReuseRepository.createProduct(tenantActor, {
|
||||
productCode: 'TEA',
|
||||
name: 'Tea replacement',
|
||||
unitName: 'bottle',
|
||||
description: '',
|
||||
coverUrl: '',
|
||||
images: [],
|
||||
deliveryEnabled: true,
|
||||
storageEnabled: false,
|
||||
status: 'DRAFT',
|
||||
sortOrder: 1
|
||||
}),
|
||||
{ productId: '42', version: 1 }
|
||||
);
|
||||
assert.deepEqual(
|
||||
await archiveAndReuseRepository.createSku(tenantActor, '42', {
|
||||
skuCode: 'TEA-500',
|
||||
name: 'Tea 500ml replacement',
|
||||
attributes: { size: '500ml' },
|
||||
barcode: '690000000001',
|
||||
imageUrl: '',
|
||||
salePriceCents: 500,
|
||||
marketPriceCents: 600,
|
||||
costPriceCents: 200,
|
||||
defaultInventoryPolicy: 'TRACKED',
|
||||
status: 'ACTIVE'
|
||||
}),
|
||||
{ skuId: '52', productId: '42', version: 1 }
|
||||
);
|
||||
assert.equal(skuArchiveConnection.committed, true);
|
||||
assert.equal(productArchiveAfterSkuConnection.committed, true);
|
||||
assert.equal(reusedProductConnection.committed, true);
|
||||
assert.equal(reusedSkuConnection.committed, true);
|
||||
|
||||
const settingsConnection = new ScriptedConnection([
|
||||
{ match: /FROM qipai_stores/, result: [[{ id: '11' }], []] },
|
||||
{ match: /FROM qipai_product_store_settings/, result: [[], []] },
|
||||
{
|
||||
match: /INSERT INTO qipai_product_store_settings/,
|
||||
result: [{ insertId: 41, affectedRows: 1 }, []]
|
||||
},
|
||||
{ match: /UPDATE qipai_product_store_hours/, result: [{ affectedRows: 0 }, []] },
|
||||
{
|
||||
match: /INSERT INTO qipai_product_store_hours/,
|
||||
result: [{ insertId: 51, affectedRows: 1 }, []],
|
||||
check(params) {
|
||||
assert.deepEqual(params.slice(3), [1, 1, 1320, 120, true]);
|
||||
}
|
||||
},
|
||||
{
|
||||
match: /INSERT INTO qipai_audit_logs/,
|
||||
result: [{ affectedRows: 1 }, []],
|
||||
check(params) {
|
||||
assert.equal(params[2], 'PRODUCT_STORE_SETTINGS_UPDATED');
|
||||
}
|
||||
}
|
||||
]);
|
||||
const pausedUntil = new Date('2026-08-12T00:00:00.000Z');
|
||||
const settingsResult = await repositoryFor(settingsConnection).putStoreSettings(
|
||||
storeActor,
|
||||
'11',
|
||||
0,
|
||||
{
|
||||
salesStatus: 'OPEN',
|
||||
manualPaused: true,
|
||||
manualPausedUntil: pausedUntil,
|
||||
manualPauseReason: 'inventory count',
|
||||
hours: mondayOvernight
|
||||
},
|
||||
new Date('2026-08-11T00:00:00.000Z')
|
||||
);
|
||||
assert.deepEqual(settingsResult, { settingsId: '41', storeId: '11', version: 1 });
|
||||
assert.equal(settingsConnection.committed, true);
|
||||
|
||||
const firstListingArchive = new ScriptedConnection([
|
||||
{ match: /FROM qipai_stores/, result: [[{ id: '11' }], []] },
|
||||
{
|
||||
match: /SELECT id, version FROM qipai_product_store_listings[\s\S]*deleted_at IS NULL[\s\S]*FOR UPDATE/,
|
||||
result: [[{ id: '61', version: 1 }], []]
|
||||
},
|
||||
{
|
||||
match: /UPDATE qipai_product_store_listings/,
|
||||
result: [{ affectedRows: 1 }, []],
|
||||
check(params) { assert.deepEqual(params, ['22', '7', '11', '41', '61', 1]); }
|
||||
},
|
||||
{
|
||||
match: /INSERT INTO qipai_audit_logs/,
|
||||
result: [{ affectedRows: 1 }, []],
|
||||
check(params) { assert.equal(params[4], '61'); }
|
||||
}
|
||||
]);
|
||||
const rebuiltListing = new ScriptedConnection([
|
||||
{ match: /FROM qipai_stores/, result: [[{ id: '11' }], []] },
|
||||
{
|
||||
match: /FROM qipai_products[\s\S]*FOR UPDATE/,
|
||||
result: [[{
|
||||
id: '41', status: 'ACTIVE', deliveryEnabled: true, storageEnabled: true
|
||||
}], []]
|
||||
},
|
||||
{ match: /FROM qipai_product_categories/, result: [[{ id: '31' }], []] },
|
||||
{ match: /INSERT INTO qipai_product_store_listings/, result: [{ insertId: 62, affectedRows: 1 }, []] },
|
||||
{
|
||||
match: /INSERT INTO qipai_audit_logs/,
|
||||
result: [{ affectedRows: 1 }, []],
|
||||
check(params) { assert.equal(params[4], '62'); }
|
||||
}
|
||||
]);
|
||||
const secondListingArchive = new ScriptedConnection([
|
||||
{ match: /FROM qipai_stores/, result: [[{ id: '11' }], []] },
|
||||
{
|
||||
match: /SELECT id, version FROM qipai_product_store_listings[\s\S]*deleted_at IS NULL[\s\S]*FOR UPDATE/,
|
||||
result: [[{ id: '62', version: 1 }], []]
|
||||
},
|
||||
{
|
||||
match: /UPDATE qipai_product_store_listings/,
|
||||
result: [{ affectedRows: 1 }, []],
|
||||
check(params) { assert.deepEqual(params, ['22', '7', '11', '41', '62', 1]); }
|
||||
},
|
||||
{
|
||||
match: /INSERT INTO qipai_audit_logs/,
|
||||
result: [{ affectedRows: 1 }, []],
|
||||
check(params) { assert.equal(params[4], '62'); }
|
||||
}
|
||||
]);
|
||||
const listingLifecycleRepository = repositoryForConnections([
|
||||
firstListingArchive, rebuiltListing, secondListingArchive
|
||||
]);
|
||||
assert.deepEqual(
|
||||
await listingLifecycleRepository.archiveListing(storeActor, '11', '41', 1),
|
||||
{ listingId: '61', storeId: '11', productId: '41', version: 2, archived: true }
|
||||
);
|
||||
assert.deepEqual(
|
||||
await listingLifecycleRepository.putListing(storeActor, '11', '41', 0, {
|
||||
categoryId: '31',
|
||||
status: 'INACTIVE',
|
||||
fulfillmentMode: 'SELF_SERVICE',
|
||||
sortOrder: 1
|
||||
}),
|
||||
{ listingId: '62', storeId: '11', productId: '41', version: 1 }
|
||||
);
|
||||
assert.deepEqual(
|
||||
await listingLifecycleRepository.archiveListing(storeActor, '11', '41', 1),
|
||||
{ listingId: '62', storeId: '11', productId: '41', version: 2, archived: true }
|
||||
);
|
||||
assert.equal(firstListingArchive.committed, true);
|
||||
assert.equal(rebuiltListing.committed, true);
|
||||
assert.equal(secondListingArchive.committed, true);
|
||||
|
||||
const skuReadRow = {
|
||||
id: '51',
|
||||
productId: '41',
|
||||
skuCode: 'TEA-500',
|
||||
name: 'Tea 500ml',
|
||||
attributesJson: '{"size":"500ml"}',
|
||||
barcode: '690000000001',
|
||||
imageUrl: '',
|
||||
salePriceCents: 500,
|
||||
marketPriceCents: 600,
|
||||
costPriceCents: 200,
|
||||
defaultInventoryPolicy: 'TRACKED',
|
||||
status: 'ACTIVE',
|
||||
version: 2,
|
||||
createdAt: new Date('2026-08-11T00:00:00Z'),
|
||||
updatedAt: new Date('2026-08-11T00:00:00Z')
|
||||
};
|
||||
function skuReadConnection() {
|
||||
return new ScriptedConnection([
|
||||
{ match: /SELECT id FROM qipai_products/, result: [[{ id: '41' }], []] },
|
||||
{ match: /FROM qipai_product_skus/, result: [[skuReadRow], []] }
|
||||
]);
|
||||
}
|
||||
const storeAdminSkus = await repositoryFor(skuReadConnection()).listSkus(storeActor, '41');
|
||||
assert.equal(storeAdminSkus.length, 1);
|
||||
assert.equal(Object.hasOwn(storeAdminSkus[0], 'costPriceCents'), false,
|
||||
'store administrators must not receive product cost');
|
||||
const tenantManagerSkus = await repositoryFor(skuReadConnection()).listSkus(tenantActor, '41');
|
||||
assert.equal(tenantManagerSkus[0].costPriceCents, 200,
|
||||
'tenant managers retain product cost visibility');
|
||||
|
||||
const neverUsedPool = {
|
||||
async execute() { throw new Error('scope checks must happen before SQL'); },
|
||||
async getConnection() { throw new Error('scope checks must happen before SQL'); }
|
||||
};
|
||||
const guardedRepository = new ProductCatalogRepository(neverUsedPool);
|
||||
await assert.rejects(
|
||||
() => guardedRepository.listProducts({
|
||||
...storeActor,
|
||||
access: {
|
||||
roles: ['STAFF'], capabilities: ['product.catalog.read'], storeIds: ['11']
|
||||
}
|
||||
}),
|
||||
(error) => error instanceof ProductCatalogError
|
||||
&& error.code === 'PRODUCT_GLOBAL_CATALOG_READ_FORBIDDEN'
|
||||
);
|
||||
await assert.rejects(
|
||||
() => guardedRepository.listSkus({
|
||||
...storeActor,
|
||||
access: {
|
||||
roles: ['STORE_ADMIN'], capabilities: ['product.catalog.read'], storeIds: []
|
||||
}
|
||||
}, '41'),
|
||||
(error) => error instanceof ProductCatalogError
|
||||
&& error.code === 'PRODUCT_GLOBAL_CATALOG_READ_FORBIDDEN'
|
||||
);
|
||||
await assert.rejects(
|
||||
() => guardedRepository.listCategories({
|
||||
...storeActor,
|
||||
access: { ...storeActor.access, storeIds: ['12'] }
|
||||
}, '11'),
|
||||
(error) => error instanceof ProductCatalogError
|
||||
&& error.code === 'PRODUCT_STORE_SCOPE_FORBIDDEN'
|
||||
);
|
||||
await assert.rejects(
|
||||
() => guardedRepository.createProduct(storeActor, {
|
||||
productCode: 'TEA',
|
||||
name: 'Tea',
|
||||
unitName: 'bottle',
|
||||
description: '',
|
||||
coverUrl: '',
|
||||
images: [],
|
||||
deliveryEnabled: true,
|
||||
storageEnabled: false,
|
||||
status: 'DRAFT',
|
||||
sortOrder: 1
|
||||
}),
|
||||
(error) => error instanceof ProductCatalogError
|
||||
&& error.code === 'PRODUCT_TENANT_WRITE_FORBIDDEN'
|
||||
);
|
||||
|
||||
console.log('PASS: M09-D1 product catalog covers global-read scope, cost redaction, category cycles, archive locks, listing lifecycle, hours, CAS and audit.');
|
||||
Reference in New Issue
Block a user