193 lines
15 KiB
TypeScript
193 lines
15 KiB
TypeScript
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,/完整内容请在平台任务回执中查看/);
|
||
});
|