feat(M09-D1): 完成商品目录与库存流水底座
This commit is contained in:
@@ -43,6 +43,7 @@ assert.equal(loginInput.tenantCode, 'demo');
|
||||
assert.equal(login.json().data.user.phone, undefined);
|
||||
assert.deepEqual(login.json().data.access.roles, ['TENANT_ADMIN']);
|
||||
assert.ok(login.json().data.access.menus.includes('system'));
|
||||
assert.ok(login.json().data.access.menus.includes('products'));
|
||||
assert.match(login.json().data.accessToken, /^[^.]+\.[^.]+\.[^.]+$/);
|
||||
assert.match(login.json().data.refreshToken, /^[0-9a-f-]{36}\.[A-Za-z0-9_-]{43}$/);
|
||||
const successfulSessionId = loginInput.sessionId;
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import Fastify from 'fastify';
|
||||
import { signAccessToken } from '../dist/auth/jwt.js';
|
||||
import { InventoryError } from '../dist/inventory/inventory-service.js';
|
||||
import { registerInventoryRoutes } from '../dist/routes/inventory.js';
|
||||
|
||||
const secret = 'test-only-inventory-route-jwt-secret';
|
||||
const sessionId = '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e';
|
||||
const token = signAccessToken({
|
||||
sub: '21', sid: sessionId, tid: '7', aid: '9', rv: 1
|
||||
}, secret, 900);
|
||||
const headers = {
|
||||
authorization: `Bearer ${token}`,
|
||||
'x-trace-id': 'm09d1-inventory-route'
|
||||
};
|
||||
|
||||
const calls = [];
|
||||
let currentAccess = { roles: ['STAFF'], capabilities: [], storeIds: ['11'] };
|
||||
const mutationResult = (input, operation) => ({
|
||||
inventoryId: '101',
|
||||
ledgerId: '1001',
|
||||
storeId: input.storeId,
|
||||
skuId: input.skuId,
|
||||
operation,
|
||||
policyType: input.policyType ?? 'TRACKED',
|
||||
availableQuantity: input.availableQuantity ?? 8,
|
||||
lockedQuantity: 0,
|
||||
lossQuantity: input.lossQuantity ?? 1,
|
||||
lowStockThreshold: input.lowStockThreshold ?? 2,
|
||||
version: 2,
|
||||
idempotent: false
|
||||
});
|
||||
const record = (method, result) => async (...args) => {
|
||||
calls.push({ method, args });
|
||||
return typeof result === 'function' ? result(...args) : result;
|
||||
};
|
||||
const mutate = (operation) => record(operation.toLowerCase(), (_actor, input) => {
|
||||
if (input.storeId === '12') throw new InventoryError('INVENTORY_STORE_SCOPE_FORBIDDEN');
|
||||
if (input.requestId === 'insufficient') {
|
||||
throw new InventoryError('INVENTORY_INSUFFICIENT_AVAILABLE');
|
||||
}
|
||||
return mutationResult(input, operation);
|
||||
});
|
||||
const service = {
|
||||
listStocks: record('listStocks', (_actor, input) => ({
|
||||
items: [], total: 0, page: input.page, pageSize: input.pageSize
|
||||
})),
|
||||
listLedger: record('listLedger', (_actor, input) => ({
|
||||
items: [], total: 0, page: input.page, pageSize: input.pageSize
|
||||
})),
|
||||
configurePolicy: mutate('CONFIGURE'),
|
||||
inbound: mutate('INBOUND'),
|
||||
adjust: mutate('ADJUST'),
|
||||
stocktake: mutate('STOCKTAKE'),
|
||||
recordLoss: mutate('LOSS')
|
||||
};
|
||||
|
||||
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 registerInventoryRoutes(app, {
|
||||
service,
|
||||
authRepository: {
|
||||
async validateSession() {
|
||||
return {
|
||||
id: sessionId,
|
||||
tenantId: '7',
|
||||
platformAppId: '9',
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
user: {
|
||||
id: '21', tenantId: '7', userType: 'STAFF', status: 'ACTIVE',
|
||||
roleVersion: 1, nickname: '', avatarUrl: '', phone: ''
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
accessControl: {
|
||||
async getAccessProfile() { return currentAccess; }
|
||||
},
|
||||
jwtSecret: secret
|
||||
});
|
||||
|
||||
const unauthorized = await app.inject({
|
||||
method: 'GET', url: '/admin-api/inventory/stocks?storeId=11'
|
||||
});
|
||||
assert.equal(unauthorized.statusCode, 401);
|
||||
assert.equal(unauthorized.json().code, 'AUTH_SESSION_INVALID');
|
||||
|
||||
const forbidden = await app.inject({
|
||||
method: 'GET', url: '/admin-api/inventory/stocks?storeId=11', headers
|
||||
});
|
||||
assert.equal(forbidden.statusCode, 403);
|
||||
assert.equal(forbidden.json().code, 'INVENTORY_OPERATION_FORBIDDEN');
|
||||
|
||||
currentAccess = {
|
||||
roles: ['STAFF'], capabilities: ['inventory.read'], storeIds: ['11']
|
||||
};
|
||||
const listed = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin-api/inventory/stocks?storeId=11&page=2&pageSize=25&search=tea',
|
||||
headers
|
||||
});
|
||||
assert.equal(listed.statusCode, 200);
|
||||
assert.deepEqual(listed.json().data, { items: [], total: 0, page: 2, pageSize: 25 });
|
||||
const listCall = calls.find((call) => call.method === 'listStocks');
|
||||
assert.equal(listCall.args[0].tenantId, '7');
|
||||
assert.equal(listCall.args[0].traceId, 'm09d1-inventory-route');
|
||||
assert.deepEqual(listCall.args[1], {
|
||||
storeId: '11', page: 2, pageSize: 25, search: 'tea'
|
||||
});
|
||||
|
||||
const ledger = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin-api/inventory/stocks/101/ledger?storeId=11&page=1&pageSize=10',
|
||||
headers
|
||||
});
|
||||
assert.equal(ledger.statusCode, 200);
|
||||
const ledgerCall = calls.find((call) => call.method === 'listLedger');
|
||||
assert.equal(ledgerCall.args[1].inventoryId, '101');
|
||||
|
||||
const readOnlyWrite = await app.inject({
|
||||
method: 'POST', url: '/admin-api/inventory/stocks/5/inbound', headers,
|
||||
payload: { storeId: '11', requestId: 'inbound-1', reason: 'receive', quantity: 2 }
|
||||
});
|
||||
assert.equal(readOnlyWrite.statusCode, 403);
|
||||
|
||||
currentAccess = {
|
||||
roles: ['STORE_ADMIN'], capabilities: ['inventory.adjust'], storeIds: ['11']
|
||||
};
|
||||
const invalid = await app.inject({
|
||||
method: 'POST', url: '/admin-api/inventory/stocks/5/adjust', headers,
|
||||
payload: {
|
||||
storeId: '11', requestId: 'adjust-zero', reason: 'nothing',
|
||||
availableDelta: 0, lossDelta: 0
|
||||
}
|
||||
});
|
||||
assert.equal(invalid.statusCode, 400);
|
||||
assert.equal(invalid.json().code, 'INVALID_INVENTORY_REQUEST');
|
||||
|
||||
const requests = [
|
||||
['PUT', '/admin-api/inventory/stocks/5/policy', {
|
||||
storeId: '11', requestId: 'policy-1', reason: 'configure',
|
||||
expectedVersion: 0, policyType: 'TRACKED', lowStockThreshold: 2
|
||||
}, 'configure'],
|
||||
['POST', '/admin-api/inventory/stocks/5/inbound', {
|
||||
storeId: '11', requestId: 'inbound-1', reason: 'receive', quantity: 2
|
||||
}, 'inbound'],
|
||||
['POST', '/admin-api/inventory/stocks/5/adjust', {
|
||||
storeId: '11', requestId: 'adjust-1', reason: 'manual correction',
|
||||
availableDelta: -1, lossDelta: 1
|
||||
}, 'adjust'],
|
||||
['POST', '/admin-api/inventory/stocks/5/stocktake', {
|
||||
storeId: '11', requestId: 'stocktake-1', reason: 'count',
|
||||
expectedVersion: 1, availableQuantity: 8, lossQuantity: 1
|
||||
}, 'stocktake'],
|
||||
['POST', '/admin-api/inventory/stocks/5/loss', {
|
||||
storeId: '11', requestId: 'loss-1', reason: 'damaged', quantity: 1
|
||||
}, 'loss']
|
||||
];
|
||||
for (const [method, url, payload, calledMethod] of requests) {
|
||||
const response = await app.inject({ method, url, headers, payload });
|
||||
assert.equal(response.statusCode, 200, `${method} ${url}`);
|
||||
assert.equal(response.json().code, 0);
|
||||
const call = calls.findLast((item) => item.method === calledMethod);
|
||||
assert.equal(call.args[1].skuId, '5');
|
||||
assert.equal(call.args[1].storeId, '11');
|
||||
}
|
||||
|
||||
for (const [method, url, payload] of [
|
||||
['PUT', '/admin-api/inventory/stocks/5/policy', {
|
||||
storeId: '11', requestId: 'policy-no-version', reason: 'configure',
|
||||
policyType: 'TRACKED', lowStockThreshold: 2
|
||||
}],
|
||||
['POST', '/admin-api/inventory/stocks/5/stocktake', {
|
||||
storeId: '11', requestId: 'stocktake-no-version', reason: 'count',
|
||||
availableQuantity: 8, lossQuantity: 1
|
||||
}]
|
||||
]) {
|
||||
const response = await app.inject({ method, url, headers, payload });
|
||||
assert.equal(response.statusCode, 400, `${method} ${url} requires expectedVersion`);
|
||||
assert.equal(response.json().code, 'INVALID_INVENTORY_REQUEST');
|
||||
}
|
||||
|
||||
const insufficient = await app.inject({
|
||||
method: 'POST', url: '/admin-api/inventory/stocks/5/inbound', headers,
|
||||
payload: { storeId: '11', requestId: 'insufficient', reason: 'conflict', quantity: 2 }
|
||||
});
|
||||
assert.equal(insufficient.statusCode, 409);
|
||||
assert.equal(insufficient.json().code, 'INVENTORY_INSUFFICIENT_AVAILABLE');
|
||||
|
||||
const outOfScope = await app.inject({
|
||||
method: 'POST', url: '/admin-api/inventory/stocks/5/loss', headers,
|
||||
payload: { storeId: '12', requestId: 'loss-outside', reason: 'wrong store', quantity: 1 }
|
||||
});
|
||||
assert.equal(outOfScope.statusCode, 403);
|
||||
assert.equal(outOfScope.json().code, 'INVENTORY_STORE_SCOPE_FORBIDDEN');
|
||||
|
||||
const internalRoute = await app.inject({
|
||||
method: 'POST', url: '/admin-api/inventory/stocks/5/lock', headers,
|
||||
payload: { storeId: '11', requestId: 'lock-1', reason: 'must stay internal', quantity: 1 }
|
||||
});
|
||||
assert.equal(internalRoute.statusCode, 404);
|
||||
|
||||
await app.close();
|
||||
console.log('PASS: M09-D1 inventory routes enforce auth, inventory.adjust, validation, error mapping and HTTP boundaries.');
|
||||
@@ -0,0 +1,446 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
InventoryError,
|
||||
InventoryService
|
||||
} from '../dist/inventory/inventory-service.js';
|
||||
|
||||
const state = {
|
||||
stocks: [
|
||||
stock('101', '5', 10, 0, 1, 2, 'TRACKED'),
|
||||
stock('102', '2', 4, 0, 0, 1, 'TRACKED'),
|
||||
stock('103', '9', 0, 0, 0, 0, 'UNLIMITED')
|
||||
],
|
||||
skus: new Map([
|
||||
['2', 'TRACKED'], ['4', 'TRACKED'], ['5', 'TRACKED'], ['9', 'UNLIMITED']
|
||||
]),
|
||||
inactiveSkus: new Set(),
|
||||
archivedSkus: new Set(),
|
||||
requests: [],
|
||||
ledgers: [],
|
||||
audits: [],
|
||||
lockOrder: [],
|
||||
nextStockId: 104,
|
||||
nextLedgerId: 1001,
|
||||
commits: 0,
|
||||
rollbacks: 0
|
||||
};
|
||||
|
||||
function stock(id, skuId, availableQuantity, lockedQuantity, lossQuantity,
|
||||
lowStockThreshold, policyType) {
|
||||
return {
|
||||
id, tenantId: '7', storeId: '11', skuId, policyType,
|
||||
availableQuantity, lockedQuantity, lossQuantity, lowStockThreshold, version: 1
|
||||
};
|
||||
}
|
||||
|
||||
function cloneMutableState() {
|
||||
return structuredClone({
|
||||
stocks: state.stocks,
|
||||
requests: state.requests,
|
||||
ledgers: state.ledgers,
|
||||
audits: state.audits,
|
||||
nextStockId: state.nextStockId,
|
||||
nextLedgerId: state.nextLedgerId
|
||||
});
|
||||
}
|
||||
|
||||
let transactionSnapshot = null;
|
||||
const pool = {
|
||||
async getConnection() { return connection; },
|
||||
async execute(sql, params = []) { return execute(sql, params); }
|
||||
};
|
||||
const connection = {
|
||||
async beginTransaction() { transactionSnapshot = cloneMutableState(); },
|
||||
async commit() { state.commits += 1; transactionSnapshot = null; },
|
||||
async rollback() {
|
||||
state.rollbacks += 1;
|
||||
if (transactionSnapshot) {
|
||||
state.stocks = transactionSnapshot.stocks;
|
||||
state.requests = transactionSnapshot.requests;
|
||||
state.ledgers = transactionSnapshot.ledgers;
|
||||
state.audits = transactionSnapshot.audits;
|
||||
state.nextStockId = transactionSnapshot.nextStockId;
|
||||
state.nextLedgerId = transactionSnapshot.nextLedgerId;
|
||||
}
|
||||
transactionSnapshot = null;
|
||||
},
|
||||
release() {},
|
||||
async execute(sql, params = []) { return execute(sql, params); }
|
||||
};
|
||||
|
||||
function execute(sql, params) {
|
||||
if (/\b(UPDATE|DELETE)\s+qipai_product_inventory_ledger/i.test(sql)) {
|
||||
throw new Error('immutable ledger was mutated');
|
||||
}
|
||||
if (sql.includes('SELECT COUNT(*) AS total') && sql.includes('qipai_product_inventory_ledger')) {
|
||||
const matches = state.ledgers.filter((item) => item.tenantId === String(params[0])
|
||||
&& item.storeId === String(params[1]) && item.inventoryId === String(params[2]));
|
||||
return [[{ total: matches.length }], []];
|
||||
}
|
||||
if (sql.includes('FROM qipai_product_inventory_ledger')
|
||||
&& sql.includes('ORDER BY id DESC')) {
|
||||
const rows = state.ledgers.filter((item) => item.tenantId === String(params[0])
|
||||
&& item.storeId === String(params[1]) && item.inventoryId === String(params[2]))
|
||||
.sort((left, right) => Number(right.id) - Number(left.id));
|
||||
return [rows, []];
|
||||
}
|
||||
if (sql.includes('SELECT COUNT(*) AS total') && sql.includes('qipai_product_inventory i')) {
|
||||
const matches = state.stocks.filter((item) => item.tenantId === String(params[0])
|
||||
&& item.storeId === String(params[1]));
|
||||
return [[{ total: matches.length }], []];
|
||||
}
|
||||
if (sql.includes('FROM qipai_product_inventory i') && sql.includes('ORDER BY p.sort_order')) {
|
||||
const rows = state.stocks.filter((item) => item.tenantId === String(params[0])
|
||||
&& item.storeId === String(params[1])).map((item) => ({
|
||||
...item,
|
||||
skuCode: `SKU-${item.skuId}`,
|
||||
skuName: `规格 ${item.skuId}`,
|
||||
productId: `8${item.skuId}`,
|
||||
productCode: `P-${item.skuId}`,
|
||||
productName: `商品 ${item.skuId}`,
|
||||
salePriceCents: 1200
|
||||
}));
|
||||
return [rows, []];
|
||||
}
|
||||
if (sql.includes('INSERT IGNORE INTO qipai_product_inventory')) {
|
||||
const tenantId = String(params[0]);
|
||||
const storeId = String(params[3]);
|
||||
const skuId = String(params[5]);
|
||||
const existing = findStock(tenantId, storeId, skuId);
|
||||
if (existing || tenantId !== '7' || storeId !== '11' || !state.skus.has(skuId)) {
|
||||
return [{ affectedRows: 0 }, []];
|
||||
}
|
||||
state.stocks.push({
|
||||
id: String(state.nextStockId++), tenantId, storeId, skuId,
|
||||
policyType: state.skus.get(skuId), availableQuantity: 0, lockedQuantity: 0,
|
||||
lossQuantity: 0, lowStockThreshold: 0, version: 1
|
||||
});
|
||||
return [{ affectedRows: 1 }, []];
|
||||
}
|
||||
if (sql.includes('INSERT INTO qipai_product_inventory_requests')) {
|
||||
const tenantId = String(params[0]);
|
||||
const requestId = String(params[2]);
|
||||
if (state.requests.some((item) => item.tenantId === tenantId
|
||||
&& item.requestId === requestId)) {
|
||||
throw Object.assign(new Error('duplicate inventory request'), { code: 'ER_DUP_ENTRY' });
|
||||
}
|
||||
state.requests.push({
|
||||
tenantId, storeId: String(params[1]), requestId,
|
||||
operation: params[3], businessType: String(params[4]), businessId: String(params[5]),
|
||||
fingerprint: String(params[6]), itemCount: Number(params[7])
|
||||
});
|
||||
return [{ insertId: state.requests.length, affectedRows: 1 }, []];
|
||||
}
|
||||
if (sql.includes('FROM qipai_product_inventory_requests') && sql.includes('FOR UPDATE')) {
|
||||
const row = state.requests.find((item) => item.tenantId === String(params[0])
|
||||
&& item.requestId === String(params[1]));
|
||||
return [row ? [{ ...row }] : [], []];
|
||||
}
|
||||
if (sql.includes('SELECT product_id AS productId')
|
||||
&& sql.includes('FROM qipai_product_skus')) {
|
||||
const skuId = String(params[1]);
|
||||
return [state.skus.has(skuId) ? [{ productId: `8${skuId}` }] : [], []];
|
||||
}
|
||||
if (sql.includes('SELECT id FROM qipai_products') && sql.includes('FOR SHARE')) {
|
||||
return [[{ id: String(params[1]) }], []];
|
||||
}
|
||||
if (sql.includes('SELECT id FROM qipai_product_skus') && sql.includes('FOR SHARE')) {
|
||||
const skuId = String(params[2]);
|
||||
const requiresActive = sql.includes("status = 'ACTIVE'");
|
||||
return [state.skus.has(skuId) && !state.archivedSkus.has(skuId)
|
||||
&& (!requiresActive || !state.inactiveSkus.has(skuId))
|
||||
? [{ id: skuId }] : [], []];
|
||||
}
|
||||
if (sql.includes('SELECT id FROM qipai_product_inventory')) {
|
||||
const found = findStock(String(params[0]), String(params[1]), String(params[2]));
|
||||
return [found ? [{ id: found.id }] : [], []];
|
||||
}
|
||||
if (sql.includes('FROM qipai_product_inventory') && sql.includes('FOR UPDATE')) {
|
||||
const skuId = String(params[2]);
|
||||
state.lockOrder.push(skuId);
|
||||
const found = findStock(String(params[0]), String(params[1]), skuId);
|
||||
return [found ? [{ ...found }] : [], []];
|
||||
}
|
||||
if (sql.includes('FROM qipai_product_inventory_ledger')
|
||||
&& sql.includes('request_id = ?')) {
|
||||
const row = state.ledgers.find((item) => item.tenantId === String(params[0])
|
||||
&& item.inventoryId === String(params[1]) && item.requestId === String(params[2]));
|
||||
return [row ? [{ ...row }] : [], []];
|
||||
}
|
||||
if (sql.includes('AS reservedQuantity') && sql.includes('business_type = ?')) {
|
||||
const inventoryIds = new Set(params.slice(4).map(String));
|
||||
const balances = new Map();
|
||||
for (const item of state.ledgers) {
|
||||
if (item.tenantId !== String(params[0]) || item.storeId !== String(params[1])
|
||||
|| item.businessType !== String(params[2]) || item.businessId !== String(params[3])
|
||||
|| !inventoryIds.has(item.inventoryId)) continue;
|
||||
const metadata = JSON.parse(item.metadata);
|
||||
const reservationDelta = metadata.inventoryReservationDelta ?? Number(item.lockedDelta);
|
||||
balances.set(item.inventoryId,
|
||||
(balances.get(item.inventoryId) ?? 0) + Number(reservationDelta));
|
||||
}
|
||||
return [[...balances.entries()].map(([inventoryId, reservedQuantity]) => ({
|
||||
inventoryId, reservedQuantity
|
||||
})), []];
|
||||
}
|
||||
if (sql.includes('AS reservedQuantity')
|
||||
&& sql.includes('WHERE tenant_id = ? AND inventory_id = ?')) {
|
||||
const reservedQuantity = state.ledgers
|
||||
.filter((item) => item.tenantId === String(params[0])
|
||||
&& item.inventoryId === String(params[1]))
|
||||
.reduce((total, item) => {
|
||||
const metadata = JSON.parse(item.metadata);
|
||||
return total + Number(metadata.inventoryReservationDelta ?? item.lockedDelta);
|
||||
}, 0);
|
||||
return [[{ reservedQuantity }], []];
|
||||
}
|
||||
if (sql.startsWith('UPDATE qipai_product_inventory')) {
|
||||
const found = state.stocks.find((item) => item.tenantId === String(params[7])
|
||||
&& item.id === String(params[8]) && item.version === Number(params[9]));
|
||||
if (!found) return [{ affectedRows: 0 }, []];
|
||||
Object.assign(found, {
|
||||
policyType: params[0], availableQuantity: Number(params[1]),
|
||||
lockedQuantity: Number(params[2]), lossQuantity: Number(params[3]),
|
||||
lowStockThreshold: Number(params[4]), version: Number(params[5])
|
||||
});
|
||||
return [{ affectedRows: 1 }, []];
|
||||
}
|
||||
if (sql.includes('INSERT INTO qipai_product_inventory_ledger')) {
|
||||
const duplicate = state.ledgers.some((item) => item.tenantId === String(params[0])
|
||||
&& item.inventoryId === String(params[1]) && item.requestId === String(params[4]));
|
||||
if (duplicate) throw new Error('duplicate inventory ledger request');
|
||||
const row = {
|
||||
id: String(state.nextLedgerId++),
|
||||
tenantId: String(params[0]), inventoryId: String(params[1]),
|
||||
storeId: String(params[2]), skuId: String(params[3]), requestId: String(params[4]),
|
||||
businessType: String(params[5]), businessId: String(params[6]), operation: params[7],
|
||||
availableDelta: Number(params[8]), lockedDelta: Number(params[9]),
|
||||
lossDelta: Number(params[10]), availableAfter: Number(params[11]),
|
||||
lockedAfter: Number(params[12]), lossAfter: Number(params[13]),
|
||||
versionAfter: Number(params[14]), operatorId: params[15] == null ? null : String(params[15]),
|
||||
traceId: String(params[16]), reason: String(params[17]), metadata: String(params[18]),
|
||||
createdAt: new Date()
|
||||
};
|
||||
state.ledgers.push(row);
|
||||
return [{ insertId: row.id, affectedRows: 1 }, []];
|
||||
}
|
||||
if (sql.includes('INSERT INTO qipai_audit_logs')) {
|
||||
state.audits.push({ action: params[3], resourceId: String(params[4]), metadata: params[8] });
|
||||
return [{ insertId: state.audits.length, affectedRows: 1 }, []];
|
||||
}
|
||||
throw new Error(`Unexpected SQL: ${sql}`);
|
||||
}
|
||||
|
||||
function findStock(tenantId, storeId, skuId) {
|
||||
return state.stocks.find((item) => item.tenantId === tenantId
|
||||
&& item.storeId === storeId && item.skuId === skuId);
|
||||
}
|
||||
|
||||
const manager = {
|
||||
tenantId: '7', userId: '21',
|
||||
access: { roles: ['STORE_ADMIN'], capabilities: ['inventory.read', 'inventory.adjust'], storeIds: ['11'] },
|
||||
traceId: 'inventory-test', ip: '127.0.0.1', userAgent: 'inventory-test-agent'
|
||||
};
|
||||
const service = new InventoryService(pool);
|
||||
|
||||
const listed = await service.listStocks(manager, { storeId: '11', page: 1, pageSize: 20 });
|
||||
assert.equal(listed.total, 3);
|
||||
assert.equal(listed.items[0].tenantId, '7');
|
||||
assert.equal(listed.items[0].lossQuantity, 1);
|
||||
|
||||
const inboundInput = {
|
||||
storeId: '11', skuId: '5', requestId: 'admin-inbound-1', reason: '首批入库',
|
||||
expectedVersion: 1, quantity: 5
|
||||
};
|
||||
const inbound = await service.inbound(manager, inboundInput);
|
||||
assert.equal(inbound.availableQuantity, 15);
|
||||
assert.equal(inbound.version, 2);
|
||||
const auditCountAfterInbound = state.audits.length;
|
||||
const replay = await service.inbound(manager, inboundInput);
|
||||
assert.equal(replay.idempotent, true);
|
||||
assert.equal(replay.availableQuantity, 15);
|
||||
assert.equal(state.audits.length, auditCountAfterInbound);
|
||||
await assert.rejects(
|
||||
() => service.inbound(manager, { ...inboundInput, quantity: 6 }),
|
||||
(error) => error instanceof InventoryError && error.code === 'INVENTORY_IDEMPOTENCY_CONFLICT'
|
||||
);
|
||||
|
||||
const adjusted = await service.adjust(manager, {
|
||||
storeId: '11', skuId: '5', requestId: 'admin-adjust-1', reason: '人工纠偏',
|
||||
expectedVersion: 2, availableDelta: -2, lossDelta: 1
|
||||
});
|
||||
assert.deepEqual(
|
||||
[adjusted.availableQuantity, adjusted.lossQuantity, adjusted.version],
|
||||
[13, 2, 3]
|
||||
);
|
||||
const counted = await service.stocktake(manager, {
|
||||
storeId: '11', skuId: '5', requestId: 'admin-stocktake-1', reason: '闭店盘点',
|
||||
expectedVersion: 3, availableQuantity: 12, lossQuantity: 3
|
||||
});
|
||||
assert.deepEqual(
|
||||
[counted.availableQuantity, counted.lossQuantity, counted.version],
|
||||
[12, 3, 4]
|
||||
);
|
||||
const lost = await service.recordLoss(manager, {
|
||||
storeId: '11', skuId: '5', requestId: 'admin-loss-1', reason: '包装破损',
|
||||
expectedVersion: 4, quantity: 2
|
||||
});
|
||||
assert.deepEqual([lost.availableQuantity, lost.lossQuantity, lost.version], [10, 5, 5]);
|
||||
|
||||
const configured = await service.configurePolicy(manager, {
|
||||
storeId: '11', skuId: '4', requestId: 'admin-policy-1', reason: '启用库存追踪',
|
||||
expectedVersion: 0, policyType: 'TRACKED', lowStockThreshold: 3
|
||||
});
|
||||
assert.equal(configured.policyType, 'TRACKED');
|
||||
assert.equal(configured.lowStockThreshold, 3);
|
||||
assert.ok(findStock('7', '11', '4'));
|
||||
const reconfigured = await service.configurePolicy(manager, {
|
||||
storeId: '11', skuId: '4', requestId: 'admin-policy-2', reason: 'raise warning threshold',
|
||||
policyType: 'TRACKED', lowStockThreshold: 7, expectedVersion: 2
|
||||
});
|
||||
assert.equal(reconfigured.lowStockThreshold, 7);
|
||||
const configuredReplay = await service.configurePolicy(manager, {
|
||||
storeId: '11', skuId: '4', requestId: 'admin-policy-1', reason: '启用库存追踪',
|
||||
expectedVersion: 0, policyType: 'TRACKED', lowStockThreshold: 3
|
||||
});
|
||||
assert.equal(configuredReplay.idempotent, true);
|
||||
assert.equal(configuredReplay.lowStockThreshold, 3);
|
||||
await assert.rejects(
|
||||
() => service.configurePolicy(manager, {
|
||||
storeId: '11', skuId: '5', requestId: 'admin-policy-unlimited', reason: '错误切换',
|
||||
policyType: 'UNLIMITED', lowStockThreshold: 0, expectedVersion: 5
|
||||
}),
|
||||
(error) => error instanceof InventoryError && error.code === 'INVENTORY_POLICY_STOCK_NOT_ZERO'
|
||||
);
|
||||
|
||||
const scopedOut = { ...manager, access: { ...manager.access, storeIds: ['12'] } };
|
||||
await assert.rejects(
|
||||
() => service.listStocks(scopedOut, { storeId: '11', page: 1, pageSize: 20 }),
|
||||
(error) => error instanceof InventoryError && error.code === 'INVENTORY_STORE_SCOPE_FORBIDDEN'
|
||||
);
|
||||
|
||||
state.lockOrder.length = 0;
|
||||
const batchBase = {
|
||||
tenantId: '7', storeId: '11', requestId: 'order-lock-1',
|
||||
businessType: 'PRODUCT_ORDER', businessId: 'order-500', traceId: 'order-lock-trace',
|
||||
operatorId: '21', reason: '创建商品订单', metadata: { source: 'ORDER' },
|
||||
items: [{ skuId: '5', quantity: 3 }, { skuId: '2', quantity: 1 }, { skuId: '2', quantity: 1 }]
|
||||
};
|
||||
const locked = await service.lockMany(batchBase);
|
||||
assert.equal(locked.idempotent, false);
|
||||
assert.deepEqual(state.lockOrder.slice(-2), ['2', '5']);
|
||||
assert.deepEqual(
|
||||
[findStock('7', '11', '2').availableQuantity, findStock('7', '11', '2').lockedQuantity],
|
||||
[2, 2]
|
||||
);
|
||||
assert.deepEqual(
|
||||
[findStock('7', '11', '5').availableQuantity, findStock('7', '11', '5').lockedQuantity],
|
||||
[7, 3]
|
||||
);
|
||||
const ledgerCountAfterLock = state.ledgers.length;
|
||||
const lockedReplay = await service.lockMany(batchBase);
|
||||
assert.equal(lockedReplay.idempotent, true);
|
||||
assert.equal(state.ledgers.length, ledgerCountAfterLock);
|
||||
await assert.rejects(
|
||||
() => service.lockMany({ ...batchBase, items: [{ skuId: '5', quantity: 4 }, { skuId: '2', quantity: 2 }] }),
|
||||
(error) => error instanceof InventoryError && error.code === 'INVENTORY_IDEMPOTENCY_CONFLICT'
|
||||
);
|
||||
await assert.rejects(
|
||||
() => service.lockMany({ ...batchBase, items: [{ skuId: '9', quantity: 1 }] }),
|
||||
(error) => error instanceof InventoryError && error.code === 'INVENTORY_IDEMPOTENCY_CONFLICT'
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
() => service.releaseMany({
|
||||
...batchBase, requestId: 'foreign-order-release', businessId: 'order-foreign',
|
||||
items: [{ skuId: '5', quantity: 1 }]
|
||||
}),
|
||||
(error) => error instanceof InventoryError
|
||||
&& error.code === 'INVENTORY_INSUFFICIENT_RESERVATION'
|
||||
);
|
||||
|
||||
await service.releaseMany({
|
||||
...batchBase, requestId: 'order-release-1', reason: '部分取消',
|
||||
items: [{ skuId: '5', quantity: 1 }, { skuId: '2', quantity: 1 }]
|
||||
});
|
||||
await service.deductMany({
|
||||
...batchBase, requestId: 'order-deduct-1', reason: '支付确认扣减',
|
||||
items: [{ skuId: '5', quantity: 2 }, { skuId: '2', quantity: 1 }]
|
||||
});
|
||||
assert.deepEqual(
|
||||
[findStock('7', '11', '2').availableQuantity, findStock('7', '11', '2').lockedQuantity],
|
||||
[3, 0]
|
||||
);
|
||||
assert.deepEqual(
|
||||
[findStock('7', '11', '5').availableQuantity, findStock('7', '11', '5').lockedQuantity],
|
||||
[8, 0]
|
||||
);
|
||||
|
||||
const beforeFailedBatch = structuredClone(state.stocks);
|
||||
await assert.rejects(
|
||||
() => service.lockMany({
|
||||
...batchBase, requestId: 'order-lock-too-many', businessId: 'order-501',
|
||||
items: [{ skuId: '5', quantity: 999 }]
|
||||
}),
|
||||
(error) => error instanceof InventoryError && error.code === 'INVENTORY_INSUFFICIENT_AVAILABLE'
|
||||
);
|
||||
assert.deepEqual(state.stocks, beforeFailedBatch);
|
||||
assert.ok(state.rollbacks > 0);
|
||||
|
||||
const unlimited = await service.lockMany({
|
||||
...batchBase, requestId: 'order-unlimited-lock', businessId: 'order-502',
|
||||
items: [{ skuId: '9', quantity: 999 }]
|
||||
});
|
||||
assert.equal(unlimited.items[0].availableQuantity, 0);
|
||||
assert.equal(unlimited.items[0].lockedQuantity, 0);
|
||||
await assert.rejects(
|
||||
() => service.configurePolicy(manager, {
|
||||
storeId: '11', skuId: '9', requestId: 'unlimited-switch-active', reason: '切换追踪',
|
||||
expectedVersion: unlimited.items[0].version, policyType: 'TRACKED', lowStockThreshold: 0
|
||||
}),
|
||||
(error) => error instanceof InventoryError
|
||||
&& error.code === 'INVENTORY_POLICY_HAS_ACTIVE_RESERVATIONS'
|
||||
);
|
||||
const unlimitedDeduct = await service.deductMany({
|
||||
...batchBase, requestId: 'order-unlimited-deduct', businessId: 'order-502',
|
||||
items: [{ skuId: '9', quantity: 999 }]
|
||||
});
|
||||
const trackedAfterUnlimited = await service.configurePolicy(manager, {
|
||||
storeId: '11', skuId: '9', requestId: 'unlimited-switch-settled', reason: '切换追踪',
|
||||
expectedVersion: unlimitedDeduct.items[0].version,
|
||||
policyType: 'TRACKED', lowStockThreshold: 0
|
||||
});
|
||||
assert.equal(trackedAfterUnlimited.policyType, 'TRACKED');
|
||||
|
||||
state.inactiveSkus.add('2');
|
||||
const inactiveReplay = await service.lockMany(batchBase);
|
||||
assert.equal(inactiveReplay.idempotent, true);
|
||||
await assert.rejects(
|
||||
() => service.lockMany({
|
||||
...batchBase, requestId: 'inactive-sku-lock', businessId: 'order-503',
|
||||
items: [{ skuId: '2', quantity: 1 }]
|
||||
}),
|
||||
(error) => error instanceof InventoryError && error.code === 'INVENTORY_SKU_NOT_SELLABLE'
|
||||
);
|
||||
state.inactiveSkus.delete('2');
|
||||
|
||||
state.archivedSkus.add('5');
|
||||
const archivedAdminReplay = await service.inbound(manager, inboundInput);
|
||||
assert.equal(archivedAdminReplay.idempotent, true);
|
||||
await assert.rejects(
|
||||
() => service.inbound(manager, {
|
||||
storeId: '11', skuId: '5', requestId: 'archived-sku-inbound',
|
||||
reason: '归档规格不得重新入库', quantity: 1
|
||||
}),
|
||||
(error) => error instanceof InventoryError && error.code === 'INVENTORY_SKU_NOT_FOUND'
|
||||
);
|
||||
state.archivedSkus.delete('5');
|
||||
|
||||
const history = await service.listLedger(manager, {
|
||||
storeId: '11', inventoryId: '101', page: 1, pageSize: 100
|
||||
});
|
||||
assert.ok(history.items.length >= 7);
|
||||
assert.ok(history.items.every((item) => item.metadata.idempotencyFingerprint));
|
||||
assert.equal(state.audits.length, state.ledgers.length);
|
||||
|
||||
console.log('PASS: M09-D1 inventory policy, inbound/adjust/stocktake/loss, ordered batch locks, idempotency, immutable ledger, scope and audit work.');
|
||||
@@ -120,6 +120,15 @@ const cleaningSettlementIntegrityDownSql = read(
|
||||
const cleaningSettlementIntegrityVerifySql = read(
|
||||
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.verify.sql'
|
||||
);
|
||||
const productInventoryUpSql = read(
|
||||
'database/migrations/2026081107_m09d1_product_inventory_foundation.up.sql'
|
||||
);
|
||||
const productInventoryDownSql = read(
|
||||
'database/migrations/2026081107_m09d1_product_inventory_foundation.down.sql'
|
||||
);
|
||||
const productInventoryVerifySql = read(
|
||||
'database/migrations/2026081107_m09d1_product_inventory_foundation.verify.sql'
|
||||
);
|
||||
|
||||
const coreTables = [
|
||||
'qipai_schema_migrations',
|
||||
@@ -536,4 +545,70 @@ assert.match(cleaningSettlementIntegrityVerifySql, /'2026081006'/);
|
||||
assert.match(franchiseDownSql, /DROP TABLE IF EXISTS qipai_franchise_follow_ups/);
|
||||
assert.match(franchiseVerifySql, /idx_qipai_franchise_follow_up_history/);
|
||||
|
||||
console.log('PASS: M01-B through M09-C migration contracts are present.');
|
||||
for (const table of [
|
||||
'qipai_product_categories',
|
||||
'qipai_products',
|
||||
'qipai_product_skus',
|
||||
'qipai_product_store_listings',
|
||||
'qipai_product_store_settings',
|
||||
'qipai_product_store_hours',
|
||||
'qipai_product_inventory',
|
||||
'qipai_product_inventory_requests',
|
||||
'qipai_product_inventory_ledger'
|
||||
]) {
|
||||
assert.match(productInventoryUpSql, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`));
|
||||
assert.match(productInventoryDownSql, new RegExp(`DROP TABLE IF EXISTS ${table}`));
|
||||
assert.match(productInventoryVerifySql, new RegExp(`'${table}'`));
|
||||
}
|
||||
assert.match(productInventoryUpSql, /ADD UNIQUE KEY uq_qipai_stores_tenant_id/);
|
||||
assert.match(productInventoryUpSql, /ADD UNIQUE KEY uq_qipai_users_tenant_id/);
|
||||
assert.match(productInventoryUpSql, /ADD COLUMN checksum CHAR\(64\)/);
|
||||
assert.match(productInventoryUpSql, /store_id BIGINT UNSIGNED NOT NULL/);
|
||||
assert.match(productInventoryUpSql, /uq_qipai_product_category_scope_id/);
|
||||
assert.match(
|
||||
productInventoryUpSql,
|
||||
/FOREIGN KEY \(tenant_id, store_id, category_id\)[\s\S]*qipai_product_categories/
|
||||
);
|
||||
assert.match(productInventoryUpSql, /delivery_enabled TINYINT\(1\) NOT NULL DEFAULT 1/);
|
||||
assert.match(productInventoryUpSql, /storage_enabled TINYINT\(1\) NOT NULL DEFAULT 0/);
|
||||
assert.match(productInventoryUpSql, /default_inventory_policy VARCHAR\(16\)/);
|
||||
assert.match(productInventoryUpSql, /default_inventory_policy IN \('TRACKED', 'UNLIMITED'\)/);
|
||||
assert.match(productInventoryUpSql, /manual_paused_at DATETIME\(3\) NULL/);
|
||||
assert.match(productInventoryUpSql, /manual_paused_until DATETIME\(3\) NULL/);
|
||||
assert.match(productInventoryUpSql, /manual_pause_reason VARCHAR\(512\)/);
|
||||
assert.match(productInventoryUpSql, /crosses_midnight TINYINT\(1\) NOT NULL DEFAULT 0/);
|
||||
assert.match(productInventoryUpSql, /crosses_midnight = 1 AND close_minute <= open_minute/);
|
||||
assert.match(productInventoryUpSql, /loss_quantity BIGINT UNSIGNED NOT NULL DEFAULT 0/);
|
||||
assert.match(productInventoryUpSql, /loss_delta BIGINT NOT NULL DEFAULT 0/);
|
||||
assert.match(productInventoryUpSql, /loss_after BIGINT UNSIGNED NOT NULL/);
|
||||
for (const operation of [
|
||||
'CONFIGURE', 'INBOUND', 'ADJUST', 'LOCK', 'RELEASE',
|
||||
'DEDUCT', 'STOCKTAKE', 'LOSS', 'RETURN'
|
||||
]) assert.match(productInventoryUpSql, new RegExp(`'${operation}'`));
|
||||
assert.match(productInventoryUpSql, /uq_qipai_product_inventory_ledger_request/);
|
||||
assert.match(productInventoryUpSql, /uq_qipai_product_inventory_request/);
|
||||
assert.match(productInventoryUpSql, /fk_qipai_product_inventory_ledger_request/);
|
||||
assert.match(productInventoryUpSql, /uq_qipai_product_inventory_ledger_version/);
|
||||
assert.match(productInventoryUpSql, /qipai_product_inventory_ledger_no_update/);
|
||||
assert.match(productInventoryUpSql, /qipai_product_inventory_ledger_no_delete/);
|
||||
for (const permission of [
|
||||
'product.catalog.read', 'product.catalog.write', 'inventory.read', 'inventory.adjust'
|
||||
]) {
|
||||
assert.match(productInventoryUpSql, new RegExp(permission.replace('.', '\\.')));
|
||||
assert.match(productInventoryDownSql, new RegExp(permission.replace('.', '\\.')));
|
||||
assert.match(productInventoryVerifySql, new RegExp(permission.replace('.', '\\.')));
|
||||
}
|
||||
assert.match(productInventoryUpSql, /r\.code = 'STAFF'/);
|
||||
assert.match(productInventoryUpSql, /r\.code IN \('STORE_ADMIN', 'TENANT_ADMIN', 'PLATFORM_ADMIN'\)/);
|
||||
assert.match(productInventoryDownSql, /DROP INDEX uq_qipai_stores_tenant_id/);
|
||||
assert.match(productInventoryDownSql, /DROP INDEX uq_qipai_users_tenant_id/);
|
||||
assert.match(productInventoryDownSql, /DROP COLUMN checksum/);
|
||||
assert.match(
|
||||
productInventoryUpSql,
|
||||
/FOREIGN KEY \(tenant_id, operator_id\) REFERENCES qipai_users\(tenant_id, id\)/
|
||||
);
|
||||
assert.match(productInventoryVerifySql, /fully_granted_product_roles/);
|
||||
assert.match(productInventoryVerifySql, /'uq_qipai_product_inventory_ledger_version'/);
|
||||
assert.match(productInventoryVerifySql, /'2026081107'/);
|
||||
|
||||
console.log('PASS: M01-B through M09-D1 migration contracts are present.');
|
||||
|
||||
@@ -45,7 +45,8 @@ assert.match(plan.file, /2026081002_m08d_content_asset_scope\.up\.sql/);
|
||||
assert.match(plan.file, /2026081003_m08d_franchise_leads\.up\.sql/);
|
||||
assert.match(plan.file, /2026081004_m08d_admin_password_auth\.up\.sql/);
|
||||
assert.match(plan.file, /2026081005_m09b_cleaning_rules\.up\.sql/);
|
||||
assert.match(plan.file, /2026081006_m09c_cleaning_settlement_integrity\.up\.sql$/);
|
||||
assert.match(plan.file, /2026081006_m09c_cleaning_settlement_integrity\.up\.sql/);
|
||||
assert.match(plan.file, /2026081107_m09d1_product_inventory_foundation\.up\.sql$/);
|
||||
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
|
||||
assert.ok(plan.statements.length >= 11);
|
||||
|
||||
@@ -55,10 +56,19 @@ assert.match(verifyPlan.statements[91], /^SELECT index_name/);
|
||||
assert.match(verifyPlan.file, /2026081003_m08d_franchise_leads\.verify\.sql/);
|
||||
assert.match(verifyPlan.file, /2026081004_m08d_admin_password_auth\.verify\.sql/);
|
||||
assert.match(verifyPlan.file, /2026081005_m09b_cleaning_rules\.verify\.sql/);
|
||||
assert.match(verifyPlan.file, /2026081006_m09c_cleaning_settlement_integrity\.verify\.sql$/);
|
||||
assert.match(verifyPlan.file, /2026081006_m09c_cleaning_settlement_integrity\.verify\.sql/);
|
||||
assert.match(
|
||||
verifyPlan.file,
|
||||
/2026081107_m09d1_product_inventory_foundation\.verify\.sql$/
|
||||
);
|
||||
|
||||
const downPlan = await loadMigrationPlan('down');
|
||||
assert.match(downPlan.file, /^database\/migrations\/2026081107_m09d1_product_inventory_foundation\.down\.sql/);
|
||||
assert.match(downPlan.file, /2026081006_m09c_cleaning_settlement_integrity\.down\.sql/);
|
||||
assert.ok(
|
||||
downPlan.file.indexOf('2026081107_m09d1_product_inventory_foundation.down.sql')
|
||||
< downPlan.file.indexOf('2026081006_m09c_cleaning_settlement_integrity.down.sql')
|
||||
);
|
||||
assert.ok(
|
||||
downPlan.file.indexOf('2026081006_m09c_cleaning_settlement_integrity.down.sql')
|
||||
< downPlan.file.indexOf('2026081005_m09b_cleaning_rules.down.sql')
|
||||
@@ -76,6 +86,51 @@ const dryRun = await executeMigrationPlan(fakePool, plan, true);
|
||||
assert.equal(dryRun.executed, false);
|
||||
assert.equal(calls.length, 0);
|
||||
|
||||
const lockCalls = [];
|
||||
let connectionReleased = false;
|
||||
const lockedResult = await executeMigrationPlan({
|
||||
async query() {
|
||||
throw new Error('pool.query must not run while a dedicated migration connection exists');
|
||||
},
|
||||
async getConnection() {
|
||||
return {
|
||||
async query(sql) {
|
||||
lockCalls.push(sql);
|
||||
if (sql.includes('GET_LOCK')) return [[{ acquired: 1 }], []];
|
||||
if (sql.includes('RELEASE_LOCK')) return [[{ released: 1 }], []];
|
||||
return [{ affectedRows: 2 }, []];
|
||||
},
|
||||
release() {
|
||||
connectionReleased = true;
|
||||
}
|
||||
};
|
||||
}
|
||||
}, {
|
||||
direction: 'down', file: 'locked.sql', checksum: 'locked', statements: ['SELECT 1']
|
||||
});
|
||||
assert.equal(lockedResult.affectedRows, 2);
|
||||
assert.equal(connectionReleased, true);
|
||||
assert.match(lockCalls[0], /GET_LOCK/);
|
||||
assert.equal(lockCalls[1], 'SELECT 1');
|
||||
assert.match(lockCalls[2], /RELEASE_LOCK/);
|
||||
|
||||
let timedOutConnectionReleased = false;
|
||||
await assert.rejects(
|
||||
() => executeMigrationPlan({
|
||||
async query() {},
|
||||
async getConnection() {
|
||||
return {
|
||||
async query() { return [[{ acquired: 0 }], []]; },
|
||||
release() { timedOutConnectionReleased = true; }
|
||||
};
|
||||
}
|
||||
}, {
|
||||
direction: 'down', file: 'lock-timeout.sql', checksum: 'lock-timeout', statements: []
|
||||
}),
|
||||
/MIGRATION_LOCK_TIMEOUT/
|
||||
);
|
||||
assert.equal(timedOutConnectionReleased, true);
|
||||
|
||||
const liveResult = await executeMigrationPlan(fakePool, {
|
||||
direction: 'up',
|
||||
file: 'test.sql',
|
||||
@@ -86,6 +141,144 @@ assert.equal(liveResult.executed, true);
|
||||
assert.equal(liveResult.affectedRows, 2);
|
||||
assert.deepEqual(calls, ['SELECT 1', 'SELECT 2']);
|
||||
|
||||
const triggerPlan = {
|
||||
direction: 'up',
|
||||
file: 'trigger.sql',
|
||||
checksum: 'trigger',
|
||||
statements: ['CREATE TRIGGER test_trigger BEFORE UPDATE ON test FOR EACH ROW SET @x = 1']
|
||||
};
|
||||
await assert.rejects(
|
||||
() => executeMigrationPlan({
|
||||
async query() { return [[{ logBin: 1, trustFunctionCreators: 0 }], []]; }
|
||||
}, triggerPlan),
|
||||
/MIGRATION_TRIGGER_PRIVILEGE_REQUIRED/
|
||||
);
|
||||
const triggerCalls = [];
|
||||
await executeMigrationPlan({
|
||||
async query(sql) {
|
||||
triggerCalls.push(sql);
|
||||
if (sql.includes('@@GLOBAL.log_bin')) {
|
||||
return [[{ logBin: 1, trustFunctionCreators: 1 }], []];
|
||||
}
|
||||
return [{ affectedRows: 0 }, []];
|
||||
}
|
||||
}, triggerPlan);
|
||||
assert.equal(triggerCalls.length, 2);
|
||||
|
||||
let indexAttempted = false;
|
||||
const retryResult = await executeMigrationPlan({
|
||||
async query(sql) {
|
||||
if (sql.includes('ADD UNIQUE KEY uq_qipai_stores_tenant_id')) {
|
||||
indexAttempted = true;
|
||||
throw Object.assign(new Error('duplicate index'), { code: 'ER_DUP_KEYNAME' });
|
||||
}
|
||||
if (sql.includes('information_schema.statistics')) {
|
||||
return [[{ nonUnique: 0, columns: 'tenant_id,id' }], []];
|
||||
}
|
||||
return [{ affectedRows: 1 }, []];
|
||||
}
|
||||
}, {
|
||||
direction: 'up', file: 'retry.sql', checksum: 'retry',
|
||||
statements: [
|
||||
'ALTER TABLE qipai_stores ADD UNIQUE KEY uq_qipai_stores_tenant_id (tenant_id, id)',
|
||||
'SELECT 1'
|
||||
]
|
||||
});
|
||||
assert.equal(indexAttempted, true);
|
||||
assert.equal(retryResult.affectedRows, 1);
|
||||
await assert.rejects(
|
||||
() => executeMigrationPlan({
|
||||
async query(sql) {
|
||||
if (sql.includes('ADD UNIQUE KEY')) {
|
||||
throw Object.assign(new Error('wrong duplicate index'), { code: 'ER_DUP_KEYNAME' });
|
||||
}
|
||||
return [[{ nonUnique: 0, columns: 'id,tenant_id' }], []];
|
||||
}
|
||||
}, {
|
||||
direction: 'up', file: 'retry-mismatch.sql', checksum: 'retry-mismatch',
|
||||
statements: [
|
||||
'ALTER TABLE qipai_stores ADD UNIQUE KEY uq_qipai_stores_tenant_id (tenant_id, id)'
|
||||
]
|
||||
}),
|
||||
/wrong duplicate index/
|
||||
);
|
||||
|
||||
const downRetryCalls = [];
|
||||
const downRetryResult = await executeMigrationPlan({
|
||||
async query(sql) {
|
||||
downRetryCalls.push(sql);
|
||||
if (/DROP INDEX uq_qipai_(?:stores|users)_tenant_id/.test(sql)
|
||||
|| /DROP COLUMN checksum/.test(sql)) {
|
||||
throw Object.assign(new Error('already removed'), { code: 'ER_CANT_DROP_FIELD_OR_KEY' });
|
||||
}
|
||||
if (sql.includes('information_schema.statistics')
|
||||
|| sql.includes('information_schema.columns')) return [[], []];
|
||||
return [{ affectedRows: 1 }, []];
|
||||
}
|
||||
}, {
|
||||
direction: 'down', file: 'retry-down.sql', checksum: 'retry-down',
|
||||
statements: [
|
||||
'ALTER TABLE qipai_stores DROP INDEX uq_qipai_stores_tenant_id',
|
||||
'ALTER TABLE qipai_users DROP INDEX uq_qipai_users_tenant_id',
|
||||
'ALTER TABLE qipai_schema_migrations DROP COLUMN checksum',
|
||||
'SELECT 1'
|
||||
]
|
||||
});
|
||||
assert.equal(downRetryResult.affectedRows, 1);
|
||||
assert.equal(downRetryCalls.filter((sql) => sql.includes('information_schema.')).length, 3);
|
||||
await assert.rejects(
|
||||
() => executeMigrationPlan({
|
||||
async query(sql) {
|
||||
if (sql.startsWith('ALTER TABLE')) {
|
||||
throw Object.assign(new Error('drop target still exists'), {
|
||||
code: 'ER_CANT_DROP_FIELD_OR_KEY'
|
||||
});
|
||||
}
|
||||
return [[{ present: 1 }], []];
|
||||
}
|
||||
}, {
|
||||
direction: 'down', file: 'retry-down-mismatch.sql', checksum: 'retry-down-mismatch',
|
||||
statements: ['ALTER TABLE qipai_stores DROP INDEX uq_qipai_stores_tenant_id']
|
||||
}),
|
||||
/drop target still exists/
|
||||
);
|
||||
|
||||
const versionedCalls = [];
|
||||
const versionedPlan = {
|
||||
direction: 'up', file: 'old.sql,new.sql', checksum: 'versioned',
|
||||
statements: ['SELECT old', 'SELECT new'],
|
||||
migrations: [
|
||||
{ version: '1', name: 'old', file: 'old.sql', checksum: 'old-checksum', statements: ['SELECT old'] },
|
||||
{ version: '2', name: 'new', file: 'new.sql', checksum: 'new-checksum', statements: ['SELECT new'] }
|
||||
]
|
||||
};
|
||||
await executeMigrationPlan({
|
||||
async query(sql, params = []) {
|
||||
versionedCalls.push({ sql, params });
|
||||
if (sql.startsWith('SELECT * FROM qipai_schema_migrations')) {
|
||||
return [[{ version: '1', name: 'old', checksum: 'old-checksum' }], []];
|
||||
}
|
||||
if (sql.startsWith('SELECT version, name')) {
|
||||
return [[{ version: '2', name: 'new' }], []];
|
||||
}
|
||||
if (sql.startsWith('UPDATE qipai_schema_migrations')) {
|
||||
return [{ affectedRows: 1 }, []];
|
||||
}
|
||||
return [{ affectedRows: 1 }, []];
|
||||
}
|
||||
}, versionedPlan);
|
||||
assert.equal(versionedCalls.some(({ sql }) => sql === 'SELECT old'), false);
|
||||
assert.equal(versionedCalls.some(({ sql }) => sql === 'SELECT new'), true);
|
||||
|
||||
await assert.rejects(
|
||||
() => executeMigrationPlan({
|
||||
async query() {
|
||||
return [[{ version: '1', name: 'old', checksum: 'changed' }], []];
|
||||
}
|
||||
}, versionedPlan),
|
||||
/MIGRATION_CHECKSUM_MISMATCH/
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
() => executeMigrationPlan({
|
||||
async query() {
|
||||
|
||||
@@ -48,6 +48,12 @@ import { hashPassword } from '../dist/auth/password.js';
|
||||
import {
|
||||
CleaningTaskError, CleaningTaskRepository
|
||||
} from '../dist/cleaning/cleaning-task-repository.js';
|
||||
import {
|
||||
ProductCatalogError, ProductCatalogRepository
|
||||
} from '../dist/products/product-catalog-repository.js';
|
||||
import {
|
||||
InventoryError, InventoryService
|
||||
} from '../dist/inventory/inventory-service.js';
|
||||
import {
|
||||
executeMigrationPlan,
|
||||
loadMigrationPlan,
|
||||
@@ -97,6 +103,15 @@ const expectedTables = [
|
||||
'qipai_payments',
|
||||
'qipai_permissions',
|
||||
'qipai_platform_apps',
|
||||
'qipai_product_categories',
|
||||
'qipai_product_inventory',
|
||||
'qipai_product_inventory_ledger',
|
||||
'qipai_product_inventory_requests',
|
||||
'qipai_product_skus',
|
||||
'qipai_product_store_hours',
|
||||
'qipai_product_store_listings',
|
||||
'qipai_product_store_settings',
|
||||
'qipai_products',
|
||||
'qipai_profit_share_policies',
|
||||
'qipai_profit_share_receivers',
|
||||
'qipai_profit_shares',
|
||||
@@ -140,18 +155,95 @@ async function readCoreTables(pool) {
|
||||
return rows.map((row) => row.tableName);
|
||||
}
|
||||
|
||||
async function assertProductInventoryMigrationRetry(pool, fullUpPlan) {
|
||||
const migrationBase = resolve(
|
||||
repoRoot,
|
||||
'database/migrations/2026081107_m09d1_product_inventory_foundation'
|
||||
);
|
||||
const [upSql, downSql] = await Promise.all([
|
||||
readFile(`${migrationBase}.up.sql`, 'utf8'),
|
||||
readFile(`${migrationBase}.down.sql`, 'utf8')
|
||||
]);
|
||||
const upStatements = splitSqlStatements(upSql);
|
||||
await executeMigrationPlan(pool, {
|
||||
direction: 'down',
|
||||
file: `${migrationBase}.down.sql`,
|
||||
checksum: 'm09d1-crash-retry-down',
|
||||
statements: splitSqlStatements(downSql)
|
||||
});
|
||||
await pool.query(upStatements[0]);
|
||||
await executeMigrationPlan(pool, fullUpPlan);
|
||||
await executeMigrationPlan(pool, fullUpPlan);
|
||||
|
||||
const interruptedDownStatements = splitSqlStatements(downSql).slice(0, -1);
|
||||
await executeMigrationPlan(pool, {
|
||||
direction: 'down',
|
||||
file: `${migrationBase}.interrupted.down.sql`,
|
||||
checksum: 'm09d1-interrupted-down',
|
||||
statements: interruptedDownStatements
|
||||
});
|
||||
await executeMigrationPlan(pool, {
|
||||
direction: 'down',
|
||||
file: `${migrationBase}.down.sql`,
|
||||
checksum: 'm09d1-crash-retry-down',
|
||||
statements: splitSqlStatements(downSql)
|
||||
});
|
||||
await executeMigrationPlan(pool, fullUpPlan);
|
||||
console.log(
|
||||
'PASS: M09-C markers skipped old migrations and D1 recovered after interrupted up/down DDL.'
|
||||
);
|
||||
}
|
||||
|
||||
async function assertMigrationAdvisoryLock(pool) {
|
||||
const lockExpression = "CONCAT('qipai:migrate:', LEFT(SHA2(DATABASE(), 256), 32))";
|
||||
const blocker = await pool.getConnection();
|
||||
let held = false;
|
||||
let pending;
|
||||
try {
|
||||
const [rows] = await blocker.query(
|
||||
`SELECT GET_LOCK(${lockExpression}, 0) AS acquired`
|
||||
);
|
||||
assert.equal(Number(rows[0]?.acquired ?? 0), 1);
|
||||
held = true;
|
||||
|
||||
pending = executeMigrationPlan(pool, {
|
||||
direction: 'down',
|
||||
file: 'concurrent-migration-lock-probe.sql',
|
||||
checksum: 'concurrent-migration-lock-probe',
|
||||
statements: ['SELECT 1']
|
||||
});
|
||||
const state = await Promise.race([
|
||||
pending.then(() => 'completed'),
|
||||
new Promise((resolve) => setTimeout(() => resolve('blocked'), 100))
|
||||
]);
|
||||
assert.equal(state, 'blocked', 'a second migration connection must wait for the advisory lock');
|
||||
|
||||
const [releaseRows] = await blocker.query(
|
||||
`SELECT RELEASE_LOCK(${lockExpression}) AS released`
|
||||
);
|
||||
assert.equal(Number(releaseRows[0]?.released ?? 0), 1);
|
||||
held = false;
|
||||
await pending;
|
||||
console.log('PASS: concurrent migration processes are serialized by a database advisory lock.');
|
||||
} finally {
|
||||
if (held) await blocker.query(`SELECT RELEASE_LOCK(${lockExpression}) AS released`);
|
||||
blocker.release();
|
||||
if (pending) await pending.catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function readMigrationVersions(pool) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT version, name
|
||||
FROM qipai_schema_migrations
|
||||
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ORDER BY version`,
|
||||
['2026061601', '2026061802', '2026061803', '2026061804',
|
||||
'2026061805', '2026061806', '2026061807', '2026061808', '2026061809',
|
||||
'2026061810', '2026061811', '2026062012', '2026062013', '2026062014',
|
||||
'2026062015', '2026062216', '2026062217', '2026062218', '2026062219',
|
||||
'2026062220', '2026081002', '2026081003', '2026081004', '2026081005',
|
||||
'2026081006']
|
||||
'2026081006', '2026081107']
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
@@ -1839,7 +1931,7 @@ async function assertSystemOperations(pool, context) {
|
||||
assert.equal(logs.items[0].metadata.nested.safe, 'visible');
|
||||
const overview = await repository.getSystemOverview(context.tenantId);
|
||||
assert.equal(overview.tenant.id, context.tenantId);
|
||||
assert.equal(overview.latestMigration.version, '2026081006');
|
||||
assert.equal(overview.latestMigration.version, '2026081107');
|
||||
assert.ok(overview.counts.userCount > 0);
|
||||
await repository.updateTenant(actor, context.tenantId, {
|
||||
name: overview.tenant.name, timezone: overview.tenant.timezone
|
||||
@@ -3120,6 +3212,791 @@ async function assertCleaningTaskTransactions(pool, context) {
|
||||
);
|
||||
}
|
||||
|
||||
async function assertProductInventoryFoundation(pool, context) {
|
||||
const [adminRows] = await pool.query(
|
||||
`SELECT u.id FROM qipai_users u
|
||||
INNER JOIN qipai_user_roles ur
|
||||
ON ur.tenant_id = u.tenant_id AND ur.user_id = u.id
|
||||
INNER JOIN qipai_roles r
|
||||
ON r.tenant_id = ur.tenant_id AND r.id = ur.role_id
|
||||
WHERE u.tenant_id = ? AND r.code = 'TENANT_ADMIN'
|
||||
AND u.deleted_at IS NULL
|
||||
ORDER BY u.id LIMIT 1`,
|
||||
[context.tenantId]
|
||||
);
|
||||
const [storeRows] = await pool.query(
|
||||
`SELECT id FROM qipai_stores
|
||||
WHERE tenant_id = ? AND name = 'M03A Store' AND deleted_at IS NULL
|
||||
ORDER BY id LIMIT 1`,
|
||||
[context.tenantId]
|
||||
);
|
||||
assert.ok(adminRows[0], 'M09-D1 requires the tenant administrator fixture.');
|
||||
assert.ok(storeRows[0], 'M09-D1 requires the M03A store fixture.');
|
||||
|
||||
const adminId = String(adminRows[0].id);
|
||||
const storeId = String(storeRows[0].id);
|
||||
const rbac = new RbacRepository(pool);
|
||||
const access = await rbac.getAccessProfile(context.tenantId, adminId);
|
||||
for (const capability of [
|
||||
'product.catalog.read', 'product.catalog.write', 'inventory.read', 'inventory.adjust'
|
||||
]) assert.ok(access.capabilities.includes(capability), `missing M09-D1 capability ${capability}`);
|
||||
const actor = {
|
||||
tenantId: context.tenantId,
|
||||
userId: adminId,
|
||||
access,
|
||||
traceId: 'm09d1-live-test',
|
||||
ip: '127.0.0.1',
|
||||
userAgent: 'M09-D1 live MySQL test'
|
||||
};
|
||||
const catalog = new ProductCatalogRepository(pool);
|
||||
const inventory = new InventoryService(pool);
|
||||
|
||||
const categoryInput = {
|
||||
parentId: null,
|
||||
categoryCode: 'M09D1-DRINKS',
|
||||
name: 'M09D1 Drinks',
|
||||
description: 'Sanitized live category',
|
||||
imageUrl: '',
|
||||
status: 'ACTIVE',
|
||||
sortOrder: 1
|
||||
};
|
||||
const category = await catalog.createCategory(actor, storeId, categoryInput);
|
||||
const [foreignUserRows] = await pool.query(
|
||||
`SELECT id FROM qipai_users WHERE tenant_id <> ? ORDER BY id LIMIT 1`,
|
||||
[context.tenantId]
|
||||
);
|
||||
assert.ok(foreignUserRows[0], 'M09-D1 requires a foreign-tenant user fixture.');
|
||||
await assert.rejects(
|
||||
() => pool.query(
|
||||
`INSERT INTO qipai_product_categories
|
||||
(tenant_id, store_id, category_code, name, created_by, updated_by)
|
||||
VALUES (?, ?, 'M09D1-FOREIGN-ACTOR', 'invalid actor', ?, ?)`,
|
||||
[context.tenantId, storeId, foreignUserRows[0].id, foreignUserRows[0].id]
|
||||
),
|
||||
(error) => error?.code === 'ER_NO_REFERENCED_ROW_2'
|
||||
);
|
||||
await assert.rejects(
|
||||
() => catalog.createCategory(actor, storeId, categoryInput),
|
||||
(error) => error instanceof ProductCatalogError
|
||||
&& error.code === 'PRODUCT_CATEGORY_CODE_CONFLICT'
|
||||
);
|
||||
|
||||
const [otherStoreRows] = await pool.query(
|
||||
`SELECT id FROM qipai_stores
|
||||
WHERE tenant_id = ? AND id <> ? AND deleted_at IS NULL
|
||||
ORDER BY id LIMIT 1`,
|
||||
[context.tenantId, storeId]
|
||||
);
|
||||
assert.ok(otherStoreRows[0]);
|
||||
const otherStoreId = String(otherStoreRows[0].id);
|
||||
await catalog.createCategory(actor, otherStoreId, categoryInput);
|
||||
const storeScopedActor = {
|
||||
...actor,
|
||||
access: {
|
||||
roles: ['STORE_ADMIN'],
|
||||
capabilities: [
|
||||
'product.catalog.read', 'product.catalog.write', 'inventory.read', 'inventory.adjust'
|
||||
],
|
||||
storeIds: [storeId]
|
||||
}
|
||||
};
|
||||
await assert.rejects(
|
||||
() => catalog.listCategories(storeScopedActor, otherStoreId),
|
||||
(error) => error instanceof ProductCatalogError
|
||||
&& error.code === 'PRODUCT_STORE_SCOPE_FORBIDDEN'
|
||||
);
|
||||
|
||||
const reusable = await catalog.createCategory(actor, storeId, {
|
||||
...categoryInput,
|
||||
categoryCode: 'M09D1-REUSABLE',
|
||||
name: 'M09D1 Reusable'
|
||||
});
|
||||
await catalog.archiveCategory(actor, storeId, reusable.categoryId, reusable.version);
|
||||
const reused = await catalog.createCategory(actor, storeId, {
|
||||
...categoryInput,
|
||||
categoryCode: 'M09D1-REUSABLE',
|
||||
name: 'M09D1 Reused'
|
||||
});
|
||||
assert.notEqual(reused.categoryId, reusable.categoryId);
|
||||
|
||||
const productInput = {
|
||||
productCode: 'M09D1-TEA',
|
||||
name: 'M09D1 Tea',
|
||||
unitName: 'bottle',
|
||||
description: 'Sanitized live product',
|
||||
coverUrl: '',
|
||||
images: [],
|
||||
deliveryEnabled: true,
|
||||
storageEnabled: true,
|
||||
status: 'ACTIVE',
|
||||
sortOrder: 1
|
||||
};
|
||||
const product = await catalog.createProduct(actor, productInput);
|
||||
await assert.rejects(
|
||||
() => catalog.createProduct(actor, productInput),
|
||||
(error) => error instanceof ProductCatalogError && error.code === 'PRODUCT_CODE_CONFLICT'
|
||||
);
|
||||
await assert.rejects(
|
||||
() => catalog.updateProduct(actor, product.productId, 99, {
|
||||
...productInput,
|
||||
name: 'M09D1 stale update'
|
||||
}),
|
||||
(error) => error instanceof ProductCatalogError && error.code === 'PRODUCT_VERSION_CONFLICT'
|
||||
);
|
||||
|
||||
const firstSkuInput = {
|
||||
skuCode: 'M09D1-TEA-500',
|
||||
name: '500ml',
|
||||
attributes: { volume: '500ml' },
|
||||
barcode: 'M09D1000001',
|
||||
imageUrl: '',
|
||||
salePriceCents: 600,
|
||||
marketPriceCents: 800,
|
||||
costPriceCents: 250,
|
||||
defaultInventoryPolicy: 'TRACKED',
|
||||
status: 'ACTIVE'
|
||||
};
|
||||
const secondSkuInput = {
|
||||
...firstSkuInput,
|
||||
skuCode: 'M09D1-TEA-330',
|
||||
name: '330ml',
|
||||
attributes: { volume: '330ml' },
|
||||
barcode: 'M09D1000002',
|
||||
salePriceCents: 500
|
||||
};
|
||||
const unlimitedSkuInput = {
|
||||
...firstSkuInput,
|
||||
skuCode: 'M09D1-TEA-GIFT',
|
||||
name: 'gift entitlement',
|
||||
attributes: { kind: 'gift' },
|
||||
barcode: 'M09D1000003',
|
||||
defaultInventoryPolicy: 'UNLIMITED',
|
||||
salePriceCents: 100
|
||||
};
|
||||
const firstSku = await catalog.createSku(actor, product.productId, firstSkuInput);
|
||||
const secondSku = await catalog.createSku(actor, product.productId, secondSkuInput);
|
||||
const unlimitedSku = await catalog.createSku(actor, product.productId, unlimitedSkuInput);
|
||||
await assert.rejects(
|
||||
() => catalog.updateSku(actor, product.productId, firstSku.skuId, 99, firstSkuInput),
|
||||
(error) => error instanceof ProductCatalogError
|
||||
&& error.code === 'PRODUCT_SKU_VERSION_CONFLICT'
|
||||
);
|
||||
await assert.rejects(
|
||||
() => pool.query(
|
||||
`INSERT INTO qipai_product_skus
|
||||
(tenant_id, product_id, sku_code, name, sale_price_cents,
|
||||
default_inventory_policy, created_by, updated_by)
|
||||
VALUES (?, ?, 'M09D1-AMOUNT-OVERFLOW', 'invalid amount', 100000001,
|
||||
'TRACKED', ?, ?)`,
|
||||
[context.tenantId, product.productId, adminId, adminId]
|
||||
),
|
||||
(error) => error?.code === 'ER_CHECK_CONSTRAINT_VIOLATED'
|
||||
);
|
||||
|
||||
const disposableProductInput = {
|
||||
...productInput,
|
||||
productCode: 'M09D1-DISPOSABLE',
|
||||
name: 'M09D1 Disposable'
|
||||
};
|
||||
const disposableProduct = await catalog.createProduct(actor, disposableProductInput);
|
||||
const disposableSkuInput = {
|
||||
...firstSkuInput,
|
||||
skuCode: 'M09D1-DISPOSABLE-SKU',
|
||||
name: 'disposable sku',
|
||||
barcode: 'M09D1000099'
|
||||
};
|
||||
const disposableSku = await catalog.createSku(
|
||||
actor, disposableProduct.productId, disposableSkuInput
|
||||
);
|
||||
await inventory.configurePolicy(actor, {
|
||||
storeId,
|
||||
skuId: disposableSku.skuId,
|
||||
requestId: 'm09d1-disposable-policy',
|
||||
reason: 'create zero inventory before archive',
|
||||
expectedVersion: 0,
|
||||
policyType: 'TRACKED',
|
||||
lowStockThreshold: 0
|
||||
});
|
||||
await catalog.archiveSku(
|
||||
actor, disposableProduct.productId, disposableSku.skuId, disposableSku.version
|
||||
);
|
||||
await assert.rejects(
|
||||
() => inventory.inbound(actor, {
|
||||
storeId,
|
||||
skuId: disposableSku.skuId,
|
||||
requestId: 'm09d1-archived-sku-inbound',
|
||||
reason: 'archived sku must reject inventory writes',
|
||||
quantity: 1
|
||||
}),
|
||||
(error) => error instanceof InventoryError && error.code === 'INVENTORY_SKU_NOT_FOUND'
|
||||
);
|
||||
await catalog.archiveProduct(
|
||||
actor, disposableProduct.productId, disposableProduct.version
|
||||
);
|
||||
|
||||
const listing = await catalog.putListing(actor, storeId, product.productId, 0, {
|
||||
categoryId: category.categoryId,
|
||||
status: 'ACTIVE',
|
||||
fulfillmentMode: 'BOTH',
|
||||
salesStartAt: null,
|
||||
salesEndAt: null,
|
||||
sortOrder: 1
|
||||
});
|
||||
await assert.rejects(
|
||||
() => catalog.putListing(actor, storeId, product.productId, 99, {
|
||||
categoryId: category.categoryId,
|
||||
status: 'ACTIVE',
|
||||
fulfillmentMode: 'BOTH',
|
||||
salesStartAt: null,
|
||||
salesEndAt: null,
|
||||
sortOrder: 2
|
||||
}),
|
||||
(error) => error instanceof ProductCatalogError
|
||||
&& error.code === 'PRODUCT_LISTING_VERSION_CONFLICT'
|
||||
);
|
||||
assert.equal(listing.version, 1);
|
||||
|
||||
const overnightHours = [{
|
||||
weekday: 1,
|
||||
slotNo: 1,
|
||||
openMinute: 22 * 60,
|
||||
closeMinute: 2 * 60,
|
||||
crossesMidnight: true
|
||||
}];
|
||||
const settings = await catalog.putStoreSettings(actor, storeId, 0, {
|
||||
salesStatus: 'OPEN',
|
||||
manualPaused: false,
|
||||
manualPausedUntil: null,
|
||||
manualPauseReason: '',
|
||||
hours: overnightHours
|
||||
}, new Date('2026-08-10T14:00:00.000Z'));
|
||||
assert.equal(
|
||||
(await catalog.getStoreSettings(
|
||||
actor, storeId, new Date('2026-08-10T15:00:00.000Z')
|
||||
)).openNow,
|
||||
true,
|
||||
'cross-midnight product hours must open on the configured weekday'
|
||||
);
|
||||
assert.equal(
|
||||
(await catalog.getStoreSettings(
|
||||
actor, storeId, new Date('2026-08-10T17:00:00.000Z')
|
||||
)).openNow,
|
||||
true,
|
||||
'cross-midnight product hours must include the previous-day window'
|
||||
);
|
||||
await assert.rejects(
|
||||
() => catalog.putStoreSettings(actor, storeId, settings.version, {
|
||||
salesStatus: 'OPEN',
|
||||
manualPaused: false,
|
||||
manualPausedUntil: null,
|
||||
manualPauseReason: '',
|
||||
hours: [
|
||||
...overnightHours,
|
||||
{ weekday: 2, slotNo: 1, openMinute: 60, closeMinute: 180, crossesMidnight: false }
|
||||
]
|
||||
}),
|
||||
(error) => error instanceof ProductCatalogError
|
||||
&& error.code === 'PRODUCT_BUSINESS_HOUR_OVERLAP'
|
||||
);
|
||||
const paused = await catalog.putStoreSettings(actor, storeId, settings.version, {
|
||||
salesStatus: 'OPEN',
|
||||
manualPaused: true,
|
||||
manualPausedUntil: null,
|
||||
manualPauseReason: 'M09-D1 manual pause',
|
||||
hours: overnightHours
|
||||
}, new Date('2026-08-10T14:30:00.000Z'));
|
||||
assert.equal(
|
||||
(await catalog.getStoreSettings(
|
||||
actor, storeId, new Date('2026-08-10T15:00:00.000Z')
|
||||
)).openNow,
|
||||
false
|
||||
);
|
||||
await catalog.putStoreSettings(actor, storeId, paused.version, {
|
||||
salesStatus: 'OPEN',
|
||||
manualPaused: false,
|
||||
manualPausedUntil: null,
|
||||
manualPauseReason: '',
|
||||
hours: overnightHours
|
||||
}, new Date('2026-08-10T14:40:00.000Z'));
|
||||
|
||||
const configuredFirst = await inventory.configurePolicy(actor, {
|
||||
storeId,
|
||||
skuId: firstSku.skuId,
|
||||
requestId: 'm09d1-policy-first',
|
||||
reason: 'configure tracked stock',
|
||||
expectedVersion: 0,
|
||||
policyType: 'TRACKED',
|
||||
lowStockThreshold: 1
|
||||
});
|
||||
const firstInbound = await inventory.inbound(actor, {
|
||||
storeId,
|
||||
skuId: firstSku.skuId,
|
||||
requestId: 'm09d1-inbound-first',
|
||||
reason: 'initial inbound stock',
|
||||
expectedVersion: configuredFirst.version,
|
||||
quantity: 5
|
||||
});
|
||||
const configuredSecond = await inventory.configurePolicy(actor, {
|
||||
storeId,
|
||||
skuId: secondSku.skuId,
|
||||
requestId: 'm09d1-policy-second',
|
||||
reason: 'configure second tracked stock',
|
||||
expectedVersion: 0,
|
||||
policyType: 'TRACKED',
|
||||
lowStockThreshold: 1
|
||||
});
|
||||
await inventory.inbound(actor, {
|
||||
storeId,
|
||||
skuId: secondSku.skuId,
|
||||
requestId: 'm09d1-inbound-second',
|
||||
reason: 'second inbound stock',
|
||||
expectedVersion: configuredSecond.version,
|
||||
quantity: 6
|
||||
});
|
||||
const configuredUnlimited = await inventory.configurePolicy(actor, {
|
||||
storeId,
|
||||
skuId: unlimitedSku.skuId,
|
||||
requestId: 'm09d1-policy-unlimited',
|
||||
reason: 'configure unlimited stock',
|
||||
expectedVersion: 0,
|
||||
policyType: 'UNLIMITED',
|
||||
lowStockThreshold: 0
|
||||
});
|
||||
const unlimitedLockInput = {
|
||||
tenantId: context.tenantId,
|
||||
storeId,
|
||||
items: [{ skuId: unlimitedSku.skuId, quantity: 2 }],
|
||||
requestId: 'm09d1-unlimited-lock',
|
||||
businessType: 'GOODS_ORDER_ITEM',
|
||||
businessId: 'm09d1-unlimited-order-item',
|
||||
traceId: 'm09d1-unlimited-lock',
|
||||
operatorId: adminId,
|
||||
reason: 'lock unlimited inventory entitlement'
|
||||
};
|
||||
const unlimitedLock = await inventory.lockMany(unlimitedLockInput);
|
||||
await assert.rejects(
|
||||
() => inventory.configurePolicy(actor, {
|
||||
storeId,
|
||||
skuId: unlimitedSku.skuId,
|
||||
requestId: 'm09d1-unlimited-switch-active',
|
||||
reason: 'active reservation must block policy switch',
|
||||
expectedVersion: unlimitedLock.items[0].version,
|
||||
policyType: 'TRACKED',
|
||||
lowStockThreshold: 0
|
||||
}),
|
||||
(error) => error instanceof InventoryError
|
||||
&& error.code === 'INVENTORY_POLICY_HAS_ACTIVE_RESERVATIONS'
|
||||
);
|
||||
const unlimitedDeduct = await inventory.deductMany({
|
||||
...unlimitedLockInput,
|
||||
requestId: 'm09d1-unlimited-deduct',
|
||||
traceId: 'm09d1-unlimited-deduct',
|
||||
reason: 'settle unlimited inventory entitlement'
|
||||
});
|
||||
const switchedUnlimited = await inventory.configurePolicy(actor, {
|
||||
storeId,
|
||||
skuId: unlimitedSku.skuId,
|
||||
requestId: 'm09d1-unlimited-switch-settled',
|
||||
reason: 'switch after reservation settlement',
|
||||
expectedVersion: unlimitedDeduct.items[0].version,
|
||||
policyType: 'TRACKED',
|
||||
lowStockThreshold: 0
|
||||
});
|
||||
assert.equal(configuredUnlimited.policyType, 'UNLIMITED');
|
||||
assert.equal(switchedUnlimited.policyType, 'TRACKED');
|
||||
|
||||
const lockInputs = ['a', 'b'].map((suffix) => ({
|
||||
tenantId: context.tenantId,
|
||||
storeId,
|
||||
items: [{ skuId: firstSku.skuId, quantity: 4 }],
|
||||
requestId: `m09d1-concurrent-lock-${suffix}`,
|
||||
businessType: 'GOODS_ORDER_ITEM',
|
||||
businessId: `m09d1-order-item-${suffix}`,
|
||||
traceId: `m09d1-concurrent-lock-${suffix}`,
|
||||
operatorId: adminId,
|
||||
ip: '127.0.0.1',
|
||||
userAgent: 'M09-D1 concurrent inventory lock',
|
||||
reason: 'concurrent inventory lock'
|
||||
}));
|
||||
const concurrentLocks = await Promise.allSettled(lockInputs.map((input) =>
|
||||
inventory.lockMany(input)
|
||||
));
|
||||
const winningIndexes = concurrentLocks
|
||||
.map((result, index) => result.status === 'fulfilled' ? index : -1)
|
||||
.filter((index) => index >= 0);
|
||||
const rejected = concurrentLocks.filter((result) => result.status === 'rejected');
|
||||
assert.equal(winningIndexes.length, 1, 'concurrent inventory lock must have one winner');
|
||||
assert.equal(rejected.length, 1, 'concurrent inventory lock must reject one contender');
|
||||
assert.ok(rejected[0].reason instanceof InventoryError);
|
||||
assert.equal(rejected[0].reason.code, 'INVENTORY_INSUFFICIENT_AVAILABLE');
|
||||
|
||||
const winningLock = lockInputs[winningIndexes[0]];
|
||||
const replay = await inventory.lockMany(winningLock);
|
||||
assert.equal(replay.idempotent, true);
|
||||
await assert.rejects(
|
||||
() => inventory.lockMany({
|
||||
...winningLock,
|
||||
items: [{ skuId: firstSku.skuId, quantity: 3 }]
|
||||
}),
|
||||
(error) => error instanceof InventoryError
|
||||
&& error.code === 'INVENTORY_IDEMPOTENCY_CONFLICT'
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
() => inventory.releaseMany({
|
||||
...winningLock,
|
||||
requestId: 'm09d1-cross-order-release',
|
||||
businessId: 'm09d1-foreign-order-item',
|
||||
items: [{ skuId: firstSku.skuId, quantity: 1 }],
|
||||
reason: 'must not release another order reservation'
|
||||
}),
|
||||
(error) => error instanceof InventoryError
|
||||
&& error.code === 'INVENTORY_INSUFFICIENT_RESERVATION'
|
||||
);
|
||||
|
||||
await inventory.releaseMany({
|
||||
tenantId: context.tenantId,
|
||||
storeId,
|
||||
items: [{ skuId: firstSku.skuId, quantity: 2 }],
|
||||
requestId: 'm09d1-release-two',
|
||||
businessType: 'GOODS_ORDER_ITEM',
|
||||
businessId: winningLock.businessId,
|
||||
traceId: 'm09d1-release-two',
|
||||
operatorId: adminId,
|
||||
reason: 'release two locked units'
|
||||
});
|
||||
await inventory.deductMany({
|
||||
tenantId: context.tenantId,
|
||||
storeId,
|
||||
items: [{ skuId: firstSku.skuId, quantity: 2 }],
|
||||
requestId: 'm09d1-deduct-two',
|
||||
businessType: 'GOODS_ORDER_ITEM',
|
||||
businessId: winningLock.businessId,
|
||||
traceId: 'm09d1-deduct-two',
|
||||
operatorId: adminId,
|
||||
reason: 'deduct two locked units'
|
||||
});
|
||||
|
||||
const caseSensitiveRequestBase = {
|
||||
tenantId: context.tenantId,
|
||||
storeId,
|
||||
items: [{ skuId: secondSku.skuId, quantity: 1 }],
|
||||
businessType: 'GOODS_ORDER_ITEM',
|
||||
businessId: 'm09d1-case-sensitive-request-order',
|
||||
traceId: 'm09d1-case-sensitive-request',
|
||||
operatorId: adminId,
|
||||
reason: 'opaque request ids are case sensitive'
|
||||
};
|
||||
const upperCaseRequest = await inventory.lockMany({
|
||||
...caseSensitiveRequestBase,
|
||||
requestId: 'M09D1-Request-Case'
|
||||
});
|
||||
const lowerCaseRequest = await inventory.lockMany({
|
||||
...caseSensitiveRequestBase,
|
||||
requestId: 'm09d1-request-case'
|
||||
});
|
||||
assert.equal(upperCaseRequest.idempotent, false);
|
||||
assert.equal(lowerCaseRequest.idempotent, false);
|
||||
assert.notEqual(upperCaseRequest.items[0].ledgerId, lowerCaseRequest.items[0].ledgerId);
|
||||
await inventory.releaseMany({
|
||||
...caseSensitiveRequestBase,
|
||||
requestId: 'm09d1-request-case-release',
|
||||
items: [{ skuId: secondSku.skuId, quantity: 2 }],
|
||||
reason: 'release both case-sensitive requests'
|
||||
});
|
||||
|
||||
const businessCaseLock = await inventory.lockMany({
|
||||
...caseSensitiveRequestBase,
|
||||
requestId: 'm09d1-business-case-lock',
|
||||
businessId: 'Order-A',
|
||||
reason: 'lock with case-sensitive business id'
|
||||
});
|
||||
await assert.rejects(
|
||||
() => inventory.releaseMany({
|
||||
...caseSensitiveRequestBase,
|
||||
requestId: 'm09d1-business-case-wrong-release',
|
||||
businessId: 'order-a',
|
||||
reason: 'business id case mismatch must reject'
|
||||
}),
|
||||
(error) => error instanceof InventoryError
|
||||
&& error.code === 'INVENTORY_INSUFFICIENT_RESERVATION'
|
||||
);
|
||||
await inventory.releaseMany({
|
||||
...caseSensitiveRequestBase,
|
||||
requestId: 'm09d1-business-case-release',
|
||||
businessId: 'Order-A',
|
||||
reason: 'release exact business id reservation'
|
||||
});
|
||||
assert.equal(businessCaseLock.idempotent, false);
|
||||
|
||||
const globalRequestInputs = [firstSku.skuId, secondSku.skuId].map((skuId) => ({
|
||||
tenantId: context.tenantId,
|
||||
storeId,
|
||||
items: [{ skuId, quantity: 1 }],
|
||||
requestId: 'm09d1-global-request-race',
|
||||
businessType: 'GOODS_ORDER_ITEM',
|
||||
businessId: 'm09d1-global-request-order',
|
||||
traceId: `m09d1-global-request-${skuId}`,
|
||||
operatorId: adminId,
|
||||
reason: 'same request must not cover disjoint sku sets'
|
||||
}));
|
||||
const globalRequestRace = await Promise.allSettled(
|
||||
globalRequestInputs.map((input) => inventory.lockMany(input))
|
||||
);
|
||||
const globalWinnerIndex = globalRequestRace.findIndex((result) => result.status === 'fulfilled');
|
||||
const globalRejected = globalRequestRace.filter((result) => result.status === 'rejected');
|
||||
assert.notEqual(globalWinnerIndex, -1, 'one global inventory request must win');
|
||||
assert.equal(globalRejected.length, 1, 'disjoint reuse of one request id must reject once');
|
||||
assert.ok(globalRejected[0].reason instanceof InventoryError);
|
||||
assert.equal(globalRejected[0].reason.code, 'INVENTORY_IDEMPOTENCY_CONFLICT');
|
||||
const globalWinner = globalRequestInputs[globalWinnerIndex];
|
||||
await inventory.releaseMany({
|
||||
...globalWinner,
|
||||
requestId: 'm09d1-global-request-release',
|
||||
traceId: 'm09d1-global-request-release',
|
||||
reason: 'release the winning global request reservation'
|
||||
});
|
||||
|
||||
const [secondVersionRows] = await pool.query(
|
||||
`SELECT version FROM qipai_product_inventory
|
||||
WHERE tenant_id = ? AND store_id = ? AND sku_id = ?`,
|
||||
[context.tenantId, storeId, secondSku.skuId]
|
||||
);
|
||||
const secondExpectedVersion = Number(secondVersionRows[0].version);
|
||||
const policyRace = await Promise.allSettled([2, 3].map((lowStockThreshold, index) =>
|
||||
inventory.configurePolicy(actor, {
|
||||
storeId,
|
||||
skuId: secondSku.skuId,
|
||||
requestId: `m09d1-policy-race-${index}`,
|
||||
reason: 'concurrent policy threshold update',
|
||||
expectedVersion: secondExpectedVersion,
|
||||
policyType: 'TRACKED',
|
||||
lowStockThreshold
|
||||
})
|
||||
));
|
||||
assert.equal(
|
||||
policyRace.filter((result) => result.status === 'fulfilled').length,
|
||||
1,
|
||||
'one inventory policy CAS must win'
|
||||
);
|
||||
const rejectedPolicy = policyRace.find((result) => result.status === 'rejected');
|
||||
assert.ok(rejectedPolicy?.reason instanceof InventoryError);
|
||||
assert.equal(rejectedPolicy.reason.code, 'INVENTORY_VERSION_CONFLICT');
|
||||
|
||||
const [beforeAtomicRows] = await pool.query(
|
||||
`SELECT sku_id AS skuId, available_quantity AS availableQuantity,
|
||||
locked_quantity AS lockedQuantity, loss_quantity AS lossQuantity, version
|
||||
FROM qipai_product_inventory
|
||||
WHERE tenant_id = ? AND store_id = ? AND sku_id IN (?, ?)
|
||||
ORDER BY sku_id`,
|
||||
[context.tenantId, storeId, firstSku.skuId, secondSku.skuId]
|
||||
);
|
||||
await assert.rejects(
|
||||
() => inventory.lockMany({
|
||||
tenantId: context.tenantId,
|
||||
storeId,
|
||||
items: [
|
||||
{ skuId: firstSku.skuId, quantity: 2 },
|
||||
{ skuId: secondSku.skuId, quantity: 7 }
|
||||
],
|
||||
requestId: 'm09d1-atomic-reject',
|
||||
businessType: 'GOODS_ORDER_ITEM',
|
||||
businessId: 'm09d1-atomic-reject',
|
||||
traceId: 'm09d1-atomic-reject',
|
||||
operatorId: adminId,
|
||||
reason: 'reject the complete batch'
|
||||
}),
|
||||
(error) => error instanceof InventoryError
|
||||
&& error.code === 'INVENTORY_INSUFFICIENT_AVAILABLE'
|
||||
);
|
||||
const [afterAtomicRows] = await pool.query(
|
||||
`SELECT sku_id AS skuId, available_quantity AS availableQuantity,
|
||||
locked_quantity AS lockedQuantity, loss_quantity AS lossQuantity, version
|
||||
FROM qipai_product_inventory
|
||||
WHERE tenant_id = ? AND store_id = ? AND sku_id IN (?, ?)
|
||||
ORDER BY sku_id`,
|
||||
[context.tenantId, storeId, firstSku.skuId, secondSku.skuId]
|
||||
);
|
||||
assert.deepEqual(afterAtomicRows, beforeAtomicRows, 'failed batch must not partially mutate stock');
|
||||
|
||||
await pool.query(
|
||||
`UPDATE qipai_product_skus SET status = 'INACTIVE', version = version + 1
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[context.tenantId, firstSku.skuId]
|
||||
);
|
||||
const inactiveSkuReplay = await inventory.lockMany(winningLock);
|
||||
assert.equal(
|
||||
inactiveSkuReplay.idempotent,
|
||||
true,
|
||||
'an exact inventory request replay must survive a later SKU deactivation'
|
||||
);
|
||||
await pool.query(
|
||||
`UPDATE qipai_product_skus SET status = 'ACTIVE', version = version + 1
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[context.tenantId, firstSku.skuId]
|
||||
);
|
||||
|
||||
const [firstStockRows] = await pool.query(
|
||||
`SELECT id, available_quantity AS availableQuantity,
|
||||
locked_quantity AS lockedQuantity, loss_quantity AS lossQuantity, version
|
||||
FROM qipai_product_inventory
|
||||
WHERE tenant_id = ? AND store_id = ? AND sku_id = ?`,
|
||||
[context.tenantId, storeId, firstSku.skuId]
|
||||
);
|
||||
const firstStock = firstStockRows[0];
|
||||
assert.deepEqual(
|
||||
{
|
||||
availableQuantity: Number(firstStock.availableQuantity),
|
||||
lockedQuantity: Number(firstStock.lockedQuantity),
|
||||
lossQuantity: Number(firstStock.lossQuantity)
|
||||
},
|
||||
{ availableQuantity: 3, lockedQuantity: 0, lossQuantity: 0 }
|
||||
);
|
||||
const loss = await inventory.recordLoss(actor, {
|
||||
storeId,
|
||||
skuId: firstSku.skuId,
|
||||
requestId: 'm09d1-loss-one',
|
||||
reason: 'record one damaged unit',
|
||||
expectedVersion: Number(firstStock.version),
|
||||
quantity: 1
|
||||
});
|
||||
const stocktakeRace = await Promise.allSettled([2, 1].map((availableQuantity, index) =>
|
||||
inventory.stocktake(actor, {
|
||||
storeId,
|
||||
skuId: firstSku.skuId,
|
||||
requestId: `m09d1-stocktake-${index}`,
|
||||
reason: 'concurrent absolute stocktake',
|
||||
expectedVersion: loss.version,
|
||||
availableQuantity,
|
||||
lossQuantity: 1
|
||||
})
|
||||
));
|
||||
const stocktakeWinners = stocktakeRace.filter((result) => result.status === 'fulfilled');
|
||||
const stocktakeRejected = stocktakeRace.filter((result) => result.status === 'rejected');
|
||||
assert.equal(stocktakeWinners.length, 1, 'one absolute stocktake CAS must win');
|
||||
assert.equal(stocktakeRejected.length, 1, 'one stale absolute stocktake CAS must reject');
|
||||
assert.ok(stocktakeRejected[0].reason instanceof InventoryError);
|
||||
assert.equal(stocktakeRejected[0].reason.code, 'INVENTORY_VERSION_CONFLICT');
|
||||
const stocktake = stocktakeWinners[0].value;
|
||||
await assert.rejects(
|
||||
() => inventory.adjust(actor, {
|
||||
storeId,
|
||||
skuId: firstSku.skuId,
|
||||
requestId: 'm09d1-negative-adjust',
|
||||
reason: 'negative inventory must fail',
|
||||
expectedVersion: stocktake.version,
|
||||
availableDelta: -3,
|
||||
lossDelta: 0
|
||||
}),
|
||||
(error) => error instanceof InventoryError
|
||||
&& error.code === 'INVENTORY_QUANTITY_OUT_OF_RANGE'
|
||||
);
|
||||
await assert.rejects(
|
||||
() => inventory.inbound(actor, {
|
||||
storeId,
|
||||
skuId: firstSku.skuId,
|
||||
requestId: 'm09d1-stale-inbound',
|
||||
reason: 'stale version must fail',
|
||||
expectedVersion: firstInbound.version,
|
||||
quantity: 1
|
||||
}),
|
||||
(error) => error instanceof InventoryError && error.code === 'INVENTORY_VERSION_CONFLICT'
|
||||
);
|
||||
await assert.rejects(
|
||||
() => inventory.listStocks({
|
||||
...storeScopedActor,
|
||||
access: { ...storeScopedActor.access, storeIds: [otherStoreId] }
|
||||
}, { storeId, page: 1, pageSize: 20 }),
|
||||
(error) => error instanceof InventoryError
|
||||
&& error.code === 'INVENTORY_STORE_SCOPE_FORBIDDEN'
|
||||
);
|
||||
await assert.rejects(
|
||||
() => pool.query(
|
||||
`UPDATE qipai_product_inventory SET available_quantity = 1000000001
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[context.tenantId, firstStock.id]
|
||||
),
|
||||
(error) => error?.code === 'ER_CHECK_CONSTRAINT_VIOLATED'
|
||||
);
|
||||
|
||||
const [ledgerRows] = await pool.query(
|
||||
`SELECT COALESCE(SUM(available_delta), 0) AS availableDelta,
|
||||
COALESCE(SUM(locked_delta), 0) AS lockedDelta,
|
||||
COALESCE(SUM(loss_delta), 0) AS lossDelta,
|
||||
COUNT(*) AS ledgerCount,
|
||||
COUNT(DISTINCT version_after) AS versionCount
|
||||
FROM qipai_product_inventory_ledger
|
||||
WHERE tenant_id = ? AND inventory_id = ?`,
|
||||
[context.tenantId, firstStock.id]
|
||||
);
|
||||
const [finalStockRows] = await pool.query(
|
||||
`SELECT available_quantity AS availableQuantity,
|
||||
locked_quantity AS lockedQuantity, loss_quantity AS lossQuantity
|
||||
FROM qipai_product_inventory
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[context.tenantId, firstStock.id]
|
||||
);
|
||||
assert.deepEqual(
|
||||
{
|
||||
availableQuantity: Number(ledgerRows[0].availableDelta),
|
||||
lockedQuantity: Number(ledgerRows[0].lockedDelta),
|
||||
lossQuantity: Number(ledgerRows[0].lossDelta)
|
||||
},
|
||||
{
|
||||
availableQuantity: Number(finalStockRows[0].availableQuantity),
|
||||
lockedQuantity: Number(finalStockRows[0].lockedQuantity),
|
||||
lossQuantity: Number(finalStockRows[0].lossQuantity)
|
||||
}
|
||||
);
|
||||
assert.equal(Number(ledgerRows[0].ledgerCount), Number(ledgerRows[0].versionCount));
|
||||
const [winningLedgerRows] = await pool.query(
|
||||
`SELECT COUNT(*) AS total FROM qipai_product_inventory_ledger
|
||||
WHERE tenant_id = ? AND inventory_id = ? AND request_id = ?`,
|
||||
[context.tenantId, firstStock.id, winningLock.requestId]
|
||||
);
|
||||
assert.equal(Number(winningLedgerRows[0].total), 1);
|
||||
|
||||
await assert.rejects(
|
||||
() => pool.query(
|
||||
`UPDATE qipai_product_inventory_ledger SET reason = 'forbidden'
|
||||
WHERE tenant_id = ? AND inventory_id = ? LIMIT 1`,
|
||||
[context.tenantId, firstStock.id]
|
||||
),
|
||||
(error) => /PRODUCT_INVENTORY_LEDGER_IMMUTABLE/.test(error?.message ?? '')
|
||||
);
|
||||
await assert.rejects(
|
||||
() => pool.query(
|
||||
`DELETE FROM qipai_product_inventory_ledger
|
||||
WHERE tenant_id = ? AND inventory_id = ? LIMIT 1`,
|
||||
[context.tenantId, firstStock.id]
|
||||
),
|
||||
(error) => /PRODUCT_INVENTORY_LEDGER_IMMUTABLE/.test(error?.message ?? '')
|
||||
);
|
||||
|
||||
const catalogView = await catalog.listStoreCatalog({
|
||||
tenantId: context.tenantId,
|
||||
storeId,
|
||||
now: new Date('2026-08-10T15:00:00.000Z')
|
||||
});
|
||||
assert.equal(catalogView.salesOpen, true);
|
||||
const publicSku = catalogView.categories[0]?.products[0]?.skus[0];
|
||||
assert.equal(Object.hasOwn(publicSku ?? {}, 'costPriceCents'), false);
|
||||
assert.equal(Object.hasOwn(publicSku ?? {}, 'availableQuantity'), false);
|
||||
|
||||
const [auditRows] = await pool.query(
|
||||
`SELECT COUNT(*) AS total FROM qipai_audit_logs
|
||||
WHERE tenant_id = ? AND (
|
||||
action LIKE 'PRODUCT_%' OR action LIKE 'PRODUCT_INVENTORY_%'
|
||||
)`,
|
||||
[context.tenantId]
|
||||
);
|
||||
assert.ok(Number(auditRows[0].total) >= 10);
|
||||
console.log(
|
||||
'PASS: M09-D1 product catalog, cross-midnight hours, concurrent inventory lock, '
|
||||
+ 'idempotency, reconciliation and immutable ledger are consistent.'
|
||||
);
|
||||
}
|
||||
|
||||
const config = loadConfig();
|
||||
assert.equal(config.mysql.passwordConfigured, true, 'Live migration test requires a temporary password.');
|
||||
assert.match(
|
||||
@@ -3142,6 +4019,9 @@ try {
|
||||
|
||||
await executeMigrationPlan(pool, plans.up);
|
||||
await executeMigrationPlan(pool, plans.verify);
|
||||
await assertMigrationAdvisoryLock(pool);
|
||||
await assertProductInventoryMigrationRetry(pool, plans.up);
|
||||
await executeMigrationPlan(pool, plans.verify);
|
||||
assert.deepEqual(await readCoreTables(pool), expectedTables);
|
||||
assert.deepEqual(await readMigrationVersions(pool), [
|
||||
{ version: '2026061601', name: 'm01b_core_schema' },
|
||||
@@ -3168,7 +4048,8 @@ try {
|
||||
{ version: '2026081003', name: 'm08d_franchise_leads' },
|
||||
{ version: '2026081004', name: 'm08d_admin_password_auth' },
|
||||
{ version: '2026081005', name: 'm09b_cleaning_rules' },
|
||||
{ version: '2026081006', name: 'm09c_cleaning_settlement_integrity' }
|
||||
{ version: '2026081006', name: 'm09c_cleaning_settlement_integrity' },
|
||||
{ version: '2026081107', name: 'm09d1_product_inventory_foundation' }
|
||||
]);
|
||||
await assertTaskDurability(pool);
|
||||
const loginContext = await assertPlatformTenantIsolation(pool);
|
||||
@@ -3191,13 +4072,14 @@ try {
|
||||
await assertDeviceTopology(pool, loginContext);
|
||||
await assertIotMessages(pool, loginContext);
|
||||
await assertCleaningTaskTransactions(pool, loginContext);
|
||||
await assertProductInventoryFoundation(pool, loginContext);
|
||||
await assertLegacyCompatibility(pool);
|
||||
console.log('PASS: first up, verify, tenant isolation and revocable auth checks completed.');
|
||||
|
||||
await executeMigrationPlan(pool, plans.down);
|
||||
assert.deepEqual(await readCoreTables(pool), []);
|
||||
await assertLegacyCompatibility(pool);
|
||||
console.log('PASS: down removed all M01-B through M09-C migration tables.');
|
||||
console.log('PASS: down removed all M01-B through M09-D1 migration tables.');
|
||||
|
||||
await executeMigrationPlan(pool, plans.up);
|
||||
await executeMigrationPlan(pool, plans.verify);
|
||||
@@ -3227,7 +4109,8 @@ try {
|
||||
{ version: '2026081003', name: 'm08d_franchise_leads' },
|
||||
{ version: '2026081004', name: 'm08d_admin_password_auth' },
|
||||
{ version: '2026081005', name: 'm09b_cleaning_rules' },
|
||||
{ version: '2026081006', name: 'm09c_cleaning_settlement_integrity' }
|
||||
{ version: '2026081006', name: 'm09c_cleaning_settlement_integrity' },
|
||||
{ version: '2026081107', name: 'm09d1_product_inventory_foundation' }
|
||||
]);
|
||||
await assertLegacyCompatibility(pool);
|
||||
console.log('PASS: second up and verify restored the schema.');
|
||||
@@ -3375,7 +4258,15 @@ try {
|
||||
'settlement reversal preserves history and releases the share',
|
||||
'processing payout blocks cancellation until provider-confirmed failure',
|
||||
'collaborative task settles only after every member share is paid',
|
||||
'settlement page and CSV export reuse identical query ordering'
|
||||
'settlement page and CSV export reuse identical query ordering',
|
||||
'store-scoped product category uniqueness and soft-delete code reuse',
|
||||
'product and SKU optimistic version conflicts',
|
||||
'cross-midnight product hours and manual pause precedence',
|
||||
'concurrent inventory lock has one winner without negative stock',
|
||||
'inventory request replay and fingerprint conflict',
|
||||
'atomic multi-SKU inventory mutation rollback',
|
||||
'inventory lock release deduct loss and stocktake reconciliation',
|
||||
'immutable inventory ledger update and delete rejection'
|
||||
]
|
||||
}, null, 2));
|
||||
} finally {
|
||||
|
||||
@@ -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.');
|
||||
@@ -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.');
|
||||
@@ -29,5 +29,9 @@ assert.match(calls.at(-1)[0], /s\.tenant_id = \?/);
|
||||
assert.match(calls.at(-1)[0], /u\.tenant_id = \?/);
|
||||
await repository.ensureCustomerRole('7', '21');
|
||||
assert.ok(calls.some(([sql]) => sql.includes("r.code = 'STAFF'") && sql.includes('store.operation.read')));
|
||||
assert.ok(calls.some(([sql]) => sql.includes("r.code = 'STAFF'")
|
||||
&& sql.includes('product.catalog.read') && sql.includes('inventory.read')));
|
||||
assert.ok(calls.some(([sql]) => sql.includes("r.code = 'STORE_ADMIN'")
|
||||
&& sql.includes('product.catalog.write') && sql.includes('inventory.adjust')));
|
||||
|
||||
console.log('PASS: M02-C roles, capabilities and tenant-scoped store grants are present.');
|
||||
|
||||
Reference in New Issue
Block a user