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(); } }, );