743 lines
27 KiB
JavaScript
743 lines
27 KiB
JavaScript
'use strict';
|
|
|
|
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const planningDatabase = require('../src/services/planningDatabase');
|
|
const productCodeClient = require('../src/services/alibaba1688ProductCodeClient');
|
|
const encodingRegistry = require('../src/services/alibaba1688ProductEncodingRegistry');
|
|
const {
|
|
MAX_CODING_SKUS,
|
|
normalizeCodingRequest,
|
|
normalizeUpstreamResult,
|
|
encodeAlibaba1688Product,
|
|
assertAlibaba1688PlanItemsEncoded,
|
|
} = require('../src/services/alibaba1688ProductEncodingService');
|
|
|
|
function productRecord(overrides = {}) {
|
|
return {
|
|
productId: '1073252781818',
|
|
title: '实木圆桌',
|
|
storeName: '测试家具厂',
|
|
skuCount: 2,
|
|
product: {
|
|
skus: [
|
|
{
|
|
index: 1,
|
|
name: '原木色 / 60cm',
|
|
specs: [{ name: '颜色', value: '原木色' }, { name: '尺寸', value: '60cm' }],
|
|
},
|
|
{
|
|
index: 2,
|
|
name: '胡桃色 / 80cm',
|
|
specs: [{ name: '颜色', value: '胡桃色' }, { name: '尺寸', value: '80cm' }],
|
|
},
|
|
],
|
|
},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function codingInput(overrides = {}) {
|
|
return {
|
|
operationCenter: '事业二部',
|
|
categoryName: '住宅家具 - 书架',
|
|
taxStatus: '含税',
|
|
invoiceType: '普票',
|
|
installationService: '不包安装',
|
|
installationMode: '组装',
|
|
skuItems: [
|
|
{
|
|
sourceSkuKey: '1:颜色=原木色|尺寸=60cm',
|
|
productName: '实木圆桌原木色60cm',
|
|
colorSpec: '颜色:原木色;尺寸:60cm',
|
|
lengthCm: 60,
|
|
widthCm: 60,
|
|
heightCm: 75,
|
|
},
|
|
{
|
|
sourceSkuKey: '2:颜色=胡桃色|尺寸=80cm',
|
|
productName: '实木圆桌胡桃色80cm',
|
|
colorSpec: '颜色:胡桃色;尺寸:80cm',
|
|
lengthCm: 80,
|
|
widthCm: 80,
|
|
heightCm: 75,
|
|
},
|
|
],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
test('builds a server-owned coding payload with every captured SKU', () => {
|
|
const normalized = normalizeCodingRequest(codingInput(), productRecord(), {
|
|
id: 7,
|
|
name: '香茗',
|
|
departmentName: '事业二部',
|
|
});
|
|
|
|
assert.equal(normalized.requestPayload.ali1688ProductId, '1073252781818');
|
|
assert.equal(normalized.requestPayload.ali1688SupplierName, '测试家具厂');
|
|
assert.equal(normalized.requestPayload.sourceSystem, 'maixiang');
|
|
assert.equal(normalized.requestPayload.operatorName, '香茗');
|
|
assert.equal(normalized.requestPayload.operatorDepartment, '事业二部');
|
|
assert.equal(normalized.requestPayload.skuItems.length, 2);
|
|
assert.match(normalized.sourceSkus[0].sourceSkuKey, /^1:/);
|
|
});
|
|
|
|
test('rejects partial SKU submission and products above the coding limit', () => {
|
|
assert.throws(
|
|
() => normalizeCodingRequest(codingInput({
|
|
skuItems: codingInput().skuItems.map((item, index) => (
|
|
index === 0 ? { ...item, widthCm: null } : item
|
|
)),
|
|
}), productRecord()),
|
|
error => error.statusCode === 400 && error.code === 'PRODUCT_CODE_SKU_DIMENSIONS_REQUIRED'
|
|
);
|
|
assert.throws(
|
|
() => normalizeCodingRequest(codingInput({ skuItems: codingInput().skuItems.slice(0, 1) }), productRecord()),
|
|
error => error.statusCode === 422 && error.code === 'PRODUCT_CODE_SKU_COUNT_MISMATCH'
|
|
);
|
|
|
|
const skus = Array.from({ length: MAX_CODING_SKUS + 1 }, (_, index) => ({
|
|
index: index + 1,
|
|
name: `规格${index + 1}`,
|
|
specs: [{ name: '规格', value: String(index + 1) }],
|
|
}));
|
|
assert.throws(
|
|
() => normalizeCodingRequest(codingInput({ skuItems: [] }), productRecord({
|
|
skuCount: skus.length,
|
|
product: { skus },
|
|
})),
|
|
error => error.statusCode === 422 && error.code === 'PRODUCT_CODE_SKU_LIMIT_EXCEEDED'
|
|
);
|
|
assert.throws(
|
|
() => normalizeCodingRequest(codingInput({
|
|
skuItems: codingInput().skuItems.map((item, index) => (
|
|
index === 0 ? { ...item, sourceSkuKey: 'stale-key' } : item
|
|
)),
|
|
}), productRecord()),
|
|
error => error.statusCode === 409 && error.code === 'PRODUCT_CODE_SKU_SNAPSHOT_CHANGED'
|
|
);
|
|
});
|
|
|
|
test('an already encoded product returns before validating or calling the upstream API', async () => {
|
|
let upstreamCalls = 0;
|
|
const encoding = {
|
|
productId: '1073252781818',
|
|
status: 'encoded',
|
|
styleCode: 'DF-A2SJ0094',
|
|
};
|
|
const result = await encodeAlibaba1688Product('1073252781818', {}, { name: '香茗' }, {
|
|
getProduct: async () => productRecord(),
|
|
getExistingEncoding: async () => encoding,
|
|
createCodes: async () => {
|
|
upstreamCalls += 1;
|
|
throw new Error('must not be called');
|
|
},
|
|
});
|
|
|
|
assert.equal(result.alreadyEncoded, true);
|
|
assert.equal(result.encoding, encoding);
|
|
assert.equal(upstreamCalls, 0);
|
|
});
|
|
|
|
test('maps returned product codes to source SKUs by the submitted order', () => {
|
|
const normalized = normalizeCodingRequest(codingInput(), productRecord(), { name: '香茗' });
|
|
const result = normalizeUpstreamResult({
|
|
created: true,
|
|
styleCode: 'DF-A2SJ0094',
|
|
productCodes: ['DF-A2SJ009401', 'DF-A2SJ009402'],
|
|
items: [
|
|
{ styleCode: 'DF-A2SJ0094', productCode: 'DF-A2SJ009401' },
|
|
{ styleCode: 'DF-A2SJ0094', productCode: 'DF-A2SJ009402' },
|
|
],
|
|
}, normalized);
|
|
|
|
assert.equal(result.productCodes.length, 2);
|
|
assert.equal(result.skuMappings[0].sourceSkuName, '原木色 / 60cm');
|
|
assert.equal(result.skuMappings[0].lengthCm, 60);
|
|
assert.equal(result.skuMappings[0].heightCm, 75);
|
|
assert.equal(result.skuMappings[1].productCode, 'DF-A2SJ009402');
|
|
assert.throws(
|
|
() => normalizeUpstreamResult({ productCodes: ['DF-A2SJ009401'] }, normalized),
|
|
error => error.statusCode === 409 && error.code === 'PRODUCT_CODE_RESPONSE_COUNT_MISMATCH'
|
|
);
|
|
assert.throws(
|
|
() => normalizeUpstreamResult({ productCodes: ['DF-A2SJ009401', 'DF-A2SJ009401'] }, normalized),
|
|
error => error.statusCode === 409 && error.code === 'PRODUCT_CODE_RESPONSE_DUPLICATE'
|
|
);
|
|
assert.throws(
|
|
() => normalizeUpstreamResult({
|
|
productCodes: ['DF-A2SJ009401', 'DF-A2SJ009402'],
|
|
items: [{ ali1688ProductId: 'different-id' }, {}],
|
|
}, normalized),
|
|
error => error.statusCode === 409 && error.code === 'PRODUCT_CODE_RESPONSE_PRODUCT_MISMATCH'
|
|
);
|
|
});
|
|
|
|
test('reused upstream codes are matched by SKU name instead of response order', () => {
|
|
const normalized = normalizeCodingRequest(codingInput(), productRecord(), { name: '香茗' });
|
|
const result = normalizeUpstreamResult({
|
|
created: false,
|
|
styleCode: 'DF-A2SJ0094',
|
|
productCodes: ['DF-A2SJ009402', 'DF-A2SJ009401'],
|
|
items: [
|
|
{ productName: '实木圆桌胡桃色80cm', productCode: 'DF-A2SJ009402' },
|
|
{ productName: '实木圆桌原木色60cm', productCode: 'DF-A2SJ009401' },
|
|
],
|
|
}, normalized);
|
|
|
|
assert.deepEqual(result.productCodes, ['DF-A2SJ009401', 'DF-A2SJ009402']);
|
|
assert.throws(
|
|
() => normalizeUpstreamResult({
|
|
created: false,
|
|
productCodes: ['DF-A2SJ009401', 'DF-A2SJ009402'],
|
|
items: [
|
|
{ productName: '无法匹配', productCode: 'DF-A2SJ009401' },
|
|
{ productName: '仍然无法匹配', productCode: 'DF-A2SJ009402' },
|
|
],
|
|
}, normalized),
|
|
error => error.statusCode === 409 && error.code === 'PRODUCT_CODE_REUSED_MAPPING_CONFLICT'
|
|
);
|
|
});
|
|
|
|
test('does not call the upstream service again when the product is already encoded', async () => {
|
|
let upstreamCalls = 0;
|
|
const result = await encodeAlibaba1688Product('1073252781818', codingInput(), { name: '香茗' }, {
|
|
getProduct: async () => productRecord(),
|
|
claim: async () => ({
|
|
claimed: false,
|
|
record: { productId: '1073252781818', status: 'encoded', productCodes: ['DF-A2SJ009401'] },
|
|
}),
|
|
createCodes: async () => {
|
|
upstreamCalls += 1;
|
|
throw new Error('must not be called');
|
|
},
|
|
});
|
|
|
|
assert.equal(result.alreadyEncoded, true);
|
|
assert.equal(upstreamCalls, 0);
|
|
});
|
|
|
|
test('persists both newly-created and reused upstream code responses', async () => {
|
|
for (const created of [true, false]) {
|
|
let completed;
|
|
const result = await encodeAlibaba1688Product('1073252781818', codingInput(), { name: '香茗' }, {
|
|
getProduct: async () => productRecord(),
|
|
claim: async () => ({ claimed: true, token: 'claim-token' }),
|
|
createCodes: async () => ({
|
|
statusCode: created ? 201 : 200,
|
|
data: {
|
|
created,
|
|
matchedExisting: !created,
|
|
styleCode: 'DF-A2SJ0094',
|
|
productCodes: ['DF-A2SJ009401', 'DF-A2SJ009402'],
|
|
items: created ? [] : [
|
|
{ productName: '实木圆桌原木色60cm', productCode: 'DF-A2SJ009401' },
|
|
{ productName: '实木圆桌胡桃色80cm', productCode: 'DF-A2SJ009402' },
|
|
],
|
|
},
|
|
}),
|
|
complete: async (_productId, token, value) => {
|
|
assert.equal(token, 'claim-token');
|
|
completed = value;
|
|
return { status: 'encoded', ...value };
|
|
},
|
|
});
|
|
|
|
assert.equal(result.alreadyEncoded, false);
|
|
assert.equal(result.encoding.status, 'encoded');
|
|
assert.equal(completed.upstreamCreated, created);
|
|
assert.equal(completed.skuMappings.length, 2);
|
|
}
|
|
});
|
|
|
|
test('marks the result as a conflict when SKUs change during the outbound request', async () => {
|
|
let conflict;
|
|
await assert.rejects(
|
|
encodeAlibaba1688Product('1073252781818', codingInput(), { name: '香茗' }, {
|
|
getProduct: async () => productRecord(),
|
|
getLatestProduct: async () => productRecord({
|
|
product: {
|
|
skus: [
|
|
{ index: 1, name: 'changed', specs: [{ name: '颜色', value: '白色' }] },
|
|
{ index: 2, name: 'changed-2', specs: [{ name: '颜色', value: '黑色' }] },
|
|
],
|
|
},
|
|
}),
|
|
claim: async () => ({ claimed: true, token: 'claim-token' }),
|
|
createCodes: async () => ({
|
|
data: {
|
|
created: true,
|
|
styleCode: 'DF-A2SJ0094',
|
|
productCodes: ['DF-A2SJ009401', 'DF-A2SJ009402'],
|
|
},
|
|
}),
|
|
conflict: async (productId, token, error) => {
|
|
conflict = { productId, token, code: error.code };
|
|
},
|
|
}),
|
|
error => error.code === 'PRODUCT_CODE_SKU_CHANGED_DURING_ENCODING'
|
|
);
|
|
assert.deepEqual(conflict, {
|
|
productId: '1073252781818',
|
|
token: 'claim-token',
|
|
code: 'PRODUCT_CODE_SKU_CHANGED_DURING_ENCODING',
|
|
});
|
|
});
|
|
|
|
test('selection plans are hydrated from canonical encoded 1688 data and reject stale mappings', async () => {
|
|
const normalized = normalizeCodingRequest(codingInput(), productRecord(), { name: '香茗' });
|
|
const encoding = {
|
|
status: 'encoded',
|
|
sourceSkuCount: normalized.sourceSkus.length,
|
|
sourceSkuFingerprint: normalized.sourceSkuFingerprint,
|
|
productCodes: ['DF-A2SJ009401', 'DF-A2SJ009402'],
|
|
skuMappings: normalized.sourceSkus.map((entry, index) => ({
|
|
sourceSkuKey: entry.sourceSkuKey,
|
|
productCode: `DF-A2SJ00940${index + 1}`,
|
|
})),
|
|
};
|
|
const rows = await assertAlibaba1688PlanItemsEncoded([
|
|
{ marketItemId: '1073252781818', channelName: '1688', sourceRow: { untrusted: true } },
|
|
], {
|
|
getProduct: async () => ({
|
|
...productRecord(),
|
|
skuFingerprint: normalized.sourceSkuFingerprint,
|
|
}),
|
|
getEncoding: async () => encoding,
|
|
});
|
|
assert.equal(rows[0].channelName, '1688');
|
|
assert.deepEqual(rows[0].sourceRow.encoding.skuMappings, encoding.skuMappings);
|
|
assert.equal(rows[0].sourceRow.source, 'alibaba_1688_products');
|
|
|
|
await assert.rejects(
|
|
assertAlibaba1688PlanItemsEncoded([{ marketItemId: '1073252781818', channelName: '1688' }], {
|
|
getProduct: async () => ({ ...productRecord(), skuFingerprint: 'changed-fingerprint' }),
|
|
getEncoding: async () => encoding,
|
|
}),
|
|
error => error.code === 'ALIBABA_1688_PRODUCT_ENCODING_STALE'
|
|
);
|
|
|
|
let ordinaryLookupCount = 0;
|
|
const ordinaryRows = await assertAlibaba1688PlanItemsEncoded([
|
|
{ marketItemId: '1073252781818', channelName: '拼多多' },
|
|
], {
|
|
getProduct: async () => {
|
|
ordinaryLookupCount += 1;
|
|
return productRecord();
|
|
},
|
|
});
|
|
assert.equal(ordinaryLookupCount, 0);
|
|
assert.equal(ordinaryRows[0].channelName, '拼多多');
|
|
});
|
|
|
|
test('records a failed claim without retrying the upstream request', async () => {
|
|
let upstreamCalls = 0;
|
|
let failed;
|
|
await assert.rejects(
|
|
encodeAlibaba1688Product('1073252781818', codingInput(), { name: '香茗' }, {
|
|
getProduct: async () => productRecord(),
|
|
claim: async () => ({ claimed: true, token: 'claim-token' }),
|
|
createCodes: async () => {
|
|
upstreamCalls += 1;
|
|
throw Object.assign(new Error('upstream timeout'), { statusCode: 504 });
|
|
},
|
|
fail: async (productId, token, error) => {
|
|
failed = { productId, token, message: error.message };
|
|
},
|
|
}),
|
|
/upstream timeout/
|
|
);
|
|
assert.equal(upstreamCalls, 1);
|
|
assert.deepEqual(failed, {
|
|
productId: '1073252781818',
|
|
token: 'claim-token',
|
|
message: 'upstream timeout',
|
|
});
|
|
});
|
|
|
|
test('the outbound client keeps the API key in a backend request header', async () => {
|
|
const previous = {
|
|
key: process.env.PRODUCT_CODE_API_KEY,
|
|
baseUrl: process.env.PRODUCT_CODE_API_BASE_URL,
|
|
timeout: process.env.PRODUCT_CODE_API_TIMEOUT_MS,
|
|
};
|
|
process.env.PRODUCT_CODE_API_KEY = 'server-only-test-key';
|
|
process.env.PRODUCT_CODE_API_BASE_URL = 'https://yyd.jiansuntuzhi.cn';
|
|
process.env.PRODUCT_CODE_API_TIMEOUT_MS = '31000';
|
|
let requestConfig;
|
|
try {
|
|
const response = await productCodeClient.getProductCodeMeta({
|
|
httpClient: async (config) => {
|
|
requestConfig = config;
|
|
return { status: 200, data: { success: true, data: { operationCenters: [] } } };
|
|
},
|
|
});
|
|
assert.deepEqual(response.data.operationCenters, []);
|
|
assert.equal(requestConfig.headers['X-API-Key'], 'server-only-test-key');
|
|
assert.equal(requestConfig.timeout, 31000);
|
|
assert.equal(requestConfig.url, 'https://yyd.jiansuntuzhi.cn/api/public/product-code-board/meta');
|
|
} finally {
|
|
if (previous.key === undefined) delete process.env.PRODUCT_CODE_API_KEY;
|
|
else process.env.PRODUCT_CODE_API_KEY = previous.key;
|
|
if (previous.baseUrl === undefined) delete process.env.PRODUCT_CODE_API_BASE_URL;
|
|
else process.env.PRODUCT_CODE_API_BASE_URL = previous.baseUrl;
|
|
if (previous.timeout === undefined) delete process.env.PRODUCT_CODE_API_TIMEOUT_MS;
|
|
else process.env.PRODUCT_CODE_API_TIMEOUT_MS = previous.timeout;
|
|
}
|
|
});
|
|
|
|
test('the outbound client refuses to run without a server-side API key', () => {
|
|
const previous = process.env.PRODUCT_CODE_API_KEY;
|
|
delete process.env.PRODUCT_CODE_API_KEY;
|
|
try {
|
|
assert.throws(
|
|
() => productCodeClient.getProductCodeApiConfig(),
|
|
error => error.statusCode === 503 && error.code === 'PRODUCT_CODE_API_KEY_MISSING'
|
|
);
|
|
} finally {
|
|
if (previous === undefined) delete process.env.PRODUCT_CODE_API_KEY;
|
|
else process.env.PRODUCT_CODE_API_KEY = previous;
|
|
}
|
|
});
|
|
|
|
test('an existing encoded registry row wins a duplicate insert race', async () => {
|
|
const originalExecute = planningDatabase.execute;
|
|
const originalQuery = planningDatabase.query;
|
|
let encodingLookupCount = 0;
|
|
planningDatabase.execute = async (sql) => {
|
|
if (sql.startsWith('CREATE TABLE')) return { affectedRows: 0 };
|
|
if (sql.startsWith('INSERT INTO')) {
|
|
throw Object.assign(new Error('duplicate'), { code: 'ER_DUP_ENTRY', errno: 1062 });
|
|
}
|
|
throw new Error(`unexpected SQL: ${sql}`);
|
|
};
|
|
planningDatabase.query = async (sql) => {
|
|
if (sql.includes('information_schema.tables')) return [{ ok: 1 }];
|
|
if (sql.includes('information_schema.columns')) {
|
|
return [{ column_name: 'source_sku_count' }, { column_name: 'source_sku_fingerprint' }];
|
|
}
|
|
encodingLookupCount += 1;
|
|
if (encodingLookupCount === 1) return [];
|
|
return [{
|
|
product_id: '1073252781818',
|
|
status: 'encoded',
|
|
style_code: 'DF-A2SJ0094',
|
|
style_codes_json: '["DF-A2SJ0094"]',
|
|
product_codes_json: '["DF-A2SJ009401"]',
|
|
sku_mapping_json: '[]',
|
|
source_sku_count: 1,
|
|
source_sku_fingerprint: 'fingerprint',
|
|
attempt_count: 1,
|
|
}];
|
|
};
|
|
|
|
try {
|
|
const normalized = normalizeCodingRequest(codingInput(), productRecord());
|
|
const result = await encodingRegistry.claimEncoding(
|
|
'1073252781818',
|
|
normalized.requestPayload,
|
|
{ name: '香茗' },
|
|
{
|
|
sourceSkuCount: normalized.sourceSkus.length,
|
|
sourceSkuFingerprint: normalized.sourceSkuFingerprint,
|
|
}
|
|
);
|
|
assert.equal(result.claimed, false);
|
|
assert.equal(result.record.status, 'encoded');
|
|
assert.deepEqual(result.record.productCodes, ['DF-A2SJ009401']);
|
|
} finally {
|
|
planningDatabase.execute = originalExecute;
|
|
planningDatabase.query = originalQuery;
|
|
}
|
|
});
|
|
|
|
test('the claim lease always exceeds the configured outbound timeout', () => {
|
|
const previousTimeout = process.env.PRODUCT_CODE_API_TIMEOUT_MS;
|
|
const previousTtl = process.env.PRODUCT_CODE_CLAIM_TTL_SECONDS;
|
|
process.env.PRODUCT_CODE_API_TIMEOUT_MS = '120000';
|
|
process.env.PRODUCT_CODE_CLAIM_TTL_SECONDS = '120';
|
|
try {
|
|
assert.equal(encodingRegistry.getClaimTtlSeconds(), 180);
|
|
} finally {
|
|
if (previousTimeout === undefined) delete process.env.PRODUCT_CODE_API_TIMEOUT_MS;
|
|
else process.env.PRODUCT_CODE_API_TIMEOUT_MS = previousTimeout;
|
|
if (previousTtl === undefined) delete process.env.PRODUCT_CODE_CLAIM_TTL_SECONDS;
|
|
else process.env.PRODUCT_CODE_CLAIM_TTL_SECONDS = previousTtl;
|
|
}
|
|
});
|
|
|
|
test('retry comparison preserves the first business request while allowing a different operator', () => {
|
|
const original = {
|
|
...codingInput(),
|
|
operatorName: '香茗',
|
|
operatorDepartment: '事业二部',
|
|
};
|
|
const sameBusiness = {
|
|
...codingInput(),
|
|
operatorName: '管理员',
|
|
operatorDepartment: '系统管理',
|
|
};
|
|
assert.equal(
|
|
encodingRegistry.retryComparableRequest(original),
|
|
encodingRegistry.retryComparableRequest(sameBusiness)
|
|
);
|
|
assert.notEqual(
|
|
encodingRegistry.retryComparableRequest(original),
|
|
encodingRegistry.retryComparableRequest({
|
|
...sameBusiness,
|
|
categoryName: '住宅家具 - 电脑桌',
|
|
})
|
|
);
|
|
});
|
|
|
|
test('legacy failed requests may fill only missing dimensions while preserving the first request', () => {
|
|
const normalized = normalizeCodingRequest(codingInput(), productRecord(), {
|
|
name: 'first-operator',
|
|
departmentName: 'first-department',
|
|
});
|
|
const legacyRequest = JSON.parse(JSON.stringify(normalized.requestPayload));
|
|
delete legacyRequest.skuItems[0].lengthCm;
|
|
legacyRequest.skuItems[0].widthCm = null;
|
|
delete legacyRequest.skuItems[1].heightCm;
|
|
|
|
const incoming = {
|
|
...normalized.requestPayload,
|
|
operatorName: 'retry-operator',
|
|
operatorDepartment: 'retry-department',
|
|
};
|
|
const upgraded = encodingRegistry.reconcileRetryRequest(legacyRequest, incoming);
|
|
assert.equal(upgraded.operatorName, 'first-operator');
|
|
assert.equal(upgraded.skuItems[0].lengthCm, 60);
|
|
assert.equal(upgraded.skuItems[0].widthCm, 60);
|
|
assert.equal(upgraded.skuItems[1].heightCm, 75);
|
|
|
|
assert.throws(
|
|
() => encodingRegistry.reconcileRetryRequest(normalized.requestPayload, {
|
|
...incoming,
|
|
skuItems: incoming.skuItems.map((item, index) => (
|
|
index === 0 ? { ...item, lengthCm: 61 } : item
|
|
)),
|
|
}),
|
|
error => error.code === 'PRODUCT_ENCODING_RETRY_REQUEST_CHANGED'
|
|
);
|
|
assert.throws(
|
|
() => encodingRegistry.reconcileRetryRequest(legacyRequest, {
|
|
...incoming,
|
|
skuItems: incoming.skuItems.map((item, index) => (
|
|
index === 0 ? { ...item, productName: 'changed-name' } : item
|
|
)),
|
|
}),
|
|
error => error.code === 'PRODUCT_ENCODING_RETRY_REQUEST_CHANGED'
|
|
);
|
|
});
|
|
|
|
test('source snapshots require the full request count and a SHA-256 fingerprint', () => {
|
|
const normalized = normalizeCodingRequest(codingInput(), productRecord());
|
|
assert.deepEqual(
|
|
encodingRegistry.normalizeSourceSnapshot({
|
|
sourceSkuCount: normalized.sourceSkus.length,
|
|
sourceSkuFingerprint: normalized.sourceSkuFingerprint,
|
|
}, normalized.requestPayload),
|
|
{
|
|
sourceSkuCount: 2,
|
|
sourceSkuFingerprint: normalized.sourceSkuFingerprint,
|
|
}
|
|
);
|
|
assert.throws(
|
|
() => encodingRegistry.normalizeSourceSnapshot({
|
|
sourceSkuCount: 1,
|
|
sourceSkuFingerprint: normalized.sourceSkuFingerprint,
|
|
}, normalized.requestPayload),
|
|
error => error.code === 'PRODUCT_ENCODING_SOURCE_SKU_CHANGED'
|
|
);
|
|
assert.throws(
|
|
() => encodingRegistry.normalizeSourceSnapshot({
|
|
sourceSkuCount: 2,
|
|
sourceSkuFingerprint: 'not-a-fingerprint',
|
|
}, normalized.requestPayload),
|
|
error => error.code === 'PRODUCT_ENCODING_SOURCE_SNAPSHOT_REQUIRED'
|
|
);
|
|
});
|
|
|
|
test('the first registry claim persists the SKU count and fingerprint in the insert', async () => {
|
|
const normalized = normalizeCodingRequest(codingInput(), productRecord());
|
|
const originalExecute = planningDatabase.execute;
|
|
const originalQuery = planningDatabase.query;
|
|
let inserted = false;
|
|
let insertSql = '';
|
|
let insertParams = [];
|
|
planningDatabase.query = async (sql) => {
|
|
if (sql.includes('information_schema.tables')) return [{ ok: 1 }];
|
|
if (sql.includes('information_schema.columns')) {
|
|
return [{ column_name: 'source_sku_count' }, { column_name: 'source_sku_fingerprint' }];
|
|
}
|
|
if (sql.includes('SELECT * FROM alibaba_1688_product_encodings')) {
|
|
if (!inserted) return [];
|
|
return [{
|
|
product_id: productRecord().productId,
|
|
status: 'encoding',
|
|
request_json: JSON.stringify(normalized.requestPayload),
|
|
source_sku_count: normalized.sourceSkus.length,
|
|
source_sku_fingerprint: normalized.sourceSkuFingerprint,
|
|
attempt_count: 1,
|
|
}];
|
|
}
|
|
throw new Error(`unexpected SQL: ${sql}`);
|
|
};
|
|
planningDatabase.execute = async (sql, params) => {
|
|
if (sql.includes('INSERT INTO alibaba_1688_product_encodings')) {
|
|
inserted = true;
|
|
insertSql = sql;
|
|
insertParams = params;
|
|
return { affectedRows: 1 };
|
|
}
|
|
throw new Error(`unexpected SQL: ${sql}`);
|
|
};
|
|
|
|
try {
|
|
const result = await encodingRegistry.claimEncoding(
|
|
productRecord().productId,
|
|
normalized.requestPayload,
|
|
{},
|
|
{
|
|
sourceSkuCount: normalized.sourceSkus.length,
|
|
sourceSkuFingerprint: normalized.sourceSkuFingerprint,
|
|
}
|
|
);
|
|
assert.equal(result.claimed, true);
|
|
assert.match(insertSql, /source_sku_count, source_sku_fingerprint/);
|
|
assert.equal(insertParams[3], normalized.sourceSkus.length);
|
|
assert.equal(insertParams[4], normalized.sourceSkuFingerprint);
|
|
} finally {
|
|
planningDatabase.execute = originalExecute;
|
|
planningDatabase.query = originalQuery;
|
|
}
|
|
});
|
|
|
|
test('a retry claim atomically upgrades only legacy missing dimensions and rejects a changed fingerprint', async () => {
|
|
const normalized = normalizeCodingRequest(codingInput(), productRecord());
|
|
const legacyRequest = JSON.parse(JSON.stringify(normalized.requestPayload));
|
|
delete legacyRequest.skuItems[0].lengthCm;
|
|
legacyRequest.skuItems[0].widthCm = null;
|
|
const originalExecute = planningDatabase.execute;
|
|
const originalQuery = planningDatabase.query;
|
|
let storedFingerprint = null;
|
|
let updateSql = '';
|
|
let updateParams = [];
|
|
let updateCount = 0;
|
|
planningDatabase.query = async (sql) => {
|
|
if (sql.includes('information_schema.tables')) return [{ ok: 1 }];
|
|
if (sql.includes('information_schema.columns')) {
|
|
return [{ column_name: 'source_sku_count' }, { column_name: 'source_sku_fingerprint' }];
|
|
}
|
|
if (sql.includes('SELECT * FROM alibaba_1688_product_encodings')) {
|
|
return [{
|
|
product_id: productRecord().productId,
|
|
status: 'failed',
|
|
request_json: JSON.stringify(legacyRequest),
|
|
source_sku_count: null,
|
|
source_sku_fingerprint: storedFingerprint,
|
|
attempt_count: 1,
|
|
}];
|
|
}
|
|
throw new Error(`unexpected SQL: ${sql}`);
|
|
};
|
|
planningDatabase.execute = async (sql, params) => {
|
|
if (sql.includes('INSERT INTO alibaba_1688_product_encodings')) {
|
|
throw Object.assign(new Error('duplicate'), { code: 'ER_DUP_ENTRY', errno: 1062 });
|
|
}
|
|
if (sql.includes('UPDATE alibaba_1688_product_encodings')) {
|
|
updateCount += 1;
|
|
updateSql = sql;
|
|
updateParams = params;
|
|
return { affectedRows: 1 };
|
|
}
|
|
throw new Error(`unexpected SQL: ${sql}`);
|
|
};
|
|
|
|
const snapshot = {
|
|
sourceSkuCount: normalized.sourceSkus.length,
|
|
sourceSkuFingerprint: normalized.sourceSkuFingerprint,
|
|
};
|
|
try {
|
|
const result = await encodingRegistry.claimEncoding(
|
|
productRecord().productId,
|
|
normalized.requestPayload,
|
|
{},
|
|
snapshot
|
|
);
|
|
assert.equal(result.claimed, true);
|
|
assert.equal(result.requestPayload.skuItems[0].lengthCm, 60);
|
|
assert.equal(result.requestPayload.skuItems[0].widthCm, 60);
|
|
assert.match(updateSql, /source_sku_count IS NULL OR source_sku_count = \?/);
|
|
assert.match(updateSql, /source_sku_fingerprint IS NULL OR source_sku_fingerprint = \?/);
|
|
assert.equal(updateParams[2], snapshot.sourceSkuCount);
|
|
assert.equal(updateParams[3], snapshot.sourceSkuFingerprint);
|
|
|
|
storedFingerprint = 'a'.repeat(64);
|
|
await assert.rejects(
|
|
encodingRegistry.claimEncoding(
|
|
productRecord().productId,
|
|
normalized.requestPayload,
|
|
{},
|
|
snapshot
|
|
),
|
|
error => error.code === 'PRODUCT_ENCODING_SOURCE_SKU_CHANGED'
|
|
);
|
|
assert.equal(updateCount, 1);
|
|
} finally {
|
|
planningDatabase.execute = originalExecute;
|
|
planningDatabase.query = originalQuery;
|
|
}
|
|
});
|
|
|
|
test('selection plans reject incomplete, duplicate or mismatched encoded SKU mappings', async () => {
|
|
const normalized = normalizeCodingRequest(codingInput(), productRecord());
|
|
const product = {
|
|
...productRecord(),
|
|
skuFingerprint: normalized.sourceSkuFingerprint,
|
|
};
|
|
const validEncoding = {
|
|
status: 'encoded',
|
|
sourceSkuCount: 2,
|
|
sourceSkuFingerprint: normalized.sourceSkuFingerprint,
|
|
productCodes: ['CODE-1', 'CODE-2'],
|
|
skuMappings: normalized.sourceSkus.map((entry, index) => ({
|
|
sourceSkuKey: entry.sourceSkuKey,
|
|
productCode: `CODE-${index + 1}`,
|
|
})),
|
|
};
|
|
const invalidEncodings = [
|
|
{ ...validEncoding, sourceSkuCount: 1 },
|
|
{ ...validEncoding, productCodes: ['CODE-1'] },
|
|
{ ...validEncoding, productCodes: ['CODE-1', 'CODE-1'] },
|
|
{ ...validEncoding, skuMappings: validEncoding.skuMappings.slice(0, 1) },
|
|
{
|
|
...validEncoding,
|
|
skuMappings: validEncoding.skuMappings.map(mapping => ({
|
|
...mapping,
|
|
sourceSkuKey: validEncoding.skuMappings[0].sourceSkuKey,
|
|
})),
|
|
},
|
|
{
|
|
...validEncoding,
|
|
skuMappings: validEncoding.skuMappings.map((mapping, index) => (
|
|
index === 0 ? { ...mapping, productCode: 'UNKNOWN-CODE' } : mapping
|
|
)),
|
|
},
|
|
];
|
|
|
|
for (const encoding of invalidEncodings) {
|
|
await assert.rejects(
|
|
assertAlibaba1688PlanItemsEncoded([
|
|
{ marketItemId: product.productId, channelName: '1688' },
|
|
], {
|
|
getProduct: async () => product,
|
|
getEncoding: async () => encoding,
|
|
}),
|
|
error => error.code === 'ALIBABA_1688_PRODUCT_ENCODING_STALE'
|
|
);
|
|
}
|
|
});
|