import test from 'node:test'; import assert from 'node:assert/strict'; import { TaskService, canResolveDispatchNoWrite, shouldIgnoreDispatchTimeout } from '../src/task-service.js'; import { loadConfig } from '../src/config.js'; import { getPool, closePool } from '../src/db.js'; import { decryptText } from '../src/crypto.js'; function fixture() { return { taskStatus: 'reconciliation_pending', attemptStatus: 'reconciliation_pending', executionId: 'execution-a', connectionId: 'connection-a', claimedConnectionId: 'connection-a', previous: { execution_id: 'execution-a', stage: 'dispatch', execution_phase: 'dispatch_uncertain', status: 'reconciliation_pending', write_attempted: true, erp_write_started: true, no_erp_write: false } as Record, incoming: { execution_id: 'execution-a', status: 'blocked', stage: 'erp_resolution', failure_source: 'plugin_executor', no_erp_write: true, write_attempted: false, report: { status: 'erp_resolution_blocked', no_erp_write: true, write_attempted: false, preflight: { stage: 'erp_readonly_resolution', no_erp_write: true, write_attempted: false }, native_request: null, server_response: null, requery: null, blockers: ['erp_resolution_navigation_failed:native_list_search_not_ready'], side_effects: ['no_procurement', 'no_payment', 'no_notification', 'no_external_send'] } as Record } as Record }; } test('same execution read-only failure resolves provisional dispatch uncertainty', () => { assert.equal(canResolveDispatchNoWrite(fixture()), true); }); test('maintenance-expired dispatch uncertainty still accepts the original explicit no-write result', () => { const f = fixture(); f.taskStatus = 'failed'; Object.assign(f.previous, { stage: 'reconciliation_timeout', reconciliation_required: true, reconciliation_stale: true }); assert.equal(canResolveDispatchNoWrite(f), true); delete f.previous.reconciliation_stale; assert.equal(canResolveDispatchNoWrite(f), false); }); test('late no-write resolution enforces execution, original connection and immutable state boundaries', () => { for (const [key, values] of Object.entries({ taskStatus: ['completed', 'cancelled', 'blocked', 'running', 'failed'], attemptStatus: ['completed', 'cancelled', 'blocked', 'running', 'failed'], executionId: ['', 'another'], connectionId: ['', 'another'], claimedConnectionId: ['', 'another'] })) for (const value of values) { assert.equal(canResolveDispatchNoWrite({ ...fixture(), [key]: value }), false, `${key}=${value}`); } for (const key of ['incoming', 'previous'] as const) { const f = fixture(); f[key].execution_id = 'another'; assert.equal(canResolveDispatchNoWrite(f), false, key); } }); test('true post-write uncertainty and prior native evidence cannot be overwritten', () => { for (const patch of [ { execution_phase: 'write_started' }, { execution_phase: '' }, { stage: 'lifecycle_live' }, { report: { native_request: { url: 'fixture' } } }, { native_request: {} }, { server_response: {} }, { erp_receipt: { group_numbers: ['TEST-001'] } } ]) { const f = fixture(); Object.assign(f.previous, patch); assert.equal(canResolveDispatchNoWrite(f), false, JSON.stringify(patch)); } }); test('bare no-write flags, contradictory write evidence and non-lookup results are rejected', () => { for (const patch of [ { report: null }, { status: 'running' }, { status: 'failed' }, { stage: 'lifecycle_live' }, { failure_source: 'program_parser' }, { no_erp_write: false }, { write_attempted: true }, { write_attempted: undefined }, { erp_write_started: true }, { execution_phase: 'submit_started' }, { native_request: {} }, { server_response: {} }, { requery: {} }, { erp_receipt: { group_numbers: ['TEST-001'] } } ]) { const f = fixture(); Object.assign(f.incoming, patch); assert.equal(canResolveDispatchNoWrite(f), false, JSON.stringify(patch)); } }); test('page-level no-write evidence must be complete and internally consistent', () => { for (const patch of [ { status: 'live_submit_blocked' }, { no_erp_write: false }, { write_attempted: true }, { native_request: {} }, { server_response: {} }, { requery: {} }, { blockers: [] }, { blockers: null }, { side_effects: ['payment'] }, { side_effects: {} }, { ajax_records: {} }, { ajax_records: [{ network_request_attempted: true }] }, { preflight: {} }, { preflight: { stage: 'erp_readonly_resolution', no_erp_write: true, write_attempted: true } } ]) { const f = fixture(); Object.assign(f.incoming.report as object, patch); assert.equal(canResolveDispatchNoWrite(f), false, JSON.stringify(patch)); } }); test('provisional timeout cannot replace already-received plugin progress or a final outcome', () => { const incoming = fixture().previous; for (const status of ['accepted', 'running', 'completed', 'blocked', 'failed', 'cancelled', 'dry_run', 'reconciliation_pending']) { assert.equal(shouldIgnoreDispatchTimeout({ execution_id: 'execution-a', stage: 'erp_resolution', status }, incoming), true, status); } for (const previous of [{}, { execution_id: 'another', stage: 'erp_resolution', status: 'running' }, { execution_id: 'execution-a', stage: 'dispatch', status: 'reconciliation_pending' }]) { assert.equal(shouldIgnoreDispatchTimeout(previous, incoming), false); } assert.equal(shouldIgnoreDispatchTimeout({ execution_id: 'execution-a', stage: 'erp_resolution', status: 'running' }, { ...incoming, execution_phase: 'write_started' }), false); }); test('real result-ingestion transaction closes only eligible dispatch failures and emits consistent facts', async t => { const config = loadConfig({ NODE_ENV: 'test', FIELD_ENCRYPTION_KEY: Buffer.alloc(32, 27).toString('base64'), DATABASE_URL: 'postgresql://fixture:fixture@127.0.0.1:1/fixture' }); const pool = getPool(config); t.after(async () => { t.mock.restoreAll(); await closePool(); }); let row: Record; let attempt: Record; let events: Array>; let queries: string[]; const client = { release() {}, async query(sql: string, params: unknown[] = []) { queries.push(sql.trim().split(/\s+/).slice(0, 2).join(' ')); if (/SELECT \* FROM task_attempts/.test(sql)) return { rowCount: params[0] === 'execution-a' ? 1 : 0, rows: [attempt] }; if (/UPDATE tasks\s/.test(sql)) { row.execution_result_ciphertext = params[0]; row.execution_result = params[1]; row.status = params[2]; row.stage = params[3]; row.message = params[4]; if (params[8]) row.lease_owner = null; return { rowCount: 1, rows: [row] }; } if (/UPDATE task_attempts/.test(sql)) { attempt.status = params[0]; attempt.response_hash = params[1]; return { rowCount: 1, rows: [] }; } if (!['BEGIN', 'COMMIT', 'ROLLBACK'].includes(sql)) throw new Error(`unexpected SQL ${sql}`); return { rowCount: 0, rows: [] }; } }; t.mock.method(pool, 'connect', async () => client); const service = new TaskService(config); Object.assign(service, { lockTaskForAccess: async () => row, requireBrowserConnection: async () => {}, getTask: async () => row, emitEvent: async (_client: unknown, _row: unknown, event: Record) => { events.push(event); return event; }, audit: async () => {}, notify: () => {} }); const context = { organizationId: 'org-a', userId: 'user-a', role: 'user' as const, requestId: 'fixture' }; for (const scenario of ['late', 'expired', 'wrong_connection', 'wrong_execution', 'wrong_actor', 'completed', 'postwrite', 'bare_flags', 'progress_first']) { const f = fixture(); if (scenario === 'expired') { f.taskStatus = 'failed'; Object.assign(f.previous, { stage: 'reconciliation_timeout', reconciliation_required: true, reconciliation_stale: true }); } if (scenario === 'postwrite') Object.assign(f.previous, { stage: 'lifecycle_live', execution_phase: 'submitted' }); if (scenario === 'bare_flags') delete f.incoming.report; if (scenario === 'progress_first') { f.taskStatus = 'running'; Object.assign(f.previous, { stage: 'erp_resolution', status: 'running', execution_phase: '' }); } row = { id: 'row-a', task_id: 'task-a', assigned_user_id: 'user-a', organization_id: 'org-a', status: scenario === 'completed' ? 'completed' : f.taskStatus, execution_result: f.previous, lease_owner: scenario === 'progress_first' ? 'browser:connection-a' : null }; attempt = { id: 'execution-a', status: f.attemptStatus, details: { connection_id: 'connection-a' } }; events = []; queries = []; const operation = service.recordExecutionResult( scenario === 'wrong_actor' ? { ...context, userId: 'user-b' } : context, 'task-a', scenario === 'progress_first' ? fixture().previous : f.incoming, scenario === 'wrong_connection' ? 'connection-b' : 'connection-a', scenario === 'wrong_execution' ? 'execution-b' : 'execution-a', { skipArtifactPersistence: true } ); if (['late', 'expired'].includes(scenario)) { await operation; const stored = JSON.parse(decryptText(config, String(row.execution_result_ciphertext))); assert.equal(row.status, 'blocked', scenario); assert.equal(attempt.status, 'blocked'); assert.equal(stored.reconciliation_resolved, true); assert.equal(stored.no_erp_write, true); assert.equal(stored.erp_write_started, false); assert.equal(stored.write_attempted, false); assert.equal(events.length, 1); assert.equal((events[0].payload as Record).reconciliation_resolved, true); assert.ok(queries.includes('COMMIT')); await service.recordExecutionResult(context, 'task-a', structuredClone(fixture().incoming), 'connection-a', 'execution-a', { skipArtifactPersistence: true }); assert.equal(events.length, 1, 'a duplicate read must not emit another terminal event'); await assert.rejects(service.recordExecutionResult(context, 'task-a', { ...fixture().incoming, message: 'changed' }, 'connection-a', 'execution-a', { skipArtifactPersistence: true }), /不可变状态/); } else if (scenario === 'progress_first') { await operation; assert.equal(row.status, 'running'); assert.equal(events.length, 0); assert.ok(!queries.includes('UPDATE tasks')); } else { await assert.rejects(operation, Error, scenario); assert.equal(events.length, 0); assert.ok(!queries.includes('UPDATE tasks'), scenario); assert.ok(queries.includes('ROLLBACK') || scenario === 'wrong_execution'); } } });