feat(ai-image): AI生图开发
This commit is contained in:
@@ -0,0 +1,350 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import sharp from 'sharp';
|
||||
import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { createMediaAiEditRequestSchema } from '@atlas/contracts';
|
||||
import { classifyAiEditFailure, normalizeAiEditOutput, pinnedLookup, providerImageResult, runMediaAiEdit } from '../src/media-ai-edit.ts';
|
||||
import { buildMediaAiPrompt } from '../src/media-ai-prompt.ts';
|
||||
|
||||
const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998';
|
||||
|
||||
test('仅扩图在供应商改写中心后仍把原图区域覆盖回结果', async () => {
|
||||
const source = await sharp({ create: { width: 600, height: 600, channels: 3, background: '#e1252a' } }).png().toBuffer();
|
||||
const generated = await sharp({ create: { width: 1152, height: 1536, channels: 3, background: '#2055d8' } }).png().toBuffer();
|
||||
const settings = createMediaAiEditRequestSchema.parse({ product_id: productId, mode: 'outpaint' });
|
||||
const output = await normalizeAiEditOutput(generated, settings, source);
|
||||
const { data, info } = await sharp(output).raw().toBuffer({ resolveWithObject: true });
|
||||
assert.equal(info.width, 1152);
|
||||
assert.equal(info.height, 1536);
|
||||
const pixel = (x, y) => [...data.subarray((y * info.width + x) * 3, (y * info.width + x) * 3 + 3)];
|
||||
assert.ok(pixel(576, 768)[0] > 200, '原图中心仍应为红色');
|
||||
assert.ok(pixel(576, 20)[2] > 170, '扩充边缘仍应为蓝色');
|
||||
});
|
||||
|
||||
test('提示词明确区分主图、辅助图和尺寸数值', () => {
|
||||
const settings = createMediaAiEditRequestSchema.parse({
|
||||
product_id: productId,
|
||||
reference_asset_ids: ['50ea00b7-6675-4cc7-b7a2-d45f05c07100'],
|
||||
dimensions: { length: 12, unit: 'cm' }, generate_size_chart: true,
|
||||
});
|
||||
const prompt = buildMediaAiPrompt(settings);
|
||||
assert.match(prompt, /Image 1 is the fixed main product image/);
|
||||
assert.match(prompt, /Images 2 through 2 are auxiliary references only/);
|
||||
assert.match(prompt, /length: 12 cm/);
|
||||
assert.match(prompt, /Do not infer other measurements/);
|
||||
});
|
||||
|
||||
test('Pcore 完成结果的空 b64_json 回退到可下载 URL', () => {
|
||||
const url = 'https://pcore.ai/v1/images/content/task-example/0';
|
||||
assert.equal(providerImageResult({ data: [{ b64_json: '', url }] }), url);
|
||||
assert.equal(providerImageResult({ data: [{ b64_json: ' ', url }] }), url);
|
||||
assert.deepEqual(providerImageResult({ data: [{ b64_json: '', b64_image: Buffer.from('image').toString('base64'), url }] }),
|
||||
Buffer.from('image'));
|
||||
assert.deepEqual(providerImageResult({ data: [{ b64_json: Buffer.from('image').toString('base64'), url }] }),
|
||||
Buffer.from('image'));
|
||||
});
|
||||
|
||||
test('固定 DNS 回调按 Node 的 all 选项返回地址列表或单个地址', () => {
|
||||
const lookup = pinnedLookup('8.8.8.8');
|
||||
lookup('pcore.ai', { all: true }, (error, addresses) => {
|
||||
assert.equal(error, null);
|
||||
assert.deepEqual(addresses, [{ address: '8.8.8.8', family: 4 }]);
|
||||
});
|
||||
lookup('pcore.ai', { all: false }, (error, address, family) => {
|
||||
assert.equal(error, null);
|
||||
assert.equal(address, '8.8.8.8');
|
||||
assert.equal(family, 4);
|
||||
});
|
||||
});
|
||||
|
||||
test('异步供应商任务分两次 Outbox 推进,并把结果登记为独立素材', async () => {
|
||||
const sourceId = '664d0d73-3d39-4a98-ac0a-5c0e521d8998';
|
||||
const referenceId = '6ebfe347-bc77-4270-8213-b2021271c6b2';
|
||||
const outputId = '113d0d73-3d39-4a98-ac0a-5c0e521d8998';
|
||||
const editId = '68db226f-3fcb-4579-ab9e-4932b1d84067';
|
||||
const source = await sharp({ create: { width: 600, height: 800, channels: 3, background: '#ad3222' } }).jpeg().toBuffer();
|
||||
const reference = await sharp({ create: { width: 400, height: 400, channels: 4, background: '#2452af80' } }).png().toBuffer();
|
||||
const generated = await sharp({ create: { width: 1152, height: 1536, channels: 3, background: '#2f72b7' } }).png().toBuffer();
|
||||
const settings = createMediaAiEditRequestSchema.parse({ product_id: productId,
|
||||
reference_asset_ids: [referenceId], prompt: '保留商品,使用明亮背景' });
|
||||
const task = { status: 'queued', source_asset_id: sourceId, reference_asset_ids: [referenceId], settings,
|
||||
provider_task_id: null, created_at: new Date() };
|
||||
const outbox = [];
|
||||
const stored = [];
|
||||
const database = { workspaceTransaction: async (_workspaceId, work) => work({
|
||||
query: async (sql, params = []) => {
|
||||
if (sql.includes('SELECT status, source_asset_id, reference_asset_ids')) return { rows: [{ ...task }] };
|
||||
if (sql.includes('SELECT id, storage_provider, bucket, object_key')) return { rows: [
|
||||
{ id: sourceId, storage_provider: 'local_minio', bucket: 'atlas-assets', object_key: 'main.jpg' },
|
||||
{ id: referenceId, storage_provider: 'local_minio', bucket: 'atlas-assets', object_key: 'reference.jpg' },
|
||||
] };
|
||||
if (sql.includes('SELECT status FROM atlas.media_ai_edits')) return { rows: [{ status: task.status }] };
|
||||
if (sql.includes('SELECT id FROM atlas.media_assets WHERE checksum_sha256')) return { rows: [] };
|
||||
if (sql.includes('INSERT INTO atlas.media_assets')) return { rows: [{ id: outputId }] };
|
||||
if (sql.includes('INSERT INTO atlas.outbox_events')) outbox.push(sql);
|
||||
if (sql.includes("SET status = 'running'")) task.status = 'running';
|
||||
if (sql.includes('SET provider_task_id =')) task.provider_task_id = params[1];
|
||||
if (sql.includes("SET status = 'succeeded'")) task.status = 'succeeded';
|
||||
return { rows: [] };
|
||||
},
|
||||
}) };
|
||||
const storage = { send: async (command) => {
|
||||
if (command instanceof GetObjectCommand) return { Body: (async function* () {
|
||||
yield command.input.Key === 'main.jpg' ? source : reference;
|
||||
})() };
|
||||
if (command instanceof PutObjectCommand) { stored.push(command.input); return {}; }
|
||||
throw new Error('unexpected object storage command');
|
||||
} };
|
||||
const oldFetch = globalThis.fetch;
|
||||
const oldBase = process.env.PCORE_BASE_URL;
|
||||
const oldKey = process.env.PCORE_API_KEY;
|
||||
process.env.PCORE_BASE_URL = 'https://pcore.ai/v1';
|
||||
process.env.PCORE_API_KEY = 'test-key';
|
||||
let requests = 0;
|
||||
globalThis.fetch = async (url, init) => {
|
||||
requests += 1;
|
||||
if (init.method === 'POST') {
|
||||
assert.equal(url, 'https://pcore.ai/v1/images/edits');
|
||||
assert.equal(init.headers['Content-Type'], 'application/json');
|
||||
const body = JSON.parse(init.body);
|
||||
assert.equal(body.model, 'gpt-image-2');
|
||||
assert.equal(body.size, '1152x1536');
|
||||
assert.equal(body.n, 1);
|
||||
assert.equal(body.async, true);
|
||||
assert.equal(body.images.length, 2);
|
||||
assert.deepEqual(body.images, [
|
||||
{ image_url: `data:image/jpeg;base64,${source.toString('base64')}` },
|
||||
{ image_url: `data:image/png;base64,${reference.toString('base64')}` },
|
||||
]);
|
||||
assert.equal(body.mask, undefined);
|
||||
return Response.json({ task_id: 'provider-123' });
|
||||
}
|
||||
assert.equal(url, 'https://pcore.ai/v1/images/edits/provider-123');
|
||||
return Response.json({ status: 'completed', data: [{ b64_json: generated.toString('base64') }] });
|
||||
};
|
||||
try {
|
||||
const job = { workspaceId: '30cf54b7-06a3-4e5f-a5fc-d74165115094', payload: { edit_id: editId } };
|
||||
await runMediaAiEdit(database, storage, job);
|
||||
assert.equal(task.status, 'running');
|
||||
assert.equal(task.provider_task_id, 'provider-123');
|
||||
assert.equal(outbox.length, 1);
|
||||
await runMediaAiEdit(database, storage, job);
|
||||
assert.equal(task.status, 'succeeded');
|
||||
assert.equal(stored.length, 1);
|
||||
assert.equal(stored[0].ContentType, 'image/jpeg');
|
||||
assert.equal(requests, 2);
|
||||
} finally {
|
||||
globalThis.fetch = oldFetch;
|
||||
if (oldBase === undefined) delete process.env.PCORE_BASE_URL; else process.env.PCORE_BASE_URL = oldBase;
|
||||
if (oldKey === undefined) delete process.env.PCORE_API_KEY; else process.env.PCORE_API_KEY = oldKey;
|
||||
}
|
||||
});
|
||||
|
||||
test('仅扩图以 JSON 图片和蒙版对象提交 Pcore', async () => {
|
||||
const sourceId = '664d0d73-3d39-4a98-ac0a-5c0e521d8998';
|
||||
const editId = '68db226f-3fcb-4579-ab9e-4932b1d84067';
|
||||
const source = await sharp({ create: { width: 600, height: 600, channels: 3, background: '#ad3222' } }).jpeg().toBuffer();
|
||||
const settings = createMediaAiEditRequestSchema.parse({ product_id: productId, mode: 'outpaint' });
|
||||
const database = { workspaceTransaction: async (_workspaceId, work) => work({
|
||||
query: async (sql) => {
|
||||
if (sql.includes('SELECT status, source_asset_id, reference_asset_ids')) return { rows: [{
|
||||
status: 'queued', source_asset_id: sourceId, reference_asset_ids: [], settings,
|
||||
provider_task_id: null, created_at: new Date(),
|
||||
}] };
|
||||
if (sql.includes('SELECT id, storage_provider, bucket, object_key')) return { rows: [{
|
||||
id: sourceId, storage_provider: 'local_minio', bucket: 'atlas-assets', object_key: 'main.jpg',
|
||||
}] };
|
||||
return { rows: [] };
|
||||
},
|
||||
}) };
|
||||
const storage = { send: async () => ({ Body: (async function* () { yield source; })() }) };
|
||||
const oldFetch = globalThis.fetch;
|
||||
const oldBase = process.env.PCORE_BASE_URL;
|
||||
const oldKey = process.env.PCORE_API_KEY;
|
||||
process.env.PCORE_BASE_URL = 'https://pcore.ai';
|
||||
process.env.PCORE_API_KEY = 'test-key';
|
||||
globalThis.fetch = async (_url, init) => {
|
||||
const body = JSON.parse(init.body);
|
||||
assert.equal(init.headers['Content-Type'], 'application/json');
|
||||
assert.equal(body.images.length, 1);
|
||||
assert.match(body.images[0].image_url, /^data:image\/png;base64,/);
|
||||
assert.match(body.mask.image_url, /^data:image\/png;base64,/);
|
||||
const image = Buffer.from(body.images[0].image_url.split(',')[1], 'base64');
|
||||
const mask = Buffer.from(body.mask.image_url.split(',')[1], 'base64');
|
||||
assert.equal((await sharp(image).metadata()).width, 1152);
|
||||
assert.equal((await sharp(mask).metadata()).height, 1536);
|
||||
return Response.json({ task_id: 'provider-outpaint' });
|
||||
};
|
||||
try {
|
||||
await runMediaAiEdit(database, storage, {
|
||||
workspaceId: '30cf54b7-06a3-4e5f-a5fc-d74165115094', payload: { edit_id: editId },
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = oldFetch;
|
||||
if (oldBase === undefined) delete process.env.PCORE_BASE_URL; else process.env.PCORE_BASE_URL = oldBase;
|
||||
if (oldKey === undefined) delete process.env.PCORE_API_KEY; else process.env.PCORE_API_KEY = oldKey;
|
||||
}
|
||||
});
|
||||
|
||||
test('Pcore 终态失败不重复轮询,泛化错误不会误判成内容拒绝', async () => {
|
||||
const sourceId = '664d0d73-3d39-4a98-ac0a-5c0e521d8998';
|
||||
const editId = '68db226f-3fcb-4579-ab9e-4932b1d84067';
|
||||
const settings = createMediaAiEditRequestSchema.parse({ product_id: productId });
|
||||
let status = 'running';
|
||||
let failureCode = null;
|
||||
let providerErrorRaw = null;
|
||||
let polls = 0;
|
||||
let providerError = {
|
||||
code: 'failed', message: '您的请求无法用于生成图像。该请求可能因安全政策被拦截。',
|
||||
};
|
||||
const database = { workspaceTransaction: async (_workspaceId, work) => work({
|
||||
query: async (sql, params = []) => {
|
||||
if (sql.includes('SELECT status, source_asset_id, reference_asset_ids')) return { rows: [{
|
||||
status, source_asset_id: sourceId, reference_asset_ids: [], settings,
|
||||
provider_task_id: 'provider-123', created_at: new Date(),
|
||||
}] };
|
||||
if (sql.includes('SELECT id, storage_provider, bucket, object_key')) return { rows: [{
|
||||
id: sourceId, storage_provider: 'local_minio', bucket: 'atlas-assets', object_key: 'main.jpg',
|
||||
}] };
|
||||
if (sql.includes("SET status = 'failed'")) {
|
||||
status = 'failed';
|
||||
failureCode = params[1];
|
||||
if (sql.includes('atlas.operations')) {
|
||||
providerErrorRaw = params[6];
|
||||
assert.equal(params[3], 'pcore');
|
||||
assert.equal(params[4], 'provider_polling');
|
||||
}
|
||||
}
|
||||
return { rows: [] };
|
||||
},
|
||||
}) };
|
||||
const oldFetch = globalThis.fetch;
|
||||
const oldBase = process.env.PCORE_BASE_URL;
|
||||
const oldKey = process.env.PCORE_API_KEY;
|
||||
process.env.PCORE_BASE_URL = 'https://pcore.ai';
|
||||
process.env.PCORE_API_KEY = 'test-key';
|
||||
globalThis.fetch = async (url) => {
|
||||
polls += 1;
|
||||
assert.equal(url, 'https://pcore.ai/v1/images/edits/provider-123');
|
||||
return Response.json({ status: 'failed', error: providerError });
|
||||
};
|
||||
try {
|
||||
const job = { workspaceId: '30cf54b7-06a3-4e5f-a5fc-d74165115094', payload: { edit_id: editId } };
|
||||
await runMediaAiEdit(database, {}, job);
|
||||
assert.equal(status, 'failed');
|
||||
assert.equal(failureCode, 'AI_IMAGE_PROVIDER_FAILED');
|
||||
assert.equal(providerErrorRaw, JSON.stringify(providerError));
|
||||
assert.equal(polls, 1);
|
||||
status = 'running';
|
||||
providerError = { code: 'content_policy_violation', message: 'The image was blocked.' };
|
||||
await runMediaAiEdit(database, {}, job);
|
||||
assert.equal(failureCode, 'AI_IMAGE_CONTENT_REJECTED');
|
||||
assert.equal(providerErrorRaw, JSON.stringify(providerError));
|
||||
assert.equal(polls, 2);
|
||||
} finally {
|
||||
globalThis.fetch = oldFetch;
|
||||
if (oldBase === undefined) delete process.env.PCORE_BASE_URL; else process.env.PCORE_BASE_URL = oldBase;
|
||||
if (oldKey === undefined) delete process.env.PCORE_API_KEY; else process.env.PCORE_API_KEY = oldKey;
|
||||
}
|
||||
});
|
||||
|
||||
test('Pcore 提交时返回错误体会保留原始内容', async () => {
|
||||
const sourceId = '664d0d73-3d39-4a98-ac0a-5c0e521d8998';
|
||||
const editId = '68db226f-3fcb-4579-ab9e-4932b1d84067';
|
||||
const source = await sharp({ create: { width: 600, height: 800, channels: 3, background: '#ad3222' } }).jpeg().toBuffer();
|
||||
const settings = createMediaAiEditRequestSchema.parse({ product_id: productId });
|
||||
const database = { workspaceTransaction: async (_workspaceId, work) => work({
|
||||
query: async (sql) => {
|
||||
if (sql.includes('SELECT status, source_asset_id, reference_asset_ids')) return { rows: [{
|
||||
status: 'queued', source_asset_id: sourceId, reference_asset_ids: [], settings,
|
||||
provider_task_id: null, created_at: new Date(),
|
||||
}] };
|
||||
if (sql.includes('SELECT id, storage_provider, bucket, object_key')) return { rows: [{
|
||||
id: sourceId, storage_provider: 'local_minio', bucket: 'atlas-assets', object_key: 'main.jpg',
|
||||
}] };
|
||||
return { rows: [] };
|
||||
},
|
||||
}) };
|
||||
const storage = { send: async () => ({ Body: (async function* () { yield source; })() }) };
|
||||
const oldFetch = globalThis.fetch;
|
||||
const oldBase = process.env.PCORE_BASE_URL;
|
||||
const oldKey = process.env.PCORE_API_KEY;
|
||||
process.env.PCORE_BASE_URL = 'https://pcore.ai';
|
||||
process.env.PCORE_API_KEY = 'test-key';
|
||||
const providerError = { code: 'invalid_request_error', message: '图像文件无效' };
|
||||
globalThis.fetch = async () => Response.json({ status: 'failed', error: providerError });
|
||||
try {
|
||||
const job = { workspaceId: '30cf54b7-06a3-4e5f-a5fc-d74165115094', payload: { edit_id: editId } };
|
||||
await assert.rejects(runMediaAiEdit(database, storage, job), (error) => {
|
||||
assert.deepEqual(classifyAiEditFailure(error), {
|
||||
code: 'AI_IMAGE_GENERATION_FAILED', source: 'pcore', stage: 'provider_submission',
|
||||
detail: 'Pcore 图片任务请求被拒绝(invalid_request_error 图像文件无效)',
|
||||
providerErrorRaw: JSON.stringify(providerError),
|
||||
});
|
||||
return true;
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = oldFetch;
|
||||
if (oldBase === undefined) delete process.env.PCORE_BASE_URL; else process.env.PCORE_BASE_URL = oldBase;
|
||||
if (oldKey === undefined) delete process.env.PCORE_API_KEY; else process.env.PCORE_API_KEY = oldKey;
|
||||
}
|
||||
});
|
||||
|
||||
test('失败诊断区分 Pcore 查询中断、Atlas 图片处理与保存失败', async () => {
|
||||
const sourceId = '664d0d73-3d39-4a98-ac0a-5c0e521d8998';
|
||||
const settings = createMediaAiEditRequestSchema.parse({ product_id: productId });
|
||||
const database = { workspaceTransaction: async (_workspaceId, work) => work({
|
||||
query: async (sql) => {
|
||||
if (sql.includes('SELECT status, source_asset_id, reference_asset_ids')) return { rows: [{
|
||||
status: 'running', source_asset_id: sourceId, reference_asset_ids: [], settings,
|
||||
provider_task_id: 'provider-123', created_at: new Date(),
|
||||
}] };
|
||||
if (sql.includes('SELECT id, storage_provider, bucket, object_key')) return { rows: [{
|
||||
id: sourceId, storage_provider: 'local_minio', bucket: 'atlas-assets', object_key: 'main.jpg',
|
||||
}] };
|
||||
return { rows: [] };
|
||||
},
|
||||
}) };
|
||||
const job = { workspaceId: '30cf54b7-06a3-4e5f-a5fc-d74165115094',
|
||||
payload: { edit_id: '68db226f-3fcb-4579-ab9e-4932b1d84067' } };
|
||||
const oldFetch = globalThis.fetch;
|
||||
const oldBase = process.env.PCORE_BASE_URL;
|
||||
const oldKey = process.env.PCORE_API_KEY;
|
||||
process.env.PCORE_BASE_URL = 'https://pcore.ai/v1';
|
||||
process.env.PCORE_API_KEY = 'test-key';
|
||||
try {
|
||||
globalThis.fetch = async () => { throw new TypeError('fetch failed'); };
|
||||
await assert.rejects(runMediaAiEdit(database, {}, job), (error) => {
|
||||
assert.deepEqual(classifyAiEditFailure(error), {
|
||||
code: 'AI_IMAGE_GENERATION_FAILED', source: 'pcore', stage: 'provider_polling',
|
||||
detail: 'fetch failed', providerErrorRaw: null,
|
||||
});
|
||||
return true;
|
||||
});
|
||||
globalThis.fetch = async () => Response.json({ status: 'completed',
|
||||
data: [{ b64_json: Buffer.from('invalid image').toString('base64') }] });
|
||||
await assert.rejects(runMediaAiEdit(database, {}, job), (error) => {
|
||||
const failure = classifyAiEditFailure(error);
|
||||
assert.equal(failure.source, 'atlas');
|
||||
assert.equal(failure.stage, 'result_processing');
|
||||
assert.match(failure.detail, /image|format|buffer/i);
|
||||
return true;
|
||||
});
|
||||
const generated = await sharp({ create: { width: 100, height: 100, channels: 3,
|
||||
background: '#445566' } }).png().toBuffer();
|
||||
globalThis.fetch = async () => Response.json({ status: 'completed',
|
||||
data: [{ b64_json: generated.toString('base64') }] });
|
||||
const storage = { send: async () => { throw new Error('private/object-key.jpg'); } };
|
||||
await assert.rejects(runMediaAiEdit(database, storage, job), (error) => {
|
||||
const failure = classifyAiEditFailure(error);
|
||||
assert.equal(failure.source, 'atlas');
|
||||
assert.equal(failure.stage, 'result_storage');
|
||||
assert.equal(failure.detail, 'Error');
|
||||
return true;
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = oldFetch;
|
||||
if (oldBase === undefined) delete process.env.PCORE_BASE_URL; else process.env.PCORE_BASE_URL = oldBase;
|
||||
if (oldKey === undefined) delete process.env.PCORE_API_KEY; else process.env.PCORE_API_KEY = oldKey;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import sharp from 'sharp';
|
||||
import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { renderThreeFour, runAspectRatioDerivative } from '../src/media-aspect-ratio.ts';
|
||||
|
||||
const assertColorNear = (actual, expected) => {
|
||||
for (let channel = 0; channel < 3; channel++)
|
||||
assert.ok(Math.abs(actual[channel] - expected[channel]) <= 6, `${actual} 与 ${expected} 色差过大`);
|
||||
};
|
||||
|
||||
const readPixels = async (output) => {
|
||||
const { data, info } = await sharp(output).raw().toBuffer({ resolveWithObject: true });
|
||||
assert.equal(info.width, 1200);
|
||||
assert.equal(info.height, 1600);
|
||||
assert.equal(info.channels, 3);
|
||||
return (x, y) => [...data.subarray((y * info.width + x) * 3, (y * info.width + x) * 3 + 3)];
|
||||
};
|
||||
|
||||
test('正方形商品图等比缩放后,上下扩充处使用红色边缘色', async () => {
|
||||
const source = await sharp({
|
||||
create: { width: 400, height: 400, channels: 3, background: '#ff0000' },
|
||||
}).png().toBuffer();
|
||||
const pixel = await readPixels(await renderThreeFour(source));
|
||||
assertColorNear(pixel(600, 0), [255, 0, 0]);
|
||||
assertColorNear(pixel(600, 800), [255, 0, 0]);
|
||||
assertColorNear(pixel(600, 1599), [255, 0, 0]);
|
||||
});
|
||||
|
||||
test('上下边缘颜色不同,各自填充对应的一侧', async () => {
|
||||
const source = await sharp({
|
||||
create: { width: 400, height: 400, channels: 3, background: '#0000ff' },
|
||||
}).composite([
|
||||
{ input: { create: { width: 400, height: 48, channels: 3, background: '#ff0000' } }, left: 0, top: 0 },
|
||||
{ input: { create: { width: 400, height: 48, channels: 3, background: '#808080' } }, left: 0, top: 352 },
|
||||
]).png().toBuffer();
|
||||
const pixel = await readPixels(await renderThreeFour(source));
|
||||
assertColorNear(pixel(600, 0), [255, 0, 0]);
|
||||
assertColorNear(pixel(600, 800), [0, 0, 255]);
|
||||
assertColorNear(pixel(600, 1599), [128, 128, 128]);
|
||||
});
|
||||
|
||||
test('同一边缘有多种颜色时,接缝相接且远处不形成竖向条纹', async () => {
|
||||
const source = await sharp({
|
||||
create: { width: 400, height: 400, channels: 3, background: '#808080' },
|
||||
}).composite([
|
||||
{ input: { create: { width: 200, height: 48, channels: 3, background: '#ff0000' } }, left: 0, top: 0 },
|
||||
{ input: { create: { width: 200, height: 48, channels: 3, background: '#0000ff' } }, left: 200, top: 0 },
|
||||
{ input: { create: { width: 200, height: 48, channels: 3, background: '#ffff00' } }, left: 0, top: 352 },
|
||||
{ input: { create: { width: 200, height: 48, channels: 3, background: '#00ff00' } }, left: 200, top: 352 },
|
||||
]).png().toBuffer();
|
||||
const pixel = await readPixels(await renderThreeFour(source));
|
||||
for (const [x, topColor, bottomColor] of [
|
||||
[300, [255, 0, 0], [255, 255, 0]],
|
||||
[900, [0, 0, 255], [0, 255, 0]],
|
||||
]) {
|
||||
assertColorNear(pixel(x, 199), topColor);
|
||||
assertColorNear(pixel(x, 199), pixel(x, 200));
|
||||
assertColorNear(pixel(x, 1400), pixel(x, 1399));
|
||||
assertColorNear(pixel(x, 1400), bottomColor);
|
||||
}
|
||||
assertColorNear(pixel(300, 0), pixel(900, 0));
|
||||
assertColorNear(pixel(300, 1599), pixel(900, 1599));
|
||||
assertColorNear(pixel(300, 0), [128, 0, 128]);
|
||||
assertColorNear(pixel(300, 1599), [128, 255, 0]);
|
||||
});
|
||||
|
||||
test('竖长图片左右扩充处分别使用左绿右黄的边缘色', async () => {
|
||||
const source = await sharp({
|
||||
create: { width: 300, height: 800, channels: 3, background: '#0000ff' },
|
||||
}).composite([
|
||||
{ input: { create: { width: 36, height: 800, channels: 3, background: '#00ff00' } }, left: 0, top: 0 },
|
||||
{ input: { create: { width: 36, height: 800, channels: 3, background: '#ffff00' } }, left: 264, top: 0 },
|
||||
]).png().toBuffer();
|
||||
const pixel = await readPixels(await renderThreeFour(source));
|
||||
assertColorNear(pixel(0, 800), [0, 255, 0]);
|
||||
assertColorNear(pixel(600, 800), [0, 0, 255]);
|
||||
assertColorNear(pixel(1199, 800), [255, 255, 0]);
|
||||
});
|
||||
|
||||
test('左右边缘沿高度变色时,接缝相接且远处不形成横向条纹', async () => {
|
||||
const source = await sharp({
|
||||
create: { width: 300, height: 800, channels: 3, background: '#808080' },
|
||||
}).composite([
|
||||
{ input: { create: { width: 36, height: 400, channels: 3, background: '#00ff00' } }, left: 0, top: 0 },
|
||||
{ input: { create: { width: 36, height: 400, channels: 3, background: '#0000ff' } }, left: 0, top: 400 },
|
||||
{ input: { create: { width: 36, height: 400, channels: 3, background: '#ffff00' } }, left: 264, top: 0 },
|
||||
{ input: { create: { width: 36, height: 400, channels: 3, background: '#ff00ff' } }, left: 264, top: 400 },
|
||||
]).png().toBuffer();
|
||||
const pixel = await readPixels(await renderThreeFour(source));
|
||||
for (const [y, leftColor, rightColor] of [
|
||||
[400, [0, 255, 0], [255, 255, 0]],
|
||||
[1200, [0, 0, 255], [255, 0, 255]],
|
||||
]) {
|
||||
assertColorNear(pixel(299, y), leftColor);
|
||||
assertColorNear(pixel(299, y), pixel(300, y));
|
||||
assertColorNear(pixel(900, y), pixel(899, y));
|
||||
assertColorNear(pixel(900, y), rightColor);
|
||||
}
|
||||
assertColorNear(pixel(0, 400), pixel(0, 1200));
|
||||
assertColorNear(pixel(1199, 400), pixel(1199, 1200));
|
||||
assertColorNear(pixel(0, 400), [0, 128, 128]);
|
||||
assertColorNear(pixel(1199, 400), [255, 128, 128]);
|
||||
});
|
||||
|
||||
test('透明边缘以白底取色', async () => {
|
||||
const source = await sharp({
|
||||
create: { width: 400, height: 400, channels: 4, background: '#00000000' },
|
||||
}).composite([{ input: {
|
||||
create: { width: 200, height: 200, channels: 3, background: '#ff0000' },
|
||||
}, left: 100, top: 100 }]).png().toBuffer();
|
||||
const pixel = await readPixels(await renderThreeFour(source));
|
||||
assertColorNear(pixel(600, 0), [255, 255, 255]);
|
||||
assertColorNear(pixel(600, 800), [255, 0, 0]);
|
||||
assertColorNear(pixel(600, 1599), [255, 255, 255]);
|
||||
});
|
||||
|
||||
test('取色和留白遵循 EXIF 旋转后的显示方向', async () => {
|
||||
const source = await sharp({
|
||||
create: { width: 400, height: 200, channels: 3, background: '#0000ff' },
|
||||
}).composite([
|
||||
{ input: { create: { width: 400, height: 24, channels: 3, background: '#ff0000' } }, left: 0, top: 0 },
|
||||
{ input: { create: { width: 400, height: 24, channels: 3, background: '#808080' } }, left: 0, top: 176 },
|
||||
]).jpeg({ quality: 100 }).withMetadata({ orientation: 6 }).toBuffer();
|
||||
const pixel = await readPixels(await renderThreeFour(source));
|
||||
assertColorNear(pixel(0, 800), [128, 128, 128]);
|
||||
assertColorNear(pixel(1199, 800), [255, 0, 0]);
|
||||
});
|
||||
|
||||
test('拒绝不受支持的来源格式', async () => {
|
||||
const source = await sharp({
|
||||
create: { width: 8, height: 8, channels: 3, background: '#ff0000' },
|
||||
}).gif().toBuffer();
|
||||
await assert.rejects(renderThreeFour(source), /格式不受支持/);
|
||||
});
|
||||
|
||||
test('任务读取原图、写入独立派生素材,并完成持久化状态', async () => {
|
||||
const source = await sharp({
|
||||
create: { width: 400, height: 400, channels: 3, background: '#ff0000' },
|
||||
}).png().toBuffer();
|
||||
const writes = [];
|
||||
const storage = { send: async (command) => {
|
||||
if (command instanceof GetObjectCommand) return {
|
||||
Body: (async function* () { yield source; })(),
|
||||
};
|
||||
if (command instanceof PutObjectCommand) {
|
||||
writes.push(command.input);
|
||||
return {};
|
||||
}
|
||||
throw new Error('未知存储操作');
|
||||
} };
|
||||
let status = 'queued';
|
||||
let outputAssetId = null;
|
||||
const queries = [];
|
||||
const database = { workspaceTransaction: async (_workspaceId, work) => work({
|
||||
query: async (sql, args) => {
|
||||
queries.push({ sql, args });
|
||||
if (sql.includes('SELECT d.status')) return { rows: [{
|
||||
status,
|
||||
storage_provider: 'local_minio',
|
||||
bucket: 'test-bucket',
|
||||
object_key: 'original.png',
|
||||
}] };
|
||||
if (sql.includes("SET status = 'running'")) status = 'running';
|
||||
if (sql.includes("SET status = 'succeeded'")) status = 'succeeded';
|
||||
if (sql.includes('SELECT id FROM atlas.media_assets')) return { rows: [] };
|
||||
if (sql.includes('INSERT INTO atlas.media_assets')) return { rows: [{ id: 'output-asset' }] };
|
||||
if (sql.includes("result = jsonb_build_object")) outputAssetId = args[1];
|
||||
return { rows: [] };
|
||||
},
|
||||
}) };
|
||||
const job = {
|
||||
workspaceId: 'workspace-id',
|
||||
payload: { derivative_id: 'derivative-id' },
|
||||
};
|
||||
await runAspectRatioDerivative(database, storage, job);
|
||||
assert.equal(status, 'succeeded');
|
||||
assert.equal(outputAssetId, 'output-asset');
|
||||
assert.equal(writes.length, 1);
|
||||
assert.equal(writes[0].Bucket, 'test-bucket');
|
||||
assert.match(writes[0].Key, /^derivatives\/workspace-id\/three-four\/[a-f0-9]{64}\.jpg$/);
|
||||
assert.deepEqual(await sharp(writes[0].Body).metadata().then(({ width, height }) => [width, height]), [1200, 1600]);
|
||||
await runAspectRatioDerivative(database, storage, job);
|
||||
assert.equal(writes.length, 1);
|
||||
assert.ok(queries.some(({ sql }) => sql.includes('UPDATE atlas.operations')));
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import sharp from 'sharp';
|
||||
import { renderWatermark } from '../src/media-watermark.ts';
|
||||
|
||||
const template = {
|
||||
kind: 'image', opacity: '1', image_size_px: 40, border_radius_px: 0,
|
||||
position_x: 1, position_y: 1, position: 'bottom_right',
|
||||
};
|
||||
|
||||
const pixel = async (buffer, x, y) => {
|
||||
const { data, info } = await sharp(buffer).raw().toBuffer({ resolveWithObject: true });
|
||||
return [...data.subarray((y * info.width + x) * info.channels,
|
||||
(y * info.width + x) * info.channels + 3)];
|
||||
};
|
||||
|
||||
test('图片水印按坐标贴近右下边,并保持源图尺寸', async () => {
|
||||
const source = await sharp({ create: { width: 120, height: 100, channels: 3, background: '#ffffff' } }).png().toBuffer();
|
||||
const mark = await sharp({ create: { width: 40, height: 40, channels: 4, background: '#ff0000ff' } }).png().toBuffer();
|
||||
const output = await renderWatermark(source, template, mark);
|
||||
assert.equal(output.width, 120);
|
||||
assert.equal(output.height, 100);
|
||||
assert.ok((await pixel(output.bytes, 90, 70))[0] > 200);
|
||||
assert.ok((await pixel(output.bytes, 90, 70))[1] < 60);
|
||||
assert.ok((await pixel(output.bytes, 15, 15))[1] > 240);
|
||||
});
|
||||
|
||||
test('大图中的水印大小和底边距与 240×320 管理预览等比一致', async () => {
|
||||
const source = await sharp({ create: { width: 1200, height: 1600, channels: 3, background: '#ffffff' } }).png().toBuffer();
|
||||
const mark = await sharp({ create: { width: 80, height: 80, channels: 4, background: '#ff0000ff' } }).png().toBuffer();
|
||||
const output = await renderWatermark(source, { ...template, image_size_px: 80 }, mark);
|
||||
assert.ok((await pixel(output.bytes, 800, 1200))[1] < 60);
|
||||
assert.ok((await pixel(output.bytes, 700, 1200))[1] > 240);
|
||||
assert.ok((await pixel(output.bytes, 1100, 1500))[1] < 60);
|
||||
assert.ok((await pixel(output.bytes, 1100, 1585))[1] > 240);
|
||||
});
|
||||
|
||||
test('图片水印允许 10px 最小尺寸', async () => {
|
||||
const source = await sharp({ create: { width: 240, height: 320, channels: 3, background: '#ffffff' } }).png().toBuffer();
|
||||
const mark = await sharp({ create: { width: 20, height: 20, channels: 4, background: '#ff0000ff' } }).png().toBuffer();
|
||||
const output = await renderWatermark(source, { ...template, image_size_px: 10 }, mark);
|
||||
assert.ok((await pixel(output.bytes, 227, 307))[1] < 80);
|
||||
assert.ok((await pixel(output.bytes, 217, 297))[1] > 220);
|
||||
});
|
||||
|
||||
test('圆角使图片水印角落保持底图颜色', async () => {
|
||||
const source = await sharp({ create: { width: 120, height: 100, channels: 3, background: '#ffffff' } }).png().toBuffer();
|
||||
const mark = await sharp({ create: { width: 40, height: 40, channels: 4, background: '#ff0000ff' } }).png().toBuffer();
|
||||
const output = await renderWatermark(source, { ...template, border_radius_px: 18 }, mark);
|
||||
assert.ok((await pixel(output.bytes, 76, 56))[1] > 200);
|
||||
assert.ok((await pixel(output.bytes, 95, 75))[1] < 60);
|
||||
});
|
||||
|
||||
test('文字水印支持 XML 特殊字符,不会把内容当成 SVG', async () => {
|
||||
const source = await sharp({ create: { width: 200, height: 100, channels: 3, background: '#ffffff' } }).png().toBuffer();
|
||||
const output = await renderWatermark(source, {
|
||||
...template, kind: 'text', text_content: '<ATLAS & 1>', font_code: 'NotoSans', font_size_px: 24,
|
||||
position_x: 0, position_y: 0,
|
||||
});
|
||||
const { data } = await sharp(output.bytes).raw().toBuffer({ resolveWithObject: true });
|
||||
assert.ok(data.some((value) => value < 100));
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import {
|
||||
mapOzonProduct,
|
||||
parseOzonInfoItems,
|
||||
parseOzonListPage,
|
||||
} from '../src/online-product-mapper.ts';
|
||||
|
||||
test('列表游标与商品标识按平台响应读取,缺失标识时拒绝继续同步', () => {
|
||||
const page = parseOzonListPage({
|
||||
result: {
|
||||
items: [{ product_id: 123, offer_id: 'offer-1' }],
|
||||
last_id: 'next-page',
|
||||
},
|
||||
});
|
||||
assert.deepEqual(page, {
|
||||
items: [{ product_id: '123', offer_id: 'offer-1' }],
|
||||
lastId: 'next-page',
|
||||
});
|
||||
assert.throws(
|
||||
() => parseOzonListPage({ result: { items: [{ product_id: 123 }] } }),
|
||||
/缺少商品标识/,
|
||||
);
|
||||
assert.throws(() => parseOzonInfoItems({ result: {} }), /响应格式异常/);
|
||||
});
|
||||
|
||||
test('可见性未知、库存零和审核错误保留各自语义', () => {
|
||||
const product = mapOzonProduct(
|
||||
{
|
||||
id: 123,
|
||||
offer_id: 'offer-1',
|
||||
name: '商品',
|
||||
price: '999.00',
|
||||
old_price: '1110.00',
|
||||
stocks: { present: 0 },
|
||||
status: {
|
||||
moderate_status: 'declined',
|
||||
validation_state: 'failed',
|
||||
decline_reasons: [{ message: '图片不符合要求' }],
|
||||
},
|
||||
images: [{ file_name: 'https://example.com/product.jpg' }],
|
||||
sources: [{ sku: 456 }],
|
||||
},
|
||||
{ product_id: '123', offer_id: 'offer-1' },
|
||||
);
|
||||
assert.equal(product.visible, null);
|
||||
assert.deepEqual(product.stock_summary, { total: 0, has_data: true });
|
||||
assert.equal(product.moderation_status, 'declined');
|
||||
assert.equal(product.remote_sku, '456');
|
||||
assert.equal(product.image_summary.url, 'https://example.com/product.jpg');
|
||||
assert.equal(product.error_payload[0].message, '图片不符合要求');
|
||||
assert.equal(product.selling_price, '999.00');
|
||||
});
|
||||
|
||||
test('不存在库存数据和非 HTTPS 图片不能伪装成有效平台数据', () => {
|
||||
const product = mapOzonProduct(
|
||||
{ id: 123, price: 'abc', primary_image: 'http://example.com/a.jpg' },
|
||||
{ product_id: '123', offer_id: 'offer-1' },
|
||||
);
|
||||
assert.deepEqual(product.stock_summary, { total: 0, has_data: false });
|
||||
assert.equal(product.image_summary.url, null);
|
||||
assert.equal(product.selling_price, null);
|
||||
});
|
||||
@@ -0,0 +1,304 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createCipheriv,
|
||||
createHash,
|
||||
randomBytes,
|
||||
randomUUID,
|
||||
} from 'node:crypto';
|
||||
import { test } from 'node:test';
|
||||
import { createApplicationDatabase } from '@atlas/database';
|
||||
import {
|
||||
archiveExpiredMissingOnlineProducts,
|
||||
markOnlineProductSyncFailed,
|
||||
runOnlineProductSync,
|
||||
} from '../src/online-product-sync.ts';
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
const encryptionKey = process.env.STORE_SECRET_ENCRYPTION_KEY;
|
||||
|
||||
test(
|
||||
'全量同步写入镜像,连续未见归档,再出现恢复且租户隔离',
|
||||
{ skip: !databaseUrl || !encryptionKey },
|
||||
async () => {
|
||||
const database = createApplicationDatabase(databaseUrl);
|
||||
const workspaceId = randomUUID();
|
||||
const storeId = randomUUID();
|
||||
const secretId = randomUUID();
|
||||
const platform = await database.query(
|
||||
"SELECT id FROM atlas.platforms WHERE code = 'OZON' LIMIT 1",
|
||||
);
|
||||
assert.ok(platform.rows[0]);
|
||||
const platformId = platform.rows[0].id;
|
||||
const originalFetch = globalThis.fetch;
|
||||
let productPresent = true;
|
||||
let failList = false;
|
||||
let remoteProductId = 93847265;
|
||||
let failAfterFirstPage = false;
|
||||
globalThis.fetch = async (url, init) => {
|
||||
if (String(url).endsWith('/v3/product/list')) {
|
||||
if (failList) return new Response('{}', { status: 503 });
|
||||
const cursor = JSON.parse(init.body).last_id;
|
||||
if (failAfterFirstPage && cursor)
|
||||
return new Response('{}', { status: 503 });
|
||||
return Response.json({
|
||||
result: {
|
||||
items: productPresent
|
||||
? [
|
||||
{
|
||||
product_id: remoteProductId,
|
||||
offer_id: 'sync-test-offer',
|
||||
},
|
||||
]
|
||||
: [],
|
||||
last_id: failAfterFirstPage ? 'second-page' : '',
|
||||
},
|
||||
});
|
||||
}
|
||||
if (String(url).endsWith('/v3/product/info/list'))
|
||||
return Response.json({
|
||||
result: {
|
||||
items: [
|
||||
{
|
||||
id: remoteProductId,
|
||||
offer_id: 'sync-test-offer',
|
||||
name: '同步测试商品',
|
||||
price: '99.00',
|
||||
old_price: '109.00',
|
||||
visible: true,
|
||||
stocks: { present: 0 },
|
||||
sources: [{ sku: 987654 }],
|
||||
status: {
|
||||
moderate_status: 'approved',
|
||||
validation_state: 'success',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
throw new Error('Unexpected Ozon path');
|
||||
};
|
||||
const key = createHash('sha256').update(encryptionKey).digest();
|
||||
const nonce = randomBytes(12);
|
||||
const cipher = createCipheriv('aes-256-gcm', key, nonce);
|
||||
const ciphertext = Buffer.concat([
|
||||
cipher.update('test-key', 'utf8'),
|
||||
cipher.final(),
|
||||
cipher.getAuthTag(),
|
||||
]);
|
||||
try {
|
||||
await database.query(
|
||||
'INSERT INTO atlas.workspaces (id, name, slug) VALUES ($1, $2, $3)',
|
||||
[workspaceId, '在线商品同步测试', `online-sync-${workspaceId}`],
|
||||
);
|
||||
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.stores (id, workspace_id, platform_id, name, platform_client_id, currency_code, is_default, status, credential_status)
|
||||
VALUES ($1, $2, $3, '测试门店', $4, 'RUB', true, 'active', 'valid')`,
|
||||
[
|
||||
storeId,
|
||||
workspaceId,
|
||||
platformId,
|
||||
`online-sync-${storeId}`,
|
||||
],
|
||||
);
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.store_secrets (id, workspace_id, store_id, kind, secret_ciphertext, secret_nonce, encryption_key_version, fingerprint, status)
|
||||
VALUES ($1, $2, $3, 'seller_api_key', $4, $5, 'v1', $6, 'valid')`,
|
||||
[
|
||||
secretId,
|
||||
workspaceId,
|
||||
storeId,
|
||||
ciphertext,
|
||||
nonce,
|
||||
randomUUID(),
|
||||
],
|
||||
);
|
||||
});
|
||||
const createJob = async () => {
|
||||
const created = await database.workspaceTransaction(
|
||||
workspaceId,
|
||||
(tx) =>
|
||||
tx.query(
|
||||
`INSERT INTO atlas.platform_product_sync_runs
|
||||
(workspace_id, store_id, platform_id, trigger_source) VALUES ($1, $2, $3, 'manual') RETURNING id`,
|
||||
[workspaceId, storeId, platformId],
|
||||
),
|
||||
);
|
||||
const runId = created.rows[0].id;
|
||||
const job = {
|
||||
eventId: randomUUID(),
|
||||
workspaceId,
|
||||
eventType: 'platform_product_sync.requested',
|
||||
payload: {
|
||||
sync_run_id: runId,
|
||||
store_id: storeId,
|
||||
platform_id: platformId,
|
||||
secret_id: secretId,
|
||||
},
|
||||
};
|
||||
return { runId, job };
|
||||
};
|
||||
const sync = async () => {
|
||||
const { runId, job } = await createJob();
|
||||
if (failList) {
|
||||
let failure;
|
||||
try {
|
||||
await runOnlineProductSync(database, job);
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
assert.ok(failure);
|
||||
await markOnlineProductSyncFailed(database, job, failure);
|
||||
await runOnlineProductSync(database, job);
|
||||
} else await runOnlineProductSync(database, job);
|
||||
const run = await database.workspaceTransaction(
|
||||
workspaceId,
|
||||
(tx) =>
|
||||
tx.query(
|
||||
'SELECT status, listed_count, upserted_count FROM atlas.platform_product_sync_runs WHERE id = $1',
|
||||
[runId],
|
||||
),
|
||||
);
|
||||
assert.equal(
|
||||
run.rows[0].status,
|
||||
failList ? 'failed' : 'succeeded',
|
||||
);
|
||||
return run.rows[0];
|
||||
};
|
||||
const first = await sync();
|
||||
assert.equal(first.listed_count, 1);
|
||||
assert.equal(first.upserted_count, 1);
|
||||
const listing = async () =>
|
||||
database.workspaceTransaction(workspaceId, (tx) =>
|
||||
tx.query(
|
||||
'SELECT id, remote_product_id, name, visible, stock_summary, missing_since, archived_at FROM atlas.platform_listings WHERE store_id = $1',
|
||||
[storeId],
|
||||
),
|
||||
);
|
||||
let row = (await listing()).rows[0];
|
||||
assert.equal(row.name, '同步测试商品');
|
||||
assert.equal(row.visible, true);
|
||||
assert.deepEqual(row.stock_summary, { total: 0, has_data: true });
|
||||
const listingId = row.id;
|
||||
remoteProductId = 93847266;
|
||||
await sync();
|
||||
row = (await listing()).rows[0];
|
||||
assert.equal(row.id, listingId);
|
||||
assert.equal(row.remote_product_id, String(remoteProductId));
|
||||
assert.equal((await listing()).rows.length, 1);
|
||||
failList = true;
|
||||
await sync();
|
||||
row = (await listing()).rows[0];
|
||||
assert.equal(row.missing_since, null);
|
||||
assert.equal(row.archived_at, null);
|
||||
failList = false;
|
||||
productPresent = false;
|
||||
await sync();
|
||||
row = (await listing()).rows[0];
|
||||
assert.ok(row.missing_since);
|
||||
assert.equal(row.archived_at, null);
|
||||
await database.workspaceTransaction(workspaceId, (tx) =>
|
||||
tx.query(
|
||||
`UPDATE atlas.platform_listings SET missing_since = now() - interval '8 days' WHERE store_id = $1`,
|
||||
[storeId],
|
||||
),
|
||||
);
|
||||
const activeRun = await database.workspaceTransaction(
|
||||
workspaceId,
|
||||
(tx) =>
|
||||
tx.query(
|
||||
`INSERT INTO atlas.platform_product_sync_runs (workspace_id, store_id, platform_id, trigger_source)
|
||||
VALUES ($1, $2, $3, 'manual') RETURNING id`,
|
||||
[workspaceId, storeId, platformId],
|
||||
),
|
||||
);
|
||||
await archiveExpiredMissingOnlineProducts(database, workspaceId);
|
||||
assert.equal((await listing()).rows[0].archived_at, null);
|
||||
await database.workspaceTransaction(workspaceId, (tx) =>
|
||||
tx.query(
|
||||
`DELETE FROM atlas.platform_product_sync_runs WHERE id = $1`,
|
||||
[activeRun.rows[0].id],
|
||||
),
|
||||
);
|
||||
await archiveExpiredMissingOnlineProducts(database, workspaceId);
|
||||
row = (await listing()).rows[0];
|
||||
assert.ok(row.archived_at);
|
||||
productPresent = true;
|
||||
await sync();
|
||||
row = (await listing()).rows[0];
|
||||
assert.equal(row.missing_since, null);
|
||||
assert.equal(row.archived_at, null);
|
||||
productPresent = false;
|
||||
await sync();
|
||||
row = (await listing()).rows[0];
|
||||
assert.ok(row.missing_since);
|
||||
assert.equal(row.archived_at, null);
|
||||
await sync();
|
||||
row = (await listing()).rows[0];
|
||||
assert.ok(row.archived_at);
|
||||
productPresent = true;
|
||||
await sync();
|
||||
row = (await listing()).rows[0];
|
||||
assert.equal(row.missing_since, null);
|
||||
assert.equal(row.archived_at, null);
|
||||
productPresent = false;
|
||||
await sync();
|
||||
const missingSinceBeforeRetry = (await listing()).rows[0].missing_since;
|
||||
assert.ok(missingSinceBeforeRetry);
|
||||
productPresent = true;
|
||||
const { job: retryJob } = await createJob();
|
||||
failAfterFirstPage = true;
|
||||
await assert.rejects(runOnlineProductSync(database, retryJob));
|
||||
row = (await listing()).rows[0];
|
||||
assert.equal(row.missing_since.toISOString(), missingSinceBeforeRetry.toISOString());
|
||||
assert.equal(row.archived_at, null);
|
||||
failAfterFirstPage = false;
|
||||
productPresent = false;
|
||||
await runOnlineProductSync(database, retryJob);
|
||||
row = (await listing()).rows[0];
|
||||
assert.ok(row.missing_since);
|
||||
assert.ok(row.archived_at);
|
||||
const otherWorkspace = await database.workspaceTransaction(
|
||||
randomUUID(),
|
||||
(tx) =>
|
||||
tx.query(
|
||||
'SELECT id FROM atlas.platform_listings WHERE store_id = $1',
|
||||
[storeId],
|
||||
),
|
||||
);
|
||||
assert.equal(otherWorkspace.rows.length, 0);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
await database
|
||||
.workspaceTransaction(workspaceId, async (tx) => {
|
||||
await tx.query(
|
||||
'DELETE FROM atlas.platform_listing_sync_snapshots WHERE workspace_id = $1',
|
||||
[workspaceId],
|
||||
);
|
||||
await tx.query(
|
||||
'DELETE FROM atlas.platform_listings WHERE workspace_id = $1',
|
||||
[workspaceId],
|
||||
);
|
||||
await tx.query(
|
||||
'DELETE FROM atlas.platform_product_sync_runs WHERE workspace_id = $1',
|
||||
[workspaceId],
|
||||
);
|
||||
await tx.query(
|
||||
'DELETE FROM atlas.store_secrets WHERE workspace_id = $1',
|
||||
[workspaceId],
|
||||
);
|
||||
await tx.query(
|
||||
'DELETE FROM atlas.stores WHERE workspace_id = $1',
|
||||
[workspaceId],
|
||||
);
|
||||
})
|
||||
.catch(() => {});
|
||||
await database
|
||||
.query('DELETE FROM atlas.workspaces WHERE id = $1', [
|
||||
workspaceId,
|
||||
])
|
||||
.catch(() => {});
|
||||
await database.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user