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.');