@bongos/core 1.24.14 → 1.24.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +296 -136
- package/.claude/skills/blocker-review/SKILL.md +2 -0
- package/.claude/skills/session-board/SKILL.md +29 -0
- package/.claude/skills/strand-fix/SKILL.md +1 -1
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +14 -0
- package/clients/bongos-client/index.cjs +14 -0
- package/clients/bongos-client/index.d.ts +23 -2
- package/clients/bongos-client/index.mjs +14 -0
- package/docs/adr/0385-money-lives-with-a-processor-credits-stay-a-project-meter.md +4 -0
- package/docs/api/openapi.json +369 -6
- package/docs/api-reference.md +19 -4
- package/docs/architecture.md +6 -0
- package/docs/copy-inventory.md +138 -123
- package/docs/copy-registry.json +296 -171
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +2 -2
- package/docs/page-inventory.json +6 -1
- package/docs/page-readings.json +1040 -1021
- package/migrations/core_289_task_short_name.sql +34 -0
- package/modules/ai-studio/db.js +10 -0
- package/modules/ai-studio/gemini.js +73 -5
- package/modules/ai-studio/openrouter.js +17 -2
- package/modules/ai-studio/routes/ai-studio.js +7 -1
- package/modules/ai-studio/studio.js +8 -0
- package/modules/builder-settings/checkout-freshness.js +71 -0
- package/modules/builder-settings/routes/settings.js +26 -0
- package/modules/builder-settings/settings.js +2 -0
- package/modules/economy/migrations/economy_002_project_account.sql +83 -0
- package/modules/economy/module.json +1 -1
- package/modules/economy/project-account.js +166 -0
- package/modules/economy/routes/project-account.js +40 -0
- package/modules/hall-ui/public/ai-studio.css +3 -0
- package/modules/hall-ui/public/ai-studio.html +2 -1
- package/modules/hall-ui/public/ai-studio.js +72 -1
- package/modules/hall-ui/public/atlas.html +1 -1
- package/modules/hall-ui/public/blockers.html +1 -1
- package/modules/hall-ui/public/board-room.html +1 -1
- package/modules/hall-ui/public/city.html +1 -1
- package/modules/hall-ui/public/collab.html +1 -1
- package/modules/hall-ui/public/commands.html +1 -1
- package/modules/hall-ui/public/copy-desk.html +1 -1
- package/modules/hall-ui/public/deploy.html +1 -1
- package/modules/hall-ui/public/diagrams.html +1 -1
- package/modules/hall-ui/public/drachmae.html +1 -1
- package/modules/hall-ui/public/fleet.html +1 -1
- package/modules/hall-ui/public/gate.html +1 -1
- package/modules/hall-ui/public/github-chip.js +85 -0
- package/modules/hall-ui/public/goal-map.html +1 -1
- package/modules/hall-ui/public/goals.html +1 -1
- package/modules/hall-ui/public/government.html +1 -1
- package/modules/hall-ui/public/idea.html +1 -1
- package/modules/hall-ui/public/ideas.html +1 -1
- package/modules/hall-ui/public/index.html +1 -1
- package/modules/hall-ui/public/market-watch-item.html +2 -2
- package/modules/hall-ui/public/market-watch-item.js +10 -3
- package/modules/hall-ui/public/market-watch-item.states.json +41 -0
- package/modules/hall-ui/public/market-watch-lib.js +36 -5
- package/modules/hall-ui/public/market-watch-rules.html +1 -1
- package/modules/hall-ui/public/market-watch-settings.html +1 -1
- package/modules/hall-ui/public/market-watch.css +5 -0
- package/modules/hall-ui/public/market-watch.html +2 -2
- package/modules/hall-ui/public/modules.html +1 -1
- package/modules/hall-ui/public/my-agents.html +1 -1
- package/modules/hall-ui/public/my-agents.js +3 -0
- package/modules/hall-ui/public/primer.html +1 -1
- package/modules/hall-ui/public/profile.html +1 -1
- package/modules/hall-ui/public/project-settings.html +1 -1
- package/modules/hall-ui/public/ranks.html +1 -1
- package/modules/hall-ui/public/roadmap.html +1 -1
- package/modules/hall-ui/public/roster.html +1 -1
- package/modules/hall-ui/public/sessions.html +1 -1
- package/modules/hall-ui/public/settings.html +1 -1
- package/modules/hall-ui/public/shell.js +22 -22
- package/modules/hall-ui/public/street.html +1 -1
- package/modules/hall-ui/public/studio.html +1 -1
- package/modules/hall-ui/public/style.css +28 -0
- package/modules/hall-ui/public/task.html +1 -1
- package/modules/hall-ui/public/thinking.html +1 -1
- package/modules/hall-ui/public/tweak-editor.html +1 -1
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/work.html +1 -1
- package/modules/ideas/blockers.js +13 -7
- package/modules/lifecycle/db-claim-reads.js +1 -1
- package/modules/lifecycle/db-ship.js +2 -2
- package/modules/lifecycle/db-tasks.js +4 -4
- package/modules/lifecycle/migrations/lifecycle_019_pay_type_gates.sql +50 -0
- package/modules/lifecycle/module.json +1 -0
- package/modules/lifecycle/pay-type-gates.js +180 -0
- package/modules/lifecycle/publish-reconciler.js +31 -1
- package/modules/lifecycle/rebase-gate.js +3 -2
- package/modules/lifecycle/routes/claims.js +3 -2
- package/modules/lifecycle/routes/pay-type-gates.js +59 -0
- package/modules/lifecycle/routes/project-settings.js +4 -0
- package/modules/lifecycle/routes/task-write-routes.js +43 -0
- package/modules/lifecycle/ship-card.js +4 -1
- package/modules/lifecycle/task-name-db.js +55 -0
- package/modules/lifecycle/task-name.js +140 -0
- package/modules/market-watch/db.js +10 -1
- package/modules/market-watch/migrations/market_watch_004_entry_corrections.sql +20 -0
- package/modules/market-watch/pages.js +20 -1
- package/modules/market-watch/proposals.js +57 -7
- package/modules/market-watch/routes/pages.js +2 -0
- package/modules/market-watch/routes/rules.js +4 -0
- package/modules/platform-identity/terms-acceptance.js +9 -9
- package/modules/public-landing/public/account-google.states.json +14 -0
- package/modules/public-landing/public/assets/cosmos.css +12 -0
- package/modules/public-landing/public/assets/hub-bar.js +44 -1
- package/modules/public-landing/public/privacy.html +5 -1
- package/modules/public-landing/public/projects-google.states.json +14 -0
- package/modules/public-landing/public/projects.html +39 -0
- package/modules/public-landing/public/terms.html +12 -2
- package/modules/ui-design/kit/fixtures/me-google.json +10 -0
- package/modules/ui-design/kit/fixtures/me-sign-in-methods-google.json +4 -0
- package/modules/ui-design/kit/serve.js +7 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +49 -1
- package/scripts/bongos/claim.js +4 -2
- package/scripts/bongos/land-watch.js +1 -0
- package/scripts/bongos/report-checkout.js +30 -0
- package/scripts/bongos/run-unit-tests.js +11 -0
- package/scripts/bongos/sessions-board.js +264 -0
- package/scripts/bongos/ship-finish.js +1 -0
- package/scripts/bongos/ship-flow.js +5 -2
- package/scripts/bongos/strand-lease.js +5 -3
- package/scripts/bongos/strand-watch.js +38 -7
- package/scripts/gds/report-checkout.js +7 -0
- package/scripts/gds/sessions-board.js +7 -0
- package/src/bongos/agent-standing-rules.js +13 -3
- package/src/bongos/routes/me.js +7 -1
- package/src/module-api.js +1 -1
- package/tests/agent_standing_rules.mjs +18 -1
- package/tests/ai_studio_billing.mjs +13 -0
- package/tests/ai_studio_gemini_models.mjs +81 -0
- package/tests/ai_studio_requests.mjs +24 -0
- package/tests/blocker_detail_hydration.mjs +5 -3
- package/tests/blocker_tasks_named_db.mjs +74 -0
- package/tests/claim_gate_ci_unblock.mjs +1 -1
- package/tests/github_chip.mjs +217 -0
- package/tests/hall_ai_studio.mjs +129 -5
- package/tests/host_topology_skips.mjs +1 -0
- package/tests/hub_account_page.mjs +3 -2
- package/tests/landing_legal_pages.mjs +3 -2
- package/tests/market_watch_data_db.mjs +6 -0
- package/tests/market_watch_pages.mjs +87 -2
- package/tests/market_watch_rules.mjs +101 -5
- package/tests/market_watch_rules_db.mjs +42 -0
- package/tests/nav_permission_atoms.mjs +1 -1
- package/tests/pay_terms.mjs +8 -0
- package/tests/pay_type_gates.mjs +234 -0
- package/tests/pay_type_gates_db.mjs +84 -0
- package/tests/project_account.mjs +125 -0
- package/tests/project_account_db.mjs +97 -0
- package/tests/self_healing_start.mjs +162 -0
- package/tests/sessions_board.mjs +153 -0
- package/tests/stop_refcheck.mjs +40 -0
- package/tests/task_name.mjs +237 -0
- package/tests/task_name_db.mjs +79 -0
- package/tests/terms_acceptance.mjs +25 -3
- package/tests/terms_grandfather_db.mjs +11 -4
|
@@ -45,7 +45,7 @@ const V1 = {
|
|
|
45
45
|
|
|
46
46
|
// ── the in-memory database ─────────────────────────────────────────────────────
|
|
47
47
|
function makeDb() {
|
|
48
|
-
const t = { versions: [], proposals: [], items: [], scores: [], runs: [{ id: 50, project: P, status: 'done' }], seq: 100 };
|
|
48
|
+
const t = { versions: [], proposals: [], items: [], scores: [], entries: [], runs: [{ id: 50, project: P, status: 'done' }], seq: 100 };
|
|
49
49
|
const nextId = () => ++t.seq;
|
|
50
50
|
t.versions.push({ id: nextId(), project: P, ...JSON.parse(JSON.stringify(V1)), approved_at: new Date('2026-10-28'), created_at: new Date('2026-10-28') });
|
|
51
51
|
t.items.push({ id: nextId(), project: P, kind: 'competitor', name: 'OpenHands', url: 'https://openhands.dev', status: 'active', why_tracked: '' });
|
|
@@ -76,7 +76,11 @@ function makeDb() {
|
|
|
76
76
|
case 'mw:proposal-lock':
|
|
77
77
|
case 'mw:proposal-get-open': {
|
|
78
78
|
const p = prop(a, b);
|
|
79
|
-
|
|
79
|
+
if (!p || !(tag === 'mw:proposal-lock' || p.status === 'open')) return one(null);
|
|
80
|
+
// Hand back only the columns the real SELECT names (task 1005026): returning the
|
|
81
|
+
// whole row hid a lock that never read log_entry_id, so every correction failed.
|
|
82
|
+
const cols = String(sql).match(/SELECT\s+([\s\S]+?)\s+FROM/)[1].split(',').map((x) => x.trim());
|
|
83
|
+
return one(Object.fromEntries(cols.map((k) => [k, p[k]])));
|
|
80
84
|
}
|
|
81
85
|
case 'mw:rules-max-version':
|
|
82
86
|
return one({ v: Math.max(0, ...t.versions.filter((v) => v.project === a).map((v) => v.version)) });
|
|
@@ -131,6 +135,18 @@ function makeDb() {
|
|
|
131
135
|
Object.assign(p, { status: c, decided_by: d, decided_at: new Date(), rule_version_id: e ?? p.rule_version_id, item_id: f ?? p.item_id, payload: { ...p.payload, ...JSON.parse(g) } });
|
|
132
136
|
return one(p);
|
|
133
137
|
}
|
|
138
|
+
case 'mw:entry-lock': {
|
|
139
|
+
// the entry, only through an item of this project, and only the proposal's item
|
|
140
|
+
const e = t.entries.find((x) => String(x.id) === String(a) && String(x.item_id) === String(c));
|
|
141
|
+
const it = e && t.items.find((x) => x.id === e.item_id && x.project === b);
|
|
142
|
+
return one(e && it ? { ...e } : null);
|
|
143
|
+
}
|
|
144
|
+
case 'mw:entry-correct': {
|
|
145
|
+
const e = t.entries.find((x) => String(x.id) === String(a));
|
|
146
|
+
if (!e || t.entries.some((o) => o.id !== e.id && o.item_id === e.item_id && o.run_id === e.run_id && o.takeaway === b)) return one(null);
|
|
147
|
+
e.takeaway = b;
|
|
148
|
+
return one({ id: e.id });
|
|
149
|
+
}
|
|
134
150
|
case 'mw:proposal-at-vote': {
|
|
135
151
|
const p = prop(a, b);
|
|
136
152
|
if (p && p.status === 'open') p.payload = { ...p.payload, matter_id: c };
|
|
@@ -282,15 +298,71 @@ test('an approved add-item proposal creates the tracked item; the run\'s and the
|
|
|
282
298
|
assert.equal(proposals.itemFromPayload({}), null);
|
|
283
299
|
});
|
|
284
300
|
|
|
285
|
-
test('remove_item takes the item off the watch
|
|
301
|
+
test('remove_item takes the item off the watch', async () => {
|
|
286
302
|
const { t, pool, withTx } = makeDb();
|
|
287
303
|
const itemId = t.items[0].id;
|
|
288
304
|
const filed = await proposals.createProposal(pool, { project: P, step: 'names', change: { op: 'remove', item_id: itemId }, reason: 'not a rival', proposedBy: ADA });
|
|
289
305
|
await proposals.decideProposal({ project: P, id: filed.proposal.id, decision: 'approve', decidedBy: OWNER, withTx });
|
|
290
306
|
assert.equal(t.items[0].status, 'removed');
|
|
291
|
-
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// task 1005016: an approved correct_entry corrects the entry it names.
|
|
310
|
+
function withEntries() {
|
|
311
|
+
const d = makeDb();
|
|
312
|
+
const itemId = d.t.items[0].id;
|
|
313
|
+
d.t.items.push({ id: 300, project: P, kind: 'competitor', name: 'Other', url: 'https://other.dev', status: 'active' });
|
|
314
|
+
d.t.items.push({ id: 400, project: 'https://other.example', kind: 'competitor', name: 'Theirs', url: 'https://theirs.dev', status: 'active' });
|
|
315
|
+
d.t.entries.push(
|
|
316
|
+
{ id: 7, item_id: itemId, run_id: 50, takeaway: 'OpenHands raised a Series B.' },
|
|
317
|
+
{ id: 8, item_id: itemId, run_id: 50, takeaway: 'OpenHands shut down.' },
|
|
318
|
+
{ id: 9, item_id: 300, run_id: 50, takeaway: 'Another item\'s entry.' },
|
|
319
|
+
{ id: 10, item_id: 400, run_id: 50, takeaway: 'Another project\'s entry.' },
|
|
320
|
+
);
|
|
321
|
+
const file = (id, payload, { item = itemId, entry = 7 } = {}) => d.t.proposals.push({
|
|
322
|
+
id, project: P, kind: 'correct_entry', origin: 'member', item_id: item, log_entry_id: entry, payload, reason: 'their post says seed', status: 'open', proposed_by: ADA,
|
|
323
|
+
});
|
|
324
|
+
return { ...d, itemId, file };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
test('CORRECT AN ENTRY: approving rewrites the entry, and keeps who, when, why and the old text', async () => {
|
|
328
|
+
const { t, withTx, file } = withEntries();
|
|
329
|
+
file(900, { takeaway: 'OpenHands raised a seed round, not a Series B.' });
|
|
292
330
|
const out = await proposals.decideProposal({ project: P, id: 900, decision: 'approve', decidedBy: OWNER, withTx });
|
|
293
|
-
assert.deepEqual([out.status, out.applied], ['
|
|
331
|
+
assert.deepEqual([out.ok, out.status, out.applied, out.log_entry_id], [true, 'applied', true, 7]);
|
|
332
|
+
assert.equal(t.entries.find((e) => e.id === 7).takeaway, 'OpenHands raised a seed round, not a Series B.', 'the entry itself now reads corrected');
|
|
333
|
+
const p = t.proposals.find((x) => x.id === 900);
|
|
334
|
+
assert.equal(p.status, 'applied');
|
|
335
|
+
assert.equal(p.proposed_by, ADA, 'who proposed it');
|
|
336
|
+
assert.equal(p.decided_by, OWNER, 'who approved it');
|
|
337
|
+
assert.ok(p.decided_at instanceof Date, 'when');
|
|
338
|
+
assert.equal(p.reason, 'their post says seed', 'why');
|
|
339
|
+
assert.equal(p.payload.was, 'OpenHands raised a Series B.', 'the text it replaced is never lost');
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test('CORRECT AN ENTRY: a wrong flag leaves the text as found and applies the flag', async () => {
|
|
343
|
+
const { t, withTx, file } = withEntries();
|
|
344
|
+
file(901, { wrong: true }, { entry: 8 });
|
|
345
|
+
const out = await proposals.decideProposal({ project: P, id: 901, decision: 'approve', decidedBy: OWNER, withTx });
|
|
346
|
+
assert.deepEqual([out.status, out.applied], ['applied', true]);
|
|
347
|
+
assert.equal(t.entries.find((e) => e.id === 8).takeaway, 'OpenHands shut down.');
|
|
348
|
+
assert.deepEqual([t.proposals.find((x) => x.id === 901).payload.wrong, t.proposals.find((x) => x.id === 901).payload.was], [true, 'OpenHands shut down.']);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test('CORRECT AN ENTRY: refused, and left open, when it names nothing it may change', async () => {
|
|
352
|
+
const { t, withTx, file } = withEntries();
|
|
353
|
+
const decide = (id) => proposals.decideProposal({ project: P, id, decision: 'approve', decidedBy: OWNER, withTx });
|
|
354
|
+
file(902, {}); // filed before task 1005016: the reason was the correction
|
|
355
|
+
assert.equal((await decide(902)).reason, 'no_correction');
|
|
356
|
+
file(903, { takeaway: 'x' }, { entry: 9 }); // another item's entry
|
|
357
|
+
assert.equal((await decide(903)).reason, 'entry_gone');
|
|
358
|
+
file(904, { takeaway: 'x' }, { item: 400, entry: 10 }); // another project's item and entry
|
|
359
|
+
assert.equal((await decide(904)).reason, 'entry_gone');
|
|
360
|
+
file(905, { takeaway: 'OpenHands shut down.' }); // would duplicate entry 8 in the same run
|
|
361
|
+
assert.equal((await decide(905)).reason, 'same_as_another_entry');
|
|
362
|
+
for (const id of [902, 903, 904, 905]) assert.equal(t.proposals.find((x) => x.id === id).status, 'open', `proposal ${id} stays open`);
|
|
363
|
+
assert.deepEqual(t.entries.map((e) => e.takeaway), ['OpenHands raised a Series B.', 'OpenHands shut down.', 'Another item\'s entry.', 'Another project\'s entry.'], 'no entry changed');
|
|
364
|
+
// a decline of the old kind still works
|
|
365
|
+
assert.equal((await proposals.decideProposal({ project: P, id: 902, decision: 'decline', decidedBy: OWNER, withTx })).status, 'rejected');
|
|
294
366
|
});
|
|
295
367
|
|
|
296
368
|
test('a project cannot decide another project\'s proposal', async () => {
|
|
@@ -422,6 +494,24 @@ test('OWNER ONLY: a member who is not the owner gets 403 and nothing changes; th
|
|
|
422
494
|
assert.equal((await call(OWNER, 'POST', `/market-watch/proposals/${id}/decide`, { decision: 'maybe' })).status, 400);
|
|
423
495
|
});
|
|
424
496
|
|
|
497
|
+
test('OWNER ONLY, correcting an entry: a member gets 403 and the entry is untouched; the owner\'s approval corrects it', async () => {
|
|
498
|
+
const itemId = RT.t.items[0].id;
|
|
499
|
+
RT.t.entries.push({ id: 70, item_id: itemId, run_id: 50, takeaway: 'Old words.' });
|
|
500
|
+
RT.t.proposals.push({ id: 970, project: P, kind: 'correct_entry', origin: 'member', item_id: itemId, log_entry_id: 70, payload: { takeaway: 'New words.' }, reason: 'r', status: 'open', proposed_by: ADA });
|
|
501
|
+
const denied = await call(ADA, 'POST', '/market-watch/proposals/970/decide', { decision: 'approve' });
|
|
502
|
+
assert.deepEqual([denied.status, denied.code], [403, 'owner_only']);
|
|
503
|
+
assert.equal(RT.t.entries.find((e) => e.id === 70).takeaway, 'Old words.');
|
|
504
|
+
assert.equal(RT.t.proposals.find((p) => p.id === 970).status, 'open');
|
|
505
|
+
assert.equal((await call(null, 'POST', '/market-watch/proposals/970/decide', { decision: 'approve' })).status, 401);
|
|
506
|
+
const ok = await call(OWNER, 'POST', '/market-watch/proposals/970/decide', { decision: 'approve' });
|
|
507
|
+
assert.equal(ok.status, 200, JSON.stringify(ok.json));
|
|
508
|
+
assert.equal(RT.t.entries.find((e) => e.id === 70).takeaway, 'New words.');
|
|
509
|
+
RT.t.proposals.push({ id: 971, project: P, kind: 'correct_entry', origin: 'member', item_id: itemId, log_entry_id: 70, payload: {}, reason: 'r', status: 'open', proposed_by: ADA });
|
|
510
|
+
const old = await call(OWNER, 'POST', '/market-watch/proposals/971/decide', { decision: 'approve' });
|
|
511
|
+
assert.deepEqual([old.status, old.code], [409, 'no_correction'], 'an old proposal with no correction says so in words');
|
|
512
|
+
assert.match(old.json.error.message, /does not say how the entry should read/);
|
|
513
|
+
});
|
|
514
|
+
|
|
425
515
|
test('a government that hands Rules to a sitting: the owner cannot decide alone (409), the page offers the vote', async () => {
|
|
426
516
|
const filed = await proposals.createProposal(RT.pool, { project: P, step: 'brief', change: { op: 'set', text: 'Five lines.' }, reason: 'r', proposedBy: ADA });
|
|
427
517
|
const sitting = {
|
|
@@ -553,4 +643,10 @@ test('the payloads the item page files are the ones an approval applies', () =>
|
|
|
553
643
|
assert.ok(n.ok, body.kind);
|
|
554
644
|
assert.ok(proposals.itemChange(n.value.kind, n.value.payload), `${body.kind}: pages.js writes a payload proposals.js applies`);
|
|
555
645
|
}
|
|
646
|
+
// correct_entry (task 1005016): both shapes the item page files are ones an approval applies
|
|
647
|
+
const fix = pages.normalizeProposal({ kind: 'correct_entry', log_entry_id: '7', takeaway: ' seed,\nnot B ', reason: 'r' }, { item });
|
|
648
|
+
assert.deepEqual(proposals.entryCorrection(fix.value.payload), { takeaway: 'seed, not B' });
|
|
649
|
+
const wrong = pages.normalizeProposal({ kind: 'correct_entry', log_entry_id: '7', wrong: true, reason: 'r' }, { item });
|
|
650
|
+
assert.deepEqual(proposals.entryCorrection(wrong.value.payload), { wrong: true, takeaway: null });
|
|
651
|
+
assert.equal(proposals.entryCorrection({}), null);
|
|
556
652
|
});
|
|
@@ -27,11 +27,14 @@ const seed = require('../modules/market-watch/seed.js');
|
|
|
27
27
|
const store = require('../modules/market-watch/store.js');
|
|
28
28
|
const proposals = require('../modules/market-watch/proposals.js');
|
|
29
29
|
const view = require('../modules/market-watch/rules-view.js');
|
|
30
|
+
const pages = require('../modules/market-watch/pages.js');
|
|
31
|
+
const db = require('../modules/market-watch/db.js');
|
|
30
32
|
const research = require('../modules/market-watch/research.js');
|
|
31
33
|
const pricing = require('../src/bongos/llm-pricing.js');
|
|
32
34
|
|
|
33
35
|
const MIGRATION = readFileSync(new URL('../modules/market-watch/migrations/market_watch_001_tables.sql', import.meta.url), 'utf8');
|
|
34
36
|
const MIGRATION_002 = readFileSync(new URL('../modules/market-watch/migrations/market_watch_002_run.sql', import.meta.url), 'utf8');
|
|
37
|
+
const MIGRATION_004 = readFileSync(new URL('../modules/market-watch/migrations/market_watch_004_entry_corrections.sql', import.meta.url), 'utf8');
|
|
35
38
|
|
|
36
39
|
const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
|
|
37
40
|
try {
|
|
@@ -60,6 +63,8 @@ const withTx = async (fn) => {
|
|
|
60
63
|
|
|
61
64
|
async function cleanup() {
|
|
62
65
|
await q(`DELETE FROM market_watch_proposals WHERE project = $1`, [P]).catch(() => {});
|
|
66
|
+
await q(`DELETE FROM market_watch_log_entries WHERE item_id IN (SELECT id FROM market_watch_items WHERE project = $1)`, [P]).catch(() => {});
|
|
67
|
+
await q(`DELETE FROM market_watch_runs WHERE project = $1`, [P]).catch(() => {});
|
|
63
68
|
await q(`DELETE FROM market_watch_items WHERE project = $1`, [P]).catch(() => {});
|
|
64
69
|
await q(`DELETE FROM market_watch_rule_versions WHERE project = $1`, [P]).catch(() => {});
|
|
65
70
|
await q(`DELETE FROM builders WHERE id = ANY($1::bigint[])`, [[OWNER, ADA]]).catch(() => {});
|
|
@@ -73,6 +78,7 @@ async function check(name, fn) {
|
|
|
73
78
|
try {
|
|
74
79
|
await q(MIGRATION);
|
|
75
80
|
await q(MIGRATION_002);
|
|
81
|
+
await q(MIGRATION_004);
|
|
76
82
|
await cleanup();
|
|
77
83
|
await insertTestBuilders(pool, [{ id: OWNER, login: 't1004941-owner' }, { id: ADA, login: 't1004941-ada' }]);
|
|
78
84
|
const r = seed.RULES_V1;
|
|
@@ -112,6 +118,42 @@ try {
|
|
|
112
118
|
assert.deepEqual([it.kind, it.url, it.added_via, String(it.added_by), it.status], ['competitor', 'https://algora.io', 'proposal', String(ADA), 'active']);
|
|
113
119
|
});
|
|
114
120
|
|
|
121
|
+
// task 1005016: an approved correct_entry rewrites the entry, keeps who/when/why and
|
|
122
|
+
// the old text on the proposal, and the case file reads the correction; a wrong flag
|
|
123
|
+
// leaves the text and reads as marked wrong.
|
|
124
|
+
await check('an approved correct_entry corrects the log entry, and the case file shows it', async () => {
|
|
125
|
+
const { rows: [it] } = await q(`SELECT id FROM market_watch_items WHERE project = $1 AND name = 'Algora'`, [P]);
|
|
126
|
+
const { rows: [run] } = await q(`INSERT INTO market_watch_runs (project, status, trigger) VALUES ($1, 'done', 'manual') RETURNING id`, [P]);
|
|
127
|
+
const { rows: [e1] } = await q(`INSERT INTO market_watch_log_entries (item_id, run_id, type, takeaway, source_url, confidence)
|
|
128
|
+
VALUES ($1, $2, 'news', 'Algora raised a Series B.', 'https://algora.io/blog', 2) RETURNING id`, [it.id, run.id]);
|
|
129
|
+
const { rows: [e2] } = await q(`INSERT INTO market_watch_log_entries (item_id, run_id, type, takeaway, source_url, confidence)
|
|
130
|
+
VALUES ($1, $2, 'news', 'Algora shut down.', 'https://algora.io/x', 1) RETURNING id`, [it.id, run.id]);
|
|
131
|
+
const file = async (body) => {
|
|
132
|
+
const n = pages.normalizeProposal({ kind: 'correct_entry', item_id: it.id, ...body }, { item: { id: Number(it.id), kind: 'competitor' } });
|
|
133
|
+
assert.equal(n.ok, true, JSON.stringify(n));
|
|
134
|
+
return pages.insertProposal(pool, { project: P, proposal: n.value, builderId: ADA });
|
|
135
|
+
};
|
|
136
|
+
const fix = await file({ log_entry_id: String(e1.id), takeaway: 'Algora raised a seed round, not a Series B.', reason: 'their post says seed' });
|
|
137
|
+
const out = await proposals.decideProposal({ project: P, id: fix.id, decision: 'approve', decidedBy: OWNER, withTx });
|
|
138
|
+
assert.deepEqual([out.ok, out.status], [true, 'applied'], JSON.stringify(out));
|
|
139
|
+
const { rows: [e] } = await q(`SELECT takeaway FROM market_watch_log_entries WHERE id = $1`, [e1.id]);
|
|
140
|
+
assert.equal(e.takeaway, 'Algora raised a seed round, not a Series B.');
|
|
141
|
+
const { rows: [p] } = await q(`SELECT status, proposed_by, decided_by, decided_at, reason, payload FROM market_watch_proposals WHERE id = $1`, [fix.id]);
|
|
142
|
+
assert.deepEqual([p.status, String(p.proposed_by), String(p.decided_by), p.reason, p.payload.was],
|
|
143
|
+
['applied', String(ADA), String(OWNER), 'their post says seed', 'Algora raised a Series B.']);
|
|
144
|
+
assert.ok(p.decided_at instanceof Date);
|
|
145
|
+
const wrong = await file({ log_entry_id: String(e2.id), wrong: true, reason: 'they are still running' });
|
|
146
|
+
assert.equal((await proposals.decideProposal({ project: P, id: wrong.id, decision: 'approve', decidedBy: OWNER, withTx })).status, 'applied');
|
|
147
|
+
const cf = await db.caseFile(pool, { project: P, itemId: it.id });
|
|
148
|
+
const byId = new Map(cf.map((x) => [String(x.id), x]));
|
|
149
|
+
assert.equal(byId.get(String(e1.id)).correction_reason, 'their post says seed');
|
|
150
|
+
assert.equal(byId.get(String(e1.id)).marked_wrong, false);
|
|
151
|
+
assert.equal(byId.get(String(e2.id)).marked_wrong, true);
|
|
152
|
+
assert.equal(byId.get(String(e2.id)).takeaway, 'Algora shut down.', 'a wrong entry keeps its text');
|
|
153
|
+
const shaped = pages.shapeItem({ item: { id: it.id }, entries: cf, history: {}, rules: null });
|
|
154
|
+
assert.equal(shaped.entries.find((x) => x.id === Number(e2.id)).corrected.wrong, true);
|
|
155
|
+
});
|
|
156
|
+
|
|
115
157
|
await check('the page read runs on the real schema', async () => {
|
|
116
158
|
await proposals.createProposal(pool, { project: P, step: 'brief', change: { op: 'set', text: 'Write five lines.' }, reason: 'shorter', proposedBy: ADA });
|
|
117
159
|
const page = await view.readView(pool, { project: P, decide: { mode: 'owner' }, pricing, model: research.MODEL });
|
|
@@ -94,7 +94,7 @@ test('the hall-preview fixture carries every atom /me resolves', () => {
|
|
|
94
94
|
// "Upgraded to core X" notice after a move; task 1004826: Standing is Accomplishments;
|
|
95
95
|
// task 1004846: the new logo (its bump landed late, in task 1004852); task 1004938: the
|
|
96
96
|
// Market Watch settings nav item and its fuel glyph.
|
|
97
|
-
const SHELL_SHA = '
|
|
97
|
+
const SHELL_SHA = '1a23b1cf13567eff';
|
|
98
98
|
|
|
99
99
|
test('shell.js has not changed without its cache-bust being bumped', () => {
|
|
100
100
|
const actual = crypto.createHash('sha256').update(SHELL).digest('hex').slice(0, 16);
|
package/tests/pay_terms.mjs
CHANGED
|
@@ -23,6 +23,14 @@ let statements = [];
|
|
|
23
23
|
const query = async (sql, params = []) => {
|
|
24
24
|
statements.push(sql);
|
|
25
25
|
if (/^\s*(BEGIN|COMMIT|ROLLBACK)/i.test(sql)) return { rows: [] };
|
|
26
|
+
if (/FROM lifecycle_pay_type_gates/.test(sql)) {
|
|
27
|
+
// task 1004991: these cases are about the terms, so every gate reads as cleared;
|
|
28
|
+
// tests/pay_type_gates.mjs is where an open gate refuses.
|
|
29
|
+
const gates = require('../modules/lifecycle/pay-type-gates.js');
|
|
30
|
+
const at = new Date(Date.UTC(2026, 9, 30));
|
|
31
|
+
return { rows: params[0].flatMap((pay_type) => gates.requirementIds(pay_type).map((requirement) => (
|
|
32
|
+
{ pay_type, requirement, state: 'cleared', evidence_ref: 'test', decided_by: 1, decided_at: at }))) };
|
|
33
|
+
}
|
|
26
34
|
if (/INSERT INTO lifecycle_pay_term_sets/.test(sql)) {
|
|
27
35
|
const row = { id: sets.length + 1, task_id: params[0], set_by: params[1], set_at: new Date(Date.UTC(2026, 9, 30, 0, sets.length)) };
|
|
28
36
|
sets.push(row);
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// tests/pay_type_gates.mjs — a pay type can be put on a task only after its gate is
|
|
2
|
+
// cleared for the project (task 1004991, goal 1000092; ADR 0385 "Pay-type gates").
|
|
3
|
+
//
|
|
4
|
+
// What is real: modules/lifecycle/pay-type-gates.js (the list, the verdict, the checker,
|
|
5
|
+
// the writer), the pay-type-gates router, the lifecycle tasks router (PATCH /tasks/:id)
|
|
6
|
+
// and the project-settings router (the standing terms). What is faked: the caller
|
|
7
|
+
// (requireBuilder, requireRank, requirePermission) and Postgres, a recording stand-in
|
|
8
|
+
// holding the gate history and the pay-term sets in memory.
|
|
9
|
+
//
|
|
10
|
+
// Run: node tests/pay_type_gates.mjs
|
|
11
|
+
|
|
12
|
+
import { strict as assert } from 'node:assert';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
15
|
+
process.env.NODE_ENV = 'test';
|
|
16
|
+
|
|
17
|
+
let gateRows = [];
|
|
18
|
+
let sets = [];
|
|
19
|
+
let terms = [];
|
|
20
|
+
const query = async (sql, params = []) => {
|
|
21
|
+
if (/^\s*(BEGIN|COMMIT|ROLLBACK)/i.test(sql)) return { rows: [] };
|
|
22
|
+
if (/INSERT INTO lifecycle_pay_type_gates/.test(sql)) {
|
|
23
|
+
const [pay_type, requirement, state, evidence_ref, decided_by] = params;
|
|
24
|
+
gateRows.push({ id: gateRows.length + 1, pay_type, requirement, state, evidence_ref, decided_by, decided_at: new Date(Date.UTC(2026, 9, 30, 0, gateRows.length)) });
|
|
25
|
+
return { rows: [] };
|
|
26
|
+
}
|
|
27
|
+
if (/FROM lifecycle_pay_type_gates/.test(sql)) {
|
|
28
|
+
const newest = new Map();
|
|
29
|
+
for (const r of gateRows.filter((x) => params[0].includes(x.pay_type))) newest.set(`${r.pay_type}/${r.requirement}`, r);
|
|
30
|
+
return { rows: [...newest.values()] };
|
|
31
|
+
}
|
|
32
|
+
if (/INSERT INTO lifecycle_pay_term_sets/.test(sql)) {
|
|
33
|
+
const row = { id: sets.length + 1, task_id: params[0], set_by: params[1], set_at: new Date() };
|
|
34
|
+
sets.push(row);
|
|
35
|
+
return { rows: [row] };
|
|
36
|
+
}
|
|
37
|
+
if (/INSERT INTO lifecycle_pay_terms/.test(sql)) {
|
|
38
|
+
params[1].forEach((pay_type, i) => terms.push({ set_id: params[0], pay_type, position: i }));
|
|
39
|
+
return { rows: [] };
|
|
40
|
+
}
|
|
41
|
+
if (/FROM lifecycle_pay_term_sets s LEFT JOIN builders/.test(sql)) return { rows: [] };
|
|
42
|
+
return { rows: [] };
|
|
43
|
+
};
|
|
44
|
+
const pool = { query, connect: async () => ({ query, release() {} }) };
|
|
45
|
+
const poolPath = require.resolve('../src/bongos/pool.js');
|
|
46
|
+
require.cache[poolPath] = { id: poolPath, filename: poolPath, loaded: true, exports: { pool } };
|
|
47
|
+
|
|
48
|
+
let caller = { id: 7, rank: 'archon' };
|
|
49
|
+
let held = [];
|
|
50
|
+
const api = require('../src/module-api.js');
|
|
51
|
+
const set = (k, v) => Object.defineProperty(api, k, { value: v, configurable: true, writable: true });
|
|
52
|
+
set('requireBuilder', (req, _res, next) => { req.builder = { ...caller }; next(); });
|
|
53
|
+
set('requireRank', (rank) => (req, res, next) => (req.builder.rank === rank ? next() : res.fail('rank_forbidden', { status: 403, message: `needs ${rank}` })));
|
|
54
|
+
set('requirePermission', (atom) => (req, res, next) => (held.includes(atom) ? next() : res.fail('permission_denied', { status: 403, message: `needs ${atom}` })));
|
|
55
|
+
set('requireActAlone', () => (_req, _res, next) => next());
|
|
56
|
+
set('withTx', async (fn) => fn({ query }));
|
|
57
|
+
|
|
58
|
+
const db = require('../modules/lifecycle/db.js');
|
|
59
|
+
const TASK = { id: 5150, title: 'a task', status: 'ready', version_id: 'BONGOS-V2' };
|
|
60
|
+
db.getTask = async (id) => (Number(id) === TASK.id ? { ...TASK } : null);
|
|
61
|
+
db.getTaskDependencies = async () => [];
|
|
62
|
+
db.getTaskDependents = async () => [];
|
|
63
|
+
|
|
64
|
+
const lib = require('../modules/lifecycle/pay-type-gates.js');
|
|
65
|
+
const payTerms = require('../modules/lifecycle/pay-terms.js');
|
|
66
|
+
|
|
67
|
+
const express = require('express');
|
|
68
|
+
const app = express();
|
|
69
|
+
app.use(express.json());
|
|
70
|
+
app.use((req, res, next) => {
|
|
71
|
+
res.fail = (code, o) => {
|
|
72
|
+
const opts = typeof o === 'number' ? { status: o } : (o || {});
|
|
73
|
+
return res.status(opts.status || 400).json({ error: code, message: opts.message });
|
|
74
|
+
};
|
|
75
|
+
next();
|
|
76
|
+
});
|
|
77
|
+
app.use('/api', require('../modules/lifecycle/routes/tasks.js')());
|
|
78
|
+
app.use('/api', require('../modules/lifecycle/routes/project-settings.js')());
|
|
79
|
+
app.use('/api', require('../modules/lifecycle/routes/pay-type-gates.js')());
|
|
80
|
+
const server = app.listen(0);
|
|
81
|
+
await new Promise((r) => server.once('listening', r));
|
|
82
|
+
const base = `http://127.0.0.1:${server.address().port}/api`;
|
|
83
|
+
const call = async (method, path, body) => {
|
|
84
|
+
const r = await fetch(base + path, { method, headers: { 'content-type': 'application/json' }, body: body ? JSON.stringify(body) : undefined });
|
|
85
|
+
return { status: r.status, body: await r.json().catch(() => null) };
|
|
86
|
+
};
|
|
87
|
+
const reset = () => { gateRows = []; sets = []; terms = []; held = ['task.edit', 'project.settings.manage', 'payout.approve']; caller = { id: 7, rank: 'archon' }; };
|
|
88
|
+
const clear = (payType) => Promise.all(lib.requirementIds(payType).map((requirement) => call('PUT', `/pay-type-gates/${payType}/${requirement}`,
|
|
89
|
+
requirement === lib.ACKNOWLEDGEMENT ? { state: 'cleared', acknowledged: true } : { state: 'cleared', evidence_ref: `doc for ${requirement}` })));
|
|
90
|
+
|
|
91
|
+
let passed = 0;
|
|
92
|
+
let failed = 0;
|
|
93
|
+
async function t(name, fn) {
|
|
94
|
+
reset();
|
|
95
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
96
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.stack}`); }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
console.log('pay-type gates (task 1004991):');
|
|
100
|
+
|
|
101
|
+
await t('every pay type a term can carry has a requirement list, and nothing else does', () => {
|
|
102
|
+
assert.deepEqual(Object.keys(lib.REQUIREMENTS).sort(), [...payTerms.PAY_TYPES].sort());
|
|
103
|
+
for (const p of payTerms.PAY_TYPES) assert.ok(lib.requirementIds(p).length > 0, `${p} has requirements`);
|
|
104
|
+
assert.deepEqual(lib.requirementIds('self_managed'), ['bongos_moves_and_guarantees_nothing']);
|
|
105
|
+
assert.ok(!('eu_labour' in lib.REQUIREMENTS), 'eu_labour has no list to clear');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
await t('a pay term whose gate is not cleared is refused, and nothing is written', async () => {
|
|
109
|
+
const r = await call('PATCH', `/tasks/${TASK.id}`, { pay_terms: [{ pay_type: 'cash', amount: '500', unit: 'USD' }] });
|
|
110
|
+
assert.equal(r.status, 409);
|
|
111
|
+
assert.equal(r.body.error, 'pay_type_gate_not_cleared');
|
|
112
|
+
assert.match(r.body.message, /processor_onboarding/);
|
|
113
|
+
assert.deepEqual([sets.length, terms.length], [0, 0]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
await t('the project\'s standing terms obey the same gate', async () => {
|
|
117
|
+
const r = await call('PATCH', '/project-settings/pay-terms', { terms: [{ pay_type: 'equity' }] });
|
|
118
|
+
assert.equal(r.status, 409);
|
|
119
|
+
assert.equal(r.body.error, 'pay_type_gate_not_cleared');
|
|
120
|
+
assert.equal(sets.length, 0);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
await t('a half-cleared gate still refuses; the last requirement opens it', async () => {
|
|
124
|
+
const [first, ...rest] = lib.requirementIds('cash');
|
|
125
|
+
assert.equal((await call('PUT', `/pay-type-gates/cash/${first}`, { state: 'cleared', evidence_ref: 'processor verdict 41' })).status, 200);
|
|
126
|
+
let r = await call('PATCH', `/tasks/${TASK.id}`, { pay_terms: [{ pay_type: 'cash' }] });
|
|
127
|
+
assert.equal(r.status, 409);
|
|
128
|
+
for (const req of rest) await call('PUT', `/pay-type-gates/cash/${req}`, { state: 'cleared', evidence_ref: 'on file' });
|
|
129
|
+
r = await call('PATCH', `/tasks/${TASK.id}`, { pay_terms: [{ pay_type: 'cash', amount: '500', unit: 'USD' }] });
|
|
130
|
+
assert.equal(r.status, 200);
|
|
131
|
+
assert.equal(terms.length, 1);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
await t('revoking a requirement closes the gate again', async () => {
|
|
135
|
+
await clear('cash');
|
|
136
|
+
const req = lib.requirementIds('cash')[0];
|
|
137
|
+
assert.equal((await call('PUT', `/pay-type-gates/cash/${req}`, { state: 'revoked', evidence_ref: 'processor paused us' })).status, 200);
|
|
138
|
+
const r = await call('PATCH', `/tasks/${TASK.id}`, { pay_terms: [{ pay_type: 'cash' }] });
|
|
139
|
+
assert.equal(r.status, 409);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
await t('self_managed clears with only the acknowledgement', async () => {
|
|
143
|
+
const req = lib.ACKNOWLEDGEMENT;
|
|
144
|
+
let r = await call('PUT', `/pay-type-gates/self_managed/${req}`, { state: 'cleared', evidence_ref: 'anything' });
|
|
145
|
+
assert.equal(r.status, 400, 'evidence alone is not the acknowledgement');
|
|
146
|
+
r = await call('PUT', `/pay-type-gates/self_managed/${req}`, { state: 'cleared', acknowledged: true });
|
|
147
|
+
assert.equal(r.status, 200);
|
|
148
|
+
assert.equal(r.body.gate.cleared, true);
|
|
149
|
+
assert.equal(r.body.gate.requirements[0].evidence_ref, payTerms.SELF_MANAGED_LABEL, 'the label is what was acknowledged');
|
|
150
|
+
const t1 = await call('PATCH', `/tasks/${TASK.id}`, { pay_terms: [{ pay_type: 'self_managed' }] });
|
|
151
|
+
assert.equal(t1.status, 200);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
await t('eu_labour can never be cleared, by any route, and never reaches a term', async () => {
|
|
155
|
+
const w = await call('PUT', '/pay-type-gates/eu_labour/anything', { state: 'cleared', evidence_ref: 'counsel says so' });
|
|
156
|
+
assert.equal(w.status, 409);
|
|
157
|
+
assert.equal(w.body.error, 'pay_type_never_clearable');
|
|
158
|
+
assert.equal(gateRows.length, 0, 'no row written');
|
|
159
|
+
const p = await call('PATCH', `/tasks/${TASK.id}`, { pay_terms: [{ pay_type: 'eu_labour' }] });
|
|
160
|
+
assert.equal(p.status, 400);
|
|
161
|
+
assert.equal(sets.length, 0);
|
|
162
|
+
const all = (await call('GET', '/pay-type-gates')).body.gates.find((g) => g.pay_type === 'eu_labour');
|
|
163
|
+
assert.deepEqual([all.cleared, all.never], [false, true]);
|
|
164
|
+
assert.deepEqual(lib.gateVerdict('eu_labour', [{ pay_type: 'eu_labour', requirement: 'x', state: 'cleared' }]).cleared, false);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
await t('a forged client request that claims a cleared gate is refused', async () => {
|
|
168
|
+
for (const forged of [
|
|
169
|
+
{ pay_type: 'cash', gate_cleared: true },
|
|
170
|
+
{ pay_type: 'cash', cleared: true, cleared_by: 7 },
|
|
171
|
+
{ pay_type: 'cash', gate: { state: 'cleared' } },
|
|
172
|
+
]) {
|
|
173
|
+
const r = await call('PATCH', `/tasks/${TASK.id}`, { pay_terms: [forged] });
|
|
174
|
+
assert.equal(r.status, 400, JSON.stringify(forged));
|
|
175
|
+
}
|
|
176
|
+
const top = await call('PATCH', `/tasks/${TASK.id}`, { gate_cleared: true, pay_terms: [{ pay_type: 'cash' }] });
|
|
177
|
+
assert.ok(top.status >= 400, 'a top-level claim does not open it either');
|
|
178
|
+
assert.deepEqual([sets.length, terms.length, gateRows.length], [0, 0, 0]);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
await t('only the owner records a requirement, and who recorded it is the caller, not the body', async () => {
|
|
182
|
+
const req = lib.requirementIds('cash')[0];
|
|
183
|
+
caller = { id: 9, rank: 'metic' }; held = held.filter((a) => a !== 'payout.approve');
|
|
184
|
+
let r = await call('PUT', `/pay-type-gates/cash/${req}`, { state: 'cleared', evidence_ref: 'x' });
|
|
185
|
+
assert.equal(r.status, 403);
|
|
186
|
+
assert.equal(gateRows.length, 0);
|
|
187
|
+
caller = { id: 7, rank: 'archon' }; held = [...held, 'payout.approve'];
|
|
188
|
+
r = await call('PUT', `/pay-type-gates/cash/${req}`, { state: 'cleared', evidence_ref: 'x', decided_by: 99 });
|
|
189
|
+
assert.equal(r.status, 400, 'a recorder in the body is refused');
|
|
190
|
+
r = await call('PUT', `/pay-type-gates/cash/${req}`, { state: 'cleared', evidence_ref: 'x' });
|
|
191
|
+
assert.equal(r.status, 200);
|
|
192
|
+
assert.equal(gateRows[0].decided_by, 7);
|
|
193
|
+
assert.equal(r.body.gate.requirements[0].cleared_by, 7);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
await t('a non-owner sees met or missing, never the evidence or who cleared it', async () => {
|
|
197
|
+
await clear('cash');
|
|
198
|
+
const owner = (await call('GET', '/pay-type-gates')).body.gates.find((g) => g.pay_type === 'cash');
|
|
199
|
+
assert.ok(owner.requirements[0].evidence_ref && owner.requirements[0].cleared_by, 'the owner sees both');
|
|
200
|
+
caller = { id: 9, rank: 'metic' }; held = held.filter((a) => a !== 'payout.approve');
|
|
201
|
+
const other = (await call('GET', '/pay-type-gates')).body.gates.find((g) => g.pay_type === 'cash');
|
|
202
|
+
assert.equal(other.cleared, true);
|
|
203
|
+
for (const r of other.requirements) {
|
|
204
|
+
assert.equal(r.met, true);
|
|
205
|
+
assert.ok(!('evidence_ref' in r) && !('cleared_by' in r) && !('cleared_at' in r));
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
await t('a write needs a known requirement, a state and evidence', async () => {
|
|
210
|
+
const req = lib.requirementIds('cash')[0];
|
|
211
|
+
assert.equal((await call('PUT', '/pay-type-gates/wages/x', { state: 'cleared', evidence_ref: 'x' })).status, 404);
|
|
212
|
+
assert.equal((await call('PUT', '/pay-type-gates/cash/not_a_requirement', { state: 'cleared', evidence_ref: 'x' })).status, 404);
|
|
213
|
+
assert.equal((await call('PUT', `/pay-type-gates/cash/${req}`, { state: 'maybe', evidence_ref: 'x' })).status, 400);
|
|
214
|
+
assert.equal((await call('PUT', `/pay-type-gates/cash/${req}`, { state: 'cleared', evidence_ref: ' ' })).status, 400);
|
|
215
|
+
assert.equal((await call('PUT', `/pay-type-gates/cash/${req}`, { state: 'cleared', evidence_ref: 'x'.repeat(501) })).status, 400);
|
|
216
|
+
assert.equal(gateRows.length, 0);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
await t('a stray row for a requirement not on the list cannot clear a gate', () => {
|
|
220
|
+
const rows = [{ pay_type: 'cash', requirement: 'made_up', state: 'cleared', evidence_ref: 'x', decided_by: 1, decided_at: new Date() }];
|
|
221
|
+
assert.equal(lib.gateVerdict('cash', rows).cleared, false);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
await t('no terms means no gate read; clearing every type then lets a mixed list through', async () => {
|
|
225
|
+
const r0 = await call('PATCH', `/tasks/${TASK.id}`, { pay_terms: [] });
|
|
226
|
+
assert.equal(r0.status, 200);
|
|
227
|
+
for (const p of payTerms.PAY_TYPES) await clear(p);
|
|
228
|
+
const all = await call('PATCH', `/tasks/${TASK.id}`, { pay_terms: payTerms.PAY_TYPES.map((pay_type) => ({ pay_type })) });
|
|
229
|
+
assert.equal(all.status, 200);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
233
|
+
server.close();
|
|
234
|
+
process.exit(failed ? 1 : 0);
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// tests/pay_type_gates_db.mjs — task 1004991 (ADR 0385 "Pay-type gates"). Real DB.
|
|
2
|
+
// The gate table built from the real migration (lifecycle_019) in a throwaway schema,
|
|
3
|
+
// written and read through the real modules/lifecycle/pay-type-gates.js, proving the
|
|
4
|
+
// DATABASE holds the rules the DB-free tests/pay_type_gates.mjs assumes: the history
|
|
5
|
+
// cannot be edited or deleted, a row for eu_labour or an unknown type is refused, a
|
|
6
|
+
// state or evidence the writer would never send is refused, and the read SQL resolves
|
|
7
|
+
// the newest record per requirement into a verdict.
|
|
8
|
+
// Self-skips without a reachable Postgres.
|
|
9
|
+
// Run: DATABASE_URL=... node tests/pay_type_gates_db.mjs
|
|
10
|
+
import { createRequire } from 'node:module';
|
|
11
|
+
import { readFileSync } from 'node:fs';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { dirname, join } from 'node:path';
|
|
14
|
+
import { strict as assert } from 'node:assert';
|
|
15
|
+
const require = createRequire(import.meta.url);
|
|
16
|
+
const { Pool } = require('pg');
|
|
17
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
|
|
19
|
+
const pgPool = new Pool({ connectionString: process.env.DATABASE_URL || undefined, max: 1 });
|
|
20
|
+
let client;
|
|
21
|
+
try { client = await pgPool.connect(); } catch (e) {
|
|
22
|
+
console.log(`pay_type_gates_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
|
|
23
|
+
await pgPool.end().catch(() => {});
|
|
24
|
+
process.exit(0);
|
|
25
|
+
}
|
|
26
|
+
const SCHEMA = `t1004991_${process.pid}`;
|
|
27
|
+
let pass = 0;
|
|
28
|
+
const ok = (c, m) => { assert.ok(c, m); pass++; console.log(` ok - ${m}`); };
|
|
29
|
+
const refused = async (sql, params, m) => {
|
|
30
|
+
await client.query('SAVEPOINT s');
|
|
31
|
+
let threw = false;
|
|
32
|
+
try { await client.query(sql, params); } catch { threw = true; }
|
|
33
|
+
await client.query('ROLLBACK TO SAVEPOINT s');
|
|
34
|
+
ok(threw, m);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
await client.query(`CREATE SCHEMA ${SCHEMA}`);
|
|
39
|
+
await client.query(`SET search_path TO ${SCHEMA}`);
|
|
40
|
+
await client.query('CREATE TABLE builders (id bigserial PRIMARY KEY, github_login text NOT NULL)');
|
|
41
|
+
await client.query(readFileSync(join(here, '..', 'modules', 'lifecycle', 'migrations', 'lifecycle_019_pay_type_gates.sql'), 'utf8'));
|
|
42
|
+
await client.query("INSERT INTO builders (github_login) VALUES ('owner'), ('lead')");
|
|
43
|
+
await client.query('BEGIN');
|
|
44
|
+
|
|
45
|
+
const lib = require('../modules/lifecycle/pay-type-gates.js');
|
|
46
|
+
const o = { db: client };
|
|
47
|
+
const rec = (payType, requirement, state, evidenceRef, builderId = 1) =>
|
|
48
|
+
lib.recordRequirement({ payType, requirement, state, evidenceRef, builderId }, o);
|
|
49
|
+
|
|
50
|
+
let v = await rec('self_managed', lib.ACKNOWLEDGEMENT, 'cleared', 'acknowledged');
|
|
51
|
+
ok(v.cleared === true && Number(v.requirements[0].cleared_by) === 1, 'the one self-managed requirement clears its gate, naming who cleared it');
|
|
52
|
+
|
|
53
|
+
for (const req of lib.requirementIds('cash')) v = await rec('cash', req, 'cleared', `ref ${req}`, 2);
|
|
54
|
+
ok(v.cleared === true, 'every cash requirement cleared opens the gate');
|
|
55
|
+
v = await rec('cash', 'tax_details', 'revoked', 'details withdrawn');
|
|
56
|
+
ok(v.cleared === false && v.missing.join() === 'tax_details', 'the newest record per requirement wins: a revoke closes it again');
|
|
57
|
+
v = await rec('cash', 'tax_details', 'cleared', 'collected again');
|
|
58
|
+
ok(v.cleared === true, 'a later clearing reopens it');
|
|
59
|
+
|
|
60
|
+
const refusal = await lib.refuseUnclearedGates([{ pay_type: 'equity' }, { pay_type: 'cash' }], o);
|
|
61
|
+
ok(refusal && refusal.pay_type === 'equity', 'the checker names the first type whose gate is open');
|
|
62
|
+
ok((await lib.refuseUnclearedGates([{ pay_type: 'cash' }, { pay_type: 'self_managed' }], o)) === null, 'cleared types pass');
|
|
63
|
+
|
|
64
|
+
const all = await lib.readGates(o);
|
|
65
|
+
ok(all.length === 7 && all.find((g) => g.pay_type === 'eu_labour').never === true, 'the owner view lists eu_labour as never clearable');
|
|
66
|
+
|
|
67
|
+
const { rows: [aRow] } = await client.query('SELECT id FROM lifecycle_pay_type_gates LIMIT 1');
|
|
68
|
+
await refused('UPDATE lifecycle_pay_type_gates SET state = $1 WHERE id = $2', ['revoked', aRow.id], 'a record cannot be edited');
|
|
69
|
+
await refused('DELETE FROM lifecycle_pay_type_gates WHERE id = $1', [aRow.id], 'a record cannot be deleted');
|
|
70
|
+
await refused("INSERT INTO lifecycle_pay_type_gates (pay_type, requirement, state, evidence_ref, decided_by) VALUES ('eu_labour', 'x', 'cleared', 'y', 1)", [], 'the database refuses any row for eu_labour');
|
|
71
|
+
await refused("INSERT INTO lifecycle_pay_type_gates (pay_type, requirement, state, evidence_ref, decided_by) VALUES ('wages', 'x', 'cleared', 'y', 1)", [], 'an unknown pay type is refused');
|
|
72
|
+
await refused("INSERT INTO lifecycle_pay_type_gates (pay_type, requirement, state, evidence_ref, decided_by) VALUES ('cash', 'x', 'pending', 'y', 1)", [], 'a state other than cleared or revoked is refused');
|
|
73
|
+
await refused("INSERT INTO lifecycle_pay_type_gates (pay_type, requirement, state, evidence_ref, decided_by) VALUES ('cash', 'x', 'cleared', '', 1)", [], 'a record with no evidence reference is refused');
|
|
74
|
+
await refused("INSERT INTO lifecycle_pay_type_gates (pay_type, requirement, state, evidence_ref, decided_by) VALUES ('cash', 'x', 'cleared', 'y', 999)", [], 'a recorder who is not a builder is refused');
|
|
75
|
+
|
|
76
|
+
await client.query('ROLLBACK');
|
|
77
|
+
console.log(`pay_type_gates_db: ${pass} passed`);
|
|
78
|
+
} finally {
|
|
79
|
+
await client.query('ROLLBACK').catch(() => {});
|
|
80
|
+
await client.query('SET search_path TO public').catch(() => {});
|
|
81
|
+
await client.query(`DROP SCHEMA IF EXISTS ${SCHEMA} CASCADE`).catch(() => {});
|
|
82
|
+
client.release();
|
|
83
|
+
await pgPool.end();
|
|
84
|
+
}
|