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} = {}): 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,/完整内容请在平台任务回执中查看/); });