workshop-report-parity.test.js 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342
  1. import test from 'node:test';
  2. import assert from 'node:assert/strict';
  3. import fs from 'node:fs';
  4. import os from 'node:os';
  5. import path from 'node:path';
  6. import crypto from 'node:crypto';
  7. import { createApplication } from '../server/src/core.mjs';
  8. import { attachWorkshopHandlers } from '../server/src/workshop.mjs';
  9. import { runWorkshop } from '../skills/ksf-workshop-runtime/scripts/ksf-workshop.mjs';
  10. import { buildModel } from '../src/core/kfs/build.js';
  11. import { entriesForType, reportCenter, TYPE_FOR_ARTIFACT } from '../src/core/library/reports.js';
  12. import { reportOverview } from '../src/core/report.js';
  13. import { seedMemberGroups } from '../src/core/member-groups.js';
  14. import { workshopReview, workshopOutputRefs } from '../src/core/workshop-outputs.js';
  15. import { createFileAdapter } from '../server/src/files.mjs';
  16. import { CORE_DELIVERABLES, DELIVERY_KINDS, DELIVERY_SCHEMA, reportCategoryOf } from '../skills/ksf-workshop-runtime/scripts/report-contract.mjs';
  17. import { saveAndVerifyReport, prepareHtmlReport } from '../skills/ksf-workshop-runtime/scripts/save-report.mjs';
  18. import { reportAttachments } from '../src/core/report-attachments.js';
  19. import { STATISTICS_COLUMNS, statisticsRows } from '../skills/ksf-workshop-runtime/scripts/report-statistics.mjs';
  20. import { COURSE_ASSESSMENT_COLUMNS, courseAssessmentRows } from '../skills/ksf-workshop-runtime/scripts/course-assessment.mjs';
  21. // Isolated storage: these tests never connect to the production service.
  22. const data = JSON.parse(fs.readFileSync(new URL('../server/references/runtime-data.json', import.meta.url)));
  23. const course = fs.readFileSync(new URL('../skills/ksf-workshop-runtime/references/workshop-course-flow.md', import.meta.url), 'utf8');
  24. const curriculum = [...course.matchAll(/^\| (D[12]-(?:\d\d|N)) \| \d+ \| [^|]+ \| (S[1-5]) \| ([^\n]+)\|/gm)]
  25. .flatMap(([, module, stage, types]) => [...types.matchAll(/`(\w+)`/g)].map(([, type]) => ({ module, stage, type })));
  26. const actor = { id: 'parity-service', kind: 'service', scopes: ['data:read', 'ingest:write'], projectIds: ['*'] };
  27. const agentProfile = { key: 'parity-agent', name: '测试共用智能体', role: 'workshop', runtimePlatform: 'test', channel: 'test' };
  28. function fixture() {
  29. let rows = {}, seq = 0;
  30. const store = {
  31. async transaction(fn) { const prior = structuredClone(rows); try { return await fn(store); } catch (error) { rows = prior; throw error; } },
  32. async lock() {},
  33. async get(name, id) { return structuredClone(rows[name]?.find(row => row.objectId === id) || null); },
  34. async list(name, { where = {}, afterId, limit = 200 } = {}) {
  35. return structuredClone((rows[name] || []).filter(row => Object.entries(where).every(([key, value]) => JSON.stringify(row[key] ?? null) === JSON.stringify(value)))
  36. .filter(row => !afterId || row.objectId > afterId).sort((a, b) => a.objectId.localeCompare(b.objectId)).slice(0, limit));
  37. },
  38. async create(name, values) {
  39. for (const key of Object.keys(values)) assert.ok(data.tables[name][key], `${name}.${key} must exist in the schema`);
  40. const row = { ...structuredClone(values), objectId: 'parity' + String(++seq).padStart(8, '0'), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() };
  41. (rows[name] ||= []).push(row); return structuredClone(row);
  42. },
  43. async update(name, id, values) { const row = rows[name]?.find(row => row.objectId === id); assert.ok(row); Object.assign(row, structuredClone(values)); return structuredClone(row); },
  44. };
  45. const settings = { signingSecret: 'parity-test-only', functionsUrl: 'https://kangshifu.fmode.cn/api/functions' };
  46. const files = createFileAdapter({ store, crypto, Parse: {}, data, settings });
  47. const app = createApplication({ store, crypto, Parse: {}, data, settings, files });
  48. attachWorkshopHandlers(app, { store, data, files });
  49. const call = async (name, input, identity = actor) => (await app.dispatch(name, structuredClone(input), identity)).data;
  50. return { store, call, files,
  51. async start(index, workshopKey = 'parity-topic') { return call('ksfStartWorkshopSession', {
  52. requestId: `start-${index}-${workshopKey}`, workshopKey, dataMode: 'sample', groupId: `G${index % 5 + 1}`,
  53. participant: { key: `participant-${index}`, name: index < 2 ? '同名组员' : `测试组员${index}`, identityBasis: 'participant_registration' },
  54. agent: { key: agentProfile.key, name: agentProfile.name, role: 'workshop', identityBasis: 'operator_registration', runtimePlatform: 'test', channel: 'test' },
  55. conversation: { key: `conversation-${index}`, channel: 'test' },
  56. }); },
  57. async save(sessionId, item, index, version = 1, chapterId = 'analysis') {
  58. const body = `隔离测试正文 ${index},版本 ${version}。不是实际调研成果。`;
  59. return runWorkshop('workshop-save', {
  60. sessionId, taskKey: `parity-topic:${item.module}:report-${index}`, version, stageCode: item.stage,
  61. title: `隔离测试报告${index}`, artifactType: item.type, rawText: body, sourceIds: [], pendingQuestions: ['需要实际研究验证'],
  62. report: { executive: [body], chapters: [{ id: chapterId, title: '测试正文', sections: [
  63. { kind: 'para', text: body }, { kind: 'table', data: { cols: ['范围', '结论'], rows: [['隔离测试', body]] } },
  64. ] }] },
  65. }, { requestId: `save-${sessionId}-${index}-v${version}`, agentProfile, call });
  66. },
  67. async publicData() {
  68. const raw = Object.fromEntries(['project', 'artifact', 'insight', 'source', 'session', 'opportunity'].map(name => [name, []]));
  69. let cursor;
  70. do { const page = await call('ksfGetPublicBoard', { limit: 7, ...(cursor ? { cursor } : {}) }, null);
  71. for (const name of Object.keys(raw)) raw[name].push(...page[name]); cursor = page.nextCursor;
  72. } while (cursor);
  73. return raw;
  74. },
  75. };
  76. }
  77. test('Skill课表13环节的每种报告均经保存入口、精确回读、公开分页、正式分类和全文模型保留正文', async () => {
  78. assert.equal(new Set(curriculum.map(item => item.module)).size, 13);
  79. assert.equal(new Set(curriculum.map(item => item.type)).size, 7);
  80. const f = fixture(), { sessionId } = await f.start(0), saved = [];
  81. for (const [index, item] of curriculum.entries()) {
  82. const receipt = await f.save(sessionId, item, index);
  83. const read = await f.call('ksfGetRecord', { className: 'KfArtifact', objectId: receipt.artifactId });
  84. assert.equal(read.record.artifactType, item.type);
  85. assert.equal(read.record.stageCode, item.stage);
  86. assert.match(read.record.rawText, new RegExp(`隔离测试正文 ${index}`));
  87. saved.push({ item, receipt, read });
  88. }
  89. const db = buildModel(await f.publicData()), view = workshopReview(db, seedMemberGroups(db.members), { mode: 'sample' });
  90. assert.equal(db.artifacts.length, curriculum.length);
  91. assert.equal(view.rows.length, curriculum.length);
  92. assert.equal(view.hierarchy.flatMap(group => group.members.flatMap(member => member.reports)).length, curriculum.length);
  93. for (const { item, receipt, read } of saved) {
  94. const record = db.artifactById[receipt.artifactId];
  95. assert.deepEqual(record.report, read.record.report);
  96. assert.equal(record.rawText, read.record.rawText);
  97. assert.ok(entriesForType(db, TYPE_FOR_ARTIFACT[item.type]).some(entry => entry.kind === 'artifact' && entry.id === receipt.artifactId));
  98. assert.equal(reportOverview(db, receipt.artifactId).chapters[0].sections[1].data.rows.length, 1);
  99. }
  100. assert.equal(reportCenter(db).entries.filter(entry => entry.kind === 'artifact').length, curriculum.length);
  101. assert.equal(workshopReview(db, null).rows.length, 0, '样例按默认真实资料筛选不会显示,不能误报漏存');
  102. });
  103. test('35位组员共用同一智能体,5组各人的同系列报告和版本不混合,报告分类与组员归属对应', async () => {
  104. const f = fixture(), expected = new Map();
  105. for (let index = 0; index < 35; index++) {
  106. const { sessionId } = await f.start(index), item = curriculum[index % curriculum.length];
  107. await f.save(sessionId, item, 0, 1);
  108. const latest = await f.save(sessionId, item, 0, 2);
  109. expected.set(sessionId, latest.artifactId);
  110. }
  111. const db = buildModel(await f.publicData()), groups = seedMemberGroups(db.members);
  112. const latest = workshopReview(db, groups, { mode: 'sample' });
  113. assert.equal(db.members.length, 35);
  114. assert.equal(groups.groups.length, 5);
  115. assert.equal(latest.rows.length, 35);
  116. assert.equal(workshopReview(db, groups, { mode: 'sample', history: '1' }).rows.length, 70);
  117. for (const group of latest.hierarchy.filter(group => group.memberCount)) {
  118. assert.equal(group.memberCount, 7);
  119. for (const member of group.members) {
  120. assert.equal(member.reports.length, 1);
  121. const row = member.reports[0];
  122. assert.equal(row.artifact.memberId, member.id);
  123. assert.equal(row.id, expected.get(row.artifact.sessionId));
  124. assert.equal(row.artifact.version, 2);
  125. assert.equal(row.reportType, TYPE_FOR_ARTIFACT[row.artifact.type]);
  126. }
  127. }
  128. });
  129. test('七类核心交付物可承载在正式报告类型中,但章节导航与报告分类是两个维度', async () => {
  130. const f = fixture(), { sessionId } = await f.start(0);
  131. const outputs = [
  132. ['market', 'market-analysis', 'category_report'], ['social', 'social-needs', 'voc_report'],
  133. ['evidence', 'evidence-pool', 'voc_report'], ['opportunities', 'opportunity-cards', 'opportunity_card'],
  134. ['validation', 'opportunity-validation', 'assessment_report'], ['value', 'buying-points', 'concept'],
  135. ['proposal', 'product-proposal', 'concept'],
  136. ];
  137. for (const [index, [, anchor, type]] of outputs.entries()) await f.save(sessionId, { module: 'D2-08', stage: 'S5', type }, index, 1, anchor);
  138. const db = buildModel(await f.publicData());
  139. for (const [key] of outputs) assert.equal(db.artifacts.filter(artifact => workshopOutputRefs(artifact).refs.some(ref => ref.key === key)).length, 1);
  140. assert.equal(new Set(db.artifacts.map(reportCategoryOf)).size, 7, '七类核心交付物现在可分别分类');
  141. });
  142. function deliveryTask(sessionId, key = 'market', overrides = {}) {
  143. const output = CORE_DELIVERABLES.find(o => o.key === key), type = DELIVERY_KINDS[key].types[0];
  144. return { sessionId, taskKey: `parity-topic:D2-08:${key}`, version: 1, stageCode: 'S5',
  145. title: '隔离测试:' + output.name, artifactType: type, sourceIds: [], pendingQuestions: ['测试内容待实际研究验证'],
  146. rawText: '隔离测试的完整正文,不代表实际消费者研究。',
  147. report: { executive: ['隔离测试'], delivery: { schema: DELIVERY_SCHEMA, moduleId: 'D2-08', kind: key, contentStatus: 'ready', outputs: [key] },
  148. chapters: [{ id: output.anchor, title: output.name, sections: [{ kind: 'para', text: '该环节的完整测试正文。' }] }] }, ...overrides };
  149. }
  150. function verifier(f, override = {}) {
  151. return { requestId: 'new-save', invoke: (command, task, requestId) => runWorkshop(command, task, { requestId, agentProfile, call: f.call }),
  152. publicRead: p => f.call('ksfGetPublicBoard', p, null),
  153. readAttachment: async url => (await f.files.readFile({ query: Object.fromEntries(new URL(url).searchParams) }, null)).bytes,
  154. ...override };
  155. }
  156. const inlineFile = (name, contentType, bytes) => ({ name, contentType, sha256: crypto.createHash('sha256').update(bytes).digest('hex'), sizeBytes: bytes.length, dataBase64: bytes.toString('base64') });
  157. test('七类新报告经实际Skill保存、精确回读、公开读取校验,个人七项提交状态与分类一致', async () => {
  158. const f = fixture(), { sessionId } = await f.start(0);
  159. for (const { key } of CORE_DELIVERABLES) {
  160. const result = await saveAndVerifyReport(deliveryTask(sessionId, key), verifier(f, { requestId: 'verified-' + key }));
  161. assert.equal(result.recordVerified, true);assert.equal(result.publicDataVerified, true);assert.equal(result.browserVerified, false);
  162. }
  163. const db = buildModel(await f.publicData()), state = seedMemberGroups(db.members), view = workshopReview(db, state, { mode: 'sample' });
  164. const member = view.hierarchy.flatMap(g => g.members).find(m => m.reports.length);
  165. assert.equal(member.topics.length, 1);assert.deepEqual(member.topics[0].outputs.map(o => o.state), Array(7).fill('saved'));
  166. assert.equal(new Set(member.reports.map(r => r.reportType)).size, 7);
  167. for (const row of member.reports) assert.ok(entriesForType(db, row.reportType).some(e => e.id === row.id && e.href));
  168. });
  169. test('默认HTML生成后走实际保存、编号回读、公开投影和文件字节回读;员工确认保持待处理', async t => {
  170. const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ksf-html-parity-'));
  171. t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
  172. const f = fixture(), { sessionId } = await f.start(0);
  173. for (const { key } of CORE_DELIVERABLES) {
  174. const { task, htmlPath } = prepareHtmlReport(deliveryTask(sessionId, key), { directory });
  175. const receipt = await saveAndVerifyReport(task, verifier(f, { requestId: 'html-' + key }));
  176. assert.equal(receipt.filesVerified, 1);assert.equal(receipt.employeeConfirmation, 'pending');
  177. assert.equal(receipt.htmlFiles.length, 1);assert.equal(receipt.publicDataVerified, true);
  178. const file = receipt.htmlFiles[0];
  179. assert.equal(new URL(file.previewUrl).searchParams.get('artifactId'), receipt.artifactId);
  180. assert.equal(new URL(file.previewUrl).searchParams.get('preview'), '1');
  181. const actual = await f.files.readFile({ query: Object.fromEntries(new URL(file.url).searchParams) }, null);
  182. assert.ok(actual.bytes.equals(fs.readFileSync(htmlPath)));
  183. const row = await f.store.get('KfArtifact', receipt.artifactId);
  184. assert.deepEqual(row.report.chapters, task.report.chapters);
  185. assert.equal(row.meta.workshop.formalConfirmation, false);
  186. }
  187. assert.equal((await f.store.list('KfArtifact')).length, CORE_DELIVERABLES.length);
  188. });
  189. test('全部相关资料去向、正反证统计与代表原话随HTML及正文保存、回读和公开展示,分子不由引文卡数代替', async t => {
  190. const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ksf-statistics-parity-'));
  191. t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
  192. const f = fixture(), { sessionId } = await f.start(0), task = deliveryTask(sessionId, 'social');
  193. const corpus = [...Array(10)].map((_, i) => ({ id: 'test-' + i, disposition: 'included', stance: i < 4 ? 'support' : i < 6 ? 'oppose' : 'unknown', text: '合成原话' + i }));
  194. corpus.push({ id: 'test-10', disposition: 'excluded-irrelevant' }, { id: 'test-11', disposition: 'excluded-duplicate' }, { id: 'test-12', disposition: 'pending-body' });
  195. const source = await f.store.create('KfSource', { title: '隔离合成测试资料', rawText: JSON.stringify(corpus), dataMode: 'sample', meta: { ownerId: actor.id } });
  196. task.sourceIds = [source.objectId];task.report.evidence = { e: { sourceId: source.objectId, claim: '合成测试,原话不等于全量分子', quotes: [{ text: '合成原话0', ref: 'https://example.org/test' }] } };
  197. const included = corpus.filter(r => r.disposition === 'included');
  198. const summary = { id: 'statistics-summary', kind: 'table', data: { cols: [...STATISTICS_COLUMNS], rows: ['support', 'oppose', 'unknown'].map(stance => [stance,
  199. included.filter(r => r.stance === stance).length, included.length, '合成测试|13条候选:10纳入、2排除、1待核;非真实消费者资料', '全体已判定记录统计;相反材料也保留', '仅测试数据流,不形成实际研发建议', 'e']) } };
  200. task.report.chapters[0].sections.push({ kind: 'table', title: '全部相关资料处理去向', data: { cols: ['记录', '去向', '判定'], rows: corpus.map(r => [r.id, r.disposition, r.stance || '未判定']) } }, summary);
  201. task.rawText = JSON.stringify({ corpus, summary, explanation: '隔离测试;精选一条引文不代表只分析了一条资料' });
  202. const { task: prepared, htmlPath } = prepareHtmlReport(task, { directory, dataMode: 'sample' });
  203. const saved = await saveAndVerifyReport(prepared, verifier(f, { requestId: 'statistics-saved' }));
  204. const db = buildModel(await f.publicData()), actual = db.artifactById[saved.artifactId];
  205. assert.deepEqual(actual.report.chapters, task.report.chapters);
  206. assert.deepEqual(statisticsRows(actual.report.chapters[0].sections.at(-1)).map(row => row.percent), ['40.0%', '20.0%', '40.0%']);
  207. const file = await f.files.readFile({ query: Object.fromEntries(new URL(saved.htmlFiles[0].url).searchParams) }, null);
  208. assert.ok(file.bytes.equals(fs.readFileSync(htmlPath)));assert.match(file.bytes.toString(), /2排除、1待核/);
  209. assert.equal(actual.report.evidence.e.quotes.length, 1);assert.equal(saved.filesVerified, 1);
  210. assert.equal(statisticsRows(summary)[0].numerator, 4);
  211. });
  212. test('课程三维与未知项以现有报告表保存,公开数据及HTML字节回读一致,无新增数据库字段', async t => {
  213. const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ksf-course-parity-'));
  214. t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
  215. const f = fixture(), { sessionId } = await f.start(0), task = deliveryTask(sessionId, 'validation');
  216. task.taskKey = 'parity-topic:D2-05:priority-list';
  217. task.report.chapters[0].sections.push({ kind: 'table', id: 'course-priority', data: {
  218. cols: [...COURSE_ASSESSMENT_COLUMNS], rows: [['测试A', 2, 3, 2, '隔离测试依据,商业价值需本组复核', '第1'],
  219. ['测试B', 2, null, 2, '测试工艺未知,另表列出补证方式', '先补证']],
  220. } });
  221. const prepared = prepareHtmlReport(task, { directory, dataMode: 'sample' });
  222. const saved = await saveAndVerifyReport(prepared.task, verifier(f, { requestId: 'course-score-saved' }));
  223. const actual = buildModel(await f.publicData()).artifactById[saved.artifactId];
  224. assert.deepEqual(courseAssessmentRows(actual.report.chapters[0].sections.at(-1)).map(row => row.scores), [[2, 3, 2], [2, null, 2]]);
  225. const file = await f.files.readFile({ query: Object.fromEntries(new URL(saved.htmlFiles[0].url).searchParams) }, null);
  226. assert.ok(file.bytes.equals(fs.readFileSync(prepared.htmlPath)));assert.match(file.bytes.toString(), /data-course-assessment/);
  227. assert.equal(saved.publicDataVerified, true);assert.equal(saved.employeeConfirmation, 'pending');
  228. });
  229. test('HTML与图片随报告原子保存、文件字节回读、公开预览关联;直接匿名取私有文件被拒', async () => {
  230. const f = fixture(), { sessionId } = await f.start(0), task = deliveryTask(sessionId, 'proposal');
  231. task.report.attachments = [inlineFile('最终提案.html', 'text/html', Buffer.from('<!doctype html><meta charset="utf-8"><h1>隔离测试提案</h1>')),
  232. inlineFile('示意图.png', 'image/png', Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a5YkAAAAASUVORK5CYII=', 'base64'))];
  233. const result = await saveAndVerifyReport(task, verifier(f));assert.equal(result.filesVerified, 2);
  234. const replay = await saveAndVerifyReport(task, verifier(f));assert.equal(replay.artifactId, result.artifactId);
  235. assert.equal((await f.store.list('KsfFileObject')).length, 2, '同请求重放不产生重复文件');
  236. const row = await f.store.get('KfArtifact', result.artifactId);
  237. assert.equal(JSON.stringify(row.report).includes('dataBase64'), false);
  238. const files = row.report.attachments;assert.equal(files.length, 2);
  239. assert.equal(reportAttachments({ id: row.objectId, report: row.report }).length, 2);
  240. await assert.rejects(f.files.readFile({ query: { fileId: files[0].id } }, null), { code: 'AUTH_REQUIRED' });
  241. const publicFile = await f.files.readFile({ query: { artifactId: row.objectId, fileId: files[0].id } }, null);
  242. assert.equal(publicFile.name, '最终提案.html');assert.match(publicFile.bytes.toString(), /隔离测试提案/);
  243. await assert.rejects(f.files.readFile({ query: { artifactId: 'another-report', fileId: files[0].id } }, null), { code: 'REFERENCE_NOT_FOUND' });
  244. await f.store.update('KfArtifact', row.objectId, { recordState: 'archived' });
  245. await assert.rejects(f.files.readFile({ query: { artifactId: row.objectId, fileId: files[0].id } }, null), { code: 'REFERENCE_NOT_FOUND' });
  246. });
  247. test('附件内容损坏、伪造回执、跨身份取文件、空交付物均拒绝,失败不遗留报告或文件', async () => {
  248. const f = fixture(), { sessionId } = await f.start(0);
  249. const bad = deliveryTask(sessionId);bad.report.chapters[0].sections = [{ kind: 'table', data: { cols: ['标题'], rows: [[' ']] } }];
  250. await assert.rejects(f.call('ksfSaveWorkshopTask', { ...bad, requestId: 'empty-report' }), { code: 'VALIDATION_FAILED' });
  251. const attachment = inlineFile('bad.png', 'image/png', Buffer.from('not an image'));
  252. bad.report = deliveryTask(sessionId).report;bad.report.attachments = [attachment];
  253. await assert.rejects(f.call('ksfSaveWorkshopTask', { ...bad, requestId: 'bad-image' }), { code: 'VALIDATION_FAILED' });
  254. bad.report.attachments = [inlineFile('ok.txt', 'text/plain', Buffer.from('保存后也须回滚')), { ...inlineFile('bad.txt', 'text/plain', Buffer.from('正文')), sha256: 'a'.repeat(64) }];
  255. await assert.rejects(f.call('ksfSaveWorkshopTask', { ...bad, requestId: 'bad-hash' }), { code: 'UPLOAD_INCOMPLETE' });
  256. bad.report.attachments = [{ name: 'fake.txt', contentType: 'text/plain', sizeBytes: 1, sha256: 'a'.repeat(64), receipt: { fileId: 'fake' } }];
  257. await assert.rejects(f.call('ksfSaveWorkshopTask', { ...bad, requestId: 'fake-file' }), { code: 'UPLOAD_INCOMPLETE' });
  258. assert.equal((await f.store.list('KfArtifact')).length, 0);assert.equal((await f.store.list('KsfFileObject')).length, 0);
  259. assert.equal((await f.store.list('KfSource')).length, 0);
  260. const good = deliveryTask(sessionId);good.report.attachments = [inlineFile('private.txt', 'text/plain', Buffer.from('私有原件'))];
  261. const saved = await saveAndVerifyReport(good, verifier(f));
  262. const [file] = await f.store.list('KsfFileObject');
  263. await assert.rejects(f.files.readFile({ query: { fileId: file.objectId } }, { id: 'other', kind: 'service', scopes: ['data:read'] }), { code: 'FORBIDDEN' });
  264. await f.store.update('KfArtifact', saved.artifactId, { meta: {}, dashboardState: 'draft' });
  265. await assert.rejects(f.files.readFile({ query: { artifactId: saved.artifactId, fileId: file.objectId } }, null), { code: 'REFERENCE_NOT_FOUND' });
  266. });
  267. test('仅有boardVisible回执不算验证完成;回读不符保留编号且不重试保存,旧服务在写前停止', async () => {
  268. const f = fixture(), { sessionId } = await f.start(0), task = deliveryTask(sessionId);
  269. let saves = 0;
  270. const base = verifier(f);
  271. await assert.rejects(saveAndVerifyReport(task, { ...base, invoke: async (command, input, id) => {
  272. if (command === 'workshop-save') saves++;
  273. return base.invoke(command, input, id);
  274. }, publicRead: async () => ({ artifact: [], nextCursor: null }) }), error => {
  275. assert.equal(error.code, 'REPORT_PUBLIC_READBACK_MISMATCH');assert.ok(error.partial.receipt.artifactId);assert.equal(error.partial.automaticRetry, false);return true;
  276. });
  277. assert.equal(saves, 1);assert.equal((await f.store.list('KfArtifact')).length, 1);
  278. await assert.rejects(saveAndVerifyReport(task, { ...base, invoke: async command => {
  279. assert.equal(command, 'workshop-contract');return { workshop: { version: 'ksf.workshop.v1' } };
  280. } }), { code: 'REPORT_DELIVERY_CAPABILITY_UNAVAILABLE' });
  281. });
  282. test('个人交付核对按课题、资料模式、最新版本隔离;草稿不计已交,缺正文、旧成果待定位均明示', async () => {
  283. const f = fixture(), first = await f.start(0), other = await f.start(0, 'another-topic');
  284. await saveAndVerifyReport(deliveryTask(first.sessionId), verifier(f));
  285. const draft = deliveryTask(first.sessionId);draft.version = 2;draft.report.delivery.contentStatus = 'draft';
  286. await saveAndVerifyReport(draft, verifier(f, { requestId: 'save-draft' }));
  287. await saveAndVerifyReport(deliveryTask(other.sessionId, 'proposal', { taskKey: 'another-topic:D2-08:proposal' }), verifier(f, { requestId: 'other-topic' }));
  288. const raw = await f.publicData();
  289. const db = buildModel(raw), view = workshopReview(db, seedMemberGroups(db.members), { mode: 'all', history: '1', reportType: 'value' });
  290. const topics = view.hierarchy.flatMap(g => g.members.flatMap(m => m.topics));
  291. const a = topics.find(t => t.workshopKey === 'parity-topic'), b = topics.find(t => t.workshopKey === 'another-topic');
  292. assert.equal(a.outputs.find(o => o.key === 'market').state, 'draft');
  293. assert.equal(a.outputs.find(o => o.key === 'proposal').state, 'missing');
  294. assert.equal(b.outputs.find(o => o.key === 'proposal').state, 'saved');
  295. assert.equal(b.outputs.find(o => o.key === 'market').state, 'missing');
  296. const legacy = structuredClone(raw.artifact[0]);legacy.objectId = 'legacy-empty';legacy.reportSeriesKey = 'legacy';legacy.version = 1;
  297. legacy.report = { chapters: [{ id: 'evidence-pool', sections: [] }] };legacy.rawText = '';
  298. raw.artifact.push(legacy);
  299. const db2 = buildModel(raw), v2 = workshopReview(db2, seedMemberGroups(db2.members), { mode: 'sample' });
  300. assert.equal(v2.hierarchy.flatMap(g => g.members.flatMap(m => m.topics)).find(t => t.workshopKey === 'parity-topic').outputs.find(o => o.key === 'evidence').state, 'missing-body');
  301. });
  302. test('报告中心仅计成果报告;旧原文可读、无正文不伪造,独立机会及资料分开统计', () => {
  303. const db = buildModel({ artifact: [{ objectId: 'text', title: '旧报告', artifactType: 'voc_report', rawText: '已保存的真实原文。' },
  304. { objectId: 'empty', title: '缺正文', artifactType: 'concept' }], opportunity: [{ objectId: 'opp', title: '独立机会' }],
  305. source: [{ objectId: 'category-source', sourceType: 'category' }] });
  306. const center = reportCenter(db);assert.equal(center.total, 2);assert.equal(center.separateObjects.opportunities, 1);
  307. assert.ok(center.entries.every(e => e.kind === 'artifact'));
  308. assert.equal(center.entries.find(e => e.id === 'text').href, '/report/text');
  309. const read = reportOverview(db, 'text');assert.equal(read.bodyStatus, 'text');assert.match(read.report.subtitle, /尚非结构化/);
  310. assert.equal(read.chapters[0].sections[0].text, '已保存的真实原文。');assert.equal(db.artifactById.text.report, null);
  311. assert.equal(reportOverview(db, 'empty'), null);
  312. });
  313. test('已有上传回执可关联报告;跨身份回执及超限附件在写入前拒绝', async () => {
  314. const f = fixture(), { sessionId } = await f.start(0), bytes = Buffer.from('已通过文件通道的正文。');
  315. const input = inlineFile('来源文件.txt', 'text/plain', bytes);
  316. const prepared = await f.call('ksfPrepareFileUpload', { requestId: 'prepare-report-file', fileName: input.name,
  317. contentType: input.contentType, sizeBytes: input.sizeBytes, sha256: input.sha256 }, { ...actor, scopes: [...actor.scopes,'source:write'] });
  318. const receipt = await f.files.upload({ query: Object.fromEntries(new URL(prepared.upload.url).searchParams),
  319. headers: { 'x-ksf-upload-token': prepared.upload.headers['X-Ksf-Upload-Token'] }, body: bytes });
  320. const attachment = { ...input, receipt };delete attachment.dataBase64;
  321. await assert.rejects(f.store.transaction(s => f.files.saveReportAttachments(s, [attachment], { ...actor, id: 'other-service' })), { code: 'UPLOAD_INCOMPLETE' });
  322. const task = deliveryTask(sessionId);task.report.attachments = [attachment];
  323. const saved = await saveAndVerifyReport(task, verifier(f));assert.equal(saved.filesVerified, 1);
  324. const large = { ...attachment, sizeBytes: 1024 * 1024 + 1 };
  325. await assert.rejects(f.store.transaction(s => f.files.saveReportAttachments(s, [large], actor)), { code: 'VALIDATION_FAILED' });
  326. });