1654 lines
76 KiB
TypeScript
1654 lines
76 KiB
TypeScript
import assert from 'node:assert/strict';
|
||
import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||
import { join } from 'node:path';
|
||
import { tmpdir } from 'node:os';
|
||
import test from 'node:test';
|
||
import { loadConfig } from '../src/config.js';
|
||
import { decryptBytes, decryptText, encryptBytes, encryptText, hashToken, sameTokenHash, sha256Bytes } from '../src/crypto.js';
|
||
import { aiServiceConnected, buildServer } from '../src/server.js';
|
||
import { REQUIRED_SCHEMA_VERSION } from '../src/db.js';
|
||
import {
|
||
TaskService,
|
||
authorizeLifecycleOperationForManualConfirmation,
|
||
buildImmediateParserPromotionGates,
|
||
classifyExpiredExecutionResult,
|
||
classifyExecutionResult,
|
||
canAccessTask,
|
||
canExecuteBusinessRoute,
|
||
canViewOperationsDashboard,
|
||
executionLifecycleFacts,
|
||
failureSummary,
|
||
executionStatusImmutable,
|
||
executionUpdateAllowed,
|
||
hasPrewriteNoErpEvidence,
|
||
hasLifecycleTestContext,
|
||
isTaskOwnerRestricted,
|
||
isLifecycleOperation,
|
||
parseLifecycleFacts,
|
||
prepareParsedOperationForConfirmation,
|
||
prepareExecutionArtifacts,
|
||
shouldAutomaticallyConfirm,
|
||
successReceiptFromResult
|
||
} from '../src/task-service.js';
|
||
import { detectBusinessDirective, isNewDirectiveMessage } from '../src/message-routing.js';
|
||
import { DatabaseArtifactStore, OssArtifactStore } from '../src/artifact-store.js';
|
||
|
||
test('message routing starts a new session for a business directive, not for a supplement', () => {
|
||
assert.equal(detectBusinessDirective('安排用车\n团号:LW-260903A-A'), '安排用车');
|
||
assert.equal(detectBusinessDirective('\uFEFF 安排 用车:\n团号:LW-260903A-A'), '安排用车');
|
||
assert.equal(detectBusinessDirective('散拼团拼单\n预订客户:辽宁康辉'), '散拼团拼单');
|
||
assert.equal(detectBusinessDirective('补充车型/项目说明:旅游大巴'), null);
|
||
assert.equal(detectBusinessDirective('请继续处理,安排用车'), null);
|
||
assert.equal(detectBusinessDirective('AgentBus 转发\n安排用车\n团号:LW-260903A-A'), '安排用车');
|
||
assert.equal(detectBusinessDirective(`发团日期:09-03\n预订客户:老挝联泰人名币\n产品搜索:遇见老挝\n预估人数:15+1\n用房数量:8标1单`), null);
|
||
assert.equal(isNewDirectiveMessage('导入散拼子单名单\n预订客户:辽宁康辉'), true);
|
||
assert.equal(isNewDirectiveMessage('数量改为 2'), false);
|
||
});
|
||
|
||
test('task access contract isolates users and team leads while preserving administrator and worker access', () => {
|
||
const cases = [
|
||
{ name: 'administrator can inspect another assigned task', access: { userId: 'admin', role: 'admin' as const }, assignedUserId: 'user-a', allowed: true },
|
||
{ name: 'trusted worker can inspect an unassigned task', access: { userId: '', role: undefined }, assignedUserId: null, allowed: true },
|
||
{ name: 'team lead sees own manual task', access: { userId: 'lead-a', role: 'team_lead' as const }, assignedUserId: 'lead-a', allowed: true },
|
||
{ name: 'team lead cannot use the normal task path for another task', access: { userId: 'lead-a', role: 'team_lead' as const }, assignedUserId: 'user-b', allowed: false },
|
||
{ name: 'team lead sees own AgentBus work', access: { userId: 'lead-a', role: 'team_lead' as const }, assignedUserId: 'lead-a', allowed: true },
|
||
{ name: 'ordinary user sees own manual task', access: { userId: 'user-a', role: 'user' as const }, assignedUserId: 'user-a', allowed: true },
|
||
{ name: 'ordinary user cannot see another task', access: { userId: 'user-a', role: 'user' as const }, assignedUserId: 'user-b', allowed: false },
|
||
{ name: 'ordinary user sees own AgentBus task', access: { userId: 'user-a', role: 'user' as const }, assignedUserId: 'user-a', allowed: true },
|
||
{ name: 'ordinary user without an actor cannot see a task', access: { userId: '', role: 'user' as const }, assignedUserId: '', allowed: false }
|
||
];
|
||
for (const item of cases) {
|
||
assert.equal(canAccessTask(item.access, { assignedUserId: item.assignedUserId }), item.allowed, item.name);
|
||
}
|
||
assert.equal(isTaskOwnerRestricted('admin'), false);
|
||
assert.equal(isTaskOwnerRestricted('team_lead'), true);
|
||
assert.equal(isTaskOwnerRestricted('user'), true);
|
||
assert.equal(canViewOperationsDashboard('admin'), true);
|
||
assert.equal(canViewOperationsDashboard('team_lead'), true);
|
||
assert.equal(canViewOperationsDashboard('user'), false);
|
||
});
|
||
|
||
test('business route authorization is an explicit allowlist for team leads and ordinary users', () => {
|
||
const routeId = 'arrangement_hotel_create' as const;
|
||
assert.equal(canExecuteBusinessRoute({
|
||
role: 'admin', source: 'manual', routeId: null, authorizedRouteIds: []
|
||
}), true, 'administrators retain all registered and unclassified manual intake');
|
||
assert.equal(canExecuteBusinessRoute({
|
||
role: 'team_lead', source: 'manual', routeId, authorizedRouteIds: [routeId]
|
||
}), true, 'team lead can use a granted route');
|
||
assert.equal(canExecuteBusinessRoute({
|
||
role: 'team_lead', source: 'manual', routeId, authorizedRouteIds: []
|
||
}), false, 'team lead cannot use an ungranted route');
|
||
assert.equal(canExecuteBusinessRoute({
|
||
role: 'user', source: 'manual', routeId, authorizedRouteIds: [routeId]
|
||
}), true, 'ordinary user can use a granted route');
|
||
assert.equal(canExecuteBusinessRoute({
|
||
role: 'user', source: 'manual', routeId: null, authorizedRouteIds: [routeId]
|
||
}), false, 'unclassified manual input fails closed for non-administrators');
|
||
assert.equal(canExecuteBusinessRoute({
|
||
role: 'user', source: 'agentbus', routeId, authorizedRouteIds: [routeId]
|
||
}), true, 'bound AgentBus intake uses the employee route allowlist');
|
||
assert.equal(canExecuteBusinessRoute({
|
||
role: undefined, source: 'agentbus', routeId, authorizedRouteIds: [routeId]
|
||
}), false, 'unbound AgentBus intake fails closed');
|
||
assert.equal(canExecuteBusinessRoute({
|
||
role: 'admin', source: 'agentbus', routeId, authorizedRouteIds: [routeId]
|
||
}), false, 'administrators cannot be AgentBus execution owners');
|
||
});
|
||
|
||
test('field encryption round-trips without storing plaintext', () => {
|
||
const config = loadConfig({
|
||
NODE_ENV: 'test',
|
||
FIELD_ENCRYPTION_KEY: Buffer.alloc(32, 7).toString('base64')
|
||
});
|
||
const plaintext = '含有客户信息的测试指令';
|
||
const encrypted = encryptText(config, plaintext);
|
||
assert.notEqual(encrypted, plaintext);
|
||
assert.equal(decryptText(config, encrypted), plaintext);
|
||
});
|
||
|
||
test('field encryption round-trips binary task attachments', () => {
|
||
const config = loadConfig({
|
||
NODE_ENV: 'test',
|
||
FIELD_ENCRYPTION_KEY: Buffer.alloc(32, 17).toString('base64')
|
||
});
|
||
const content = Buffer.from([0, 1, 2, 127, 128, 255]);
|
||
const encrypted = encryptBytes(config, content);
|
||
assert.equal(encrypted.includes(content.toString('base64')), false);
|
||
assert.deepEqual(decryptBytes(config, encrypted), content);
|
||
});
|
||
|
||
test('database artifact store sends encrypted bytes to the persistence boundary', async () => {
|
||
const config = loadConfig({
|
||
NODE_ENV: 'test',
|
||
FIELD_ENCRYPTION_KEY: Buffer.alloc(32, 18).toString('base64')
|
||
});
|
||
const content = Buffer.from([0, 1, 2, 127, 128, 255]);
|
||
let queryValues: unknown[] = [];
|
||
const store = new DatabaseArtifactStore(config);
|
||
const artifact = await store.put({
|
||
async query(_sql: string, values: unknown[]) {
|
||
queryValues = values;
|
||
return {
|
||
rowCount: 1,
|
||
rows: [{
|
||
id: '11111111-1111-4111-8111-111111111111',
|
||
organization_id: '22222222-2222-4222-8222-222222222222',
|
||
task_id: '33333333-3333-4333-8333-333333333333',
|
||
execution_id: '44444444-4444-4444-8444-444444444444',
|
||
artifact_index: 0,
|
||
artifact_type: 'liantai-confirm',
|
||
file_name: 'team.doc',
|
||
content_type: 'application/msword',
|
||
byte_size: content.byteLength,
|
||
sha256: 'a'.repeat(64),
|
||
storage_backend: 'database',
|
||
created_at: '2026-08-16T00:00:00.000Z'
|
||
}]
|
||
};
|
||
}
|
||
} as any, {
|
||
organizationId: '22222222-2222-4222-8222-222222222222',
|
||
taskRowId: '33333333-3333-4333-8333-333333333333',
|
||
taskId: 'TASK-TEST',
|
||
executionId: '44444444-4444-4444-8444-444444444444',
|
||
artifactIndex: 0,
|
||
type: 'liantai-confirm',
|
||
fileName: 'team.doc',
|
||
contentType: 'application/msword',
|
||
byteSize: content.byteLength,
|
||
sha256: 'a'.repeat(64),
|
||
content
|
||
});
|
||
assert.equal(artifact.storage_backend, 'database');
|
||
assert.equal(typeof queryValues[9], 'string');
|
||
assert.notEqual(queryValues[9], content.toString('base64'));
|
||
assert.deepEqual(decryptBytes(config, queryValues[9] as string), content);
|
||
});
|
||
|
||
test('production OSS configuration requires provider credentials and derives the region', () => {
|
||
assert.throws(
|
||
() => loadConfig({ NODE_ENV: 'test', ARTIFACT_STORAGE_BACKEND: 'oss' }),
|
||
/OSS artifact storage is enabled but missing configuration/
|
||
);
|
||
const config = loadConfig({
|
||
NODE_ENV: 'test',
|
||
ARTIFACT_STORAGE_BACKEND: 'oss',
|
||
OSS_ACCESS_KEY_ID: 'test-access-key',
|
||
OSS_ACCESS_KEY_SECRET: 'test-access-secret',
|
||
OSS_ENDPOINT: 'oss-cn-guangzhou.aliyuncs.com',
|
||
OSS_BUCKET_NAME: 'test-bucket'
|
||
});
|
||
assert.equal(config.ossRegion, 'cn-guangzhou');
|
||
assert.equal(config.DATA_RETENTION_ENABLED, false);
|
||
});
|
||
|
||
test('OSS artifact store writes metadata with an idempotent UUID execution path and cleans captured keys', async () => {
|
||
const config = loadConfig({
|
||
NODE_ENV: 'test',
|
||
FIELD_ENCRYPTION_KEY: Buffer.alloc(32, 19).toString('base64'),
|
||
ARTIFACT_STORAGE_BACKEND: 'oss',
|
||
OSS_ACCESS_KEY_ID: 'test-access-key',
|
||
OSS_ACCESS_KEY_SECRET: 'test-access-secret',
|
||
OSS_ENDPOINT: 'oss-cn-guangzhou.aliyuncs.com',
|
||
OSS_BUCKET_NAME: 'test-bucket'
|
||
});
|
||
const calls: Array<{ operation: string; key: string }> = [];
|
||
const client = {
|
||
async putObject(key: string) { calls.push({ operation: 'put', key }); },
|
||
async deleteObject(key: string) { calls.push({ operation: 'delete', key }); },
|
||
publicUrl(key: string) { return `https://test-bucket.oss-cn-guangzhou.aliyuncs.com/${key}`; }
|
||
};
|
||
const store = new OssArtifactStore(config, client);
|
||
const artifact = await store.put({
|
||
async query(sql: string, values: unknown[]) {
|
||
assert.match(sql, /storage_backend, storage_key, content_ciphertext/);
|
||
assert.equal(values[2], '44444444-4444-4444-8444-444444444444');
|
||
assert.equal(values[9], 'liansyn-platform/attachments/44444444-4444-4444-8444-444444444444/0');
|
||
return {
|
||
rowCount: 1,
|
||
rows: [{
|
||
id: '11111111-1111-4111-8111-111111111111',
|
||
organization_id: '22222222-2222-4222-8222-222222222222',
|
||
task_id: '33333333-3333-4333-8333-333333333333',
|
||
execution_id: '44444444-4444-4444-8444-444444444444',
|
||
artifact_index: 0,
|
||
artifact_type: 'liantai-confirm',
|
||
file_name: 'team.doc',
|
||
content_type: 'application/msword',
|
||
byte_size: 3,
|
||
sha256: 'a'.repeat(64),
|
||
storage_backend: 'oss',
|
||
storage_key: 'liansyn-platform/attachments/44444444-4444-4444-8444-444444444444/0',
|
||
created_at: '2026-08-16T00:00:00.000Z'
|
||
}]
|
||
};
|
||
}
|
||
} as any, {
|
||
organizationId: '22222222-2222-4222-8222-222222222222',
|
||
taskRowId: '33333333-3333-4333-8333-333333333333',
|
||
taskId: 'TASK-TEST',
|
||
executionId: '44444444-4444-4444-8444-444444444444',
|
||
artifactIndex: 0,
|
||
type: 'liantai-confirm',
|
||
fileName: 'team.doc',
|
||
contentType: 'application/msword',
|
||
byteSize: 3,
|
||
sha256: 'a'.repeat(64),
|
||
content: Buffer.from('abc')
|
||
});
|
||
assert.equal(artifact.storage_backend, 'oss');
|
||
assert.equal(artifact.public_url, 'https://test-bucket.oss-cn-guangzhou.aliyuncs.com/liansyn-platform/attachments/44444444-4444-4444-8444-444444444444/0');
|
||
assert.deepEqual(calls, [{ operation: 'put', key: 'liansyn-platform/attachments/44444444-4444-4444-8444-444444444444/0' }]);
|
||
await store.cleanup([artifact]);
|
||
assert.deepEqual(calls, [
|
||
{ operation: 'put', key: 'liansyn-platform/attachments/44444444-4444-4444-8444-444444444444/0' },
|
||
{ operation: 'delete', key: 'liansyn-platform/attachments/44444444-4444-4444-8444-444444444444/0' }
|
||
]);
|
||
});
|
||
|
||
test('token hashes compare safely and do not expose the original token', () => {
|
||
const token = 'temporary-session-token';
|
||
const hash = hashToken(token);
|
||
assert.equal(hash.toString('utf8').includes(token), false);
|
||
assert.equal(sameTokenHash(hash, hashToken(token)), true);
|
||
assert.equal(sameTokenHash(hash, hashToken(`${token}-other`)), false);
|
||
});
|
||
|
||
test('organization automation applies uniformly to every valid task source', () => {
|
||
const eligible = {
|
||
needsInput: false,
|
||
blocked: false,
|
||
source: 'agentbus',
|
||
automationEnabled: true
|
||
};
|
||
assert.equal(shouldAutomaticallyConfirm({ ...eligible, source: 'agentbus' }), true);
|
||
assert.equal(shouldAutomaticallyConfirm({ ...eligible, source: 'manual' }), true);
|
||
assert.equal(shouldAutomaticallyConfirm({ ...eligible, needsInput: true }), false);
|
||
assert.equal(shouldAutomaticallyConfirm({ ...eligible, blocked: true }), false);
|
||
assert.equal(shouldAutomaticallyConfirm({ ...eligible, automationEnabled: false }), false);
|
||
});
|
||
|
||
test('control plane exposes a live health endpoint without a database connection', async () => {
|
||
const config = loadConfig({
|
||
NODE_ENV: 'test',
|
||
FIELD_ENCRYPTION_KEY: Buffer.alloc(32, 8).toString('base64'),
|
||
DATABASE_URL: 'postgresql://invalid:invalid@127.0.0.1:1/invalid'
|
||
});
|
||
const { app } = await buildServer({
|
||
config,
|
||
startParserLoop: false,
|
||
parser: {
|
||
async parse() {
|
||
return { blockers: ['test parser'] };
|
||
},
|
||
async checkConnection() {
|
||
return { ok: false, configured: false };
|
||
}
|
||
}
|
||
});
|
||
const response = await app.inject({ method: 'GET', url: '/health/live' });
|
||
assert.equal(response.statusCode, 200);
|
||
assert.deepEqual(response.json(), { ok: true, service: 'ltjt-control-plane' });
|
||
await app.close();
|
||
});
|
||
|
||
test('AI primary connection state follows service reachability, not historical authentication evidence', () => {
|
||
assert.equal(aiServiceConnected(true, {
|
||
configured: true,
|
||
reachable: true,
|
||
authenticated: false,
|
||
last_auth_failure_code: 'external_unauthorized'
|
||
}), true);
|
||
assert.equal(aiServiceConnected(false, { configured: true, reachable: true }), false);
|
||
assert.equal(aiServiceConnected(true, { configured: false, reachable: true }), false);
|
||
assert.equal(aiServiceConnected(true, { configured: true, reachable: false }), false);
|
||
});
|
||
|
||
test('migration contains the durable state tables and safety fields', async () => {
|
||
const { readFile } = await import('node:fs/promises');
|
||
const sql = await readFile(new URL('../migrations/001_initial.sql', import.meta.url), 'utf8');
|
||
for (const marker of ['organizations', 'users', 'sessions', 'tasks', 'task_events', 'task_attempts', 'idempotency_keys', 'audit_events', 'browser_connections', 'outbox_events', 'execution_result']) {
|
||
assert.match(sql, new RegExp(`\\b${marker}\\b`));
|
||
}
|
||
});
|
||
|
||
test('control plane requires the latest durable task-outcome migration before readiness', async () => {
|
||
const { readFile } = await import('node:fs/promises');
|
||
const db = await readFile(new URL('../src/db.ts', import.meta.url), 'utf8');
|
||
const server = await readFile(new URL('../src/server.ts', import.meta.url), 'utf8');
|
||
assert.equal(REQUIRED_SCHEMA_VERSION, '018_agentbus_account_workers');
|
||
assert.match(db, /schema_migrations/);
|
||
assert.match(db, /databaseReadiness/);
|
||
assert.match(db, /assertDatabaseSchema/);
|
||
assert.match(db, /pool\.on\('error'/);
|
||
assert.match(db, /transaction rollback failed/);
|
||
assert.match(server, /await assertDatabaseSchema\(config\)/);
|
||
assert.match(server, /await app\.close\(\)\.catch/);
|
||
assert.match(server, /required_migration/);
|
||
});
|
||
|
||
test('ERP execution results fail closed after a possible write', () => {
|
||
assert.deepEqual(classifyExecutionResult({ status: 'running' }), {
|
||
rawStatus: 'running',
|
||
status: 'running',
|
||
terminal: false,
|
||
uncertain: false
|
||
});
|
||
assert.deepEqual(classifyExecutionResult({
|
||
status: 'running',
|
||
stage: 'verification',
|
||
write_attempted: true,
|
||
execution_phase: 'submitted'
|
||
}), {
|
||
rawStatus: 'running',
|
||
status: 'running',
|
||
terminal: false,
|
||
uncertain: false
|
||
});
|
||
assert.equal(classifyExecutionResult({ status: 'completed', write_attempted: true }).status, 'reconciliation_pending');
|
||
assert.equal(classifyExecutionResult({
|
||
status: 'completed',
|
||
write_attempted: true,
|
||
erp_receipt: { group_numbers: ['LW-260907A-B'] }
|
||
}).status, 'completed');
|
||
assert.equal(classifyExecutionResult({ status: 'blocked', no_erp_write: true }).status, 'blocked');
|
||
assert.equal(classifyExecutionResult({ status: 'blocked', write_attempted: true }).status, 'reconciliation_pending');
|
||
assert.equal(classifyExecutionResult({ status: 'saved_unverified' }).status, 'reconciliation_pending');
|
||
assert.equal(classifyExecutionResult({ status: 'unexpected_plugin_state' }).status, 'reconciliation_pending');
|
||
assert.equal(executionUpdateAllowed('queued'), true);
|
||
assert.equal(executionUpdateAllowed('confirmed'), false);
|
||
assert.equal(executionStatusImmutable('completed'), true);
|
||
assert.equal(executionStatusImmutable('reconciliation_pending'), true);
|
||
});
|
||
|
||
test('successful ERP executor results expose consistent lifecycle boundary facts', () => {
|
||
assert.deepEqual(executionLifecycleFacts({
|
||
status: 'completed',
|
||
execution_phase: 'submitted',
|
||
write_attempted: true,
|
||
no_erp_write: false,
|
||
erp_receipt: {
|
||
group_numbers: ['LW-260907A-B', 'LW-260915A-A']
|
||
},
|
||
verification: {
|
||
status: 'parent_group_found',
|
||
group_numbers: ['LW-260907A-B', 'LW-260915A-A']
|
||
}
|
||
}, 'completed', true), {
|
||
agent_returned: true,
|
||
plugin_dispatch_started: true,
|
||
erp_write_started: true,
|
||
no_plugin_dispatch: false,
|
||
no_erp_write: false,
|
||
write_attempted: true,
|
||
reconciliation_resolved: true
|
||
});
|
||
});
|
||
|
||
test('reconciliation can only be downgraded to no-write with explicit page evidence', () => {
|
||
const storedMisclassification = {
|
||
status: 'execution_uncertain',
|
||
write_attempted: true,
|
||
execution_phase: 'write_started',
|
||
report: {
|
||
status: 'live_submit_blocked',
|
||
submit_safety: { live_submit_attempted: false },
|
||
native_request: null,
|
||
server_response: null,
|
||
requery: null,
|
||
side_effects: []
|
||
}
|
||
};
|
||
assert.equal(hasPrewriteNoErpEvidence(storedMisclassification), true);
|
||
assert.equal(hasPrewriteNoErpEvidence({
|
||
...storedMisclassification,
|
||
report: {
|
||
...storedMisclassification.report,
|
||
submit_safety: { live_submit_attempted: true }
|
||
}
|
||
}), false);
|
||
assert.equal(hasPrewriteNoErpEvidence({
|
||
...storedMisclassification,
|
||
report: {
|
||
...storedMisclassification.report,
|
||
ajax_records: [{ prevented_from_network: false, network_request_attempted: true }]
|
||
}
|
||
}), false);
|
||
assert.equal(hasPrewriteNoErpEvidence({
|
||
...storedMisclassification,
|
||
report: { ...storedMisclassification.report, side_effects: { unknown: true } }
|
||
}), false);
|
||
});
|
||
|
||
test('success receipts require verifiable ERP evidence', () => {
|
||
assert.deepEqual(successReceiptFromResult({
|
||
status: 'completed',
|
||
erp_receipt: { group_numbers: ['LW-260907A-B', 'LW-260915A-A'] },
|
||
verification: { status: 'team_batch_verification_completed' }
|
||
}), {
|
||
group_numbers: ['LW-260907A-B', 'LW-260915A-A'],
|
||
group_number: 'LW-260907A-B',
|
||
verification_status: 'team_batch_verification_completed'
|
||
});
|
||
assert.equal(successReceiptFromResult({ status: 'completed', message: '执行器报告完成,但没有团号。' }), null);
|
||
});
|
||
|
||
test('multi-date split-order probes require complete child facts and no manual review', () => {
|
||
const perDate = ['2026-09-02', '2026-09-15', '2026-09-28'].map((date, index) => ({
|
||
date,
|
||
facts_determined: true,
|
||
outcome: 'concrete_shared_children_with_requested_facts',
|
||
parent_group_no: `LW-${date.replaceAll('-', '').slice(2)}TEST-202609-E2E3-A`,
|
||
parent_tid: String(15001 + index),
|
||
customer_requested: true,
|
||
customer_persisted_on_native_list: true,
|
||
passenger_counts_requested: true,
|
||
passenger_counts_persisted_on_native_list: true,
|
||
child_count: 1,
|
||
child_refs: [{ tid: String(15001 + index), ddid: String(16001 + index), child_order_no: `D${16001 + index}` }]
|
||
}));
|
||
const groupNumbers = perDate.map((row) => row.parent_group_no);
|
||
const result: any = {
|
||
status: 'completed',
|
||
report: {
|
||
status: 'split_parent_completed',
|
||
manual_review_required: false,
|
||
blockers: [],
|
||
erp_receipt: {
|
||
group_numbers: groupNumbers,
|
||
split_order_probe: {
|
||
status: 'all_dates_facts_determined',
|
||
per_date: perDate,
|
||
manual_review_required: false
|
||
}
|
||
},
|
||
verification: {
|
||
status: 'parent_groups_and_split_order_facts_determined',
|
||
group_numbers: groupNumbers,
|
||
split_order_probe: { facts_status: 'all_dates_facts_determined', per_date: perDate }
|
||
}
|
||
}
|
||
};
|
||
const receipt = successReceiptFromResult(result);
|
||
assert.deepEqual(receipt?.group_numbers, groupNumbers);
|
||
assert.equal(receipt?.verification_status, 'split_parent_completed');
|
||
|
||
const parentOnly = structuredClone(result);
|
||
parentOnly.report.manual_review_required = true;
|
||
parentOnly.report.erp_receipt.split_order_probe.manual_review_required = true;
|
||
parentOnly.report.erp_receipt.split_order_probe.per_date[0].outcome = 'parent_row_facts_without_concrete_child';
|
||
parentOnly.report.erp_receipt.split_order_probe.per_date[0].child_count = 0;
|
||
parentOnly.report.erp_receipt.split_order_probe.per_date[0].child_refs = [];
|
||
assert.equal(successReceiptFromResult(parentOnly), null);
|
||
|
||
const incomplete = structuredClone(result);
|
||
incomplete.report.verification.split_order_probe.facts_status = 'partial_dates_facts_determined';
|
||
assert.equal(successReceiptFromResult(incomplete), null);
|
||
});
|
||
|
||
test('unified lifecycle response plus fresh requery is accepted as completion evidence', () => {
|
||
const result = {
|
||
status: 'completed',
|
||
write_attempted: true,
|
||
no_erp_write: false,
|
||
summary: { action: 'arrangement_hotel' },
|
||
report: {
|
||
status: 'lifecycle_completed',
|
||
resolved_refs: {
|
||
kind: 'independent_order', identifier: 'LW-260912-TEST-202609-R0511',
|
||
tid: '17100', ddid: '17101'
|
||
},
|
||
server_response: { completed: true, http_status: 200 },
|
||
requery: { matched: true },
|
||
blockers: [],
|
||
manual_review_required: false
|
||
}
|
||
};
|
||
assert.deepEqual(successReceiptFromResult(result), {
|
||
status: 'verified',
|
||
success: true,
|
||
identifier: 'LW-260912-TEST-202609-R0511',
|
||
target_kind: 'independent_order',
|
||
action: 'arrangement_hotel',
|
||
server_response_completed: true,
|
||
requery_matched: true,
|
||
source_only: false,
|
||
group_number: 'LW-260912-TEST-202609-R0511',
|
||
verification_status: 'lifecycle_completed'
|
||
});
|
||
assert.equal(classifyExecutionResult(result).status, 'completed');
|
||
|
||
const missingRequery = structuredClone(result) as any;
|
||
missingRequery.report.requery.matched = false;
|
||
assert.equal(successReceiptFromResult(missingRequery), null);
|
||
assert.equal(classifyExecutionResult(missingRequery).status, 'reconciliation_pending');
|
||
|
||
const reviewRequired = structuredClone(result) as any;
|
||
reviewRequired.report.manual_review_required = true;
|
||
assert.equal(successReceiptFromResult(reviewRequired), null);
|
||
});
|
||
|
||
test('passenger list explicit server success does not require a post-save row requery', () => {
|
||
const result = {
|
||
status: 'completed',
|
||
write_attempted: true,
|
||
no_erp_write: false,
|
||
summary: { action: 'passenger_list_import' },
|
||
report: {
|
||
status: 'lifecycle_completed',
|
||
resolved_refs: {
|
||
kind: 'shared_child_order', identifier: 'D12345',
|
||
tid: '17100', ddid: '12345'
|
||
},
|
||
server_response: { completed: true, http_status: 200 },
|
||
requery: {
|
||
matched: true,
|
||
required: false,
|
||
skipped: true,
|
||
completion_policy: 'passenger_list_explicit_server_success'
|
||
},
|
||
blockers: [],
|
||
manual_review_required: false
|
||
}
|
||
};
|
||
assert.deepEqual(successReceiptFromResult(result), {
|
||
status: 'verified',
|
||
success: true,
|
||
identifier: 'D12345',
|
||
target_kind: 'shared_child_order',
|
||
action: 'passenger_list_import',
|
||
server_response_completed: true,
|
||
requery_matched: false,
|
||
requery_required: false,
|
||
completion_policy: 'passenger_list_explicit_server_success',
|
||
source_only: false,
|
||
order_number: 'D12345',
|
||
verification_status: 'lifecycle_completed'
|
||
});
|
||
assert.equal(classifyExecutionResult(result).status, 'completed');
|
||
|
||
const wrongAction = structuredClone(result) as any;
|
||
wrongAction.summary.action = 'arrangement_hotel';
|
||
assert.equal(successReceiptFromResult(wrongAction), null);
|
||
assert.equal(classifyExecutionResult(wrongAction).status, 'reconciliation_pending');
|
||
});
|
||
|
||
test('backend-attached export requires frozen hashes and explicit no-delivery evidence before it becomes a success receipt', () => {
|
||
const artifacts = Array.from({ length: 10 }, (_, index) => ({
|
||
type: `source-${index + 1}`,
|
||
ok: true,
|
||
http_status: 200,
|
||
bytes: 128 + index,
|
||
content_type: 'application/msword',
|
||
sha256: String(index + 1).padStart(64, 'a').slice(-64),
|
||
login_timeout: false,
|
||
permission_error: false,
|
||
downloaded: false,
|
||
backend_stored: true,
|
||
artifact_id: '11111111-1111-4111-8111-111111111111',
|
||
converted: false,
|
||
scheduled: false,
|
||
sent: false
|
||
}));
|
||
const result = {
|
||
status: 'completed',
|
||
no_erp_write: true,
|
||
write_attempted: false,
|
||
summary: { action: 'confirmation_export' },
|
||
report: {
|
||
status: 'export_source_completed',
|
||
resolved_refs: {
|
||
kind: 'shared_child_order', child_order_no: 'D17102', tid: '17100', ddid: '17102'
|
||
},
|
||
server_response: { completed: true, http_status: 200, artifact_count: 10 },
|
||
requery: { matched: true, hashes_frozen: true },
|
||
artifacts,
|
||
no_erp_write: true,
|
||
write_attempted: false,
|
||
downloaded: false,
|
||
backend_stored: true,
|
||
converted: false,
|
||
scheduled: false,
|
||
sent: false,
|
||
backend_storage: 'database',
|
||
blockers: [],
|
||
manual_review_required: false
|
||
}
|
||
};
|
||
assert.deepEqual(successReceiptFromResult(result), {
|
||
status: 'verified',
|
||
success: true,
|
||
identifier: 'D17102',
|
||
target_kind: 'shared_child_order',
|
||
action: 'confirmation_export',
|
||
server_response_completed: true,
|
||
requery_matched: true,
|
||
source_only: true,
|
||
order_number: 'D17102',
|
||
artifact_count: 10,
|
||
hashes_frozen: true,
|
||
verification_status: 'export_source_completed'
|
||
});
|
||
assert.equal(classifyExecutionResult(result).status, 'completed');
|
||
const converted = structuredClone(result) as any;
|
||
converted.report.converted = true;
|
||
converted.report.conversion_status = 'pdf';
|
||
converted.report.artifacts = converted.report.artifacts.map((artifact: Record<string, unknown>) => ({
|
||
...artifact,
|
||
file_name: 'source.pdf',
|
||
content_type: 'application/pdf',
|
||
converted: true,
|
||
conversion_status: 'converted'
|
||
}));
|
||
assert.equal(successReceiptFromResult(converted)?.artifact_count, 10);
|
||
const unfrozen = structuredClone(result) as any;
|
||
unfrozen.report.requery.hashes_frozen = false;
|
||
assert.equal(successReceiptFromResult(unfrozen), null);
|
||
const scheduled = structuredClone(result) as any;
|
||
scheduled.report.scheduled = true;
|
||
assert.equal(successReceiptFromResult(scheduled), null);
|
||
const scheduledArtifact = structuredClone(result) as any;
|
||
scheduledArtifact.report.artifacts[0].scheduled = true;
|
||
assert.equal(successReceiptFromResult(scheduledArtifact), null);
|
||
const mismatchedCount = structuredClone(result) as any;
|
||
mismatchedCount.report.server_response.artifact_count = 9;
|
||
assert.equal(successReceiptFromResult(mismatchedCount), null);
|
||
});
|
||
|
||
test('confirmation export archives visitor XLS, delivers real XLSX, and converts other types to PDF', {
|
||
skip: process.platform === 'win32' ? 'Unix fake converter fixture is not executable through Windows execFile.' : false
|
||
}, async () => {
|
||
const temporaryDirectory = await mkdtemp(join(tmpdir(), 'ltjt-selective-conversion-test-'));
|
||
const converterPath = join(temporaryDirectory, 'fake-soffice.sh');
|
||
await writeFile(converterPath, [
|
||
'#!/bin/sh',
|
||
'outdir=""',
|
||
'convert_to=""',
|
||
'next_is_outdir=0',
|
||
'next_is_convert_to=0',
|
||
'for arg in "$@"; do',
|
||
' if [ "$next_is_convert_to" = "1" ]; then convert_to="$arg"; next_is_convert_to=0; continue; fi',
|
||
' if [ "$next_is_outdir" = "1" ]; then outdir="$arg"; next_is_outdir=0; continue; fi',
|
||
' if [ "$arg" = "--convert-to" ]; then next_is_convert_to=1; continue; fi',
|
||
' if [ "$arg" = "--outdir" ]; then next_is_outdir=1; continue; fi',
|
||
'done',
|
||
'case "$convert_to" in',
|
||
' xlsx:*) printf "PK\\003\\004fake-xlsx" > "$outdir/Visitor.xlsx" ;;',
|
||
' pdf:*) printf "%s" "%PDF-1.7\\nconverted" > "$outdir/team.pdf" ;;',
|
||
' *) exit 41 ;;',
|
||
'esac'
|
||
].join('\n'), { mode: 0o700 });
|
||
await chmod(converterPath, 0o700);
|
||
try {
|
||
const config = loadConfig({
|
||
NODE_ENV: 'test',
|
||
FIELD_ENCRYPTION_KEY: Buffer.alloc(32, 26).toString('base64'),
|
||
DOCUMENT_CONVERTER_PATH: converterPath
|
||
});
|
||
const visitorContent = Buffer.from('<!doctype html><html><body><table><tr><td>游客</td></tr></table></body></html>', 'utf8');
|
||
const wordContent = Buffer.from('<html><body>team</body></html>');
|
||
const sourceArtifact = (type: string, fileName: string, contentType: string, content: Buffer) => ({
|
||
type,
|
||
ok: true,
|
||
http_status: 200,
|
||
content_type: contentType,
|
||
content_disposition: `attachment; filename="${fileName}"`,
|
||
bytes: content.byteLength,
|
||
sha256: sha256Bytes(content),
|
||
content_base64: content.toString('base64'),
|
||
downloaded: false,
|
||
converted: false,
|
||
scheduled: false,
|
||
sent: false
|
||
});
|
||
const prepared = await prepareExecutionArtifacts(config, {
|
||
report: {
|
||
status: 'export_source_completed',
|
||
artifacts: [
|
||
sourceArtifact('visitor-list', 'Visitor.xls', 'application/vnd.ms-excel', visitorContent),
|
||
sourceArtifact('liantai-confirm', 'team.doc', 'application/msword', wordContent)
|
||
]
|
||
}
|
||
});
|
||
|
||
assert.equal(prepared.failureCode, '');
|
||
assert.equal(prepared.artifacts.length, 3);
|
||
assert.deepEqual(prepared.artifacts[0].content, visitorContent);
|
||
assert.equal(prepared.artifacts[0].fileName, 'Visitor.xls');
|
||
assert.equal(prepared.artifacts[0].contentType, 'application/vnd.ms-excel');
|
||
assert.equal(prepared.artifacts[0].source.converted, false);
|
||
assert.equal(prepared.artifacts[0].source.conversion_status, 'source_native');
|
||
assert.equal(prepared.artifacts[0].source.artifact_role, 'source_archive');
|
||
assert.equal(prepared.artifacts[0].source.agentbus_visible, false);
|
||
assert.equal(prepared.artifacts[1].fileName, 'Visitor.xlsx');
|
||
assert.equal(prepared.artifacts[1].contentType, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||
assert.equal(prepared.artifacts[1].source.converted, true);
|
||
assert.equal(prepared.artifacts[1].source.conversion_status, 'converted_xlsx');
|
||
assert.equal(prepared.artifacts[1].source.artifact_role, 'mobile_delivery');
|
||
assert.equal(prepared.artifacts[1].source.agentbus_visible, true);
|
||
assert.deepEqual(prepared.artifacts[1].content.subarray(0, 4), Buffer.from([0x50, 0x4b, 0x03, 0x04]));
|
||
assert.equal(prepared.artifacts[2].fileName, 'team.pdf');
|
||
assert.equal(prepared.artifacts[2].contentType, 'application/pdf');
|
||
assert.equal(prepared.artifacts[2].source.converted, true);
|
||
assert.equal(prepared.artifacts[2].source.conversion_status, 'converted');
|
||
const sanitizedReport = prepared.sanitizedResult.report as Record<string, unknown>;
|
||
assert.equal(sanitizedReport.converted, false);
|
||
assert.equal(sanitizedReport.conversion_status, 'mixed');
|
||
assert.equal(sanitizedReport.native_source_count, 1);
|
||
assert.equal(sanitizedReport.converted_count, 2);
|
||
assert.equal(sanitizedReport.pdf_converted_count, 1);
|
||
assert.equal(sanitizedReport.xlsx_count, 1);
|
||
assert.equal(sanitizedReport.source_artifact_count, 2);
|
||
assert.equal(sanitizedReport.derived_artifact_count, 1);
|
||
assert.equal(sanitizedReport.delivered_artifact_count, 3);
|
||
assert.match(String(prepared.sanitizedResult.message), /移动端兼容 XLSX/);
|
||
assert.equal((sanitizedReport.artifacts as Array<Record<string, unknown>>)[0].content_base64, undefined);
|
||
} finally {
|
||
await rm(temporaryDirectory, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test('visitor XLSX conversion failure blocks export delivery instead of returning raw XLS', async () => {
|
||
const config = loadConfig({
|
||
NODE_ENV: 'test',
|
||
FIELD_ENCRYPTION_KEY: Buffer.alloc(32, 27).toString('base64'),
|
||
DOCUMENT_CONVERTER_PATH: '/path/that/does/not/exist'
|
||
});
|
||
const content = Buffer.from('<html><body>游客</body></html>', 'utf8');
|
||
const prepared = await prepareExecutionArtifacts(config, {
|
||
report: {
|
||
status: 'export_source_completed',
|
||
artifacts: [{
|
||
type: 'visitor-list',
|
||
ok: true,
|
||
http_status: 200,
|
||
content_type: 'application/vnd.ms-excel',
|
||
content_disposition: 'attachment; filename="Visitor.xls"',
|
||
bytes: content.byteLength,
|
||
sha256: sha256Bytes(content),
|
||
content_base64: content.toString('base64')
|
||
}]
|
||
}
|
||
});
|
||
assert.match(prepared.failureCode, /^visitor_xlsx_conversion_failed:/);
|
||
assert.deepEqual(prepared.artifacts, []);
|
||
});
|
||
|
||
test('pre-write ERP blockers remain explicit after plugin dispatch', () => {
|
||
assert.deepEqual(executionLifecycleFacts({
|
||
status: 'blocked',
|
||
no_erp_write: true,
|
||
write_attempted: false,
|
||
blockers: ['product lookup keyword expected exactly one candidate, found 0']
|
||
}, 'blocked', true), {
|
||
agent_returned: true,
|
||
plugin_dispatch_started: true,
|
||
erp_write_started: false,
|
||
no_plugin_dispatch: false,
|
||
no_erp_write: true,
|
||
write_attempted: false,
|
||
reconciliation_resolved: false
|
||
});
|
||
});
|
||
|
||
test('write-capable running progress is not mislabeled as write-started', () => {
|
||
assert.deepEqual(executionLifecycleFacts({
|
||
status: 'running',
|
||
stage: 'browser_execution',
|
||
no_erp_write: false,
|
||
execution_phase: 'preflight',
|
||
write_attempted: false
|
||
}, 'running', true), {
|
||
agent_returned: true,
|
||
plugin_dispatch_started: true,
|
||
erp_write_started: false,
|
||
no_plugin_dispatch: false,
|
||
no_erp_write: false,
|
||
write_attempted: false,
|
||
reconciliation_resolved: false
|
||
});
|
||
});
|
||
|
||
test('successful parse events show Agent return and explicit pre-plugin no-write boundaries', () => {
|
||
assert.deepEqual(parseLifecycleFacts({
|
||
status: 'agent_parse_passed',
|
||
operation: { action: 'shared_plan_create' }
|
||
}, null), {
|
||
agent_returned: true,
|
||
plugin_dispatch_started: false,
|
||
erp_write_started: false,
|
||
no_plugin_dispatch: true,
|
||
no_erp_write: true
|
||
});
|
||
});
|
||
|
||
test('lifecycle live-write authority is removed after parsing and injected only by manual confirmation', () => {
|
||
const operation = {
|
||
action: 'order_update_shared_child',
|
||
order_nature: 'formal',
|
||
submit_mode: 'dry_run',
|
||
source: {
|
||
test_context: {
|
||
run_id: 'REPLAY-0511',
|
||
marker: 'TEST-202609',
|
||
account: '测试ai员工账号',
|
||
native_baseline_id: 'C09-native-baseline',
|
||
allow_live_write: true,
|
||
live_window: '2026-09',
|
||
target_dates: ['2026-09-16'],
|
||
created_refs: [{
|
||
kind: 'shared_child_order', parent_group_no: 'LW-260916-TEST-202609-R0511', child_order_no: 'D16001',
|
||
tid: '16000', ddid: '16001', marker: 'TEST-202609'
|
||
}]
|
||
}
|
||
},
|
||
data: {
|
||
existing_refs: {
|
||
kind: 'shared_child_order', parent_group_no: 'LW-260916-TEST-202609-R0511', child_order_no: 'D16001',
|
||
tid: '16000', ddid: '16001', departure_date: '2026-09-16', owner_account: '测试ai员工账号', marker: 'TEST-202609'
|
||
},
|
||
updates: { actions: [{ target: 'lodging_note', operation: 'append', value: 'TEST-202609 replay' }] }
|
||
}
|
||
};
|
||
assert.equal(isLifecycleOperation(operation), true);
|
||
const prepared = prepareParsedOperationForConfirmation(operation)!;
|
||
assert.equal(((prepared.source as any).test_context as any).allow_live_write, false);
|
||
assert.equal((operation.source.test_context as any).allow_live_write, true, 'input must not be mutated');
|
||
|
||
const authorized = authorizeLifecycleOperationForManualConfirmation(prepared);
|
||
assert.equal(((authorized.source as any).test_context as any).allow_live_write, true);
|
||
assert.equal(((authorized.source as any).test_context as any).operator, 'control-plane-admin-confirmation');
|
||
|
||
const missingCreatedRef = structuredClone(prepared) as any;
|
||
missingCreatedRef.source.test_context.created_refs = [];
|
||
assert.throws(
|
||
() => authorizeLifecycleOperationForManualConfirmation(missingCreatedRef),
|
||
/created_refs 未精确包含当前对象/
|
||
);
|
||
|
||
const outsideWindow = structuredClone(prepared) as any;
|
||
outsideWindow.data.existing_refs.departure_date = '2026-10-01';
|
||
outsideWindow.source.test_context.target_dates = ['2026-10-01'];
|
||
assert.throws(
|
||
() => authorizeLifecycleOperationForManualConfirmation(outsideWindow),
|
||
/2026 年 9 月/
|
||
);
|
||
assert.equal(isLifecycleOperation({ action: 'team_order_create' }), false);
|
||
});
|
||
|
||
test('normal lifecycle confirmation keeps platform metadata outside the business operation', () => {
|
||
const operation = {
|
||
action: 'order_cancel',
|
||
order_nature: 'formal',
|
||
submit_mode: 'dry_run',
|
||
data: { existing_refs: { identifier: 'LW-EXAMPLE-001' } }
|
||
};
|
||
assert.equal(isLifecycleOperation(operation), true, 'the action remains a lifecycle action');
|
||
assert.equal(hasLifecycleTestContext(operation), false);
|
||
const prepared = prepareParsedOperationForConfirmation(operation)!;
|
||
assert.deepEqual(prepared, operation);
|
||
assert.equal(Object.hasOwn(prepared, 'source'), false);
|
||
assert.deepEqual(authorizeLifecycleOperationForManualConfirmation(prepared), operation);
|
||
});
|
||
|
||
test('lifecycle delete authorization rechecks the frozen evidence and allowlist at confirmation time', () => {
|
||
const refs = {
|
||
kind: 'independent_order', identifier: 'LW-260912-TEST-202609-R0511', tid: '17000', ddid: '17001',
|
||
departure_date: '2026-09-12', owner_account: '测试ai员工账号', marker: 'TEST-202609'
|
||
};
|
||
const operation = prepareParsedOperationForConfirmation({
|
||
action: 'order_delete',
|
||
order_nature: 'formal',
|
||
submit_mode: 'dry_run',
|
||
source: { test_context: {
|
||
run_id: 'REPLAY-0511', marker: 'TEST-202609', account: '测试ai员工账号', native_baseline_id: 'baseline',
|
||
allow_live_write: true, live_window: '2026-09', target_dates: ['2026-09-12'], allowlist_id: 'ALLOW-0511',
|
||
created_refs: [{ kind: refs.kind, identifier: refs.identifier, tid: refs.tid, ddid: refs.ddid, marker: refs.marker }]
|
||
} },
|
||
data: {
|
||
existing_refs: refs,
|
||
delete_guard: {
|
||
marker: 'TEST-202609', allowlist_id: 'ALLOW-0511', account: '测试ai员工账号', run_id: 'REPLAY-0511',
|
||
created_refs: [{ kind: refs.kind, identifier: refs.identifier, tid: refs.tid, ddid: refs.ddid, marker: refs.marker }],
|
||
post_delete_requery: true, export_evidence_frozen: true, delete_authorized: true
|
||
}
|
||
}
|
||
})!;
|
||
assert.equal((authorizeLifecycleOperationForManualConfirmation(operation).source as any).test_context.allow_live_write, true);
|
||
(operation.data as any).delete_guard.export_evidence_frozen = false;
|
||
assert.throws(() => authorizeLifecycleOperationForManualConfirmation(operation), /证据冻结/);
|
||
});
|
||
|
||
test('uncertain ERP writes have a reconciliation-specific failure code', () => {
|
||
const failure = failureSummary({
|
||
status: 'execution_uncertain',
|
||
blockers: ['ERP response was HTTP 500 after write attempt'],
|
||
write_attempted: true,
|
||
no_erp_write: false
|
||
}, '', 'reconciliation_pending', 'browser_execution');
|
||
assert.equal(failure?.error_code, 'erp_result_uncertain');
|
||
assert.match(failure?.failure_message || '', /ERP 写入结果不确定/);
|
||
});
|
||
|
||
test('successful parse lifecycle facts are not promoted to a task failure', () => {
|
||
const failure = failureSummary({
|
||
status: 'agent_parse_passed',
|
||
operation: { action: 'team_order_create' },
|
||
blockers: [],
|
||
agent_returned: true,
|
||
plugin_dispatch_started: false,
|
||
erp_write_started: false,
|
||
no_plugin_dispatch: true,
|
||
no_erp_write: true
|
||
}, '', 'completed', 'verification');
|
||
assert.equal(failure, null);
|
||
});
|
||
|
||
test('expired plugin results fail safely and preserve reconciliation when the write boundary is unknown', () => {
|
||
assert.deepEqual(classifyExpiredExecutionResult({
|
||
status: 'running',
|
||
execution_phase: 'browser_execution'
|
||
}), {
|
||
taskStatus: 'failed',
|
||
resultStatus: 'reconciliation_pending',
|
||
attemptStatus: 'reconciliation_pending',
|
||
writeAttempted: true,
|
||
noErpWrite: false,
|
||
reconciliationRequired: true
|
||
});
|
||
assert.deepEqual(classifyExpiredExecutionResult({
|
||
status: 'running',
|
||
no_erp_write: true,
|
||
write_attempted: false
|
||
}), {
|
||
taskStatus: 'failed',
|
||
resultStatus: 'failed',
|
||
attemptStatus: 'failed',
|
||
writeAttempted: false,
|
||
noErpWrite: true,
|
||
reconciliationRequired: false
|
||
});
|
||
assert.equal(classifyExecutionResult({ status: 'failed', no_erp_write: true }).status, 'failed');
|
||
});
|
||
|
||
test('ERP business-rule rejection preserves the native message for the user', () => {
|
||
const message = '此团还有【应收团款】账,不能取消!';
|
||
const failure = failureSummary({
|
||
status: 'blocked',
|
||
error_code: 'erp_business_rule_blocked',
|
||
failure_stage: 'lifecycle_live_submit',
|
||
failure_source: 'erp',
|
||
failure_message: message,
|
||
message,
|
||
blockers: [`erp_business_rule_blocked:${message}`],
|
||
write_attempted: true,
|
||
no_erp_write: false
|
||
});
|
||
assert.equal(failure?.error_code, 'erp_business_rule_blocked');
|
||
assert.equal(failure?.failure_stage, 'lifecycle_live_submit');
|
||
assert.equal(failure?.failure_source, 'erp');
|
||
assert.equal(failure?.failure_message, message);
|
||
});
|
||
|
||
test('passenger capacity rejection preserves the business message and retains technical evidence', () => {
|
||
const message = '本次名单共 25 人,超过该独立团在 ERP 中最多可录入的 16 人。请调整名单人数后重新提交。';
|
||
const blockers = [
|
||
'passenger_native_target_row_count_not_reached:16:25',
|
||
'passenger_native_target_row_missing:17',
|
||
'passenger_dom_target_row_count_too_small:16:25'
|
||
];
|
||
const failure = failureSummary({
|
||
status: 'blocked',
|
||
error_code: 'erp_passenger_count_limit_exceeded',
|
||
failure_stage: 'lifecycle_live',
|
||
failure_source: 'erp',
|
||
failure_message: message,
|
||
message,
|
||
blockers,
|
||
write_attempted: false,
|
||
no_erp_write: true
|
||
});
|
||
assert.equal(failure?.error_code, 'erp_passenger_count_limit_exceeded');
|
||
assert.equal(failure?.failure_stage, 'lifecycle_live');
|
||
assert.equal(failure?.failure_source, 'erp');
|
||
assert.equal(failure?.failure_message, message);
|
||
assert.deepEqual(failure?.validation_errors, blockers);
|
||
});
|
||
|
||
test('nested ERP business feedback is promoted when the executor omitted failure metadata', () => {
|
||
const message = '此团还有【应收团款】账,不能取消!';
|
||
const failure = failureSummary({
|
||
status: 'blocked',
|
||
report: {
|
||
status: 'browser_execution_blocked',
|
||
business_error: { code: 'erp_business_rule_blocked', message },
|
||
server_response: { business_blocked: true, response_message: message }
|
||
},
|
||
write_attempted: true,
|
||
no_erp_write: false
|
||
});
|
||
assert.equal(failure?.error_code, 'erp_business_rule_blocked');
|
||
assert.equal(failure?.failure_source, 'erp');
|
||
assert.equal(failure?.failure_message, message);
|
||
});
|
||
|
||
test('ERP migration enforces one execution attempt per task', async () => {
|
||
const { readFile } = await import('node:fs/promises');
|
||
const sql = await readFile(new URL('../migrations/004_erp_execution_idempotency.sql', import.meta.url), 'utf8');
|
||
assert.match(sql, /UNIQUE INDEX[\s\S]+ON task_attempts \(task_id\)[\s\S]+phase = 'erp'/i);
|
||
assert.match(sql, /tasks_erp_lease_idx/);
|
||
});
|
||
|
||
test('task session migration binds one encrypted conversation to each task', async () => {
|
||
const { readFile } = await import('node:fs/promises');
|
||
const sql = await readFile(new URL('../migrations/005_agent_task_sessions.sql', import.meta.url), 'utf8');
|
||
assert.match(sql, /CREATE TABLE IF NOT EXISTS agent_sessions/);
|
||
assert.match(sql, /task_id uuid NOT NULL UNIQUE REFERENCES tasks/);
|
||
assert.match(sql, /external_session_id_ciphertext/);
|
||
assert.match(sql, /CREATE TABLE IF NOT EXISTS agent_session_messages/);
|
||
assert.match(sql, /UNIQUE \(agent_session_id, turn_no, role\)/);
|
||
assert.match(sql, /agent_session_messages_idempotency_idx/);
|
||
assert.match(sql, /status IN \('queued', 'sending', 'sent', 'replayed', 'failed'\)/);
|
||
});
|
||
|
||
test('task outcome migration stores independently timestamped success and error summaries', async () => {
|
||
const { readFile } = await import('node:fs/promises');
|
||
const sql = await readFile(new URL('../migrations/006_task_outcomes.sql', import.meta.url), 'utf8');
|
||
for (const marker of ['success_receipt', 'success_receipt_at', 'error_summary', 'error_summary_at']) {
|
||
assert.match(sql, new RegExp(`\\b${marker}\\b`));
|
||
}
|
||
});
|
||
|
||
test('task source migration distinguishes manual and AgentBus tasks', async () => {
|
||
const { readFile } = await import('node:fs/promises');
|
||
const sql = await readFile(new URL('../migrations/007_task_source.sql', import.meta.url), 'utf8');
|
||
assert.match(sql, /ADD COLUMN IF NOT EXISTS source text NOT NULL DEFAULT 'manual'/);
|
||
assert.match(sql, /CHECK \(source IN \('manual', 'agentbus'\)\)/);
|
||
assert.match(sql, /tasks_source_idx/);
|
||
});
|
||
|
||
test('organization automation migration persists the switch and task confirmation mode', async () => {
|
||
const { readFile } = await import('node:fs/promises');
|
||
const sql = await readFile(new URL('../migrations/009_organization_automation.sql', import.meta.url), 'utf8');
|
||
assert.match(sql, /organizations[\s\S]+automation_enabled boolean NOT NULL DEFAULT false/);
|
||
assert.match(sql, /tasks[\s\S]+confirmation_mode text NOT NULL DEFAULT 'manual'/);
|
||
assert.match(sql, /confirmation_mode IN \('manual', 'automatic'\)/);
|
||
assert.match(sql, /tasks_confirmation_mode_idx/);
|
||
});
|
||
|
||
test('task query optimization migration adds bounded list and event indexes', async () => {
|
||
const { readFile } = await import('node:fs/promises');
|
||
const sql = await readFile(new URL('../migrations/010_task_query_optimization.sql', import.meta.url), 'utf8');
|
||
for (const marker of [
|
||
'tasks_visible_created_idx',
|
||
'tasks_active_status_created_idx',
|
||
'tasks_active_handoff_created_idx',
|
||
'task_events_org_task_idx'
|
||
]) {
|
||
assert.match(sql, new RegExp(`CREATE INDEX IF NOT EXISTS ${marker}`));
|
||
}
|
||
});
|
||
|
||
test('task artifact migration keeps encrypted bytes scoped to the owning task', async () => {
|
||
const { readFile } = await import('node:fs/promises');
|
||
const sql = await readFile(new URL('../migrations/011_task_artifacts.sql', import.meta.url), 'utf8');
|
||
assert.match(sql, /CREATE TABLE IF NOT EXISTS task_artifacts/);
|
||
assert.match(sql, /organization_id uuid NOT NULL REFERENCES organizations\(id\) ON DELETE CASCADE/);
|
||
assert.match(sql, /task_id uuid NOT NULL REFERENCES tasks\(id\) ON DELETE CASCADE/);
|
||
assert.match(sql, /storage_backend text NOT NULL DEFAULT 'database'/);
|
||
assert.match(sql, /storage_key text/);
|
||
assert.match(sql, /content_ciphertext text/);
|
||
assert.match(sql, /storage_backend = 'database' AND content_ciphertext IS NOT NULL/);
|
||
assert.match(sql, /storage_backend = 'oss' AND storage_key IS NOT NULL/);
|
||
assert.match(sql, /UNIQUE \(task_id, execution_id, artifact_index\)/);
|
||
assert.match(sql, /task_artifacts_org_task_idx/);
|
||
});
|
||
|
||
test('AgentBus channel migration scopes encrypted keys and durable replies', async () => {
|
||
const { readFile } = await import('node:fs/promises');
|
||
const sql = await readFile(new URL('../migrations/012_agentbus_user_channels.sql', import.meta.url), 'utf8');
|
||
assert.match(sql, /CREATE TABLE IF NOT EXISTS user_channels/);
|
||
assert.match(sql, /agentbus_ws_token_ciphertext text NOT NULL/);
|
||
assert.ok(sql.includes('ADD COLUMN IF NOT EXISTS channel_id uuid REFERENCES user_channels'));
|
||
assert.ok(sql.includes('CREATE TABLE IF NOT EXISTS agentbus_deliveries'));
|
||
assert.ok(sql.includes('UNIQUE (channel_id, inbound_frame_id, delivery_kind)'));
|
||
assert.ok(sql.includes("delivery_status IN ('pending', 'sending', 'delivered', 'failed')"));
|
||
const server = await readFile(new URL('../src/server.ts', import.meta.url), 'utf8');
|
||
assert.ok(server.includes("app.get('/api/channels'"));
|
||
assert.ok(server.includes("app.post('/api/channels'"));
|
||
assert.ok(server.includes("app.delete('/api/channels/:channelId'"));
|
||
assert.ok(server.includes('rotate-key'));
|
||
assert.ok(server.includes("app.get('/channels'"));
|
||
const channels = await readFile(new URL('../src/agentbus-channels.ts', import.meta.url), 'utf8');
|
||
assert.match(channels, /async delete\(/);
|
||
assert.match(channels, /DELETE FROM user_channels/);
|
||
assert.match(channels, /agentbus_channel\.deleted/);
|
||
assert.match(channels, /channel_managed_by_environment/);
|
||
assert.match(channels, /deletable:/);
|
||
assert.match(channels, /currentExternalUserRef === LEGACY_CHANNEL_REF/);
|
||
assert.match(channels, /LEGACY_CHANNEL_REF/);
|
||
assert.match(channels, /WHERE NOT EXISTS/);
|
||
assert.match(channels, /FOR UPDATE/);
|
||
assert.match(channels, /enabled = false/);
|
||
assert.match(channels, /检测到重复的兼容渠道,已停用/);
|
||
assert.match(channels, /canonical_legacy/);
|
||
const agentBus = await readFile(new URL('../src/agentbus.ts', import.meta.url), 'utf8');
|
||
assert.match(agentBus, /stop\(persistStatus = true\)/);
|
||
assert.match(channels, /listener\.stop\(false\)/);
|
||
assert.match(channels, /reloadRequested = true/);
|
||
assert.match(channels, /while \(this\.started && this\.reloadRequested\)/);
|
||
assert.match(channels, /runtimeStatusWrites/);
|
||
assert.match(channels, /previous[\s\S]+\.catch\(\(\) => undefined\)/);
|
||
});
|
||
|
||
test('business parser migration snapshots modes and encrypts dual candidates', async () => {
|
||
const { readFile } = await import('node:fs/promises');
|
||
const sql = await readFile(new URL('../migrations/013_business_parser_modes.sql', import.meta.url), 'utf8');
|
||
assert.match(sql, /CREATE TABLE IF NOT EXISTS business_parser_settings/);
|
||
assert.match(sql, /mode IN \('ai', 'shadow', 'auto', 'program'\)/);
|
||
assert.match(sql, /ADD COLUMN IF NOT EXISTS parser_config_revision/);
|
||
assert.match(sql, /ADD COLUMN IF NOT EXISTS parser_engine_affinity/);
|
||
assert.match(sql, /parser_engine_affinity = 'ai'/);
|
||
assert.match(sql, /CREATE TABLE IF NOT EXISTS parse_decisions/);
|
||
assert.match(sql, /program_result_ciphertext text/);
|
||
assert.match(sql, /ai_result_ciphertext text/);
|
||
assert.match(sql, /UNIQUE \(task_id, attempt_no\)/);
|
||
const taskService = await readFile(new URL('../src/task-service.ts', import.meta.url), 'utf8');
|
||
assert.match(taskService, /pg_advisory_xact_lock/);
|
||
assert.match(taskService, /parserFieldDifferences/);
|
||
assert.match(taskService, /parser_program_result_ciphertext/);
|
||
assert.match(taskService, /parser_unreviewed_difference_count/);
|
||
assert.match(taskService, /t\.parser_mode, t\.parser_config_revision/);
|
||
assert.match(taskService, /display_order: displayIndex \+ 1/);
|
||
assert.match(taskService, /buildImmediateParserPromotionGates/);
|
||
assert.equal([...taskService.matchAll(/resolveParserModeSnapshot\(client, context\.organizationId,/g)].length, 2);
|
||
assert.doesNotMatch(taskService, /shadow_continuous_days|shadow_real_tasks|auto_continuous_days|program_authoritative_tasks|ai_fallback_rate_percent/);
|
||
const server = await readFile(new URL('../src/server.ts', import.meta.url), 'utf8');
|
||
for (const route of [
|
||
'/api/settings/parser-routing',
|
||
'/api/settings/parser-routing/:routeId',
|
||
'/api/settings/parser-routing/emergency-ai',
|
||
'/api/tasks/:taskId/reparse',
|
||
'/api/parser-decisions/:decisionId/review'
|
||
]) assert.ok(server.includes(route), route);
|
||
});
|
||
|
||
test('parser promotion uses immediate correctness checks without time, volume, or fallback-rate waits', () => {
|
||
const ready = buildImmediateParserPromotionGates({
|
||
programVersion: 'ltjt-program-parser-test',
|
||
otherRoutesInAuto: 0,
|
||
unreviewedDifferences: 0,
|
||
programWrong: 0,
|
||
programContractErrors: 0,
|
||
programRuntimeErrors: 0,
|
||
downstreamFailures: 0
|
||
});
|
||
assert.equal(ready.shadow.eligible, true);
|
||
assert.equal(ready.auto.eligible, true);
|
||
assert.equal(ready.program.eligible, true);
|
||
const codes = Object.values(ready).flatMap((gate) => gate.requirements.map((item) => item.code));
|
||
assert.deepEqual(codes.filter((code) => /days|tasks|rate/.test(code)), []);
|
||
|
||
const blocked = buildImmediateParserPromotionGates({
|
||
programVersion: 'ltjt-program-parser-test',
|
||
otherRoutesInAuto: 1,
|
||
unreviewedDifferences: 1,
|
||
programWrong: 1,
|
||
programContractErrors: 1,
|
||
programRuntimeErrors: 1,
|
||
downstreamFailures: 1
|
||
});
|
||
assert.equal(blocked.shadow.eligible, true);
|
||
assert.equal(blocked.auto.eligible, false);
|
||
assert.equal(blocked.program.eligible, false);
|
||
assert.deepEqual(blocked.shadow.requirements.map((item) => item.code), ['program_implemented']);
|
||
assert.equal(Object.values(blocked).flatMap((gate) => gate.requirements).some((item) => item.code === 'release_sequence_ready'), false);
|
||
});
|
||
|
||
test('persistent-session migration removes automatic expiry for active sessions', async () => {
|
||
const { readFile } = await import('node:fs/promises');
|
||
const sql = await readFile(new URL('../migrations/008_persistent_sessions.sql', import.meta.url), 'utf8');
|
||
assert.match(sql, /ALTER COLUMN expires_at DROP NOT NULL/);
|
||
assert.match(sql, /ALTER COLUMN idle_expires_at DROP NOT NULL/);
|
||
assert.match(sql, /SET revoked_at = now\(\)[\s\S]+expires_at <= now\(\)/);
|
||
assert.match(sql, /WHERE revoked_at IS NULL/);
|
||
const auth = await readFile(new URL('../src/auth.ts', import.meta.url), 'utf8');
|
||
assert.doesNotMatch(auth, /s\.expires_at > now\(\)/);
|
||
assert.doesNotMatch(auth, /s\.idle_expires_at > now\(\)/);
|
||
assert.match(auth, /PERSISTENT_SESSION_COOKIE_MAX_AGE_SECONDS|expires_at, idle_expires_at/);
|
||
});
|
||
|
||
test('operator page has a login gate and uses the durable task API', async () => {
|
||
const { readFile } = await import('node:fs/promises');
|
||
const index = await readFile(new URL('../../LianSyn-platform/index.html', import.meta.url), 'utf8');
|
||
const app = await readFile(new URL('../../LianSyn-platform/app.js', import.meta.url), 'utf8');
|
||
const styles = await readFile(new URL('../../LianSyn-platform/styles.css', import.meta.url), 'utf8');
|
||
const taskService = await readFile(new URL('../src/task-service.ts', import.meta.url), 'utf8');
|
||
const server = await readFile(new URL('../src/server.ts', import.meta.url), 'utf8');
|
||
const bridge = await readFile(new URL('../../chrome-extension/ltjt-order-assistant/business-bridge.js', import.meta.url), 'utf8');
|
||
const background = await readFile(new URL('../../chrome-extension/ltjt-order-assistant/background.js', import.meta.url), 'utf8');
|
||
const inpage = await readFile(new URL('../../chrome-extension/ltjt-order-assistant/inpage.js', import.meta.url), 'utf8');
|
||
assert.match(index, /id="loginPanel"/);
|
||
assert.match(index, /id="workbench"[^>]*hidden/);
|
||
assert.match(index, /styles\.css\?v=20260902-account-routing-hard-delete-2/);
|
||
assert.match(index, /app\.js\?v=20260902-account-routing-hard-delete-2/);
|
||
assert.match(index, /id="statusDetailsPopover"/);
|
||
assert.match(index, /id="statusDetailsRefresh"/);
|
||
assert.match(app, /apiRequest\(`\/api\/tasks\?\$\{params\.toString\(\)\}`/);
|
||
assert.match(app, /HOME_TASK_LIMIT = 10/);
|
||
assert.match(app, /HISTORY_TASK_PAGE_SIZE/);
|
||
assert.match(app, /historyFilters/);
|
||
assert.match(app, /historyPagination/);
|
||
assert.match(index, /id="historyBatchActions"/);
|
||
assert.match(index, /id="historySelectAll"/);
|
||
assert.match(index, /id="historyArchiveSelectedButton"/);
|
||
assert.match(index, /id="historyDeleteSelectedButton"/);
|
||
assert.match(app, /IS_HISTORY_PAGE = CURRENT_PAGE === '\/history'/);
|
||
assert.match(index, /href="\/history"/);
|
||
assert.match(server, /app\.get\('\/history'/);
|
||
assert.match(app, /IS_CHANNELS_PAGE = CURRENT_PAGE === '\/channels'/);
|
||
assert.match(index, /id="channelsPage"[^>]*hidden/);
|
||
assert.match(index, /href="\/channels"/);
|
||
assert.match(server, /app\.get\('\/channels'/);
|
||
assert.match(app, /IS_PARSER_ROUTING_PAGE = CURRENT_PAGE === '\/parser-routing'/);
|
||
assert.match(app, /route\.display_order/);
|
||
assert.match(index, /id="parserRoutingPage"[^>]*hidden/);
|
||
assert.match(index, /href="\/parser-routing"/);
|
||
assert.match(server, /app\.get\('\/parser-routing'/);
|
||
assert.match(styles, /html\.parser-routing-page-active/);
|
||
assert.match(styles, /grid-template-rows: auto auto auto auto/);
|
||
assert.match(styles, /\.detail-panel\s*\{[^}]*grid-template-rows: auto auto auto;[^}]*overflow-y: auto;/s);
|
||
assert.match(styles, /\.task-detail\s*\{[^}]*height: auto;[^}]*overflow: visible;/s);
|
||
assert.match(styles, /\.task-detail > \.task-lifecycle-panel\s*\{[^}]*flex: 0 0 auto;/s);
|
||
assert.match(index, /全部切回 AI/);
|
||
assert.match(app, /changeParserRoutingMode/);
|
||
assert.match(app, /renameChannel\(channelId\)/);
|
||
assert.match(app, /channelAction = 'rename'/);
|
||
assert.match(app, /deleteChannel\(channelId\)/);
|
||
assert.match(app, /channelAction = 'delete'/);
|
||
assert.match(app, /apiRequest\(`\/api\/channels\/\$\{encodeURIComponent\(channelId\)\}`,[\s\S]+method: 'DELETE'/);
|
||
assert.match(app, /持久化 AgentBus 回执记录(包括未发送回执)会被移除/);
|
||
assert.match(server, /search: z\.string\(\)\.max\(200\)\.optional\(\)/);
|
||
assert.match(server, /offset: z\.coerce\.number\(\)\.int\(\)\.min\(0\)/);
|
||
assert.match(server, /include_total/);
|
||
assert.match(taskService, /async listTasksPage\(/);
|
||
assert.match(taskService, /async maintainStaleReconciliationTasks\(\)/);
|
||
assert.match(taskService, /SELECT t\.organization_id, t\.task_id[\s\S]+ORDER BY a\.finished_at/);
|
||
assert.doesNotMatch(taskService, /SELECT DISTINCT t\.organization_id, t\.task_id[\s\S]+ORDER BY a\.finished_at/);
|
||
assert.match(taskService, /a\.status IN \('accepted', 'running'\)/);
|
||
assert.match(taskService, /t\.lease_expires_at IS NOT NULL/);
|
||
assert.match(server, /staleReconciliations/);
|
||
assert.doesNotMatch(taskService, /COUNT\(\*\) OVER\(\)/);
|
||
assert.match(taskService, /SELECT COUNT\(\*\)::int AS total_count/);
|
||
assert.match(taskService, /publicTaskSummary/);
|
||
assert.match(taskService, /t\.status IN \('queued', 'accepted', 'running'\)[\s\S]+t\.handoff_status IN \('accepted', 'running'\)/);
|
||
assert.match(app, /MANUAL_RECONCILIATION_STATUSES/);
|
||
assert.match(app, /allowReconciliation/);
|
||
assert.match(app, /failedWithReconciliation/);
|
||
assert.match(app, /awaiting_user_input/);
|
||
assert.match(index, /id="rawInstruction"/);
|
||
assert.match(app, /function isAwaitingUserInput\(task\)/);
|
||
assert.match(app, /function sendTaskReply\(task, textarea, button, statusNode\)/);
|
||
assert.match(app, /apiRequest\('\/api\/messages'/);
|
||
assert.match(app, /task_id: taskId/);
|
||
assert.match(app, /taskReplyDrafts/);
|
||
assert.match(app, /id = 'inputRequestPanel'/);
|
||
assert.match(app, /id = 'sendTaskReplyButton'/);
|
||
assert.match(app, /id = 'taskReplyInput'/);
|
||
assert.match(app, /function renderTaskImportantMessage\(task\)/);
|
||
assert.match(app, /需返回\/交互用户的重要消息/);
|
||
assert.match(app, /receipt\.reply_attachments/);
|
||
assert.match(app, /important_message\?\.attachments/);
|
||
assert.match(app, /task-artifact-download/);
|
||
assert.match(styles, /\.task-input-request/);
|
||
assert.match(styles, /\.task-important-message-panel/);
|
||
assert.match(app, /\['awaiting_confirmation', 'agent_parse_passed'\]\.includes\(status\)/);
|
||
assert.match(taskService, /operation \? operationSummary\(operation\) : null/);
|
||
assert.match(index, /确认并提交到 ERP 插件/);
|
||
assert.match(app, /const MANUAL_HANDOFF_LABEL = '确认并提交到 ERP 插件'/);
|
||
assert.match(app, /async function confirmAndSubmitToErpPlugin/);
|
||
assert.doesNotMatch(app, /textContent = canStart \? '开始执行' : '确认执行'/);
|
||
assert.match(app, /fetchWithTimeout\('\/api\/auth\/login'/);
|
||
assert.match(app, /showAuthChecking\(\);[\s\S]*pingAi\(\)\.catch/);
|
||
assert.match(app, /cache: options\.cache \|\| 'no-store'/);
|
||
assert.match(app, /showAuthenticatedApp\(me\.user\);[\s\S]*任务同步失败/);
|
||
assert.match(app, /async function toggleStatusDetails/);
|
||
assert.match(app, /已连接,有告警/);
|
||
assert.match(app, /历史验证失败(不影响链路状态)/);
|
||
assert.match(app, /event\.key === 'Escape'/);
|
||
assert.match(app, /The backend is authoritative/);
|
||
assert.match(app, /syncRequested/);
|
||
assert.match(app, /api\/events\?since=/);
|
||
assert.match(taskService, /events: PublicTaskEvent\[\]/);
|
||
assert.match(taskService, /payload: redactTransportMetadata/);
|
||
assert.match(taskService, /failure_stage/);
|
||
assert.match(taskService, /success_receipt/);
|
||
assert.match(taskService, /error_summary/);
|
||
assert.match(taskService, /loadPublicTaskEvents/);
|
||
assert.match(taskService, /e\.task_id = ANY\(\$2::uuid\[\]\)/);
|
||
assert.match(app, /ensureTaskDetails/);
|
||
assert.match(app, /pollInProgress/);
|
||
assert.match(app, /fetchWithTimeout/);
|
||
assert.match(app, /Array\.isArray\(task\?\.events\)/);
|
||
assert.match(app, /任务生命周期/);
|
||
assert.match(app, /成功回执/);
|
||
assert.match(app, /订单号:/);
|
||
assert.doesNotMatch(app, /task-outcome-json/);
|
||
assert.match(app, /错误摘要/);
|
||
assert.match(app, /task-important-message-card/);
|
||
assert.match(app, /lifecycleScrollPositions/);
|
||
assert.doesNotMatch(app, /lifecycle\.scrollTop = lifecycle\.scrollHeight/);
|
||
assert.match(app, /taskState\.textContent = task \? taskStatusLabel\(task\)/);
|
||
assert.match(app, /source: 'task_error_summary'/);
|
||
assert.match(app, /data-history-task-select/);
|
||
assert.match(app, /data-history-task-delete/);
|
||
assert.match(app, /const historySelectedTaskIds = new Set\(\)/);
|
||
assert.match(app, /async function deleteHistorySelectedTasks\(\)/);
|
||
assert.match(app, /apiRequest\('\/api\/tasks\/bulk-archive'/);
|
||
assert.match(app, /function renderHistoryBatchActions\(visibleTasks/);
|
||
assert.match(app, /historySelectedTaskIds\.clear\(\)/);
|
||
assert.match(app, /recorded_at/);
|
||
assert.match(app, /技术详情/);
|
||
assert.match(styles, /\.task-important-message-card/);
|
||
assert.match(styles, /\.task-source-tag/);
|
||
assert.match(app, /function taskSourceInfo\(task\)/);
|
||
assert.match(app, /AgentBus/);
|
||
assert.match(app, /人工输入/);
|
||
assert.match(index, /id="automationToggleButton"/);
|
||
assert.match(index, /所有来源的新任务解析通过后/);
|
||
assert.match(app, /apiRequest\('\/api\/settings\/automation'/);
|
||
assert.match(app, /所有来源的新任务解析通过后将自动进入 ERP/);
|
||
assert.match(app, /automationSettingsSyncInFlight/);
|
||
assert.match(app, /eventStream\.addEventListener\('open',[\s\S]+syncAutomationSettings\(\{ background: true \}\)/);
|
||
assert.match(app, /async function refreshBackgroundState\(\)[\s\S]+syncAutomationSettings\(\{ background: true \}\)/);
|
||
assert.match(app, /window\.addEventListener\('focus',[\s\S]+refreshBackgroundState\(\)/);
|
||
assert.match(app, /async function autoDispatchReadyTasks\(\{ force = false \} = \{\}\)/);
|
||
assert.match(app, /if \(!force && retryAt > Date\.now\(\)\) continue/);
|
||
assert.match(app, /const bridgeReadyForDispatch = !wasBridgeConnected/);
|
||
assert.match(app, /autoDispatchReadyTasks\(\{ force: bridgeReadyForDispatch \}\)/);
|
||
const autoDispatchSource = app.slice(
|
||
app.indexOf('async function autoDispatchReadyTasks'),
|
||
app.indexOf('function taskStateClass')
|
||
);
|
||
assert.doesNotMatch(autoDispatchSource, /status: 'waiting_extension'/);
|
||
assert.match(app, /confirmation_mode === 'automatic'/);
|
||
assert.match(app, /function renderOperationReview\(task\)/);
|
||
assert.match(app, /if \(!operation \|\| operation\.action === 'confirmation_export'\) return null/);
|
||
assert.doesNotMatch(app, /task-artifact-info/);
|
||
assert.doesNotMatch(app, /formatArtifactBytes/);
|
||
assert.match(app, /生命周期测试写入审核/);
|
||
assert.match(app, /审核并授权本次测试写入/);
|
||
assert.match(app, /任何来源的任务都不因生命周期类型转为人工确认/);
|
||
assert.match(server, /api\/settings\/automation/);
|
||
assert.match(taskService, /automation_enabled/);
|
||
assert.match(taskService, /task\.auto_confirmed/);
|
||
assert.match(taskService, /prepareParsedOperationForConfirmation/);
|
||
assert.match(taskService, /authorizeLifecycleOperationForManualConfirmation/);
|
||
assert.match(taskService, /shouldAutomaticallyConfirm\(\{/);
|
||
assert.doesNotMatch(taskService, /text\(source\) === 'agentbus'/);
|
||
assert.doesNotMatch(taskService, /!isLifecycleOperation\(operation\)/);
|
||
assert.match(styles, /\.task-operation-review-card/);
|
||
assert.match(styles, /\.task-important-message-card\.state-ok/);
|
||
assert.match(app, /operation_contract_validation/);
|
||
assert.match(app, /api\/tasks\/\$\{encodeURIComponent\(taskId\)\}\/archive/);
|
||
assert.match(app, /api\/tasks\/\$\{encodeURIComponent\(taskId\)\}\/restore/);
|
||
assert.match(app, /const taskArchiveStates = new Map\(\)/);
|
||
assert.match(app, /const taskDeleteStates = new Map\(\)/);
|
||
assert.match(app, /taskDeleteStates\.get\(task\.task_id\)/);
|
||
assert.match(app, /taskDeleteStates\.set\(taskId, 'deleting'\)/);
|
||
assert.match(app, /deleteButton\.textContent = '强制删除中…'/);
|
||
assert.match(app, /sendToExtension\('DELETE_TASK'/);
|
||
assert.match(app, /method: 'DELETE'/);
|
||
assert.match(app, /此操作不受“正在处理”或“等待 ERP 执行”状态限制/);
|
||
assert.match(taskService, /async archiveTask\(/);
|
||
assert.match(taskService, /async archiveTasks\(/);
|
||
assert.match(taskService, /async restoreTask\(/);
|
||
assert.match(taskService, /async hardDeleteTask\(/);
|
||
assert.match(taskService, /async hardDeleteTasks\(/);
|
||
assert.match(taskService, /archived_at = now\(\), archived_by = \$1/);
|
||
assert.match(taskService, /SET archived_at = NULL, archived_by = NULL, archive_reason = NULL/);
|
||
assert.match(taskService, /const missingTaskIds = normalizedTaskIds\.filter/);
|
||
assert.match(taskService, /task\.hard_deleted/);
|
||
assert.match(taskService, /this\.artifactStore\.cleanup\(outcome\.artifacts\)/);
|
||
assert.doesNotMatch(taskService, /DELETE FROM audit_events/);
|
||
assert.match(taskService, /DELETE FROM tasks/);
|
||
assert.match(taskService, /erp-account-queue/);
|
||
assert.match(taskService, /t\.assigned_user_id = \$2/);
|
||
assert.match(taskService, /AND assigned_user_id = \$2\s+AND status = 'confirmed'/);
|
||
assert.match(server, /taskBulkDeleteSchema/);
|
||
assert.match(server, /taskBulkArchiveSchema/);
|
||
assert.match(server, /\.max\(100\)/);
|
||
assert.match(server, /app\.post\('\/api\/tasks\/bulk-delete'/);
|
||
assert.match(server, /app\.post\('\/api\/tasks\/bulk-archive'/);
|
||
assert.match(server, /app\.delete\('\/api\/tasks\/:taskId'/);
|
||
assert.match(server, /app\.post\('\/api\/tasks\/:taskId\/archive'/);
|
||
assert.match(server, /app\.post\('\/api\/tasks\/:taskId\/restore'/);
|
||
assert.match(server, /tasks\.hardDeleteTasks/);
|
||
assert.match(server, /tasks\.hardDeleteTask/);
|
||
assert.match(bridge, /status: 'deleted'/);
|
||
assert.match(background, /LTJT_HARD_DELETE_TASK/);
|
||
assert.doesNotMatch(app, /task-json-output|taskResponseJson/);
|
||
assert.doesNotMatch(styles, /\.task-json-output/);
|
||
assert.doesNotMatch(app, /appendTaskLogEntry|task\.logs/);
|
||
assert.match(app, /title: '系统解析阶段'/);
|
||
assert.match(app, /title: '系统写入处理'/);
|
||
assert.match(app, /task-stage-card is-\$\{state\.mode\}/);
|
||
assert.match(styles, /\.task-stage-card\.is-pending/);
|
||
assert.match(styles, /\.task-stage-card\.is-needs/);
|
||
assert.match(styles, /\.task-stage-card\.is-processing/);
|
||
assert.match(styles, /\.task-stage-card \{[\s\S]*display: flex;[\s\S]*justify-content: space-between;/);
|
||
assert.match(styles, /\.workbench-grid \{[\s\S]*min-width: 0;[\s\S]*overflow: hidden;/);
|
||
assert.match(styles, /overflow-x: hidden/);
|
||
assert.match(styles, /\.task-card-actions/);
|
||
assert.match(styles, /\.task-card-delete/);
|
||
assert.match(styles, /\.history-batch-actions/);
|
||
assert.match(app, /currentOption = stage\.options\.find/);
|
||
assert.match(app, /task-stage-status is-current/);
|
||
assert.doesNotMatch(app, /for \(const option of stage\.options\)/);
|
||
assert.doesNotMatch(app, /task-stage-index|task-stage-current/);
|
||
assert.ok(app.indexOf('/claim') < app.indexOf("sendToExtension('CREATE_TASK'"), 'server claim must precede extension dispatch');
|
||
assert.doesNotMatch(app, /syncPendingTasks|>重交</);
|
||
assert.match(app, /runtimeTasks\(\)\.filter\(\(task\) => taskAssignedToCurrentAccount\(task\) && isTaskPollable\(task\)\)/);
|
||
assert.match(app, /execution_id: executionId/);
|
||
assert.doesNotMatch(bridge, /async function upsertBusinessTask/);
|
||
assert.match(bridge, /task: currentBusinessTask/);
|
||
assert.match(background, /businessTaskExecutions:[\s\S]+businessTasks: tasks/);
|
||
assert.match(background, /runAutoTaskWithTimeout/);
|
||
assert.match(background, /LTJT_CLEANUP_RUNTIME_STATE/);
|
||
assert.match(background, /currentTaskAt/);
|
||
assert.match(inpage, /deferred_dialog_callbacks_suppressed/);
|
||
assert.doesNotMatch(inpage, /callback\.call\(window\)/);
|
||
});
|
||
|
||
test('system task events persist a null actor instead of an invalid empty UUID', async () => {
|
||
const config = loadConfig({
|
||
NODE_ENV: 'test',
|
||
FIELD_ENCRYPTION_KEY: Buffer.alloc(32, 9).toString('base64')
|
||
});
|
||
const calls: Array<{ sql: string; params: unknown[] }> = [];
|
||
const client = {
|
||
async query(sql: string, params: unknown[] = []) {
|
||
calls.push({ sql, params });
|
||
if (sql.includes('INSERT INTO task_events')) {
|
||
return {
|
||
rows: [{
|
||
id: '1',
|
||
organization_id: '00000000-0000-0000-0000-000000000001',
|
||
task_id: '00000000-0000-0000-0000-000000000002',
|
||
status: 'parse_failed',
|
||
stage: 'parse',
|
||
message: 'blocked',
|
||
payload: {},
|
||
created_at: new Date('2026-07-13T00:00:00.000Z')
|
||
}]
|
||
};
|
||
}
|
||
return { rows: [] };
|
||
}
|
||
};
|
||
const service = new TaskService(config) as unknown as {
|
||
emitEvent: (
|
||
client: unknown,
|
||
row: Record<string, unknown>,
|
||
data: Record<string, unknown>,
|
||
actorUserId: string
|
||
) => Promise<unknown>;
|
||
};
|
||
await service.emitEvent(client, {
|
||
id: '00000000-0000-0000-0000-000000000002',
|
||
organization_id: '00000000-0000-0000-0000-000000000001',
|
||
task_id: 'TASK-SYSTEM-EVENT'
|
||
}, {
|
||
status: 'parse_failed',
|
||
stage: 'parse',
|
||
message: 'blocked'
|
||
}, '');
|
||
assert.equal(calls[0].params[6], null);
|
||
});
|
||
|
||
test('public task wrappers keep session IDs out of operation and transport metadata', () => {
|
||
const config = loadConfig({
|
||
NODE_ENV: 'test',
|
||
FIELD_ENCRYPTION_KEY: Buffer.alloc(32, 10).toString('base64')
|
||
});
|
||
const service = new TaskService(config) as unknown as {
|
||
publicTask: (row: Record<string, unknown>) => Record<string, unknown>;
|
||
};
|
||
const parseResponse = {
|
||
status: 'agent_parse_needs_input',
|
||
blockers: [],
|
||
operation: null,
|
||
reply: '请补充线路产品名称后继续处理。',
|
||
missing_fields: ['data.product'],
|
||
questions: [{ field: 'data.product', prompt: '请补充线路产品名称。' }],
|
||
captured_facts: { action: 'team_order_create' },
|
||
session_id: 'external-session-must-not-be-public',
|
||
external_request: {
|
||
session_id: 'external-session-must-not-be-public',
|
||
idempotency_key: 'transport-key-must-not-be-public'
|
||
}
|
||
};
|
||
const task = service.publicTask({
|
||
organization_id: 'org-1',
|
||
task_id: 'TASK-INPUT',
|
||
source: 'agentbus',
|
||
original_text_ciphertext: encryptText(config, '用户补充前的原始指令'),
|
||
operation_ciphertext: encryptText(config, JSON.stringify({ action: 'team_order_create', session_id: 'operation-secret' })),
|
||
parse_response_ciphertext: encryptText(config, JSON.stringify(parseResponse)),
|
||
execution_result_ciphertext: null,
|
||
summary: {},
|
||
status: 'awaiting_user_input',
|
||
stage: 'parse',
|
||
message: '资料不完整,等待用户补充。',
|
||
error: '',
|
||
handoff_status: '',
|
||
confirmed_at: null,
|
||
created_at: '2026-08-05T00:00:00.000Z',
|
||
updated_at: '2026-08-05T00:00:00.000Z',
|
||
last_event_id: null,
|
||
agent_session_status: 'active',
|
||
agent_session_turn: 1,
|
||
agent_session_recovery_count: 0,
|
||
external_session_id_present: true
|
||
}) as {
|
||
source: string;
|
||
agent_session: { session_id_present: boolean };
|
||
input_request: { missing_fields: string[] };
|
||
important_message: { kind: string; text: string; input_request?: { missing_fields: string[] } };
|
||
operation: null;
|
||
parse_response: Record<string, unknown> & { external_request: Record<string, unknown> };
|
||
};
|
||
|
||
assert.equal(task.agent_session.session_id_present, true);
|
||
assert.equal(task.source, 'agentbus');
|
||
assert.deepEqual(task.input_request.missing_fields, ['data.product']);
|
||
assert.equal(task.important_message.kind, 'awaiting_user_input');
|
||
assert.equal(task.important_message.text, '请补充线路产品名称后继续处理。');
|
||
assert.deepEqual(task.important_message.input_request?.missing_fields, ['data.product']);
|
||
assert.equal(task.operation, null);
|
||
assert.equal(task.parse_response.session_id, undefined);
|
||
assert.equal(task.parse_response.external_request.session_id, undefined);
|
||
assert.equal(task.parse_response.external_request.idempotency_key, undefined);
|
||
});
|
||
|
||
test('public task exposes persisted outcome timestamps and keeps the latest error summary', () => {
|
||
const config = loadConfig({
|
||
NODE_ENV: 'test',
|
||
FIELD_ENCRYPTION_KEY: Buffer.alloc(32, 11).toString('base64')
|
||
});
|
||
const service = new TaskService(config) as unknown as {
|
||
publicTask: (row: Record<string, unknown>) => Record<string, unknown>;
|
||
};
|
||
const task = service.publicTask({
|
||
organization_id: 'org-1',
|
||
task_id: 'TASK-OUTCOME',
|
||
original_text_ciphertext: encryptText(config, '成功回执测试'),
|
||
operation_ciphertext: null,
|
||
parse_response_ciphertext: null,
|
||
execution_result_ciphertext: encryptText(config, JSON.stringify({
|
||
status: 'completed',
|
||
erp_receipt: { group_numbers: ['LW-260907A-B'] }
|
||
})),
|
||
success_receipt: { group_numbers: ['LW-260907A-B'], group_number: 'LW-260907A-B' },
|
||
success_receipt_at: '2026-08-06T01:02:03.000Z',
|
||
error_summary: {
|
||
error_code: 'previous_error',
|
||
stage: 'preflight',
|
||
source: 'plugin_executor',
|
||
message: '此前一次预检失败。',
|
||
validation_errors: ['字段缺失']
|
||
},
|
||
error_summary_at: '2026-08-06T01:01:03.000Z',
|
||
summary: {},
|
||
status: 'completed',
|
||
stage: 'verification',
|
||
message: '任务已完成。',
|
||
error: '',
|
||
handoff_status: 'completed',
|
||
confirmed_at: null,
|
||
created_at: '2026-08-06T01:00:00.000Z',
|
||
updated_at: '2026-08-06T01:02:03.000Z',
|
||
last_event_id: null
|
||
});
|
||
|
||
const successReceipt = task.success_receipt as { recorded_at: string; receipt: Record<string, unknown> };
|
||
assert.equal(successReceipt.recorded_at, '2026-08-06T01:02:03.000Z');
|
||
assert.deepEqual(successReceipt.receipt.group_numbers, ['LW-260907A-B']);
|
||
assert.equal(successReceipt.receipt.group_number, 'LW-260907A-B');
|
||
assert.equal(successReceipt.receipt.reply, undefined);
|
||
assert.deepEqual(task.error_summary, {
|
||
recorded_at: '2026-08-06T01:01:03.000Z',
|
||
error_code: 'previous_error',
|
||
stage: 'preflight',
|
||
source: 'plugin_executor',
|
||
message: '此前一次预检失败。',
|
||
validation_errors: ['字段缺失']
|
||
});
|
||
const importantMessage = task.important_message as {
|
||
kind: string;
|
||
text: string;
|
||
success_receipt: unknown;
|
||
};
|
||
assert.equal(importantMessage.kind, 'success');
|
||
assert.match(importantMessage.text, /LW-260907A-B/);
|
||
assert.deepEqual(importantMessage.success_receipt, task.success_receipt);
|
||
assert.equal(task.source, 'manual');
|
||
assert.equal(task.failure, null);
|
||
});
|
||
|
||
test('platform only synthesizes an error timeline entry for a currently failed task', async () => {
|
||
const app = await readFile(new URL('../../LianSyn-platform/app.js', import.meta.url), 'utf8');
|
||
const failureInfo = app.slice(
|
||
app.indexOf('function taskFailureInfo'),
|
||
app.indexOf('function failureStageLabel')
|
||
);
|
||
const lifecycleEntries = app.slice(
|
||
app.indexOf('function taskLifecycleEntries'),
|
||
app.indexOf('function lifecycleLevel')
|
||
);
|
||
assert.match(failureInfo, /isFailureStatus\(status\)/);
|
||
assert.match(lifecycleEntries, /isFailureStatus\(status\)/);
|
||
assert.match(lifecycleEntries, /source: 'task_error_summary'/);
|
||
});
|