@bongos/core 1.24.5 → 1.24.7
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 +79 -54
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +8 -1
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/adr/0371-auto-pickup-applies-a-submitted-tweak-on-the-runner.md +1 -0
- package/docs/adr/0396-google-sign-in-a-stand-in-login-and-a-browser-approved-cli-sign-in.md +8 -0
- package/docs/api/openapi.json +114 -6
- package/docs/api-reference.md +7 -4
- package/docs/copy-inventory.md +52 -43
- package/docs/copy-registry.json +145 -63
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +2 -1
- package/docs/page-readings.json +71 -63
- package/migrations/core_286_credit_log_no_spend.sql +128 -0
- package/modules/copy-desk/auto-close.js +5 -3
- package/modules/copy-desk/page-status.js +7 -4
- package/modules/copy-desk/tests/copy_page_auto_close.mjs +4 -4
- package/modules/hall-ui/public/project-settings.html +4 -4
- package/modules/hall-ui/public/project-settings.js +51 -1
- package/modules/lifecycle/autopickup.js +26 -9
- package/modules/lifecycle/db-claims.js +1 -1
- package/modules/lifecycle/routes/claims.js +7 -1
- package/modules/lifecycle/routes/project-settings.js +71 -10
- package/modules/platform-identity/account-logins.js +131 -0
- package/modules/platform-identity/migrations/platform_identity_035_account_login_email_idx.sql +19 -0
- package/modules/platform-identity/routes/profile.js +29 -1
- package/modules/platform-identity/routes/sso.js +25 -3
- package/modules/public-landing/public/account.html +114 -0
- package/modules/public-landing/public/account.states.json +1 -0
- package/modules/ui-design/kit/serve.js +24 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +24 -0
- package/scripts/bongos/run-unit-tests.js +4 -0
- package/src/bongos/auth-github.js +24 -7
- package/src/bongos/project-settings.js +59 -15
- package/src/bongos/routes/auth-google.js +71 -5
- package/src/bongos/routes/auth-link.js +132 -0
- package/src/bongos/routes.js +2 -0
- package/src/bongos/sign-in-pages.js +20 -1
- package/src/module-api.js +1 -1
- package/tests/account_linking.mjs +289 -0
- package/tests/account_logins_db.mjs +56 -0
- package/tests/autopickup_cap.mjs +203 -11
- package/tests/claims_batch_routes.mjs +79 -12
- package/tests/copy_desk_page_release.mjs +20 -0
- package/tests/copy_stuck_page_tweaks.mjs +2 -2
- package/tests/credit_log_no_spend_db.mjs +160 -0
- package/tests/effective_visibility_predicate.mjs +8 -0
- package/tests/hub_account_page.mjs +91 -5
- package/tests/project_settings_page.mjs +8 -5
|
@@ -341,16 +341,78 @@ function chainFor(path) {
|
|
|
341
341
|
return layer.route.stack.map((s) => s.handle.name || '<handler>');
|
|
342
342
|
}
|
|
343
343
|
|
|
344
|
-
test('POST /claims/batch middleware chain is pinned: requireBuilder → gateNewcomerClaim → handler', () => {
|
|
345
|
-
assert.deepEqual(chainFor('/claims/batch'),
|
|
344
|
+
test('POST /claims/batch middleware chain is pinned: requireBuilder → gateOneActiveClaimPerSession → gateNewcomerClaim → handler', () => {
|
|
345
|
+
assert.deepEqual(chainFor('/claims/batch'),
|
|
346
|
+
['requireBuilder', 'gateOneActiveClaimPerSession', 'gateNewcomerClaim', '<handler>']);
|
|
346
347
|
});
|
|
347
348
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
349
|
+
// task 1005005 (found by task 1004983): the batch route used to skip the
|
|
350
|
+
// session gate. Both routes now run the SAME middleware function, in the same
|
|
351
|
+
// order — one check, never a second copy of it.
|
|
352
|
+
test('POST /claims and POST /claims/batch run the identical gate functions in the identical order', () => {
|
|
353
|
+
const gates = (p) => router.stack.find((l) => l.route && l.route.path === p && l.route.methods.post)
|
|
354
|
+
.route.stack.slice(0, -1).map((s) => s.handle);
|
|
355
|
+
const single = gates('/claims');
|
|
356
|
+
const batch = gates('/claims/batch');
|
|
357
|
+
assert.equal(batch.length, single.length);
|
|
358
|
+
single.forEach((fn, i) => assert.equal(batch[i], fn, `gate ${i} (${fn.name}) is the same function on both routes`));
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// ── POST /claims/batch — the one-claim-per-session gate ──────────────────────
|
|
362
|
+
|
|
363
|
+
test('a thetes session already holding a claim in this tree is refused 409 session_already_has_active_claim; nothing claimed', async () => {
|
|
364
|
+
caller = { id: '42', rank: 'thetes' };
|
|
365
|
+
activeClaims = [{ claim_id: '88', task_id: '700', title: 'First task', creator_session_id: 'sess-A', worktree_name: 'wt', requested_worktree_name: 'wt' }];
|
|
366
|
+
let batchCalls = 0;
|
|
367
|
+
db.claimTasksBatch = async () => { batchCalls += 1; return { claims: [] }; };
|
|
368
|
+
const r = await post(CLAIM, { task_ids: [501, 502], worktree_name: 'wt' });
|
|
369
|
+
assert.equal(r.status, 409);
|
|
370
|
+
assert.equal(r.json.error.code, 'session_already_has_active_claim');
|
|
371
|
+
assert.equal(r.json.error.details.active_task_id, '700');
|
|
372
|
+
assert.equal(batchCalls, 0, 'claimTasksBatch never ran');
|
|
373
|
+
assert.equal(connects, 0, 'no transaction was opened');
|
|
374
|
+
assert.deepEqual(writes(), []);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test('the same session in a DIFFERENT tree may batch-claim (one claim per tree, as on POST /claims)', async () => {
|
|
378
|
+
activeClaims = [{ claim_id: '88', task_id: '700', title: 'First task', creator_session_id: 'sess-A', worktree_name: 'other', requested_worktree_name: 'other' }];
|
|
379
|
+
let batchCalls = 0;
|
|
380
|
+
db.claimTasksBatch = async () => { batchCalls += 1; return { claims: [] }; };
|
|
381
|
+
const r = await post(CLAIM, { task_ids: [501], worktree_name: 'wt' });
|
|
382
|
+
assert.equal(r.status, 201);
|
|
383
|
+
assert.equal(batchCalls, 1);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test('a claim held by ANOTHER session in the same tree does not refuse this session', async () => {
|
|
387
|
+
activeClaims = [{ claim_id: '88', task_id: '700', title: 'First task', creator_session_id: 'sess-Z', worktree_name: 'wt', requested_worktree_name: 'wt' }];
|
|
388
|
+
db.claimTasksBatch = async () => ({ claims: [] });
|
|
389
|
+
const r = await post(CLAIM, { task_ids: [501], worktree_name: 'wt' });
|
|
390
|
+
assert.equal(r.status, 201);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test('bind_session:false (the hall) has no session to scope to: no session lookup, claim proceeds', async () => {
|
|
394
|
+
activeClaims = [{ claim_id: '88', task_id: '700', title: 'First task', creator_session_id: 'sess-A', worktree_name: null, requested_worktree_name: null }];
|
|
395
|
+
let batchCalls = 0;
|
|
396
|
+
db.claimTasksBatch = async () => { batchCalls += 1; return { claims: [] }; };
|
|
397
|
+
const r = await post(CLAIM, { task_ids: [501, 502], bind_session: false });
|
|
398
|
+
assert.equal(r.status, 201);
|
|
399
|
+
assert.equal(batchCalls, 1);
|
|
400
|
+
assert.deepEqual(calls, []);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test('the session lookup failing refuses the batch (fail closed), nothing claimed', async () => {
|
|
404
|
+
const realGet = db.getActiveClaimsForSession;
|
|
405
|
+
db.getActiveClaimsForSession = async () => { throw new Error('db down'); };
|
|
406
|
+
let batchCalls = 0;
|
|
407
|
+
db.claimTasksBatch = async () => { batchCalls += 1; return { claims: [] }; };
|
|
408
|
+
try {
|
|
409
|
+
const r = await post(CLAIM, { task_ids: [501] });
|
|
410
|
+
assert.equal(r.status, 409);
|
|
411
|
+
assert.equal(r.json.error.code, 'session_claim_check_failed');
|
|
412
|
+
assert.equal(batchCalls, 0);
|
|
413
|
+
} finally {
|
|
414
|
+
db.getActiveClaimsForSession = realGet;
|
|
415
|
+
}
|
|
354
416
|
});
|
|
355
417
|
|
|
356
418
|
test('POST /claims/batch/release middleware chain is pinned: requireBuilder → handler', () => {
|
|
@@ -371,10 +433,11 @@ test('a xenos with a claim in flight is refused by gateNewcomerClaim: 403, and n
|
|
|
371
433
|
assert.equal(batchCalls, 0, 'claimTasksBatch never ran');
|
|
372
434
|
assert.equal(connects, 0, 'no transaction was opened');
|
|
373
435
|
assert.deepEqual(writes(), []);
|
|
374
|
-
|
|
436
|
+
// The session gate's read (its claim is unbound, so it passes) + the newcomer gate's read.
|
|
437
|
+
assert.equal(calls.length, 2, 'only the two gates\' own active-claims reads');
|
|
375
438
|
});
|
|
376
439
|
|
|
377
|
-
test('a thetes with
|
|
440
|
+
test('a thetes with an unbound claim in flight is NOT held by the newcomer gate — and makes no newcomer lookup', async () => {
|
|
378
441
|
caller = { id: '42', rank: 'thetes' };
|
|
379
442
|
activeClaims = [{ claim_id: '88', task_id: '700', title: 'First task' }];
|
|
380
443
|
let batchCalls = 0;
|
|
@@ -382,7 +445,9 @@ test('a thetes with a claim in flight is NOT held by the newcomer gate — and m
|
|
|
382
445
|
const r = await post(CLAIM, { task_ids: [501] });
|
|
383
446
|
assert.equal(r.status, 201);
|
|
384
447
|
assert.equal(batchCalls, 1);
|
|
385
|
-
|
|
448
|
+
// Only the session gate's read, keyed on the session id.
|
|
449
|
+
assert.equal(calls.length, 1);
|
|
450
|
+
assert.deepEqual(calls[0].params, ['sess-A']);
|
|
386
451
|
});
|
|
387
452
|
|
|
388
453
|
// ── POST /claims/batch — each documented error maps to its status and body ────
|
|
@@ -440,5 +505,7 @@ test('claim body: empty → 400 task_ids_required, 21 ids → 400, non-numeric
|
|
|
440
505
|
assert.equal(bad.status, 400);
|
|
441
506
|
assert.equal(bad.json.error.code, 'task_ids_must_be_numbers');
|
|
442
507
|
assert.equal(batchCalls, 0);
|
|
443
|
-
|
|
508
|
+
// The session gate runs before body validation (as on POST /claims): reads only.
|
|
509
|
+
assert.deepEqual(writes(), []);
|
|
510
|
+
assert.ok(calls.every((c) => /^\s*SELECT/i.test(c.sql)), 'only the session gate\'s reads');
|
|
444
511
|
});
|
|
@@ -184,6 +184,26 @@ try {
|
|
|
184
184
|
assert.equal(task(7006).status, 'active');
|
|
185
185
|
assert.equal((await lifecyclePort.releaseStuckTweak({ taskId: '7003', builderId: CARL, expectedStatus: 'active' })).outcome, 'round_moved');
|
|
186
186
|
});
|
|
187
|
+
|
|
188
|
+
// task 1004976: the whole path, the sweep into the real lifecycle port. An idle
|
|
189
|
+
// draft expires after one day and its words are still on the task afterwards.
|
|
190
|
+
await test('an idle draft expires after a day through the real port, and the draft text stays in the record', async () => {
|
|
191
|
+
const autoClose = require('../modules/copy-desk/auto-close.js');
|
|
192
|
+
const draftLines = [{ key: 'L0002', section: 'hero', before: 'Build here', after: 'Build it right here', placement: 'placed' }];
|
|
193
|
+
const draftBlock = pages.composeDraftBlock({ page_id: 'status:explore', reading_hash: 'h', saved_at: '2026-09-19T00:00:00Z', lines: draftLines });
|
|
194
|
+
const body = `Page tweak round 2 for status:explore.\n\n${draftBlock}`;
|
|
195
|
+
const now = Date.parse('2026-09-21T00:00:00Z');
|
|
196
|
+
db.tasks.push({ id: '7010', status: 'ready', title: 'Tweak: Explore', description: body, source: 'page-tweak', source_ref: pages.roundSourceRef('status:explore', 2), touches: [], updated_at: '2026-09-19T23:00:00Z' });
|
|
197
|
+
db.tasks.push({ id: '7011', status: 'ready', title: 'Tweak: Explore', description: body, source: 'page-tweak', source_ref: pages.roundSourceRef('status:explore', 3), touches: [], updated_at: '2026-09-20T12:00:00Z' });
|
|
198
|
+
const r = await autoClose.runAutoClose({ now, founder: async () => ({ id: CARL }) });
|
|
199
|
+
const mine = r.closed.filter((c) => ['7010', '7011'].includes(c.task_id));
|
|
200
|
+
assert.deepEqual(mine.map((c) => [c.task_id, c.why, c.outcome]), [['7010', 'draft_expired', 'abandoned']], 'the draft touched 12 hours ago stays');
|
|
201
|
+
assert.equal(task(7010).status, 'abandoned');
|
|
202
|
+
assert.match(task(7010).value_summary, /^Abandoned: Closed by itself: the draft sat untouched for a day\./);
|
|
203
|
+
assert.equal(task(7010).description, body, 'the draft is kept: the description is not touched');
|
|
204
|
+
assert.equal(pages.parseDraftBlock(task(7010).description).ok, true, 'and still parses as the draft');
|
|
205
|
+
assert.equal(task(7011).status, 'ready');
|
|
206
|
+
});
|
|
187
207
|
} finally {
|
|
188
208
|
server.close();
|
|
189
209
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
@@ -56,7 +56,7 @@ await test('every state has an owner and a deadline: overdue only past it, told
|
|
|
56
56
|
const approved = pages.composeWordsApprovedBlock({ at: ago(1), by: ARTIST });
|
|
57
57
|
// [state, task shape, owner, days until overdue]
|
|
58
58
|
const table = [
|
|
59
|
-
['draft', (id) => ({ status: 'ready', description: desc(id, [draftBlock(id)]) }), 'artist',
|
|
59
|
+
['draft', (id) => ({ status: 'ready', description: desc(id, [draftBlock(id)]) }), 'artist', 1], // task 1004976: due when it expires, no reminder first,
|
|
60
60
|
['writing', () => ({ status: 'active', holder_id: ARTIST, holder_worktree: false }), 'artist', 7],
|
|
61
61
|
['words_review', (id) => ({ status: 'ready', description: desc(id, [batchBlock(id)]) }), 'artist', 3],
|
|
62
62
|
['submitted', (id) => ({ status: 'ready', description: desc(id, [batchBlock(id), approved]) }), 'appliers', 3],
|
|
@@ -82,7 +82,7 @@ await test('every state has an owner and a deadline: overdue only past it, told
|
|
|
82
82
|
}
|
|
83
83
|
const draft = status.composePages({ inventory: INVENTORY, readings: null, tasks: [round(51, 'landing:projects', { status: 'ready', description: desc('landing:projects', [draftBlock('landing:projects')]), updated_at: ago(2) })], now: NOW }).in_flight[0];
|
|
84
84
|
assert.equal(Date.parse(draft.expires_at) - Date.parse(draft.since), status.DRAFT_EXPIRES_AFTER, 'a draft says when it expires');
|
|
85
|
-
assert.equal(status.DRAFT_EXPIRES_AFTER,
|
|
85
|
+
assert.equal(status.DRAFT_EXPIRES_AFTER, D, 'a draft expires after one day (owner, task 1004971)');
|
|
86
86
|
});
|
|
87
87
|
|
|
88
88
|
await test('submitted words left unreviewed wait on their artist, not the appliers (task 1004900)', () => {
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// tests/credit_log_no_spend_db.mjs — task 1004988 (goal 1000092; ADR 0385
|
|
2
|
+
// decision 2, ADR 0387). Real DB.
|
|
3
|
+
//
|
|
4
|
+
// Builds credit_log in a throwaway schema, applies the REAL migration
|
|
5
|
+
// (migrations/core_286_credit_log_no_spend.sql), and proves the DATABASE refuses a row that spends,
|
|
6
|
+
// redeems or transfers credits, while every existing reversal path still books:
|
|
7
|
+
//
|
|
8
|
+
// - task.confirmed_reversal — the only reversal path that exists today: the
|
|
9
|
+
// ADR 0120 clawback, migrations/core_194_confirmed_backlog_clawback.sql. It is
|
|
10
|
+
// re-run here, verbatim, AFTER the trigger, exactly as the integration suite's
|
|
11
|
+
// clawback_confirmed_backlog test re-runs it on a fully migrated database.
|
|
12
|
+
// - reversal:<id> — the general shape the trigger defines for any future one.
|
|
13
|
+
//
|
|
14
|
+
// modules/economy itself writes no negative row today (every writer there books
|
|
15
|
+
// earnings, and the descent and idea writers drop a non-positive delta), so the
|
|
16
|
+
// economy writers are covered by the "positive and zero rows are untouched"
|
|
17
|
+
// cases rather than by a reversal of their own.
|
|
18
|
+
//
|
|
19
|
+
// The probe the task asks for is built in: the last block DROPS the trigger and
|
|
20
|
+
// shows the same spend row is then admitted, so a test that kept passing without
|
|
21
|
+
// the trigger would fail that block.
|
|
22
|
+
//
|
|
23
|
+
// Self-skips without a reachable Postgres.
|
|
24
|
+
// Run: DATABASE_URL=... node tests/credit_log_no_spend_db.mjs
|
|
25
|
+
import { createRequire } from 'node:module';
|
|
26
|
+
import { readFileSync } from 'node:fs';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
import { dirname, join } from 'node:path';
|
|
29
|
+
import { strict as assert } from 'node:assert';
|
|
30
|
+
const require = createRequire(import.meta.url);
|
|
31
|
+
const { Pool } = require('pg');
|
|
32
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
33
|
+
const MIGRATION = 'core_286_credit_log_no_spend.sql';
|
|
34
|
+
|
|
35
|
+
const pgPool = new Pool({ connectionString: process.env.DATABASE_URL || undefined, max: 1 });
|
|
36
|
+
let client;
|
|
37
|
+
try { client = await pgPool.connect(); } catch (e) {
|
|
38
|
+
console.log(`credit_log_no_spend_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
|
|
39
|
+
await pgPool.end().catch(() => {});
|
|
40
|
+
process.exit(0);
|
|
41
|
+
}
|
|
42
|
+
const SCHEMA = `t1004988_${process.pid}`;
|
|
43
|
+
let pass = 0;
|
|
44
|
+
const ok = (c, m) => { assert.ok(c, m); pass++; console.log(` ok - ${m}`); };
|
|
45
|
+
const refused = async (sql, params, m) => {
|
|
46
|
+
await client.query('SAVEPOINT s');
|
|
47
|
+
let err = null;
|
|
48
|
+
try { await client.query(sql, params); } catch (e) { err = e; }
|
|
49
|
+
await client.query('ROLLBACK TO SAVEPOINT s');
|
|
50
|
+
ok(err && err.code === '23514', `${m}${err ? '' : ' (it was admitted)'}`);
|
|
51
|
+
};
|
|
52
|
+
const ins = (builder, task, delta, reason) => client.query(
|
|
53
|
+
'INSERT INTO credit_log (builder_id, task_id, delta, reason) VALUES ($1, $2, $3, $4) RETURNING id',
|
|
54
|
+
[builder, task, delta, reason],
|
|
55
|
+
).then((r) => r.rows[0].id);
|
|
56
|
+
const balance = async (b) => Number((await client.query('SELECT total_credits FROM builders WHERE id = $1', [b])).rows[0].total_credits);
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
await client.query(`CREATE SCHEMA ${SCHEMA}`);
|
|
60
|
+
await client.query(`SET search_path TO ${SCHEMA}`);
|
|
61
|
+
// The columns credit_log and its balance trigger carry (migrations/003_pms.sql
|
|
62
|
+
// plus the later description/session columns), and the task columns core_194
|
|
63
|
+
// reads and writes.
|
|
64
|
+
await client.query(`CREATE TABLE builders (id bigserial PRIMARY KEY, total_credits integer NOT NULL DEFAULT 0)`);
|
|
65
|
+
await client.query(`CREATE TABLE tasks (id bigserial PRIMARY KEY, status text NOT NULL,
|
|
66
|
+
needs_rebase boolean NOT NULL DEFAULT false, needs_rebase_reason text, needs_rebase_at timestamptz, updated_at timestamptz)`);
|
|
67
|
+
await client.query(`CREATE TABLE credit_log (id bigserial PRIMARY KEY, builder_id bigint NOT NULL REFERENCES builders(id),
|
|
68
|
+
task_id bigint REFERENCES tasks(id), delta integer NOT NULL, reason text NOT NULL, description text,
|
|
69
|
+
session_id text, recorded_at timestamptz NOT NULL DEFAULT now())`);
|
|
70
|
+
await client.query(`CREATE FUNCTION pms_apply_credit() RETURNS trigger AS $$
|
|
71
|
+
BEGIN UPDATE builders SET total_credits = total_credits + NEW.delta WHERE id = NEW.builder_id; RETURN NEW; END;
|
|
72
|
+
$$ LANGUAGE plpgsql`);
|
|
73
|
+
await client.query('CREATE TRIGGER trg_credit_log_apply AFTER INSERT ON credit_log FOR EACH ROW EXECUTE FUNCTION pms_apply_credit()');
|
|
74
|
+
await client.query(readFileSync(join(here, '..', 'migrations', MIGRATION), 'utf8'));
|
|
75
|
+
|
|
76
|
+
await client.query('INSERT INTO builders DEFAULT VALUES'); // 1
|
|
77
|
+
await client.query('INSERT INTO builders DEFAULT VALUES'); // 2
|
|
78
|
+
await client.query('INSERT INTO builders DEFAULT VALUES'); // 3
|
|
79
|
+
await client.query("INSERT INTO tasks (status) VALUES ('confirmed'), ('shipped')"); // 1, 2
|
|
80
|
+
await client.query('BEGIN');
|
|
81
|
+
|
|
82
|
+
// Earning and audit rows are untouched.
|
|
83
|
+
const earned = await ins(1, 2, 30, 'task.shipped');
|
|
84
|
+
await ins(1, null, 12, 'session.token_reward');
|
|
85
|
+
await ins(1, null, 0, 'rank_change');
|
|
86
|
+
ok(await balance(1) === 42, 'positive and zero rows book as before');
|
|
87
|
+
// Every reason the economy writers book today (modules/economy/credits.js and
|
|
88
|
+
// achievements.js). All are positive; none may be caught by the spend names.
|
|
89
|
+
const ECONOMY_REASONS = [
|
|
90
|
+
'task.shipped', 'task.confirmed', 'session.token_reward', 'idea_promotion_bonus',
|
|
91
|
+
'ship.net_negative', 'tweak.page_approved:2', 'idea.credit.task_ship:7:2',
|
|
92
|
+
'idea.credit.full_pass:7', 'idea.credit.governance_pass:7', 'achievement.first-ship',
|
|
93
|
+
];
|
|
94
|
+
for (const reason of ECONOMY_REASONS) await ins(3, null, 1, reason);
|
|
95
|
+
ok(await balance(3) === ECONOMY_REASONS.length, `every economy writer's reason still books (${ECONOMY_REASONS.join(', ')})`);
|
|
96
|
+
|
|
97
|
+
// Spend, redemption, transfer: refused.
|
|
98
|
+
await refused('INSERT INTO credit_log (builder_id, task_id, delta, reason) VALUES (1, NULL, -5, $1)', ['spend'], 'a raw negative spend row is refused');
|
|
99
|
+
await refused('INSERT INTO credit_log (builder_id, task_id, delta, reason) VALUES (1, NULL, -5, $1)', ['redemption'], 'a raw negative redemption row is refused');
|
|
100
|
+
await refused('INSERT INTO credit_log (builder_id, task_id, delta, reason) VALUES (1, NULL, -5, $1)', ['module.purchase'], 'any other negative row is refused');
|
|
101
|
+
await refused('INSERT INTO credit_log (builder_id, task_id, delta, reason) VALUES (2, NULL, 5, $1)', ['transfer.in'], 'the positive half of a transfer is refused too');
|
|
102
|
+
await refused('INSERT INTO credit_log (builder_id, task_id, delta, reason) VALUES (1, NULL, 0, $1)', ['redemption'], 'a zero-value redemption row is refused');
|
|
103
|
+
ok(await balance(1) === 42, 'no refused row moved the balance');
|
|
104
|
+
// The refused names match as whole words, so a reason that only starts with
|
|
105
|
+
// the letters still books.
|
|
106
|
+
await ins(3, null, 1, 'achievement.spender-of-the-month');
|
|
107
|
+
await ins(3, null, 1, 'spender-of-the-month');
|
|
108
|
+
ok(await balance(3) === ECONOMY_REASONS.length + 2, 'a reason that merely starts with "spend" is not refused');
|
|
109
|
+
await refused('INSERT INTO credit_log (builder_id, task_id, delta, reason) VALUES (1, NULL, 1, $1)', ['spend:7'], 'spend:<x> is refused');
|
|
110
|
+
await refused('INSERT INTO credit_log (builder_id, task_id, delta, reason) VALUES (1, NULL, 1, $1)', ['Transfer-out'], 'and the match ignores case');
|
|
111
|
+
|
|
112
|
+
// reversal:<id> — exists, same builder, positive, never more than booked.
|
|
113
|
+
await refused('INSERT INTO credit_log (builder_id, task_id, delta, reason) VALUES (1, NULL, -5, $1)', ['reversal:999999'], 'a reversal naming no row is refused');
|
|
114
|
+
await refused('INSERT INTO credit_log (builder_id, task_id, delta, reason) VALUES (2, NULL, -5, $1)', [`reversal:${earned}`], 'a reversal of another builder\'s row is refused');
|
|
115
|
+
await refused('INSERT INTO credit_log (builder_id, task_id, delta, reason) VALUES (1, NULL, -31, $1)', [`reversal:${earned}`], 'a reversal larger than the row is refused');
|
|
116
|
+
await ins(1, 2, -20, `reversal:${earned}`);
|
|
117
|
+
await ins(1, 2, -10, `reversal:${earned}`);
|
|
118
|
+
ok(await balance(1) === 12, 'reversal:<id> books, in parts, up to the whole row');
|
|
119
|
+
await refused('INSERT INTO credit_log (builder_id, task_id, delta, reason) VALUES (1, NULL, -1, $1)', [`reversal:${earned}`], 'a reversal past the row\'s total is refused');
|
|
120
|
+
const audit = (await client.query("SELECT id FROM credit_log WHERE reason = 'rank_change'")).rows[0].id;
|
|
121
|
+
await refused('INSERT INTO credit_log (builder_id, task_id, delta, reason) VALUES (1, NULL, -1, $1)', [`reversal:${audit}`], 'a zero row cannot be reversed');
|
|
122
|
+
|
|
123
|
+
// task.confirmed_reversal — the ADR 0120 clawback, re-run verbatim.
|
|
124
|
+
await ins(2, 1, 15, 'task.confirmed');
|
|
125
|
+
const core194 = readFileSync(join(here, '..', 'migrations', 'core_194_confirmed_backlog_clawback.sql'), 'utf8')
|
|
126
|
+
.replace(/^\s*(BEGIN|COMMIT);\s*$/gim, ''); // already inside this test's transaction
|
|
127
|
+
await client.query(core194);
|
|
128
|
+
ok(await balance(2) === 0, 'core_194 (task.confirmed_reversal) still reverses a confirmed task\'s credit');
|
|
129
|
+
await refused('INSERT INTO credit_log (builder_id, task_id, delta, reason) VALUES (2, 1, -1, $1)', ['task.confirmed_reversal'], 'that reversal cannot go past what was booked');
|
|
130
|
+
await refused('INSERT INTO credit_log (builder_id, task_id, delta, reason) VALUES (2, 2, -1, $1)', ['task.confirmed_reversal'], 'nor reverse a task that booked nothing');
|
|
131
|
+
await refused('INSERT INTO credit_log (builder_id, task_id, delta, reason) VALUES (2, NULL, -1, $1)', ['task.confirmed_reversal'], 'nor run without a task');
|
|
132
|
+
|
|
133
|
+
// Existing rows are never rewritten: the trigger is INSERT-only.
|
|
134
|
+
const { rows: trig } = await client.query(
|
|
135
|
+
"SELECT tgtype FROM pg_trigger WHERE tgname = 'trg_credit_log_refuse_spend' AND tgrelid = 'credit_log'::regclass");
|
|
136
|
+
// tgtype bits: 1 ROW, 2 BEFORE, 4 INSERT, 8 DELETE, 16 UPDATE.
|
|
137
|
+
ok(trig.length === 1 && (trig[0].tgtype & 2) && (trig[0].tgtype & 4) && !(trig[0].tgtype & 24), 'the trigger is BEFORE INSERT only');
|
|
138
|
+
const { rows: idx } = await client.query(
|
|
139
|
+
"SELECT pg_get_indexdef(indexrelid) AS def FROM pg_index WHERE indexrelid = 'idx_credit_log_reversals'::regclass");
|
|
140
|
+
ok(idx.length === 1 && /\(builder_id, reason\) WHERE \(delta < 0\)/.test(idx[0].def),
|
|
141
|
+
'the reversal sums read a partial index of negative rows only, not the whole ledger');
|
|
142
|
+
const { rows: cmt } = await client.query(`SELECT col_description('credit_log'::regclass,
|
|
143
|
+
(SELECT attnum FROM pg_attribute WHERE attrelid = 'credit_log'::regclass AND attname = 'delta')) AS d,
|
|
144
|
+
obj_description('credit_log'::regclass) AS t`);
|
|
145
|
+
ok(!/redemption|spent \(future\)/i.test(`${cmt[0].d} ${cmt[0].t}`), 'the comments no longer offer redemption or a future spend');
|
|
146
|
+
|
|
147
|
+
// The probe: without the trigger the same spend row is admitted.
|
|
148
|
+
await client.query('DROP TRIGGER trg_credit_log_refuse_spend ON credit_log');
|
|
149
|
+
await ins(1, null, -5, 'spend');
|
|
150
|
+
ok(await balance(1) === 7, 'probe: with the trigger dropped, the spend row books — the trigger is what refuses it');
|
|
151
|
+
|
|
152
|
+
await client.query('ROLLBACK');
|
|
153
|
+
console.log(`credit_log_no_spend_db: ${pass} passed`);
|
|
154
|
+
} finally {
|
|
155
|
+
await client.query('ROLLBACK').catch(() => {});
|
|
156
|
+
await client.query('SET search_path TO public').catch(() => {});
|
|
157
|
+
await client.query(`DROP SCHEMA IF EXISTS ${SCHEMA} CASCADE`).catch(() => {});
|
|
158
|
+
client.release();
|
|
159
|
+
await pgPool.end();
|
|
160
|
+
}
|
|
@@ -172,6 +172,14 @@ const NON_PUBLIC_READS = [
|
|
|
172
172
|
fn: 'listLoginsForPerson',
|
|
173
173
|
why: 'the own-scope read behind GET /me/sign-in-methods (task 1004992): which ways in the signed-in person has, keyed on their authenticated key only. It returns their own providers and addresses to themselves; a private account must still see its own sign-in methods.',
|
|
174
174
|
},
|
|
175
|
+
{
|
|
176
|
+
fn: 'linkLogin',
|
|
177
|
+
why: 'linking a second way in (task 1004995): finds the signed-in person\'s own account row by their authenticated key to attach the login to. It returns only linked/refused to that person; a private account must still be able to link.',
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
fn: 'unlinkLogin',
|
|
181
|
+
why: 'removing a way in (task 1004995): locks the signed-in person\'s own logins by their authenticated key to count them. It returns only unlinked/refused to that person; a private account must still be able to unlink.',
|
|
182
|
+
},
|
|
175
183
|
{
|
|
176
184
|
fn: 'hiddenGithubIds',
|
|
177
185
|
why: 'the closeness-graph consent gate — it answers only "which of these accounts set hide_stats", the OPPOSITE question to visibility. Its callers are requireBuilder-gated and it must stay fail-closed on hide alone (ADR 0171 D4); joining addressability would silently empty the graph of every private account for readers who are allowed to see the edge.',
|
|
@@ -83,8 +83,8 @@ test('the script builds no markup from strings, and the controls are real markup
|
|
|
83
83
|
|
|
84
84
|
test('the door returns here, and the page calls only its own-scoped routes', () => {
|
|
85
85
|
assert.match(PAGE, /href="\/api\/bongos\/auth\/web\/start\?return=\/account&repos=1"/, 'sign-in lands back on /account, asking for repo access once (task 1004549)');
|
|
86
|
-
const calls = [...SCRIPT.matchAll(/request\('(GET|PATCH|POST)', '([^']+)'/g)].map((m) => `${m[1]} ${m[2]}`);
|
|
87
|
-
assert.deepEqual([...new Set(calls)].sort(), ['GET /me/cross-project', 'PATCH /me/cross-project', 'PATCH /me/handle', 'PATCH /me/profile', 'GET /my-projects', 'POST /my-projects/', 'GET /me/guilds'].sort());
|
|
86
|
+
const calls = [...SCRIPT.matchAll(/request\('(GET|PATCH|POST|DELETE)', '([^']+)'/g)].map((m) => `${m[1]} ${m[2]}`);
|
|
87
|
+
assert.deepEqual([...new Set(calls)].sort(), ['GET /me/cross-project', 'PATCH /me/cross-project', 'PATCH /me/handle', 'PATCH /me/profile', 'GET /my-projects', 'POST /my-projects/', 'GET /me/guilds', 'GET /me/sign-in-methods', 'DELETE /me/sign-in-methods/'].sort());
|
|
88
88
|
// the bar's two calls live in the shared asset: the identity read and sign out
|
|
89
89
|
const barCalls = [...BAR.matchAll(/fetch\(API \+ '([^']+)', \{ method: '(GET|POST)'/g)].map((m) => `${m[2]} ${m[1]}`);
|
|
90
90
|
assert.deepEqual(barCalls.sort(), ['GET /me', 'POST /auth/logout']);
|
|
@@ -183,7 +183,7 @@ const ACCOUNT = () => ({
|
|
|
183
183
|
// A DOM stub just wide enough for this page: every id in the markup is an
|
|
184
184
|
// element (hidden where the markup says so), every radio and link input is
|
|
185
185
|
// addressable by the selectors the script uses, and fetch answers from a handler.
|
|
186
|
-
function boot(answer) {
|
|
186
|
+
function boot(answer, { search = '' } = {}) {
|
|
187
187
|
const els = new Map();
|
|
188
188
|
const listeners = (o) => { o._on = {}; o.addEventListener = (t, f) => { (o._on[t] ||= []).push(f); }; return o; };
|
|
189
189
|
// textContent reads the node's own text plus its children's, and setting it
|
|
@@ -299,7 +299,7 @@ function boot(answer) {
|
|
|
299
299
|
};
|
|
300
300
|
const location = { hostname: 'cloudbongos.com', pathname: '/account' };
|
|
301
301
|
// one global, as in the browser: hub-bar.js sets window.HubBar, then the page reads it
|
|
302
|
-
const ctx = vm.createContext({ document, fetch, location, window: { location: { assign(p) { dom.assigned = p; } } }, URL, Array, Object, JSON, Promise });
|
|
302
|
+
const ctx = vm.createContext({ document, fetch, location, window: { location: { search, assign(p) { dom.assigned = p; } } }, URL, Array, Object, JSON, Promise });
|
|
303
303
|
vm.runInContext(BAR, ctx);
|
|
304
304
|
vm.runInContext(SCRIPT, ctx);
|
|
305
305
|
return dom;
|
|
@@ -498,7 +498,7 @@ test('the section ships inside the signed-in body, last, as a list with an empty
|
|
|
498
498
|
test('signed in: the list is read once, after the account, and each row names its project and host with its own Leave', async () => {
|
|
499
499
|
const dom = boot(building(BUILDING));
|
|
500
500
|
await settle();
|
|
501
|
-
assert.deepEqual(dom.calls.filter((c) => c.method === 'GET' && c.path !== '/me').map((c) => c.path), ['/me/cross-project', '/my-projects', '/me/guilds']);
|
|
501
|
+
assert.deepEqual(dom.calls.filter((c) => c.method === 'GET' && c.path !== '/me').map((c) => c.path), ['/me/cross-project', '/my-projects', '/me/guilds', '/me/sign-in-methods']);
|
|
502
502
|
assert.equal(dom.els.get('buildingLoading').hidden, true);
|
|
503
503
|
assert.equal(dom.els.get('buildingEmpty').hidden, true);
|
|
504
504
|
const rows = rowsOf(dom);
|
|
@@ -857,3 +857,89 @@ test('the hall trusts only a bare https hub origin (OTB.hubOrigin, shared by Set
|
|
|
857
857
|
assert.equal(origin('https://cloudbongos.com/account'), null, 'a value with a path is not an origin');
|
|
858
858
|
assert.equal(origin('https://user@cloudbongos.com'), null);
|
|
859
859
|
});
|
|
860
|
+
|
|
861
|
+
// ── ways to sign in (task 1004995) ───────────────────────────────────────────
|
|
862
|
+
// Link is a link to the sign-in routes; their answer comes back in the address.
|
|
863
|
+
// A way in the server says cannot be removed shows why, never a Remove.
|
|
864
|
+
|
|
865
|
+
const WAYS = (methods) => (m, p) => {
|
|
866
|
+
if (m === 'GET' && p === '/me/cross-project') return { status: 200, data: ACCOUNT() };
|
|
867
|
+
if (m === 'GET' && p === '/me/sign-in-methods') return { status: 200, data: { methods, providers: ['github', 'google'] } };
|
|
868
|
+
if (m === 'DELETE' && p === '/me/sign-in-methods/google') return { status: 200, data: { ok: true } };
|
|
869
|
+
if (m === 'DELETE' && p === '/me/sign-in-methods/github') return { status: 409, data: { error: { code: 'last_way_in', message: 'this is your only way in, so it cannot be removed — link another first' } } };
|
|
870
|
+
return { status: 500, data: null };
|
|
871
|
+
};
|
|
872
|
+
const waysOf = (dom) => dom.els.get('waysList').children;
|
|
873
|
+
const actionOf = (row) => row.children[1].children[0];
|
|
874
|
+
|
|
875
|
+
test('the ways section sits in the signed-in body with its list and status line', () => {
|
|
876
|
+
const body = PAGE.split('<div id="acctBody" hidden>')[1].split('</main>')[0];
|
|
877
|
+
const sec = (body.match(/<section class="aSec" id="aWaysIn" aria-labelledby="aWaysIn-h">[\s\S]*?<\/section>/) || [''])[0];
|
|
878
|
+
assert.ok(sec, 'ways to sign in is a section of the account body (the link routes land on #aWaysIn)');
|
|
879
|
+
assert.match(sec, /<ul class="mineRows" id="waysList"><\/ul>/);
|
|
880
|
+
assert.match(sec, /Linking never joins two accounts\./, 'the page says the no-merge rule up front');
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
test('one row per provider: a held way in shows its address and Remove; a missing one links to its sign-in', async () => {
|
|
884
|
+
const dom = boot(WAYS([
|
|
885
|
+
{ provider: 'github', email: null, unlink_refused: 'born_with' },
|
|
886
|
+
]));
|
|
887
|
+
await settle();
|
|
888
|
+
const rows = waysOf(dom);
|
|
889
|
+
assert.equal(rows.length, 2);
|
|
890
|
+
assert.equal(rows[0].children[0].children[0].textContent, 'GitHub');
|
|
891
|
+
assert.equal(rows[0].children[0].children[1].textContent, 'linked', 'no address shown → linked');
|
|
892
|
+
assert.equal(rows[0].children[0].children[2].textContent, 'Your account was made with GitHub, so GitHub stays linked.');
|
|
893
|
+
assert.equal(rows[0].children[1].children.length, 0, 'no Remove for a way in the server keeps');
|
|
894
|
+
assert.equal(rows[1].children[0].children[1].textContent, 'not linked');
|
|
895
|
+
assert.equal(actionOf(rows[1]).textContent, 'Link Google');
|
|
896
|
+
assert.equal(actionOf(rows[1]).href, '/api/bongos/auth/google/start?link=1');
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
test('Link GitHub goes to the GitHub link route; the only way in says why it stays', async () => {
|
|
900
|
+
const dom = boot(WAYS([{ provider: 'google', email: 'ana@example.com', unlink_refused: 'last_way_in' }]));
|
|
901
|
+
await settle();
|
|
902
|
+
const [gh, goo] = waysOf(dom);
|
|
903
|
+
assert.equal(actionOf(gh).href, '/api/bongos/auth/link/github/start');
|
|
904
|
+
assert.equal(goo.children[0].children[1].textContent, 'ana@example.com');
|
|
905
|
+
assert.match(goo.children[0].children[2].textContent, /^Your only way in, so it stays\./);
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
test('Remove sends one DELETE, says so, and re-reads the list', async () => {
|
|
909
|
+
const dom = boot(WAYS([
|
|
910
|
+
{ provider: 'github', email: null, unlink_refused: null },
|
|
911
|
+
{ provider: 'google', email: 'ana@example.com', unlink_refused: null },
|
|
912
|
+
]));
|
|
913
|
+
await settle();
|
|
914
|
+
const google = waysOf(dom)[1];
|
|
915
|
+
dom.click(actionOf(google));
|
|
916
|
+
assert.equal(actionOf(google).textContent, 'Removing…');
|
|
917
|
+
await settle();
|
|
918
|
+
assert.deepEqual(dom.calls.filter((c) => c.method === 'DELETE').map((c) => c.path), ['/me/sign-in-methods/google']);
|
|
919
|
+
assert.equal(dom.text('waysMsg'), 'Google is removed. It no longer opens this account.');
|
|
920
|
+
assert.equal(dom.calls.filter((c) => c.path === '/me/sign-in-methods' && c.method === 'GET').length, 2);
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
test('a refused Remove is said in the server’s words and keeps the button', async () => {
|
|
924
|
+
const dom = boot(WAYS([
|
|
925
|
+
{ provider: 'github', email: null, unlink_refused: null },
|
|
926
|
+
{ provider: 'google', email: null, unlink_refused: null },
|
|
927
|
+
]));
|
|
928
|
+
await settle();
|
|
929
|
+
const gh = waysOf(dom)[0];
|
|
930
|
+
dom.click(actionOf(gh));
|
|
931
|
+
await settle();
|
|
932
|
+
assert.equal(dom.text('waysMsg'), 'This is your only way in, so it cannot be removed — link another first.');
|
|
933
|
+
assert.equal(actionOf(gh).disabled, false);
|
|
934
|
+
assert.equal(actionOf(gh).textContent, 'Remove');
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
test('a link’s answer is said plainly: linked, already its own account (never merged), cancelled', async () => {
|
|
938
|
+
const say = async (search) => { const d = boot(WAYS([]), { search }); await settle(); return [d.text('waysMsg'), d.els.get('waysMsg').className]; };
|
|
939
|
+
assert.deepEqual(await say('?linked=google'), ['Google is linked. You can now sign in with either.', 'projMsg ok']);
|
|
940
|
+
const [taken, cls] = await say('?link_refused=taken&provider=github');
|
|
941
|
+
assert.match(taken, /^That GitHub account already has its own account here, so it was not linked\. We never join two accounts; nothing changed\.$/);
|
|
942
|
+
assert.equal(cls, 'projMsg err');
|
|
943
|
+
assert.match((await say('?link_refused=cancelled&provider=google'))[0], /^You cancelled linking Google\. Nothing changed\.$/);
|
|
944
|
+
assert.match((await say('?link_refused=whatever&provider=google'))[0], /^Couldn’t link Google\. Try again\.$/);
|
|
945
|
+
});
|
|
@@ -65,9 +65,11 @@ function buildRouterWithStubs({ timer, categories, reward = WORKING_REWARD }) {
|
|
|
65
65
|
list: gc.listCategories,
|
|
66
66
|
set: api.projectSettings.set,
|
|
67
67
|
resolveShipMode: api.projectSettings.resolveShipMode,
|
|
68
|
+
resolveTweak: api.projectSettings.resolveTweakAutopickup,
|
|
68
69
|
};
|
|
69
70
|
api.resolveRotDays = async () => timer;
|
|
70
71
|
api.projectSettings.resolveShipMode = async () => ({ mode: 'auto', source: 'default', chosen: false, invalid: null });
|
|
72
|
+
api.projectSettings.resolveTweakAutopickup = async () => ({ mode: 'off', cap: 200, rank_caps: {}, source: { mode: 'default', cap: 'default', rank_caps: 'default' }, invalid: [] });
|
|
71
73
|
// reward: WORKING_REWARD (default) | null (port absent) | 'throws' (rewardConfig raises).
|
|
72
74
|
const rewardPort = reward === 'throws'
|
|
73
75
|
? { rewardConfig: () => { throw new Error('reward boom'); } }
|
|
@@ -84,6 +86,7 @@ function buildRouterWithStubs({ timer, categories, reward = WORKING_REWARD }) {
|
|
|
84
86
|
gc.listCategories = saved.list;
|
|
85
87
|
api.projectSettings.set = saved.set;
|
|
86
88
|
api.projectSettings.resolveShipMode = saved.resolveShipMode;
|
|
89
|
+
api.projectSettings.resolveTweakAutopickup = saved.resolveTweak;
|
|
87
90
|
};
|
|
88
91
|
// The handler is the LAST layer on the matching route (requireBuilder and the
|
|
89
92
|
// permission gate sit in front); calling it directly is what keeps this a unit test
|
|
@@ -103,22 +106,22 @@ const CATS = [
|
|
|
103
106
|
{ id: 2, slug: 'retired-thing', label: 'Retired thing', sort_order: 20, active: false },
|
|
104
107
|
];
|
|
105
108
|
|
|
106
|
-
test('one read returns all
|
|
109
|
+
test('one read returns all seven fundamentals, each naming where its value came from', async () => {
|
|
107
110
|
const { handlerFor, restore } = buildRouterWithStubs({ timer: TIMER_OK, categories: CATS });
|
|
108
111
|
try {
|
|
109
112
|
const res = fakeRes();
|
|
110
113
|
await handlerFor('get', '/project-settings')({ query: {} }, res);
|
|
111
114
|
const rows = res.payload.rows;
|
|
112
|
-
assert.equal(rows.length,
|
|
115
|
+
assert.equal(rows.length, 7, 'the page draws seven rows, so the read returns seven');
|
|
113
116
|
assert.deepEqual(rows.map((r) => r.key),
|
|
114
|
-
['ship_mode', 'rot_timer', 'work_categories', 'credit_calculation', 'equity_calculation', 'upmarket_percent']);
|
|
117
|
+
['ship_mode', 'tweak_round_cap', 'rot_timer', 'work_categories', 'credit_calculation', 'equity_calculation', 'upmarket_percent']);
|
|
115
118
|
// Provenance is the point of the page: every row says where its value came from.
|
|
116
119
|
for (const r of rows) {
|
|
117
120
|
assert.ok(r.reads && r.reads.file, `${r.key} names no file`);
|
|
118
121
|
assert.ok(r.sentence && r.sentence.length > 20, `${r.key} has no readable sentence`);
|
|
119
122
|
}
|
|
120
|
-
//
|
|
121
|
-
assert.deepEqual(rows.filter((r) => r.editable).map((r) => r.key), ['ship_mode', 'rot_timer', 'work_categories']);
|
|
123
|
+
// Four knobs: the criterion's two, ship mode (task 1004628) and the monthly tweak-round cap (task 1005003).
|
|
124
|
+
assert.deepEqual(rows.filter((r) => r.editable).map((r) => r.key), ['ship_mode', 'tweak_round_cap', 'rot_timer', 'work_categories']);
|
|
122
125
|
// A no-store receipt: a cached settings read is wrong the moment a knob turns.
|
|
123
126
|
assert.equal(res.headers['Cache-Control'], 'no-store');
|
|
124
127
|
} finally { restore(); }
|