682 lines
22 KiB
JavaScript
682 lines
22 KiB
JavaScript
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.');
|