import assert from 'node:assert/strict'; import type { AddressInfo } from 'node:net'; import test from 'node:test'; import express, { type ErrorRequestHandler } from 'express'; import { ZodError } from 'zod'; import { ApiError } from '../src/http/api-error.js'; import type { DomesticDataset, DomesticMetricSummary, DomesticProduct } from '../src/types/domestic-dataset.js'; import { LocalSyncJobStore } from '../src/modules/domestic-voc/local/local-sync-job.store.js'; import { createAuthenticationMiddleware, type RequestAuthenticator, WorkspaceAccessService, } from '../src/modules/saas-platform/auth.js'; import { prepareAnalysisRunPatch } from '../src/modules/saas-platform/domain.js'; import { LocalPlatformRepository } from '../src/modules/saas-platform/local-platform.repository.js'; import { createSaasPlatformRouter } from '../src/modules/saas-platform/routes.js'; const emptyMetrics: DomesticMetricSummary = { gmv: 0, soldUnits: 0, transactionOrders: 0, transactionCustomers: 0, impressions: 0, clicks: 0, views: 0, visitors: 0, cartUnits: 0, orderAmount: 0, orderUnits: 0, orderCount: 0, refundAmount: 0, refundUnits: 0, refundOrders: 0, conversionRate: 0, clickThroughRate: 0, averageUnitPrice: 0, refundToGmvRate: 0, }; function product(productId: string, role: DomesticProduct['role']): DomesticProduct { return { platform: 'jd', productId, productKey: `jd:${productId}`, asin: productId, role, brand: role === 'own' ? 'Demashi' : 'Competitor', title: `Product ${productId}`, model: `M-${productId}`, category1: 'Commercial appliance', category2: 'Kitchen equipment', category3: 'Oven', source: 'test', relationCount: role === 'own' ? 1 : 0, summary: emptyMetrics, trend: [], }; } const dataset: DomesticDataset = { schemaVersion: 1, generatedAt: '2026-08-06T00:00:00.000Z', caseName: 'Demashi', platform: 'jd', source: { sourceFile: 'analysis-run.json', sourceHash: 'test', sheets: [], dateRange: { start: '2026-08-01', end: '2026-08-06' }, }, summary: { metricRows: 0, metricProducts: 1, mappingRows: 0, relations: 0, uniqueCompetitorProducts: 0, category2Count: 1, category3Count: 1, reviewCount: 0, }, dailyTotals: [], products: [product('1001', 'own')], mappingGroups: [], relations: [], reviews: [], quality: { orphanMappings: [], mappingsWithoutCompetitor: [], brandWithoutProductId: [] }, }; async function listen(app: ReturnType) { const server = await new Promise>((resolve) => { const listening = app.listen(0, '127.0.0.1', () => resolve(listening)); }); const address = server.address() as AddressInfo; return { baseUrl: `http://127.0.0.1:${address.port}`, close: () => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())), }; } async function json(response: Response): Promise> { return await response.json() as Record; } test('analysis run patch helper enforces state machine and required payloads', () => { const pending = { id: 'analysis-1', workspaceId: 'demashi', analysisType: 'voc_insight' as const, targetKind: 'workspace' as const, targetKey: '', status: 'pending' as const, input: {}, result: null, evidenceCount: 0, requestedBy: 'local-admin', errorSummary: null, requestedAt: '2026-08-06T00:00:00.000Z', startedAt: null, completedAt: null, }; const processingPatch = prepareAnalysisRunPatch(pending, { status: 'processing' }); assert.equal(typeof processingPatch.startedAt, 'string'); const cancelledPatch = prepareAnalysisRunPatch(pending, { status: 'cancelled' }); assert.equal(typeof cancelledPatch.completedAt, 'string'); assert.throws(() => prepareAnalysisRunPatch(pending, { status: 'completed' }), (error: unknown) => ( error instanceof ApiError && error.status === 409 && error.code === 'analysis_invalid_transition' )); const processing = { ...pending, status: 'processing' as const, startedAt: '2026-08-06T00:01:00.000Z' }; assert.throws(() => prepareAnalysisRunPatch(processing, { status: 'completed' }), (error: unknown) => ( error instanceof ApiError && error.status === 400 && error.code === 'analysis_result_required' )); assert.throws(() => prepareAnalysisRunPatch(processing, { status: 'partial' }), (error: unknown) => ( error instanceof ApiError && error.status === 400 && error.code === 'analysis_result_required' )); assert.throws(() => prepareAnalysisRunPatch(processing, { status: 'failed' }), (error: unknown) => ( error instanceof ApiError && error.status === 400 && error.code === 'analysis_error_summary_required' )); const partialPatch = prepareAnalysisRunPatch(processing, { status: 'partial', result: { insight: 'ok' } }); assert.equal(typeof partialPatch.completedAt, 'string'); const completed = { ...processing, status: 'completed' as const, result: { insight: 'ok' }, completedAt: '2026-08-06T00:02:00.000Z' }; assert.throws(() => prepareAnalysisRunPatch(completed, { status: 'failed', errorSummary: 'late' }), (error: unknown) => ( error instanceof ApiError && error.status === 409 && error.code === 'analysis_terminal' )); }); test('analysis run patch persists through local repository and router audit', async () => { const jobs = new LocalSyncJobStore(dataset); const repository = new LocalPlatformRepository(dataset, jobs, { userId: 'local-admin', email: 'local-admin@localhost', displayName: 'Local Admin', }); await repository.upsertMember({ workspaceId: 'demashi', userId: 'viewer-user', email: 'viewer@example.test', displayName: 'Viewer', role: 'viewer', status: 'active', }); const authenticator: RequestAuthenticator = { async authenticate(request) { const userId = request.header('X-Test-User') || 'local-admin'; return { userId, email: `${userId}@example.test`, displayName: userId, authMode: 'disabled' }; }, }; const app = express(); app.use(express.json()); app.use('/api', createAuthenticationMiddleware(authenticator)); app.use('/api/saas', createSaasPlatformRouter({ repository, access: new WorkspaceAccessService(repository), })); const errorHandler: ErrorRequestHandler = (error, _request, response, _next) => { if (error instanceof ApiError) { response.status(error.status).json({ error: error.code }); return; } if (error instanceof ZodError) { response.status(400).json({ error: 'invalid_request' }); return; } response.status(500).json({ error: 'internal_error' }); }; app.use(errorHandler); const server = await listen(app); try { const ownerHeaders = { 'Content-Type': 'application/json', 'X-Test-User': 'local-admin' }; const viewerHeaders = { 'Content-Type': 'application/json', 'X-Test-User': 'viewer-user' }; const createResponse = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/analyses`, { method: 'POST', headers: ownerHeaders, body: JSON.stringify({ analysisType: 'voc_insight', targetKind: 'workspace', input: { source: 'local' } }), }); assert.equal(createResponse.status, 202); const created = (await json(createResponse)).analysis as { id: string; status: string }; assert.equal(created.status, 'pending'); const processingResponse = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/analyses/${created.id}`, { method: 'PATCH', headers: ownerHeaders, body: JSON.stringify({ status: 'processing', startedAt: '2026-08-06T00:10:00.000Z', evidenceCount: 2 }), }); assert.equal(processingResponse.status, 200); const processing = (await json(processingResponse)).analysis as { status: string; startedAt: string | null; evidenceCount: number; }; assert.equal(processing.status, 'processing'); assert.equal(processing.startedAt, '2026-08-06T00:10:00.000Z'); assert.equal(processing.evidenceCount, 2); const completedResponse = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/analyses/${created.id}`, { method: 'PATCH', headers: ownerHeaders, body: JSON.stringify({ status: 'completed', result: { findings: ['Need more evidence links'] }, evidenceCount: 4, completedAt: '2026-08-06T00:12:00.000Z', }), }); assert.equal(completedResponse.status, 200); const completed = (await json(completedResponse)).analysis as { status: string; result: Record | null; completedAt: string | null; }; assert.equal(completed.status, 'completed'); assert.deepEqual(completed.result, { findings: ['Need more evidence links'] }); assert.equal(completed.completedAt, '2026-08-06T00:12:00.000Z'); const deniedViewerPatch = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/analyses/${created.id}`, { method: 'PATCH', headers: viewerHeaders, body: JSON.stringify({ status: 'cancelled' }), }); assert.equal(deniedViewerPatch.status, 403); assert.equal((await json(deniedViewerPatch)).error, 'workspace_permission_denied'); const auditResponse = await fetch(`${server.baseUrl}/api/saas/workspaces/demashi/audit?limit=20`, { headers: ownerHeaders }); assert.equal(auditResponse.status, 200); const auditActions = (await json(auditResponse)).items.map((item: { action: string }) => item.action); assert.ok(auditActions.includes('analysis.created')); assert.ok(auditActions.includes('analysis.updated')); const stored = await repository.listAnalyses({ workspaceId: 'demashi', limit: 10, cursor: null, status: '' }); assert.equal(stored.items[0]?.status, 'completed'); assert.deepEqual(stored.items[0]?.result, { findings: ['Need more evidence links'] }); assert.equal(stored.items[0]?.evidenceCount, 4); } finally { await server.close(); } });