Files
LWLT-AIBOT/control-plane/test/team-progress.test.ts
2026-09-23 12:02:56 +08:00

193 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import assert from 'node:assert/strict';
import test from 'node:test';
import { readFile } from 'node:fs/promises';
import { loadConfig } from '../src/config.js';
import { encryptText } from '../src/crypto.js';
import { TeamProgressLedger, progressEventsFromSuccess, teamProgressText, type ProgressInput, type ProgressEvent, type ProgressSnapshot } from '../src/team-progress.js';
import { successReceiptFromResult } from '../src/task-service.js';
import { buildCustomerSuccessReceipt, replyTextFromReceipt } from '../src/reply-contract.js';
import { progressMemory } from './support/team-progress-memory.js';
const config = loadConfig({ NODE_ENV:'test', FIELD_ENCRYPTION_KEY:Buffer.alloc(32,24).toString('base64') });
const scope = {organizationId:'org-a',userId:'user-a'};
const now = '2026-09-20T01:00:00.123Z';
function input(action = 'team_order_create', child = '', parent = 'LW-TEST-A'): ProgressInput {
return {taskId:'TASK-test',executionId:'e-1',effectiveAt:now,recordedAt:now,
operation:{action,data:{customer:{name:'客户检索词'},product:{name:'产品检索词'},departure_dates:['2026-10-01']}},
result:{status:'completed',report:{resolved_refs:{kind:child?'shared_child_order':'independent_order',identifier:child||parent,...(child?{parent_group_no:parent}: {})}}},
receipt:child?{success:true,order_number:child}:{success:true,group_number:parent}};
}
function ev(action: string, options: {child?:string,parent?:string,time?:string,details?:Record<string,unknown>} = {}): ProgressEvent {
const i=input(action,options.child,options.parent);
i.effectiveAt=options.time||now;i.recordedAt=i.effectiveAt;i.executionId=`e-${action}-${i.effectiveAt}-${options.child||''}`;
const e=progressEventsFromSuccess(i)[0];assert.ok(e);return {...e,details:{...e.details,...options.details}};
}
function snapshot(events:ProgressEvent[]):ProgressSnapshot {
return {version:1,scope:'current_account',recorded_at:now,groups:[{group_number:'LW-TEST-A',events,arrangements:[]}],unlinked_subjects:[]};
}
test('all business actions project accepted success without roster contents or arbitrary result fields',()=>{
for(const action of ['team_order_create','team_order_batch_create','shared_plan_create','shared_child_order_create','passenger_list_import','order_update_independent',
'order_update_shared_plan','order_update_shared_child','order_cancel','order_restore','confirmation_export','arrangement_guide','arrangement_vehicle','arrangement_hotel','arrangement_transport','arrangement_other']) {
const i=input(action,action.includes('shared_child')?'D100':'');
(i.operation as any).data.passenger_list={row_count:20,tsv:'SECRET PASSPORT'};
(i.result as any).raw_html='SECRET HTML';
const events=progressEventsFromSuccess(i);assert.equal(events.length,1,action);
assert.doesNotMatch(JSON.stringify(events),/SECRET/);
}
});
test('failed, uncertain, blocker and unproved results do not create completed business records',()=>{
for(const status of ['failed','blocked','running','reconciliation_pending','dry_run']) {
const i=input();(i.result as any).status=status;assert.deepEqual(progressEventsFromSuccess(i),[]);
}
for(const patch of [{uncertain:true},{manual_review_required:true},{blockers:['x']}]){
const i=input();Object.assign(i.result as any,patch);assert.deepEqual(progressEventsFromSuccess(i),[]);
}
const i=input();i.receipt=null;assert.deepEqual(progressEventsFromSuccess(i),[]);
});
test('individual group conflicts are rejected and full suffixes remain distinct',()=>{
const i=input();(i.receipt as any).group_number='LW-TEST-C';assert.deepEqual(progressEventsFromSuccess(i),[]);
assert.equal(ev('team_order_create',{parent:' lw-test-c '}).group_number,'LW-TEST-C');
});
test('internal identifiers and conflicting child-parent evidence cannot become group associations',()=>{
for (const parent of ['12345','D12345']) assert.deepEqual(progressEventsFromSuccess(input('confirmation_export','',parent)),[]);
const i=input('shared_child_order_create','D1');(i.receipt as any).parent_group_no='LW-OTHER';
assert.deepEqual(progressEventsFromSuccess(i),[]);
const j=input('shared_child_order_create');j.receipt={success:true};
assert.deepEqual(progressEventsFromSuccess(j),[],'a parent identifier alone is not a child identity');
});
test('batch children are explicitly mapped to their respective parents, never zipped by array order',()=>{
const i=input('shared_child_order_batch_create');
(i.result as any).report.batch_results=[{status:'completed',parent_group_no:'LW-A',child_order_no:'D2',departure_date:'2026-10-01'},
{status:'completed',parent_group_no:'LW-C',child_order_no:'D1',departure_date:'2026-10-03'}];
assert.deepEqual(progressEventsFromSuccess(i).map(e=>[e.group_number,e.subject_number,e.details.departure_date]),[['LW-A','D2','2026-10-01'],['LW-C','D1','2026-10-03']]);
(i.result as any).report.batch_results[0].status='not_started';assert.equal(progressEventsFromSuccess(i).length,1);
});
test('native child-create receipts carry the verified parent; missing parents remain unlinked',()=>{
const i=input('shared_child_order_create','D123');(i.result as any).report={erp_receipt:{order_number:'D123',parent_group_no:'LW-P'}};
assert.equal(progressEventsFromSuccess(i)[0].group_number,'LW-P');
(i.result as any).report={};assert.equal(progressEventsFromSuccess(i)[0].group_number,'');
});
test('roster counts use the latest import per subject and never add repeated uploads',()=>{
const events=[ev('passenger_list_import',{details:{roster_count:20}}),ev('passenger_list_import',{time:'2026-09-20T02:00:00Z',details:{roster_count:18}})];
const text=teamProgressText(snapshot(events));assert.match(text,/最近一次 18 人/);assert.doesNotMatch(text,/38人|20人/);
const late={...events[0],recorded_at:'2026-09-21T00:00:00Z'};
assert.match(teamProgressText(snapshot([events[1],late])),/最近一次 18 人/);
});
test('shared-plan summaries separate each child and never claim every roster is complete',()=>{
const events=[ev('shared_plan_create'),ev('passenger_list_import',{child:'D1',details:{roster_count:20}}),ev('shared_child_order_create',{child:'D2'})];
const text=teamProgressText(snapshot(events));assert.match(text,/不代表母团全部子单/);assert.match(text,/D1 名单:已导入/);assert.match(text,/D2 名单:暂无/);
});
test('cancel/restore updates current state without erasing arrangements or uncancelling children',()=>{
const s=snapshot([ev('shared_plan_create'),ev('order_cancel'),ev('order_cancel',{child:'D1'}),ev('order_restore',{time:'2026-09-20T02:00:00Z'})]);
s.groups[0].arrangements=[{action:'arrangement_guide',details:{resource_name:'导游甲',phone:'000',grade:'A'}}];
const text=teamProgressText(s);assert.match(text,/订单状态:已恢复/);assert.match(text,/子单 D1.*已取消/);assert.match(text,/导游:已安排/);
});
test('verified parent cancellation propagates only proven child references',()=>{
const i=input('order_cancel');(i.result as any).report.resolved_refs.kind='shared_plan';
(i.result as any).report.requery={child_status_propagation_matched:true,child_refs:[{ddid:'123'}]};
assert.deepEqual(progressEventsFromSuccess(i).map(e=>e.subject_number),['LW-TEST-A','D123']);
});
test('all current arrangement rows render supported details; unknown legacy detail is explicit',()=>{
const s=snapshot([]);s.groups[0].arrangements=[
{action:'arrangement_guide',details:{resource_name:'导游甲',phone:'000',grade:'A'}},
{action:'arrangement_vehicle',details:{resource_name:'车队甲',item:'25座',start_date:'2026-10-01',end_date:'2026-10-03',quantity:1,vehicle_number:'车牌甲',phone:'001'}},
{action:'arrangement_hotel',details:{resource_name:'酒店甲',item:'TWN',start_date:'2026-10-01',end_date:'2026-10-02',quantity:12}},
{action:'arrangement_hotel',details:{}},
{action:'arrangement_transport',details:{resource_name:'票务甲',item:'C91',date:'2026-10-01',quantity:20}},
{action:'arrangement_other',details:{resource_name:'备案项目',filing_number:'ABC',filing_entry_port:'口岸甲',filing_exit_port:'口岸乙',remark:'备注'}}];
const text=teamProgressText(s);for(const term of ['酒店:已安排,共 2 条','酒店甲','明细未记录','车牌甲','司机电话','备案号:ABC','C91'])assert.ok(text.includes(term),term);
});
test('exports are latest per file type and labelled stale after later changes without deleting records',()=>{
const i=input('confirmation_export');(i.result as any).report.artifacts=[{type:'visitor-list',agentbus_visible:true,name:'SECRET NAME'},{type:'visitor-list',agentbus_visible:false},{type:'hotel-preorder'}];
const exported=progressEventsFromSuccess(i)[0];assert.deepEqual(exported.details.files,['visitor-list','hotel-preorder']);
const s=snapshot([exported,ev('order_update_independent',{time:'2026-09-20T02:00:00Z',details:{updates:[{label:'标间',value:'12'}]}})]);
const text=teamProgressText(s);assert.match(text,/可能需要重新导出/);assert.doesNotMatch(text,/SECRET NAME/);assert.match(text,/标间改为12/);
const unknown=teamProgressText(snapshot([ev('confirmation_export')]));
assert.match(unknown,/已导出,文件类型未记录/);assert.doesNotMatch(unknown,/暂无本系统导出记录/);
});
test('receipts stay frozen and rebuilding never duplicates the progress block',()=>{
const i=input('order_update_independent'),base={success:true,group_number:'LW-TEST-A',team_progress:snapshot([ev('team_order_create')])};
const first=buildCustomerSuccessReceipt(base,i.result,i.operation);
assert.equal(replyTextFromReceipt(buildCustomerSuccessReceipt(first,i.result,i.operation)),replyTextFromReceipt(first));
assert.equal(replyTextFromReceipt(first).match(/📌【当前安排】/g)?.length,1);
});
test('encrypted ledger isolates accounts and organizations, deduplicates, and survives task deletion',async()=>{
const db=progressMemory(config),ledger=new TeamProgressLedger(config),e=ev('team_order_create');
await ledger.prepare(db,scope,successReceiptFromResult);await ledger.insert(db,scope,e);await ledger.insert(db,scope,e);
assert.equal(db.records.length,1);assert.doesNotMatch(db.records[0].detail_ciphertext,/LW-TEST/);
assert.equal((await ledger.history(db,scope,e.group_number)).length,1);
assert.equal((await ledger.history(db,{...scope,userId:'other'},e.group_number)).length,0);
assert.equal((await ledger.history(db,{...scope,organizationId:'other'},e.group_number)).length,0);
await ledger.markTasksDeleted(db,scope,[e.source_task_id]);assert.ok((await ledger.history(db,scope,e.group_number))[0].source_task_deleted_at);
});
test('unlinked child history joins only a uniquely known parent in the same account',async()=>{
const db=progressMemory(config),ledger=new TeamProgressLedger(config);
const orphan=ev('passenger_list_import',{child:'D1',parent:'',details:{roster_count:20}});await ledger.insert(db,scope,orphan);
let s=await ledger.snapshot(db,scope,[orphan],now,async()=>[]);assert.equal(s.groups.length,0);assert.match(teamProgressText(s),/暂无法关联团号/);
const link=ev('shared_child_order_create',{child:'D1',parent:'LW-A'});await ledger.insert(db,scope,link);
s=await ledger.snapshot(db,scope,[orphan],now,async()=>[]);assert.equal(s.groups[0].group_number,'LW-A');assert.equal(s.groups[0].events.length,2);
await ledger.insert(db,scope,ev('shared_child_order_create',{child:'D1',parent:'LW-C'}));
s=await ledger.snapshot(db,scope,[orphan],now,async()=>[]);assert.equal(s.groups.length,0);
assert.equal((await ledger.history(db,scope,'LW-A')).length,1,'ambiguous orphan never leaks into either team');
});
test('history backfill preserves only the matching successful execution including archived tasks',async()=>{
const db=progressMemory(config),ledger=new TeamProgressLedger(config),i=input();
const result={status:'completed',execution_id:'e-1',erp_receipt:{success:true,group_number:'LW-TEST-A'}};
const row={task_id:i.taskId,execution_id:'e-1',effective_at:now,success_receipt_at:now,organization_id:scope.organizationId,assigned_user_id:scope.userId,
status:'completed',archived_at:now,operation_ciphertext:encryptText(config,JSON.stringify(i.operation)),execution_result:result,execution_result_ciphertext:encryptText(config,JSON.stringify(result))};
db.tasks.push(row,{...row,execution_id:'other-attempt'},{...row,task_id:'TASK-other',assigned_user_id:'other'});
await ledger.prepare(db,scope,successReceiptFromResult);assert.equal(db.records.length,1);
await ledger.prepare(db,scope,successReceiptFromResult);assert.equal(db.records.length,1);
});
test('migration preserves business records without task foreign keys and prevents administrator ownership',async()=>{
const sql=await readFile(new URL('../migrations/026_team_progress.sql',import.meta.url),'utf8');
assert.doesNotMatch(sql,/REFERENCES\s+(tasks|task_attempts)\b/i);assert.match(sql,/reject_admin_task_principal/);assert.match(sql,/detail_ciphertext text NOT NULL/);
});
test('partial batches retain only independently verified completed rows',async()=>{
const {partialBatchProgressEvents}=await import('../src/team-progress.js');
const i=input('shared_child_order_batch_create');
i.result={status:'reconciliation_pending',uncertain:true,blockers:['stop'],report:{status:'split_child_batch_stopped_after_write',batch_results:[
{status:'completed',adapter_status:'split_child_completed',parent_group_no:'LW-A',child_order_no:'D1',blockers:[]},
{status:'uncertain',adapter_status:'split_child_live_uncertain',parent_group_no:'LW-C',child_order_no:'D2'},
{status:'not_started',parent_group_no:'LW-D',child_order_no:''}]}};
assert.deepEqual(partialBatchProgressEvents(i).map(e=>[e.group_number,e.subject_number]),[['LW-A','D1']]);
assert.deepEqual(progressEventsFromSuccess(i),[],'does not relabel the parent task as successful');
(i.result as any).report.batch_results[0].adapter_status='unverified';assert.deepEqual(partialBatchProgressEvents(i),[]);
const j=input('team_order_batch_create');j.result={status:'blocked',report:{status:'batch_fallback_incomplete'},batch_results:[
{status:'completed',erp_receipt:{group_number:'LW-A',success:true},date:'2026-10-01'},
{status:'saved_unverified',erp_receipt:{group_number:'LW-B'}}]};
assert.equal(partialBatchProgressEvents(j).length,1);
assert.equal(partialBatchProgressEvents(j)[0].action,'team_order_batch_create');
});
test('batch team dates follow their own receipt rows and never copy a date onto all groups',()=>{
const i=input('team_order_batch_create');(i.operation as any).data.departure_dates=['2026-10-01','2026-10-02'];
i.receipt={success:true,group_numbers:['LW-A','LW-C']};i.result={status:'completed',batch_results:[
{date:'2026-10-02',erp_receipt:{group_number:'LW-C'}},{date:'2026-10-01',erp_receipt:{group_number:'LW-A'}}]};
assert.deepEqual(progressEventsFromSuccess(i).map(e=>[e.group_number,e.details.departure_date]),[['LW-A','2026-10-01'],['LW-C','2026-10-02']]);
});
test('channel length limits clearly disclose truncation while leaving short replies unchanged',async()=>{
const {boundedAgentBusReplyText}=await import('../src/agentbus-delivery.js');
assert.equal(boundedAgentBusReplyText('已完成'),'已完成');
const long=boundedAgentBusReplyText('一条明细\n'.repeat(6000));assert.ok(long.length<=20000);assert.match(long,/完整内容请在平台任务回执中查看/);
});