application.test.mjs 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130
  1. import test from 'node:test';
  2. import assert from 'node:assert/strict';
  3. import fs from 'node:fs';
  4. import crypto from 'node:crypto';
  5. import {createApplication,extractReport} from '../src/core.mjs';
  6. import {attachConfirmationHandlers} from '../src/confirmation.mjs';
  7. const data=JSON.parse(fs.readFileSync(new URL('../references/runtime-data.json',import.meta.url),'utf8'));
  8. const clone=x=>structuredClone(x);
  9. function memoryStore(){let rows={},seq=0;const store={rows:()=>rows,async transaction(fn){const backup=clone(rows);try{return await fn(store);}catch(e){rows=backup;throw e;}},async lock(){},async get(n,id){return clone(rows[n]?.find(x=>x.objectId===id)||null);},async list(n,{where={},afterId,limit=200,search}={}){return clone((rows[n]||[]).filter(r=>Object.entries(where).every(([k,v])=>JSON.stringify(r[k]??null)===JSON.stringify(v))).filter(r=>!afterId||r.objectId>afterId).filter(r=>!search||JSON.stringify(r).includes(search)).sort((a,b)=>a.objectId.localeCompare(b.objectId)).slice(0,limit));},async create(n,d){const now=new Date().toISOString();const r={...clone(d),objectId:'test'+String(++seq).padStart(6,'0'),createdAt:now,updatedAt:now};(rows[n]??=[]).push(r);return clone(r);},async update(n,id,d,{expectedUpdatedAt}={}){const r=rows[n]?.find(x=>x.objectId===id);if(!r||expectedUpdatedAt&&r.updatedAt!==expectedUpdatedAt)throw Object.assign(Error('conflict'),{code:'VERSION_CONFLICT'});Object.assign(r,clone(d),{updatedAt:new Date(Date.now()+seq).toISOString()});return clone(r);}};return store;}
  10. async function fixture(){const store=memoryStore(),settings={masterKey:'test-admin-secret-only',signingSecret:'test-signing-secret-only',functionsUrl:'https://example.test/api/functions',parseUrl:'https://example.test/parse'};
  11. const files={verify:async()=>{throw Object.assign(Error('not uploaded'),{code:'UPLOAD_INCOMPLETE'});},createPreviewFile:async()=>({__type:'File',name:'preview.html',url:'https://example.test/preview'}),retrieve:async()=>Buffer.from('<h1>测试报告</h1>')};const app=createApplication({store,crypto,Parse:{},data,settings,files});attachConfirmationHandlers(app,{store,files,settings,data});
  12. const actor={id:'serviceA',kind:'service',scopes:['*'],projectIds:['*']},employee={id:'employeeA',kind:'employee',scopes:['*'],projectIds:['*']};
  13. const original={content:'方便面偏咸',likes:0};const source=await app.dispatch('ksfRegisterSource',{requestId:'source-1',source:{title:'本地测试样本',sourceKind:'dataset',dataMode:'sample',rawText:JSON.stringify(original)},contentHash:app.hash(JSON.stringify(original))},actor);
  14. const record={sourceRef:app.pointer('KfSource',source.data.sourceId),platform:'douyin',idOrigin:'upstream',externalObjectId:'platform001',recordIndex:0,payloadLevel:'voc_normalized',rawPayload:original,payloadHash:app.hash('ksf.payload.json.v1\n'+app.canonical(original)),content:'方便面偏咸',collectedAt:null,sourceUrlVerified:false,dataMode:'sample'};
  15. const params={requestId:'import-1',schemaVersion:data.schemaVersion,sourceId:source.data.sourceId,producer:{skillName:'fixture-only',skillVersion:'1'},items:[{itemKey:'sample-1',className:'KfRawDouyinVideo',externalKey:'fixture:video:1',data:record}]};
  16. return{app,store,actor,employee,params,source,record,settings};}
  17. test('公开合同不授权写表,公布实际动态入口和受限能力',async()=>{const{app}=await fixture();const r=await app.dispatch('ksfGetIngestContract',{},null);assert.equal(r.ok,true);assert.equal(r.data.allowedClasses.length,0);assert.equal(r.data.capabilities.asynchronousJobs,false);assert.equal(r.data.capabilities.projectTransition,false);assert.equal(r.data.transport.body.path,'/{functionName}');});
  18. test('没有身份不能写入',async()=>{const{app,params}=await fixture();await assert.rejects(()=>app.dispatch('ksfSubmitImport',params,null),{code:'AUTH_REQUIRED'});});
  19. test('服务凭据以 hash 验证,并核验过期时间和作用范围',async()=>{const{app,store}=await fixture();await store.create('KsfServiceCredential',{tokenHash:app.hash('local-test-token'),enabled:true,expiresAt:new Date(Date.now()+60000).toISOString(),scopes:['data:read'],projectIds:[]});const a=await app.authenticate({'X-Ksf-Service-Token':'local-test-token'});assert.deepEqual(a.scopes,['data:read']);await assert.rejects(()=>app.authenticate({'X-Ksf-Service-Token':'bad-token'}),{code:'AUTH_REQUIRED'});});
  20. test('真实三态:未知采集时间和未判断噪声保持未知',async()=>{const{app,params,actor,store}=await fixture();const result=await app.dispatch('ksfSubmitImport',params,actor);const row=(await store.list('KfRawDouyinVideo'))[0];assert.equal(row.collectedAt,null);assert.equal(row.isNoise,undefined);assert.equal(result.data.status,'succeeded');assert.equal(result.data.counts.created,1);});
  21. test('同请求重放回执一致且不增加记录;不同内容同 requestId 拒绝',async()=>{const{app,params,actor,store}=await fixture();const a=await app.dispatch('ksfSubmitImport',params,actor),b=await app.dispatch('ksfSubmitImport',clone(params),actor);assert.deepEqual(a.data,b.data);assert.equal((await store.list('KfRawDouyinVideo')).length,1);const changed=clone(params);changed.producer.skillVersion='2';await assert.rejects(()=>app.dispatch('ksfSubmitImport',changed,actor),{code:'IDEMPOTENCY_CONFLICT'});});
  22. test('跨批重导报告 unchanged,成功计数来自实际写入',async()=>{const{app,params,actor}=await fixture();await app.dispatch('ksfSubmitImport',params,actor);const next={...params,requestId:'import-2'};const r=await app.dispatch('ksfSubmitImport',next,actor);assert.equal(r.data.counts.unchanged,1);assert.equal(r.data.counts.created,0);});
  23. test('来源跨requestId复用,URL、模式和owner分开,原件字节变化保留',async()=>{
  24. const{app,actor,store}=await fixture();
  25. const register=(requestId,overrides={},who=actor)=>{const source={title:'不同批次标题',sourceKind:'dataset',dataMode:'live',rawText:'真实原文占位',sourceUrl:'https://example.test/a',...overrides};return app.dispatch('ksfRegisterSource',{requestId,source,contentHash:app.hash(source.rawText)},who);};
  26. const a=await register('dedup-source-a'),b=await register('dedup-source-b',{title:'另一标题',collectedAt:'2026-11-02T00:00:00Z'});
  27. assert.equal(a.data.sourceId,b.data.sourceId);assert.equal(b.data.operation,'unchanged');
  28. for(const [i,change]of [{sourceUrl:'https://example.test/b'},{dataMode:'sample'},{rawText:'不同原文'}].entries()){assert.notEqual((await register('dedup-different-'+i,change)).data.sourceId,a.data.sourceId);}
  29. assert.notEqual((await register('dedup-other-owner',{}, {...actor,id:'service-b'})).data.sourceId,a.data.sourceId);
  30. assert.equal((await store.list('KfSource')).length,6);
  31. });
  32. test('分类按版本和代码保护唯一身份:相同定义复用,不同定义返回可处理冲突',async()=>{
  33. const{app,actor,store}=await fixture();
  34. const categoryData={dataMode:'sample',code:'KSF-TEST-MIXED-NOODLES',name:'测试拌面分类',taxonomyVersion:'ksf-test-v1',axis:'食用方式',scopeNote:'测试范围'};
  35. const category=await store.create('KfCategory',{...app.metadata('category:first','sample'),...categoryData});
  36. const sourceBody={title:'分类复用测试',sourceKind:'dataset',dataMode:'sample',rawText:'只用于本地分类身份测试'};
  37. const source=await app.dispatch('ksfRegisterSource',{requestId:'category-identity-source',source:sourceBody,contentHash:app.hash(sourceBody.rawText)},actor);
  38. const item=(externalKey,data)=>({itemKey:'category',className:'KfCategory',externalKey,data});
  39. const base={schemaVersion:data.schemaVersion,sourceId:source.data.sourceId,producer:{skillName:'fixture',skillVersion:'1'}};
  40. const exact={requestId:'category-identity-exact',...base,items:[item('category:second',categoryData)]};
  41. const reused=await app.dispatch('ksfSubmitImport',exact,actor);
  42. assert.equal(reused.data.counts.created,0);assert.equal(reused.data.counts.unchanged,1);
  43. assert.equal(reused.data.recordRefs[0].objectId,category.objectId);
  44. const changed={...categoryData,scopeNote:'不同的分类范围'};
  45. const duplicate={requestId:'category-identity-conflict',...base,items:[item('category:third',changed)]};
  46. await assert.rejects(()=>app.dispatch('ksfCheckDuplicates',{source:sourceBody,records:duplicate.items},actor),{code:'CATEGORY_DEFINITION_CONFLICT'});
  47. await assert.rejects(()=>app.dispatch('ksfSubmitImport',duplicate,actor),{code:'CATEGORY_DEFINITION_CONFLICT'});
  48. assert.equal((await store.list('KfCategory')).length,1);
  49. assert.equal((await store.get('KfCategory',category.objectId)).scopeNote,categoryData.scopeNote);
  50. });
  51. test('跨批换业务键精确重复复用原始记录,并保留已有人工噪声状态',async()=>{
  52. const{app,params,actor,store}=await fixture();const first=await app.dispatch('ksfSubmitImport',params,actor),id=first.data.recordRefs[0].objectId;
  53. await store.update('KfRawDouyinVideo',id,{isNoise:false,noiseReason:'人工判定有效',noiseScope:'test',noiseHistory:[{method:'manual'}]});
  54. const second=clone(params);second.requestId='another-batch';second.items[0].externalKey='different:key';second.items[0].data.recordIndex=19;second.items[0].data.collectedAt='2026-11-02T12:00:00Z';
  55. const result=await app.dispatch('ksfSubmitImport',second,actor);assert.equal(result.data.counts.unchanged,1);assert.equal(result.data.recordRefs[0].objectId,id);assert.equal((await store.list('KfRawDouyinVideo')).length,1);assert.equal((await store.get('KfRawDouyinVideo',id)).isNoise,false);
  56. assert.equal((await store.list('KfImportJob')).length,2);assert.equal((await store.list('KfImportItem')).length,2);
  57. });
  58. test('不同批次来源下同平台对象同载荷复用,内容变化保留新快照',async()=>{
  59. const{app,params,actor,store}=await fixture();const first=await app.dispatch('ksfSubmitImport',params,actor);
  60. const source=await app.dispatch('ksfRegisterSource',{requestId:'new-source-batch',source:{title:'新采集批次',sourceKind:'dataset',dataMode:'sample',rawText:'另一批原文'},contentHash:app.hash('另一批原文')},actor);
  61. const next=clone(params);next.requestId='new-source-import';next.sourceId=source.data.sourceId;next.items[0].externalKey='cross-source-key';next.items[0].data.sourceRef=app.pointer('KfSource',next.sourceId);
  62. const duplicate=await app.dispatch('ksfSubmitImport',next,actor);assert.equal(duplicate.data.recordRefs[0].objectId,first.data.recordRefs[0].objectId);
  63. next.requestId='new-content-import';next.items[0].externalKey='new-content-key';next.items[0].data.rawPayload.likes=9;next.items[0].data.payloadHash=app.hash('ksf.payload.json.v1\n'+app.canonical(next.items[0].data.rawPayload));
  64. const changed=await app.dispatch('ksfSubmitImport',next,actor);assert.equal(changed.data.counts.created,1);assert.equal((await store.list('KfRawDouyinVideo')).length,2);
  65. });
  66. test('相同短评不同上游对象不合并,缺ID不同索引不合并',async()=>{
  67. const{app,params,actor,store}=await fixture();await app.dispatch('ksfSubmitImport',params,actor);
  68. const next=clone(params);next.requestId='different-person';next.items[0].externalKey='different-person';next.items[0].data.externalObjectId='platform002';await app.dispatch('ksfSubmitImport',next,actor);
  69. next.requestId='missing-id-1';next.items[0].externalKey='missing-id-1';next.items[0].data.idOrigin='missing';delete next.items[0].data.externalObjectId;await app.dispatch('ksfSubmitImport',next,actor);
  70. next.requestId='missing-id-2';next.items[0].externalKey='missing-id-2';next.items[0].data.recordIndex=2;await app.dispatch('ksfSubmitImport',next,actor);
  71. assert.equal((await store.list('KfRawDouyinVideo')).length,4);
  72. });
  73. test('同源产品同字段复用,不同规格和价格时间不被名称合并',async()=>{
  74. const{app,actor,source,store}=await fixture();
  75. const category=await store.create('KfCategory',{...app.metadata('fixture-category','sample'),name:'测试分类'});
  76. const params={requestId:'product-a',schemaVersion:data.schemaVersion,sourceId:source.data.sourceId,producer:{skillName:'fixture',skillVersion:'1'},items:[{itemKey:'product',className:'KfProduct',externalKey:'product:a',data:{dataMode:'sample',name:'同名产品',brandName:'样例品牌',categoryRef:null,isOwnBrand:false,productLevel:'product',sourceRef:app.pointer('KfSource',source.data.sourceId),observedRetailPrice:5,priceObservedAt:'2026-11-01T00:00:00Z'}}]};
  77. params.items[0].data.categoryRef=app.pointer('KfCategory',category.objectId);
  78. const first=await app.dispatch('ksfSubmitImport',params,actor);const next=clone(params);next.requestId='product-b';next.items[0].externalKey='product:b';
  79. // Match PostgreSQL: optional columns absent from input are returned as null.
  80. await store.update('KfProduct',first.data.recordRefs[0].objectId,{skuId:null,launchDate:null});
  81. assert.equal((await app.dispatch('ksfSubmitImport',next,actor)).data.recordRefs[0].objectId,first.data.recordRefs[0].objectId);
  82. next.requestId='product-new-price';next.items[0].externalKey='product:price';next.items[0].data.observedRetailPrice=6;next.items[0].data.priceObservedAt='2026-11-02T00:00:00Z';await app.dispatch('ksfSubmitImport',next,actor);assert.equal((await store.list('KfProduct')).length,2);
  83. });
  84. test('只读查重不写记录,依赖待解析不宣称命中,提交仍独立复核',async()=>{
  85. const{app,params,actor,store}=await fixture();await app.dispatch('ksfSubmitImport',params,actor);const before=clone(store.rows());
  86. const source={title:'不同标题',sourceKind:'dataset',dataMode:'sample',rawText:JSON.stringify({content:'方便面偏咸',likes:0})};
  87. const result=await app.dispatch('ksfCheckDuplicates',{source,records:[...params.items,{itemKey:'dependent',className:'KfProduct',data:{dataMode:'sample',name:'待解析',categoryRef:{$item:'category'}}}]},actor);
  88. assert.equal(result.data.readOnly,true);assert.equal(result.data.items[0].decision,'reuse');assert.equal(result.data.items[1].decision,'deferred');assert.deepEqual(store.rows(),before);
  89. const next=clone(params);next.requestId='after-check';next.items[0].externalKey='after-check';assert.equal((await app.dispatch('ksfSubmitImport',next,actor)).data.counts.unchanged,1);
  90. });
  91. test('来源不跨服务身份泄漏或合并,查重扫描未完成明确失败',async()=>{
  92. const{app,params,actor,store}=await fixture();await app.dispatch('ksfSubmitImport',params,actor);
  93. const source={title:'本地测试样本',sourceKind:'dataset',dataMode:'sample',rawText:JSON.stringify({content:'方便面偏咸',likes:0})};
  94. const result=await app.dispatch('ksfCheckDuplicates',{source,records:params.items},{...actor,id:'another-service'});assert.equal(result.data.source.sourceId,undefined);assert.notEqual(result.data.items[0].decision,'reuse');
  95. const original=store.list;store.list=async(name,args)=>name==='KfSource'&&args?.where?.contentHash?Array(data.limits.maxReadScan+1).fill({}):original(name,args);
  96. await assert.rejects(()=>app.dispatch('ksfCheckDuplicates',{source,records:params.items},actor),{code:'DEDUP_SCAN_LIMIT'});
  97. });
  98. test('字段脏数据不静默删除;rawPayload 内同名字段原样保留',async()=>{const{app,params,actor,store}=await fixture();const p=clone(params);p.items[0].data.__className='bad';const v=await app.dispatch('ksfValidateImport',p,actor);assert.equal(v.data.valid,false);assert.match(v.data.itemResults[0].errors[0].field,/__className/);const raw=params.items[0].data.rawPayload;raw.objectId='upstream-object';raw.isNoise=false;params.items[0].data.payloadHash=app.hash('ksf.payload.json.v1\n'+app.canonical(raw));await app.dispatch('ksfSubmitImport',params,actor);assert.deepEqual((await store.list('KfRawDouyinVideo'))[0].rawPayload,raw);});
  99. test('业务键相同但原始载荷变化拒绝,不清空历史判断',async()=>{const{app,params,actor,store}=await fixture();await app.dispatch('ksfSubmitImport',params,actor);const row=(await store.list('KfRawDouyinVideo'))[0];await store.update('KfRawDouyinVideo',row.objectId,{noiseHistory:[{requestId:'manual-test'}]});const p=clone(params);p.requestId='different';p.items[0].data.rawPayload.likes=2;p.items[0].data.payloadHash=app.hash('ksf.payload.json.v1\n'+app.canonical(p.items[0].data.rawPayload));await assert.rejects(()=>app.dispatch('ksfSubmitImport',p,actor),{code:'IDEMPOTENCY_CONFLICT'});assert.equal((await store.list('KfRawDouyinVideo'))[0].noiseHistory.length,1);});
  100. test('错误 Pointer 和不存在的来源会拒绝',async()=>{const{app,params,actor}=await fixture();params.items[0].data.sourceRef={className:'KfSource',sourceId:'bad'};const v=await app.dispatch('ksfValidateImport',params,actor);assert.equal(v.data.valid,false);await assert.rejects(()=>app.dispatch('ksfValidateImport',{...params,sourceId:'no-such-id'},actor),{code:'REFERENCE_NOT_FOUND'});});
  101. test('正文指纹保留空白、标点与负面评价;互动数不影响指纹',async()=>{const{app}=await fixture();const row={content:'真难吃?! 👍\r\n A B',likeCount:0};const a=app.noiseHash('KfRawDouyinVideo',row);assert.equal(a,app.noiseHash('KfRawDouyinVideo',{...row,likeCount:99}));assert.notEqual(a,app.noiseHash('KfRawDouyinVideo',{content:'真难吃?! 👍\n A B'}));});
  102. test('回执使用 data.job 和逐条当前快照,分页游标不能跨任务',async()=>{const{app,params,actor}=await fixture();const r=await app.dispatch('ksfSubmitImport',params,actor);const receipt=await app.dispatch('ksfGetImportResult',{jobId:r.data.jobId},actor);assert.equal(receipt.data.job.status,'succeeded');assert.equal(receipt.data.items[0].targetSnapshot.updatedAt,receipt.data.items[0].updatedAt);assert.equal(receipt.data.items[0].targetSnapshot.fingerprintSpecId,'ksf.noise.text.nfc.v1');assert.equal(receipt.data.status,undefined);});
  103. test('人工噪声判断服务身份不能冒充,false 也必须真实保存',async()=>{const{app,params,actor,employee,store}=await fixture();await app.dispatch('ksfSubmitImport',params,actor);const row=(await store.list('KfRawDouyinVideo'))[0];const p={requestId:'noise-1',className:'KfRawDouyinVideo',recordId:row.objectId,expectedUpdatedAt:row.updatedAt,noiseScope:'ksf.instant_noodles.consumer.v1',noiseRuleVersion:'ksf.noise.consumer-text.v1',decision:{isNoise:false,noiseReason:'有效的负面体验',method:'manual',contentHash:app.noiseHash('KfRawDouyinVideo',row),provenance:{channel:'authenticated-ui'}}};await assert.rejects(()=>app.dispatch('ksfSetNoiseAssessment',p,actor),{code:'NOISE_REVIEW_REQUIRED'});const r=await app.dispatch('ksfSetNoiseAssessment',p,employee);assert.equal(r.data.assessment.isNoise,false);assert.equal((await store.get('KfRawDouyinVideo',row.objectId)).noiseHistory.length,1);const again=await app.dispatch('ksfSetNoiseAssessment',p,employee);assert.deepEqual(again.data,r.data);});
  104. test('过期版本和正文指纹不符不能更新噪声',async()=>{const{app,params,actor,employee,store}=await fixture();await app.dispatch('ksfSubmitImport',params,actor);const row=(await store.list('KfRawDouyinVideo'))[0];const p={requestId:'noise-stale',className:'KfRawDouyinVideo',recordId:row.objectId,expectedUpdatedAt:'2000-01-01T00:00:00.000Z',noiseScope:'ksf.banmian.consumer.v1',noiseRuleVersion:'ksf.noise.consumer-text.v1',decision:{isNoise:true,noiseReason:'无关',method:'manual',contentHash:'0'.repeat(64),provenance:{}}};await assert.rejects(()=>app.dispatch('ksfSetNoiseAssessment',p,employee),{code:'VERSION_CONFLICT'});p.expectedUpdatedAt=row.updatedAt;await assert.rejects(()=>app.dispatch('ksfSetNoiseAssessment',p,employee),{code:'NOISE_CONTENT_CHANGED'});});
  105. test('原始查询必须有研究口径;样例默认不进入正式样本',async()=>{const{app,params,actor}=await fixture();await app.dispatch('ksfSubmitImport',params,actor);await assert.rejects(()=>app.dispatch('ksfSearchRecords',{className:'KfRawDouyinVideo'},actor),{code:'NOISE_CONTEXT_REQUIRED'});const r=await app.dispatch('ksfSearchRecords',{className:'KfRawDouyinVideo',noiseContext:{scope:'ksf.instant_noodles.consumer.v1',ruleVersion:'ksf.noise.consumer-text.v1',mode:'all'}},actor);assert.equal(r.data.noiseSummary.rawCount,0);});
  106. test('不能查询账号密码、凭据、内部事件表',async()=>{const{app,actor}=await fixture();for(const name of ['_User','_Role','KsfRuntimeConfig','KsfServiceCredential']){await assert.rejects(()=>app.dispatch('ksfGetRecord',{className:name,objectId:'test000001'},actor),{code:'VALIDATION_FAILED'});await assert.rejects(()=>app.dispatch('ksfSearchRecords',{className:name},actor),{code:'VALIDATION_FAILED'});}});
  107. test('伪造报告确认字段不能创建可信确认事件',async()=>{const{app,actor}=await fixture();await assert.rejects(()=>app.dispatch('ksfConfirmReport',{requestId:'fake',reportIdentity:{version:1},confirmationEvent:{eventId:'nonexistent'},employeeConfirmed:true},actor),{code:'REPORT_NOT_CONFIRMED'});await assert.rejects(()=>app.dispatch('ksfEmployeeConfirmation',{requestId:'fake'},actor),{code:'REPORT_NOT_CONFIRMED'});});
  108. test('真实员工会话上下文才能建立事件,确认回执绑定 hash 与版本',async()=>{const{app,actor,employee}=await fixture();const html='<h1>测试报告</h1>',identity={reportSeriesKey:'test-report',version:1,reportContentHash:app.hash(Buffer.from(html))};const preview=await app.dispatch('ksfCreateReportPreview',{requestId:'preview-1',reportIdentity:identity,html,reportType:'workshop_auxiliary',artifactSubtype:'acceptance_record',dataMode:'manual'},actor);const event=await app.dispatch('ksfEmployeeConfirmation',{requestId:'employee-event',previewId:preview.data.previewId,action:'confirm_report',reportContentHash:identity.reportContentHash},employee);const conf=await app.dispatch('ksfConfirmReport',{requestId:'receipt',reportIdentity:identity,confirmationEvent:{eventId:event.data.eventId}},actor);assert.equal(conf.data.confirmedIdentity.confirmedBy,employee.id);assert.equal(typeof conf.data.confirmationReceipt,'string');await assert.rejects(()=>app.dispatch('ksfConfirmReport',{requestId:'receipt2',reportIdentity:{...identity,version:2},confirmationEvent:{eventId:event.data.eventId}},actor),{code:'REPORT_CONTENT_CHANGED'});});
  109. test('未配置立项门槛不伪报成功;知识库同步明确无需执行',async()=>{const{app,actor}=await fixture();for(const n of ['ksfCreateProjectFromOpportunity','ksfTransitionProject'])await assert.rejects(()=>app.dispatch(n,{},actor),{code:'CAPABILITY_UNAVAILABLE'});assert.equal((await app.dispatch('ksfSyncKnowledge',{},actor)).data.status,'not_required');});
  110. test('文件未真实完成传输不能登记成来源',async()=>{const{app,actor}=await fixture();await assert.rejects(()=>app.dispatch('ksfRegisterSource',{requestId:'file-no-transfer',source:{title:'report',sourceKind:'report_artifact',dataMode:'manual',fileReceipt:{bytes:1,sha256:'0'.repeat(64)}},contentHash:'0'.repeat(64)},actor),{code:'UPLOAD_INCOMPLETE'});});
  111. async function reportFixture(){const f=await fixture(),{app,actor,employee,store}=f;const html='<h1>测试报告</h1>',extracted=extractReport(html,'local-report');
  112. const identity={reportSeriesKey:'local-report',version:1,reportContentHash:app.hash(Buffer.from(html))};
  113. const preview=await app.dispatch('ksfCreateReportPreview',{requestId:'report-preview',reportIdentity:identity,html,reportType:'workshop_auxiliary',artifactSubtype:'research_gap_list',dataMode:'manual'},actor);
  114. const event=await app.dispatch('ksfEmployeeConfirmation',{requestId:'report-employee',previewId:preview.data.previewId,action:'confirm_report',reportContentHash:identity.reportContentHash},employee);
  115. const conf=await app.dispatch('ksfConfirmReport',{requestId:'report-conf',reportIdentity:identity,confirmationEvent:{eventId:event.data.eventId}},actor);
  116. const source=await store.create('KfSource',{...app.metadata('local-file','manual'),sourceKind:'report_artifact',dataMode:'manual',contentHash:identity.reportContentHash,file:{__type:'File',name:'local-fixture.html'},meta:{ownerId:actor.id}});
  117. const params={requestId:'report-import',schemaVersion:data.schemaVersion,sourceId:source.objectId,producer:{skillName:'local-fixture',skillVersion:'1'},items:[{itemKey:'local-report@v1',className:'KfArtifact',externalKey:'local-report@v1',data:{...identity,stageCode:'S2',contentSchema:'ksf.html-outline.v1',title:extracted.title,rawText:extracted.rawText,content:{summary:extracted.summary,headings:extracted.headings},reportType:'workshop_auxiliary',artifactSubtype:'research_gap_list',dataMode:'manual',sourceRef:app.pointer('KfSource',source.objectId)}}],reportConfirmations:[{itemKey:'local-report@v1',confirmationReceipt:conf.data.confirmationReceipt}]};return{...f,reportParams:params};
  118. }
  119. test('本地夹具报告确认后入库、子类型保存、独立发布及卡片回读',async()=>{const{app,actor,store,reportParams:p}=await reportFixture();const r=await app.dispatch('ksfSubmitImport',p,actor);const row=await store.get('KfArtifact',r.data.recordRefs[0].objectId);assert.equal(row.dashboardState,'unpublished');assert.equal(row.artifactSubtype,'research_gap_list');const published=await app.dispatch('ksfPublishToDashboard',{requestId:'publish',artifactId:row.objectId,version:1,reportContentHash:row.reportContentHash},actor);assert.equal(published.data.publicationStatus,'published');assert.equal(published.data.readable,true);const cards=await app.dispatch('ksfGetDashboardView',{view:'reports',limit:1},actor);assert.equal(cards.data.cards[0].entityId,row.objectId);});
  120. test('修改已确认报告的正文、类型或数据模式被拒绝',async()=>{const{app,actor,reportParams:p}=await reportFixture();for(const change of [{rawText:'与原件不符'},{dataMode:'live'},{reportType:'market_trend_report',artifactSubtype:null}]){const q=clone(p);Object.assign(q.items[0].data,change);const v=await app.dispatch('ksfValidateImport',q,actor);assert.equal(v.data.valid,false);assert.equal(v.data.itemResults[0].errors[0].code,'REPORT_CONTENT_CHANGED');}});
  121. test('报告看板在分页之前筛选 published,草稿不会挤空首页',async()=>{const{app,actor,store}=await fixture();await store.create('KfArtifact',{...app.metadata('draft','manual'),title:'草稿',dashboardState:'unpublished'});const expected=await store.create('KfArtifact',{...app.metadata('published','manual'),title:'发布稿',dashboardState:'published'});const r=await app.dispatch('ksfGetDashboardView',{view:'reports',limit:1},actor);assert.equal(r.data.cards.length,1);assert.equal(r.data.cards[0].entityId,expected.objectId);assert.equal(r.data.nextCursor,null);});