board-actions.test.mjs 5.4 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667
  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 } from '../src/core.mjs';
  6. import { attachBoardActionHandlers } from '../src/board-actions.mjs';
  7. const data = JSON.parse(fs.readFileSync(new URL('../references/runtime-data.json', import.meta.url)));
  8. function fixture() {
  9. let rows = {}, seq = 0;
  10. const clone = (x) => structuredClone(x);
  11. const store = {
  12. async transaction(fn) { const before = clone(rows); try { return await fn(store); } catch (error) { rows = before; throw error; } },
  13. async lock() {},
  14. async get(name, id) { return clone(rows[name]?.find((row) => row.objectId === id) || null); },
  15. async list(name, { where = {} } = {}) { return clone((rows[name] || []).filter((row) => Object.entries(where).every(([key, value]) => row[key] === value))); },
  16. async create(name, value) { const row = { ...clone(value), objectId: 'test' + ++seq, updatedAt: new Date(1800000000000 + seq).toISOString() }; (rows[name] ||= []).push(row); return clone(row); },
  17. async update(name, id, changes) { const row = rows[name].find((row) => row.objectId === id); Object.assign(row, clone(changes), { updatedAt: new Date(1800000000000 + ++seq).toISOString() }); return clone(row); },
  18. };
  19. const app = createApplication({ store, crypto, Parse: {}, data, settings: { signingSecret: 'test-only' }, files: {} });
  20. attachBoardActionHandlers(app, { store });
  21. return { store, app };
  22. }
  23. async function card(f, extra = {}) { return f.store.create('KfOpportunity', { title: 'test', status: 'candidate', externalKey: 'workshop:opportunity:test', schemaVersion: 'ksf.v0.7', linkedProject: 'kept-project', linkedArtifacts: ['kept-report'], ...extra }); }
  24. const params = (row, extra = {}) => ({ requestId: 'request-' + crypto.randomUUID(), opportunityId: row.objectId, expectedUpdatedAt: row.updatedAt, by: '自报姓名', ...extra });
  25. test('匿名三状态持久化,保留互链与旧决策,不产生项目或企业批准', async () => {
  26. const f = fixture(); let row = await card(f);
  27. for (const status of ['approved', 'rejected', 'candidate']) {
  28. const result = (await f.app.dispatch('ksfSetPublicOpportunityStatus', params(row, { status }), null)).data;
  29. assert.equal(result.opportunity.status, status); assert.equal(result.identityVerified, false); assert.equal(result.projectCreated, false); assert.equal(result.enterpriseApproval, false);
  30. assert.equal(result.opportunity.decision.via, 'public_board'); assert.equal(result.opportunity.decision.identityVerified, false);
  31. row = await f.store.get('KfOpportunity', row.objectId); assert.equal(row.status, status);
  32. assert.equal(row.linkedProject, 'kept-project'); assert.deepEqual(row.linkedArtifacts, ['kept-report']);
  33. }
  34. assert.equal(row.decisionLog.length, 3); assert.equal((await f.store.list('KfProject')).length, 0);
  35. assert.equal((await f.store.list('KsfOperation')).length, 3);
  36. });
  37. test('重复请求复用回执,改内容复用请求键、过期版本均不写入', async () => {
  38. const f = fixture(), row = await card(f), p = params(row, { status: 'approved' });
  39. const first = await f.app.dispatch('ksfSetPublicOpportunityStatus', p, null);
  40. assert.deepEqual((await f.app.dispatch('ksfSetPublicOpportunityStatus', p, null)).data, first.data);
  41. await assert.rejects(() => f.app.dispatch('ksfSetPublicOpportunityStatus', { ...p, status: 'rejected' }, null), { code: 'IDEMPOTENCY_CONFLICT' });
  42. await assert.rejects(() => f.app.dispatch('ksfSetPublicOpportunityStatus', params(row, { status: 'rejected' }), null), { code: 'VERSION_CONFLICT' });
  43. assert.equal((await f.store.list('KsfOperation')).length, 1);
  44. assert.equal((await f.store.get('KfOpportunity', row.objectId)).decisionLog.length, 1);
  45. });
  46. test('私有/归档/未知记录、越权字段与坏状态被拒绝,回执不泄露私有字段', async () => {
  47. const f = fixture();
  48. for (const extra of [{ externalKey: 'private:test' }, { recordState: 'archived' }]) {
  49. const row = await card(f, extra);
  50. await assert.rejects(() => f.app.dispatch('ksfSetPublicOpportunityStatus', params(row, { status: 'approved' }), null), { code: 'REFERENCE_NOT_FOUND' });
  51. }
  52. const row = await card(f, { privateInfo: 'must-not-leak' });
  53. for (const extra of [{ status: 'completed' }, { status: 'approved', linkedProject: 'hack' }, { status: 'approved', expectedUpdatedAt: null }]) {
  54. await assert.rejects(() => f.app.dispatch('ksfSetPublicOpportunityStatus', params(row, extra), null), { code: 'VALIDATION_FAILED' });
  55. }
  56. const result = await f.app.dispatch('ksfSetPublicOpportunityStatus', params(row, { status: 'approved', by: '' }), null);
  57. assert.equal(result.data.opportunity.privateInfo, undefined); assert.equal(result.data.opportunity.decision.by, '匿名访客');
  58. });
  59. test('匿名评分校验与同署名覆盖,保留其他评分', async () => {
  60. const f = fixture(); let row = await card(f, { scores: [{ user: '其他人', score: 60 }] });
  61. for (const score of [70, 80]) {
  62. await f.app.dispatch('ksfRatePublicOpportunity', params(row, { score, comment: 'test' }), null);
  63. row = await f.store.get('KfOpportunity', row.objectId);
  64. }
  65. assert.equal(row.scores.length, 2); assert.equal(row.scores.find((s) => s.user === '自报姓名').score, 80);
  66. for (const extra of [{ score: 101 }, { score: '80' }, { score: 50, by: '' }]) await assert.rejects(() => f.app.dispatch('ksfRatePublicOpportunity', params(row, extra), null), { code: 'VALIDATION_FAILED' });
  67. });