305 lines
13 KiB
JavaScript
305 lines
13 KiB
JavaScript
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();
|
|
}
|
|
},
|
|
);
|