Files
airz-uniapp/scripts/test-documents.cjs
T
hujun c95482a0f3 feat: 实现协议全文与同意记录功能
新增协议全文后台管理、版本化发布及本人同意记录功能,支持可撤销授权;完善资料与授权相关边界提示,调整评估提交时的资料与授权校验逻辑,同步更新相关页面文案与路由。
2026-10-14 14:53:13 +08:00

49 lines
18 KiB
JavaScript

// Execute the UTS service in memory; no network or real local files.
const fs=require('node:fs'),vm=require('node:vm'),assert=require('node:assert/strict');
const ts=require('D:/Develop/HBuilderX/plugins/uniapp-uts-v1/node_modules/@dcloudio/uni-uts-v1/lib/typescript/lib/typescript.js');
class Json {constructor(v={}){Object.assign(this,v)}getString(k){return typeof this[k]==='string'?this[k]:null}getNumber(k){return typeof this[k]==='number'?this[k]:null}getBoolean(k){return typeof this[k]==='boolean'?this[k]:null}getArray(k){return Array.isArray(this[k])?this[k]:null}}
let owner=new Json({token:'synthetic-a',userId:'1'}),calls=[],download,upload,reply,listReply=async()=>({documents:[]});
const api={API_BASE_URL:'https://synthetic.invalid/api/v1',getSession:()=>owner,clearSession:()=>{owner=null},uploadJson:async(...args)=>{calls.push(args);return new Json(await upload(...args))},requestJson:async(...args)=>{calls.push(args);return new Json(await (args[0]==='/consents'&&args[1]==='GET'?listReply:reply)(...args))}};
const uni={downloadFile:options=>{download=options},getFileSystemManager:()=>({unlink:options=>{calls.push(['unlink',options.filePath]);options.success?.({})}})};
const modules={};const path=require('node:path');
function load(name){if(modules[name])return modules[name];const exports={};modules[name]=exports;vm.runInNewContext(ts.transpileModule(fs.readFileSync(path.join(__dirname,'..',name),'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2020}}).outputText,{exports,require:p=>p==='@/services/api.uts'||p==='./api.uts'?api:load(p.startsWith('@/')?p.slice(2):path.posix.normalize(path.posix.dirname(name)+'/'+p)),uni,UTSJSONObject:Json,Error,Number,Date,Promise,URL},{filename:name});return exports}
let hooks={},picker,sheet,navigated,preview;
function page(){hooks={};const exports={};const source=fs.readFileSync(path.join(__dirname,'../packages/finance/assessment/assessment.uvue'),'utf8').match(/<script setup lang="uts">([\s\S]*?)<\/script>/)[1];
vm.runInNewContext(ts.transpileModule(source+'\nexports.page={documents,documentConsent,documentBusy,documentMessage,step,form,chooseDocument,uploadSelectedDocument,previewDocument,removeDocument,documentAction,toggleDocumentConsent,validateStep,showAgreement,next,restoreDocuments,documentPath};',{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2020}}).outputText,{exports,require:p=>p==='@/services/api.uts'?api:p==='@/services/layout.uts'?{usePageLayout:()=>({bottomStyle:()=>'',contentStyle:()=>'',pageStyle:()=>''})}:p.endsWith('.uvue')?{}:load(p.slice(2)),UTSJSONObject:Json,Error,Date,Math,Number,uni:Object.assign(uni,{showToast:()=>{},redirectTo:()=>{},navigateTo:o=>{navigated=o.url},chooseImage:o=>{picker=o},showActionSheet:o=>{sheet=o},previewImage:o=>{preview=o.urls},getCurrentPages:()=>[]}),getCurrentPages:()=>[],ref:value=>({value}),computed:f=>({get value(){return f()}}),onLoad:f=>hooks.load=f,onShow:f=>hooks.show=f,onHide:f=>hooks.hide=f,onUnload:f=>hooks.unload=f,onBackPress:()=>{},setInterval:()=>1,clearInterval:()=>{}},{filename:'assessment.uvue'});
hooks.load({});hooks.show();return exports.page;
}
const flush=async()=>{for(let i=0;i<18;i++)await Promise.resolve()};
const agreement={revisionId:'1',code:'document-use',version:'synthetic-v1',scope:'documents',title:'合成协议',content:'仅供测试',contentHash:'a'.repeat(64),effectiveAt:'2026-01-01T00:00:00Z'};
function consentReply(url){if(url==='/agreements/document-use')return agreement;return {consentId:'9',scope:'documents',agreementCode:'document-use',agreementVersion:'synthetic-v1',contentHash:'a'.repeat(64),grantedAt:'2026-01-01T00:00:00Z',revokedAt:null,canRevoke:true}}
let checks=0;async function test(name,f){await f();checks++;console.log('PASS '+name)}
(async()=>{
const s=load('services/documents.uts');
await test('saved IDs recover after leaving flow, including duplicates and pending deletion',async()=>{owner=new Json({token:'synthetic-a',userId:'1'});reply=async url=>consentReply(url);let p=page();await flush();p.documents.value.push({name:'身份证正面',path:'',documentId:'7',consentId:'9',deletePending:false});hooks.hide();hooks.unload();listReply=async()=>({documents:[new Json({documentId:'8',category:'id-front',consentId:'9',deletePending:true}),new Json({documentId:'7',category:'id-front',consentId:'9',deletePending:false}),new Json({documentId:'10',category:'business-license',consentId:'9',deletePending:false})]});p=page();await flush();assert.equal(p.documents.value.length,3);assert.equal(p.documents.value[0].deletePending,true);reply=async()=>({documentId:'8',deleted:true});await p.removeDocument('身份证正面','8');assert.equal(p.documents.value.length,2);assert.equal(p.documents.value[0].documentId,'7');listReply=async()=>({documents:[]})});
await test('recovery validates IDs, categories and forbids storage paths',async()=>{for(const item of [{documentId:'/tmp/x',category:'id-front',consentId:'9',deletePending:false},{documentId:'7',category:'unknown',consentId:'9',deletePending:false},{documentId:'7',category:'id-front',consentId:'9',deletePending:false,path:'/private/x'}]){listReply=async()=>({documents:[new Json(item)]});await assert.rejects(s.fetchOwnedDocuments())}listReply=async()=>({documents:[]})});
await test('late recovery after owner change is refused',async()=>{let done;listReply=()=>new Promise(r=>done=r);const pending=s.fetchOwnedDocuments();owner=new Json({token:'synthetic-b',userId:'2'});done({documents:[]});await assert.rejects(pending);owner=new Json({token:'synthetic-a',userId:'1'});listReply=async()=>({documents:[]})});
await test('recovery failure is retryable without losing unsent local photo',async()=>{reply=async url=>consentReply(url);const p=page();await flush();p.documents.value.push({name:'收入证明',path:'C:/local-pending.png',documentId:'',consentId:'',deletePending:false});listReply=async()=>{throw new Error('synthetic recovery failure')};await p.restoreDocuments();assert.ok(p.documentMessage.value.includes('recovery failure'));assert.equal(p.documents.value[0].path,'C:/local-pending.png');listReply=async()=>({documents:[new Json({documentId:'7',category:'id-front',consentId:'9',deletePending:false})]});await p.restoreDocuments();assert.equal(p.documents.value.length,2);assert.equal(p.documents.value.find(x=>x.documentId==='').path,'C:/local-pending.png');listReply=async()=>({documents:[]})});
await test('late recovery after leaving page never repopulates hidden form',async()=>{let done;listReply=()=>new Promise(r=>done=r);const p=page();hooks.hide();done({documents:[new Json({documentId:'7',category:'id-front',consentId:'9',deletePending:false})]});await flush();assert.equal(p.documents.value.length,0);listReply=async()=>({documents:[]})});
await test('recovered remote same-category photo does not trap unsent local selection',async()=>{reply=async url=>consentReply(url);const p=page();await flush();p.documents.value.push({name:'身份证正面',path:'C:/unknown-receipt.png',documentId:'',consentId:'',deletePending:false});listReply=async()=>({documents:[new Json({documentId:'7',category:'id-front',consentId:'9',deletePending:false})]});await p.restoreDocuments();p.documentAction('身份证正面');assert.equal(sheet.itemList[2],'移除未上传照片');sheet.success({tapIndex:2});assert.equal(p.documents.value.length,1);assert.equal(p.documents.value[0].documentId,'7');p.step.value=3;assert.equal(p.validateStep(),true);listReply=async()=>({documents:[]})});
await test('uploaded photo stays visible in its original card',async()=>{reply=async url=>consentReply(url);const p=page();await flush();await p.toggleDocumentConsent();upload=async()=>({documentId:'7',category:'id-front',mimeType:'image/png',byteSize:10});p.chooseDocument('身份证正面');picker.success({tempFilePaths:['C:/selected.png']});await flush();assert.equal(p.documentPath('身份证正面'),'C:/selected.png')});
await test('same category saved photos remain individually deletable through original card',async()=>{reply=async url=>consentReply(url);listReply=async()=>({documents:[new Json({documentId:'8',category:'id-front',consentId:'9',deletePending:false}),new Json({documentId:'7',category:'id-front',consentId:'9',deletePending:false})]});const p=page();await flush();p.documentAction('身份证正面');assert.equal(sheet.itemList.length,2);assert.ok(sheet.itemList[0].includes('照片 1'));sheet.success({tapIndex:1});assert.equal(sheet.itemList[1],'删除照片');reply=async()=>({documentId:'7',deleted:true});sheet.success({tapIndex:1});await flush();assert.equal(p.documents.value.length,1);assert.equal(p.documents.value[0].documentId,'8');listReply=async()=>({documents:[]})});
await test('restored card thumbnail uses protected download and late thumbnail is cleaned',async()=>{reply=async url=>consentReply(url);const p=page();await flush();p.step.value=3;listReply=async()=>({documents:[new Json({documentId:'7',category:'id-front',consentId:'9',deletePending:false})]});await p.restoreDocuments();assert.ok(download.url.endsWith('/documents/7/content'));download.success({statusCode:200,tempFilePath:'/tmp/recovered-thumb.png'});await flush();assert.equal(p.documentPath('身份证正面'),'/tmp/recovered-thumb.png');await p.restoreDocuments();hooks.hide();download.success({statusCode:200,tempFilePath:'/tmp/late-thumb.png'});await flush();assert.equal(p.documents.value.length,0);assert.ok(calls.some(c=>c[0]==='unlink'&&c[1]==='/tmp/late-thumb.png'));listReply=async()=>({documents:[]})});
await test('late automatic thumbnail cannot delete the current manual preview',async()=>{reply=async url=>consentReply(url);const p=page();await flush();p.step.value=3;listReply=async()=>({documents:[new Json({documentId:'7',category:'id-front',consentId:'9',deletePending:false})]});await p.restoreDocuments();const automatic=download;const manual=p.previewDocument('身份证正面','7');download.success({statusCode:200,tempFilePath:'/tmp/manual-preview.png'});await manual;const count=calls.length;automatic.success({statusCode:200,tempFilePath:'/tmp/automatic-thumb.png'});await flush();assert.equal(p.documentPath('身份证正面'),'/tmp/manual-preview.png');assert.ok(!calls.slice(count).some(c=>c[0]==='unlink'&&c[1]==='/tmp/manual-preview.png'));assert.ok(calls.slice(count).some(c=>c[0]==='unlink'&&c[1]==='/tmp/automatic-thumb.png'));listReply=async()=>({documents:[]})});
await test('many recovered photos stay manageable within native action-sheet limits',async()=>{reply=async url=>consentReply(url);listReply=async()=>({documents:Array.from({length:7},(_,i)=>new Json({documentId:String(i+1),category:'id-front',consentId:'9',deletePending:false}))});const p=page();await flush();p.documentAction('身份证正面');assert.ok(sheet.itemList.length<=6);assert.equal(sheet.itemList.at(-1),'更多照片');sheet.success({tapIndex:sheet.itemList.length-1});assert.ok(sheet.itemList[1].includes('照片 7'));sheet.success({tapIndex:1});reply=async()=>({documentId:'7',deleted:true});sheet.success({tapIndex:1});await flush();assert.equal(p.documents.value.length,6);assert.ok(!p.documents.value.some(x=>x.documentId==='7'));listReply=async()=>({documents:[]})});
await test('valid server receipt required; local path is not ID',async()=>{upload=async()=>({documentId:'C:/photo.jpg',category:'id-front',mimeType:'image/png',byteSize:10});await assert.rejects(s.uploadDocument('C:/local.jpg','id-front','9'))});
await test('multipart receives category and consent ID',async()=>{upload=async()=>({documentId:'7',category:'id-front',mimeType:'image/png',byteSize:10});const r=await s.uploadDocument('C:/local.jpg','id-front','9');assert.equal(r,'7');assert.equal(calls.at(-1)[2].consentId,'9')});
await test('unknown or public receipts refused',async()=>{upload=async()=>({documentId:'7',category:'income',mimeType:'image/png',byteSize:10,url:'/uploads/x'});await assert.rejects(s.uploadDocument('x','id-front','9'))});
await test('protected download carries bearer without URL token',async()=>{const p=s.downloadDocument('7');assert.equal(download.header.Authorization,'Bearer synthetic-a');assert.equal(download.url,'https://synthetic.invalid/api/v1/documents/7/content');download.success({statusCode:200,tempFilePath:'/tmp/private.png'});assert.equal(await p,'/tmp/private.png')});
await test('late download after account switch discarded and temp cleaned',async()=>{const p=s.downloadDocument('7');owner=new Json({token:'synthetic-b',userId:'2'});download.success({statusCode:200,tempFilePath:'/tmp/late.png'});await assert.rejects(p);assert.ok(calls.some(c=>c[0]==='unlink'&&c[1]==='/tmp/late.png'))});
await test('old 401 cannot clear new session',async()=>{owner=new Json({token:'synthetic-a',userId:'1'});const p=s.downloadDocument('7');owner=new Json({token:'synthetic-b',userId:'2'});download.success({statusCode:401,tempFilePath:'/tmp/error'});await assert.rejects(p);assert.equal(owner.userId,'2')});
await test('logout during upload cannot accept receipt',async()=>{owner=new Json({token:'synthetic-a',userId:'1'});let done;upload=()=>new Promise(r=>done=r);const p=s.uploadDocument('x','id-front','9');owner=null;done({documentId:'7',category:'id-front',mimeType:'image/png',byteSize:10});await assert.rejects(p)});
await test('delete only accepts same ID confirmed true',async()=>{owner=new Json({token:'synthetic-a',userId:'1'});reply=async()=>({documentId:'8',deleted:true});await assert.rejects(s.deleteDocument('7'));reply=async()=>({documentId:'7',deleted:false});await assert.rejects(s.deleteDocument('7'));reply=async()=>({documentId:'7',deleted:true});await s.deleteDocument('7')});
await test('not logged in means no download/upload/delete dispatch',async()=>{owner=null;const n=calls.length;await assert.rejects(s.uploadDocument('x','id-front','9'));await assert.rejects(s.downloadDocument('7'));await assert.rejects(s.deleteDocument('7'));assert.equal(calls.length,n)});
await test('card requires server consent before picker; failed upload remains retryable',async()=>{owner=new Json({token:'synthetic-a',userId:'1'});reply=async url=>consentReply(url);const p=page();await flush();picker=null;p.chooseDocument('身份证正面');assert.equal(picker,null);await p.toggleDocumentConsent();upload=async()=>{throw new Error('synthetic network loss')};p.chooseDocument('身份证正面');picker.success({tempFilePaths:['C:/local.png']});await flush();assert.equal(p.documents.value[0].documentId,'');assert.equal(p.documents.value[0].path,'C:/local.png');p.step.value=3;assert.equal(p.validateStep(),false);upload=async()=>({documentId:'7',category:'id-front',mimeType:'image/png',byteSize:10});await p.uploadSelectedDocument('身份证正面');assert.equal(p.documents.value[0].documentId,'7');assert.equal(p.validateStep(),true)});
await test('delete failure keeps original ID and forbids preview until explicit retry',async()=>{reply=async url=>consentReply(url);const p=page();await flush();p.documents.value.push({name:'身份证正面',path:'',documentId:'7',consentId:'9',deletePending:false});reply=async()=>{throw new Error('synthetic delete failure')};await p.removeDocument('身份证正面','');assert.equal(p.documents.value[0].documentId,'7');assert.equal(p.documents.value[0].deletePending,true);download=null;await p.previewDocument('身份证正面','');assert.equal(download,null);reply=async()=>({documentId:'7',deleted:true});await p.removeDocument('身份证正面','');assert.equal(p.documents.value.length,0)});
await test('late upload after leaving page never restores card',async()=>{reply=async url=>consentReply(url);const p=page();await flush();await p.toggleDocumentConsent();let done;upload=()=>new Promise(r=>done=r);p.chooseDocument('身份证正面');picker.success({tempFilePaths:['C:/local.png']});hooks.hide();done({documentId:'7',category:'id-front',mimeType:'image/png',byteSize:10});await flush();assert.equal(p.documents.value.length,0)});
await test('late download after leaving page never previews and cleans temp',async()=>{const p=page();await flush();p.documents.value.push({name:'身份证正面',path:'',documentId:'7',consentId:'9',deletePending:false});const pending=p.previewDocument('身份证正面','');hooks.hide();preview=null;download.success({statusCode:200,tempFilePath:'/tmp/late-page.png'});await pending;assert.equal(preview,null);assert.ok(calls.some(c=>c[0]==='unlink'&&c[1]==='/tmp/late-page.png'))});
await test('viewing agreement preserves form and server IDs; temps are not put into flow',async()=>{const p=page();await flush();p.form.value.name='合成姓名';p.documents.value.push({name:'身份证正面',path:'/tmp/private.png',documentId:'7',consentId:'9',deletePending:false});p.showAgreement('资料使用协议');hooks.hide();hooks.show();assert.equal(p.form.value.name,'合成姓名');assert.equal(p.documents.value[0].documentId,'7');assert.equal(p.documents.value[0].path,'');const saved=load('services/assessment-flow.uts').readAssessmentFlow('synthetic-a');assert.equal(saved.documents[0].path,'')});
await test('uploaded photos stay out of assessment JSON; local paths and external grants refused',async()=>{const records=load('packages/finance/services/records.uts');const payload=new Json({...load('services/assessment.uts').emptyAssessmentForm(),type:'personal',documents:[new Json({name:'身份证正面',path:'C:/private.png',documentId:'7',consentId:'9',deletePending:false})],authorizations:[]});reply=async()=>({assessmentId:'2',revisionId:'3',submissionStatus:'accepted'});await records.createAssessment(payload);const body=calls.at(-1)[2];assert.equal(body.documents.length,0);assert.equal(body.consentIds.length,0);assert.ok(!JSON.stringify(body).includes('private.png'));payload.documents=[new Json({path:'C:/local.png'})];await assert.rejects(records.createAssessment(payload));payload.documents=[];payload.authorizations=['credit'];await assert.rejects(records.createAssessment(payload))});
console.log('PASS: '+checks+' private document service scenarios');
})().catch(e=>{console.error(e.message);process.exitCode=1});