@bongos/core 1.19.810 → 1.19.812
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 +97 -37
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +24 -0
- package/clients/bongos-client/index.cjs +24 -0
- package/clients/bongos-client/index.d.ts +39 -0
- package/clients/bongos-client/index.mjs +24 -0
- package/docs/adr/0296-a-role-speciality-is-data-over-three-existing-surfaces.md +71 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +867 -73
- package/docs/api-reference.md +19 -3
- package/docs/copy-inventory.md +48 -20
- package/docs/copy-registry.json +275 -23
- package/docs/module-api-changelog.md +4 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +24 -0
- package/modules/hall-ui/public/settings-specialities.js +241 -0
- package/modules/hall-ui/public/settings.css +56 -0
- package/modules/hall-ui/public/settings.html +65 -0
- package/modules/lifecycle/db-claim-reads.js +5 -1
- package/modules/memory/learnings.js +21 -2
- package/modules/provisioning/starter-bundles.js +1 -0
- package/modules/specialities/CLAUDE.md +15 -0
- package/modules/specialities/db.js +234 -0
- package/modules/specialities/migrations/specialities_001_specialities.sql +143 -0
- package/modules/specialities/module.json +12 -0
- package/modules/specialities/routes/specialities.js +379 -0
- package/modules/specialities/specialities.js +198 -0
- package/modules/ui-design/kit/serve.js +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/publish-manifest.js +1 -0
- package/scripts/gds/search-ingest-db.js +37 -0
- package/src/bongos/routes/me.js +17 -0
- package/src/module-api.js +1 -1
- package/tests/cli_exit_no_abort.mjs +45 -0
- package/tests/module_loader.mjs +1 -1
- package/tests/own_scope_guard.mjs +10 -0
- package/tests/session_caps.mjs +16 -1
- package/tests/specialities.mjs +214 -0
- package/tests/speciality_corpus_scope.mjs +97 -0
- package/tests/speciality_db_shape.mjs +124 -0
- package/tests/speciality_injection.mjs +107 -0
|
@@ -90,6 +90,43 @@ const SOURCES = [
|
|
|
90
90
|
map: (r) => buildRows({ sourceRef: `session_log:${r.id}`, title: `Session log ${r.id}${r.task_id ? ` · task ${r.task_id}` : ''}`, body: r.notes_md, scrub: true }) },
|
|
91
91
|
{ name: 'task_grades', query: `SELECT id, task_id, notes_md FROM task_grades WHERE notes_md IS NOT NULL`,
|
|
92
92
|
map: (r) => buildRows({ sourceRef: `grade:${r.id}`, title: `Grade · task ${r.task_id}`, body: r.notes_md, scrub: true }) },
|
|
93
|
+
// Speciality documents → OWNER-SCOPED unless the speciality is public.
|
|
94
|
+
//
|
|
95
|
+
// The knowledge an expert trained INTO a speciality (task 1003855). This source
|
|
96
|
+
// is what makes the injected contract's "search it with /recall" true rather
|
|
97
|
+
// than aspirational.
|
|
98
|
+
//
|
|
99
|
+
// SCOPING ERRS TOWARD UNDER-SHARING. This index expresses only two axes —
|
|
100
|
+
// min_rank and owner_builder_id — and a 'project' speciality is neither: it is
|
|
101
|
+
// visible to a set of builders the index cannot name. So only a PUBLIC
|
|
102
|
+
// speciality's documents are corpus-wide; private AND project both fall back to
|
|
103
|
+
// owner-scoped. A project member therefore sees the speciality and can adopt it,
|
|
104
|
+
// but does not get its documents in /recall until it is public. That is a
|
|
105
|
+
// smaller answer than the sharing rule allows, which is the right direction for
|
|
106
|
+
// an index to be wrong in — the alternative leaks one project's material to
|
|
107
|
+
// every builder on the instance.
|
|
108
|
+
{ name: 'specialities_documents',
|
|
109
|
+
// A row is indexed ONLY if it is public (corpus-wide, deliberately) or has a
|
|
110
|
+
// real builder owner to scope it to. The WHERE clause is the first of two
|
|
111
|
+
// guards: a project-owned, non-public speciality never leaves Postgres.
|
|
112
|
+
query: `SELECT d.id, d.title, d.body_md, s.name AS speciality_name,
|
|
113
|
+
s.visibility, s.owner_builder_id
|
|
114
|
+
FROM specialities_documents d JOIN specialities_catalog s ON s.id = d.speciality_id
|
|
115
|
+
WHERE s.visibility = 'public' OR s.owner_builder_id IS NOT NULL`,
|
|
116
|
+
map: (r) => {
|
|
117
|
+
// The second guard, and NOT redundant: buildRows treats a null owner as
|
|
118
|
+
// corpus-wide, so any future widening of the query above would silently
|
|
119
|
+
// publish private material. The mapper refuses to emit an unscoped chunk
|
|
120
|
+
// for anything that is not public, whatever the query hands it.
|
|
121
|
+
const isPublic = r.visibility === 'public';
|
|
122
|
+
if (!isPublic && !r.owner_builder_id) return [];
|
|
123
|
+
return buildRows({
|
|
124
|
+
sourceRef: `speciality_doc:${r.id}`,
|
|
125
|
+
title: `${r.speciality_name} · ${r.title}`,
|
|
126
|
+
body: r.body_md,
|
|
127
|
+
ownerBuilderId: isPublic ? null : r.owner_builder_id,
|
|
128
|
+
});
|
|
129
|
+
} },
|
|
93
130
|
// Per-builder memory → OWNER-SCOPED + scrubbed.
|
|
94
131
|
{ name: 'builder_memory', query: `SELECT builder_id, path, content FROM builder_memory`,
|
|
95
132
|
map: (r) => buildRows({ sourceRef: `memory:${r.builder_id}:${r.path}`, title: r.path, body: r.content, ownerBuilderId: r.builder_id, scrub: true }) },
|
package/src/bongos/routes/me.js
CHANGED
|
@@ -174,6 +174,11 @@ module.exports = function buildMeRouter() {
|
|
|
174
174
|
// present in prod; degrades to a null state row (shapeForApi synthesizes the
|
|
175
175
|
// "nothing cleared" view) when the module is off.
|
|
176
176
|
const onboarding = seams.resolveOptional('onboarding');
|
|
177
|
+
// task 1003855: role SUB-SPECIALITIES. A speciality carries a contract that is
|
|
178
|
+
// injected exactly like the interaction profile beside it — same port pattern,
|
|
179
|
+
// same GET /me -> Conductor path, deliberately not a second mechanism.
|
|
180
|
+
// default-on, so present in prod; null when the module is off.
|
|
181
|
+
const specialities = seams.resolveOptional('specialities');
|
|
177
182
|
const [
|
|
178
183
|
builder, activeClaims, estVsActual, unlocked, lockedWithProgress, streakInfo,
|
|
179
184
|
recentGrades, builderAvg, onboardingRow, shipCount, recentKarma, karma30d, discordLink, boxConnected, cliAuthed,
|
|
@@ -452,6 +457,18 @@ module.exports = function buildMeRouter() {
|
|
|
452
457
|
// session-start injection + /settings UI read this; describeInteraction is
|
|
453
458
|
// the SSOT (builder-settings port). null when the prefs module is off.
|
|
454
459
|
interaction: settings && settings.describeInteraction ? settings.describeInteraction(behaviorPrefs) : null,
|
|
460
|
+
// task 1003855: the builder's ACTIVE specialities, one per craft at most
|
|
461
|
+
// (the speciality_adoptions partial unique index enforces that). Each entry
|
|
462
|
+
// carries its own injectable contract; the Conductor picks the one matching
|
|
463
|
+
// the craft of the claim in hand rather than injecting all of them, because
|
|
464
|
+
// an artist's expert contract in an engineering session is noise at best.
|
|
465
|
+
specialities: await (async () => {
|
|
466
|
+
// Resolved through the port, never a direct require: the core must keep
|
|
467
|
+
// working with the module off, and [] reads as "no specialities", which
|
|
468
|
+
// is exactly what a session with the module disabled should see.
|
|
469
|
+
if (!specialities || typeof specialities.describeActiveFor !== 'function') return [];
|
|
470
|
+
try { return await specialities.describeActiveFor(builder.id); } catch { return []; }
|
|
471
|
+
})(),
|
|
455
472
|
// task 2011: the resolved subagent model allocation (main + subagent tiers),
|
|
456
473
|
// builder override → instance default (branding.models) → code default. The
|
|
457
474
|
// agent consults `effective` when spawning subagents; §2 links here.
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.812'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -493,3 +493,48 @@ test('main-audit.js exits 0 after a SUCCESSFUL roster request — the teardown t
|
|
|
493
493
|
proc.kill();
|
|
494
494
|
}
|
|
495
495
|
});
|
|
496
|
+
|
|
497
|
+
// ---- tests/session_caps.mjs (task 1003827) ----
|
|
498
|
+
//
|
|
499
|
+
// Not a CLI, but the same abort and the same cure. It printed "9 passed, 0
|
|
500
|
+
// failed" and then died 127 in 4 of 6 runs on Windows + Node 24. run-unit-tests.js
|
|
501
|
+
// reads the exit code, so a suite that fully passed was reported FAILED in most
|
|
502
|
+
// local baselines — which is worse than a plain red, because it teaches builders
|
|
503
|
+
// that a failing name is environmental noise.
|
|
504
|
+
const SESSION_CAPS = path.join(ROOT, 'tests', 'session_caps.mjs');
|
|
505
|
+
|
|
506
|
+
test('session_caps.mjs exits 0 after its server teardown, repeatedly (task 1003827)', () => {
|
|
507
|
+
// Three runs, because the abort was NONDETERMINISTIC — a single clean run
|
|
508
|
+
// proved nothing before the fix and would prove nothing after a regression.
|
|
509
|
+
for (let i = 0; i < 3; i++) {
|
|
510
|
+
const r = spawnSync(process.execPath, [SESSION_CAPS], { encoding: 'utf8', timeout: 60000 });
|
|
511
|
+
assert.notEqual(r.signal, 'SIGTERM', `run ${i + 1} timed out — a leaked handle, which is what removing the hard exit risks`);
|
|
512
|
+
assert.doesNotMatch(r.stderr || '', ABORT_RE, `run ${i + 1} aborted natively:\n${r.stderr}`);
|
|
513
|
+
assert.equal(r.status, 0, `run ${i + 1} exited ${r.status} (127 = the abort); stderr: ${r.stderr}`);
|
|
514
|
+
assert.match(r.stdout || '', /0 failed/, `run ${i + 1} did not report a passing suite`);
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
test('session_caps.mjs sets exitCode rather than calling process.exit (task 1003827)', () => {
|
|
519
|
+
// The deterministic half: the behavioural test above can pass by luck on a
|
|
520
|
+
// platform where the race does not fire, so the shape is pinned directly.
|
|
521
|
+
const src = fs.readFileSync(SESSION_CAPS, 'utf8');
|
|
522
|
+
// Comments stripped first: the fix's own comment explains what it does NOT do
|
|
523
|
+
// and names the call, so a raw scan matches the prose and reds a correct file.
|
|
524
|
+
// (Caught by this test on its first run — the guard found its own false positive.)
|
|
525
|
+
const code = src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
|
526
|
+
// Anti-vacuity: prove the pattern MATCHES something before trusting that it
|
|
527
|
+
// matches nothing here. A scanner that silently finds nothing looks identical
|
|
528
|
+
// to a scanner that works — and stripping comments is exactly the kind of step
|
|
529
|
+
// that can blank the haystack.
|
|
530
|
+
const HARD_EXIT = /process\.exit\s*\(/;
|
|
531
|
+
assert.match('process.exit(1)', HARD_EXIT, 'control: the pattern must detect a hard exit');
|
|
532
|
+
assert.ok(src.length > 1000, 'control: the suite was actually read');
|
|
533
|
+
assert.ok(code.length > src.length / 2, 'control: stripping comments did not blank the source');
|
|
534
|
+
assert.match(code, /server\.listen/, 'control: real code survived the strip');
|
|
535
|
+
assert.doesNotMatch(code, HARD_EXIT, 'session_caps.mjs must set process.exitCode and let the loop drain');
|
|
536
|
+
assert.match(code, /process\.exitCode\s*=/, 'and it must still report failure through the exit code');
|
|
537
|
+
// The other half of the cure: a hard exit MASKS leaked handles, so dropping it
|
|
538
|
+
// without releasing the keep-alive sockets trades the crash for a hang.
|
|
539
|
+
assert.match(code, /closeAllConnections/, 'sockets must be released before server.close()');
|
|
540
|
+
});
|
package/tests/module_loader.mjs
CHANGED
|
@@ -276,7 +276,7 @@ test('the live tree discovers the extracted modules cleanly (BV1.R43/R48/R47/R49
|
|
|
276
276
|
// That is precisely how the agents regression reached an open PR (task 1002485).
|
|
277
277
|
loader._reset();
|
|
278
278
|
const snap = loader.loaderSnapshot();
|
|
279
|
-
assert.deepEqual(snap.loaded, ['agents', 'autonomy', 'builder-settings', 'copy-desk', 'dev-box', 'discord', 'economy', 'government', 'grading', 'hall-ui', 'ideas', 'lifecycle', 'memory', 'onboarding', 'platform-identity', 'provisioning', 'public-landing', 'security', 'sessions', 'status-ui', 'ui-design']);
|
|
279
|
+
assert.deepEqual(snap.loaded, ['agents', 'autonomy', 'builder-settings', 'copy-desk', 'dev-box', 'discord', 'economy', 'government', 'grading', 'hall-ui', 'ideas', 'lifecycle', 'memory', 'onboarding', 'platform-identity', 'provisioning', 'public-landing', 'security', 'sessions', 'specialities', 'status-ui', 'ui-design']);
|
|
280
280
|
assert.deepEqual(snap.errors, []);
|
|
281
281
|
assert.deepEqual(snap.skipped, []);
|
|
282
282
|
loader._reset();
|
|
@@ -84,6 +84,16 @@ const OWN_SCOPED_SURFACES = [
|
|
|
84
84
|
why: "the caller's OWN auth sessions — list, revoke one, sign out everywhere (task 1003341, ADR 0206). Every statement carries builder_id = req.builder.id, so a session id belonging to somebody else matches no row. Kept OUT of routes/auth.js precisely so this whole file can be held to the own-scope rule: the admin cross-builder pair next door takes its target from the body, which is what makes it admin.",
|
|
85
85
|
crossOwner: [],
|
|
86
86
|
},
|
|
87
|
+
{
|
|
88
|
+
file: 'modules/specialities/routes/specialities.js',
|
|
89
|
+
why: "role sub-specialities (task 1003855, ADR 0296). The own-scoped route here is GET /me/specialities — the CALLER's own adoptions, taken from req.builder.id with nothing read off the request. The rest of the router is /specialities/* rather than /me/*, and those are ownership-checked per row against req.builder.id after re-reading the speciality server-side, never from a client-supplied owner id.",
|
|
90
|
+
minOwnScoped: 1,
|
|
91
|
+
// No cross-owner exemption: this guard classifies a route as cross-owner when a
|
|
92
|
+
// PATH PARAM names a builder. POST /projects/:projectId/specialities names a
|
|
93
|
+
// PROJECT, not a builder, and touches no builder record — so it is own-scoped by
|
|
94
|
+
// this guard's definition, and an exemption here would be a stale widening.
|
|
95
|
+
crossOwner: [],
|
|
96
|
+
},
|
|
87
97
|
{
|
|
88
98
|
file: 'modules/builder-settings/routes/settings.js',
|
|
89
99
|
why: 'the per-builder preference domain (wandering, skill, sound, render, model-allocation, display name) carved out of /me — read and write, all keyed on req.builder.id.',
|
package/tests/session_caps.mjs
CHANGED
|
@@ -221,6 +221,10 @@ try {
|
|
|
221
221
|
assert.equal(fixture.upserts.length, 0);
|
|
222
222
|
});
|
|
223
223
|
} finally {
|
|
224
|
+
// Drop keep-alive sockets BEFORE close(). fetch()'s global agent holds them
|
|
225
|
+
// open, and server.close() only stops new connections — it waits on the live
|
|
226
|
+
// ones, so without this the process would linger instead of exiting.
|
|
227
|
+
if (typeof server.closeAllConnections === 'function') server.closeAllConnections();
|
|
224
228
|
await new Promise((resolve) => server.close(resolve));
|
|
225
229
|
auth.requireBuilder = orig.requireBuilder;
|
|
226
230
|
db.filterTaskIdsClaimedBy = orig.filterTaskIdsClaimedBy;
|
|
@@ -235,4 +239,15 @@ await t('the audit body cap constant is exported and sane', () => {
|
|
|
235
239
|
});
|
|
236
240
|
|
|
237
241
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
238
|
-
process.exit(
|
|
242
|
+
// NOT process.exit(). On Windows this suite aborted in roughly two runs of three
|
|
243
|
+
// — "Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), file src\win\async.c"
|
|
244
|
+
// — exiting 127 AFTER printing "9 passed, 0 failed", because the hard exit tore
|
|
245
|
+
// the loop down while fetch's handles were still closing. run-unit-tests.js reads
|
|
246
|
+
// the exit code, so a fully-passing suite reported FAILED in every Windows
|
|
247
|
+
// baseline, which trains builders to wave off real failures as environmental.
|
|
248
|
+
// Setting the code and letting the loop drain is the same cure scripts/gds/
|
|
249
|
+
// CLAUDE.md documents for the CLI scripts (cliExit) and tests/cli_exit_no_abort.mjs
|
|
250
|
+
// guards for api.js. The closeAllConnections() above is the other half: a hard
|
|
251
|
+
// exit MASKS a leaked handle, so removing it without releasing the sockets would
|
|
252
|
+
// have traded a crash for a hang.
|
|
253
|
+
process.exitCode = failed === 0 ? 0 : 1;
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// tests/specialities.mjs — the rules behind role sub-specialities (task 1003855).
|
|
2
|
+
//
|
|
3
|
+
// The domain is pure on purpose, so every rule here is asserted without a
|
|
4
|
+
// database. The NEGATIVE cases are the load-bearing ones: a validator that
|
|
5
|
+
// returned {ok:true} unconditionally would pass every positive assertion in this
|
|
6
|
+
// file, so each rule gets an input it must REJECT.
|
|
7
|
+
import assert from 'node:assert/strict';
|
|
8
|
+
import test from 'node:test';
|
|
9
|
+
import { createRequire } from 'node:module';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
|
|
13
|
+
const require = createRequire(import.meta.url);
|
|
14
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
15
|
+
const S = require(path.join(ROOT, 'modules', 'specialities', 'specialities.js'));
|
|
16
|
+
|
|
17
|
+
const DISCIPLINES = ['engineer', 'ideator', 'artist', 'ui'];
|
|
18
|
+
const draft = (over = {}) => ({
|
|
19
|
+
name: 'Economics',
|
|
20
|
+
discipline: 'ideator',
|
|
21
|
+
owner_kind: 'builder',
|
|
22
|
+
owner_builder_id: '1000025',
|
|
23
|
+
...over,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// ---------- slug ----------
|
|
27
|
+
test('slugify makes a human name addressable, and strips accents rather than dropping them', () => {
|
|
28
|
+
assert.equal(S.slugify('Economics'), 'economics');
|
|
29
|
+
assert.equal(S.slugify(' Behavioural Economics '), 'behavioural-economics');
|
|
30
|
+
assert.equal(S.slugify('Éconómics'), 'economics');
|
|
31
|
+
assert.equal(S.slugify('C++ & Rust'), 'c-rust');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('a name that slugifies to nothing is REJECTED, not silently given an empty slug', () => {
|
|
35
|
+
// Two unsluggable names on one owner would both slug to '' and the second
|
|
36
|
+
// would fail as a duplicate — a confusing error about the wrong thing.
|
|
37
|
+
assert.equal(S.slugify('!!!'), '');
|
|
38
|
+
const r = S.validateDraft(draft({ name: '!!!' }), { disciplines: DISCIPLINES });
|
|
39
|
+
assert.equal(r.ok, false);
|
|
40
|
+
assert.ok(r.errors.some((e) => e.field === 'name' && e.code === 'unsluggable'));
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// ---------- validation ----------
|
|
44
|
+
test('a well-formed draft passes', () => {
|
|
45
|
+
assert.deepEqual(S.validateDraft(draft(), { disciplines: DISCIPLINES }), { ok: true, errors: [] });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('the discipline must be one the instance actually has', () => {
|
|
49
|
+
const r = S.validateDraft(draft({ discipline: 'wizard' }), { disciplines: DISCIPLINES });
|
|
50
|
+
assert.equal(r.ok, false);
|
|
51
|
+
assert.ok(r.errors.some((e) => e.field === 'discipline' && e.code === 'unknown'));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('a module-contributed craft is legal — the list is passed in, never hardcoded', () => {
|
|
55
|
+
// `ui` comes from a module (ADR 0272). Hardcoding the core three would reject it.
|
|
56
|
+
assert.equal(S.validateDraft(draft({ discipline: 'ui' }), { disciplines: DISCIPLINES }).ok, true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('ownership must be exactly one of builder/project, matching the DB CHECK', () => {
|
|
60
|
+
assert.equal(S.validateDraft(draft({ owner_kind: 'nobody' }), { disciplines: DISCIPLINES }).ok, false);
|
|
61
|
+
const missing = S.validateDraft(draft({ owner_kind: 'project', owner_builder_id: null }), { disciplines: DISCIPLINES });
|
|
62
|
+
assert.equal(missing.ok, false);
|
|
63
|
+
assert.ok(missing.errors.some((e) => e.field === 'owner_project_id' && e.code === 'required'));
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('private + sellable is refused — there is nobody it could be sold to', () => {
|
|
67
|
+
const r = S.validateDraft(draft({ sellable: true, visibility: 'private' }), { disciplines: DISCIPLINES });
|
|
68
|
+
assert.equal(r.ok, false);
|
|
69
|
+
assert.ok(r.errors.some((e) => e.field === 'sellable' && e.code === 'needs_visibility'));
|
|
70
|
+
// ...and it is allowed the moment it is actually visible to someone.
|
|
71
|
+
assert.equal(S.validateDraft(draft({ sellable: true, visibility: 'public' }), { disciplines: DISCIPLINES }).ok, true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('the injected contract is length-bounded — it is a standing cost in every session', () => {
|
|
75
|
+
const r = S.validateDraft(draft({ contract_md: 'x'.repeat(S.LIMITS.CONTRACT + 1) }), { disciplines: DISCIPLINES });
|
|
76
|
+
assert.equal(r.ok, false);
|
|
77
|
+
assert.ok(r.errors.some((e) => e.field === 'contract_md' && e.code === 'too_long'));
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('a BUILDER-owned speciality may not claim "project" visibility', () => {
|
|
81
|
+
// It names no project to resolve membership against, so canAdopt treats it as
|
|
82
|
+
// private-to-owner forever — the author believes they shared something they
|
|
83
|
+
// did not. Refused at validation rather than accepted and quietly inert.
|
|
84
|
+
const r = S.validateDraft(draft({ visibility: 'project' }), { disciplines: DISCIPLINES });
|
|
85
|
+
assert.equal(r.ok, false);
|
|
86
|
+
assert.ok(r.errors.some((e) => e.field === 'visibility' && e.code === 'project_needs_project_owner'));
|
|
87
|
+
// A PROJECT-owned one is exactly what that visibility is for.
|
|
88
|
+
const proj = S.validateDraft(
|
|
89
|
+
draft({ visibility: 'project', owner_kind: 'project', owner_builder_id: null, owner_project_id: 'p1' }),
|
|
90
|
+
{ disciplines: DISCIPLINES },
|
|
91
|
+
);
|
|
92
|
+
assert.equal(proj.ok, true);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// ---------- who may adopt (ruling 3) ----------
|
|
96
|
+
const OWNER = '1000025';
|
|
97
|
+
const OTHER = '1000099';
|
|
98
|
+
const mine = (over = {}) => ({ owner_kind: 'builder', owner_builder_id: OWNER, visibility: 'private', ...over });
|
|
99
|
+
const proj = (over = {}) => ({ owner_kind: 'project', owner_project_id: 'p1', visibility: 'project', ...over });
|
|
100
|
+
|
|
101
|
+
test('the owner may always adopt their own speciality, even while it is private', () => {
|
|
102
|
+
assert.equal(S.canAdopt(mine(), { builderId: OWNER, projectIds: [] }), true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('private is private — a stranger may not adopt it', () => {
|
|
106
|
+
assert.equal(S.canAdopt(mine(), { builderId: OTHER, projectIds: [] }), false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('public is adoptable by anyone on the instance', () => {
|
|
110
|
+
assert.equal(S.canAdopt(mine({ visibility: 'public' }), { builderId: OTHER, projectIds: [] }), true);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('project visibility reaches that projectMEMBERS only', () => {
|
|
114
|
+
assert.equal(S.canAdopt(proj(), { builderId: OTHER, projectIds: ['p1'] }), true);
|
|
115
|
+
assert.equal(S.canAdopt(proj(), { builderId: OTHER, projectIds: ['p2'] }), false);
|
|
116
|
+
assert.equal(S.canAdopt(proj(), { builderId: OTHER, projectIds: [] }), false);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('a BUILDER-owned speciality set to "project" does not degrade to public', () => {
|
|
120
|
+
// There is no project on the row to resolve membership against. The dangerous
|
|
121
|
+
// reading is "project ~ shared ~ let them in"; assert the safe one.
|
|
122
|
+
const orphan = mine({ visibility: 'project' });
|
|
123
|
+
assert.equal(S.canAdopt(orphan, { builderId: OTHER, projectIds: ['p1'] }), false);
|
|
124
|
+
assert.equal(S.canAdopt(orphan, { builderId: OWNER, projectIds: [] }), true, 'its owner still reaches it');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('canAdopt refuses rather than throws on missing inputs', () => {
|
|
128
|
+
assert.equal(S.canAdopt(null, { builderId: OWNER }), false);
|
|
129
|
+
assert.equal(S.canAdopt(mine(), null), false);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// ---------- the injected fragment ----------
|
|
133
|
+
const active = { id: '7', slug: 'economics', name: 'Economics', discipline: 'ideator', summary: 'Markets and incentives.', contract_md: 'Argue from mechanism, not vibes.' };
|
|
134
|
+
|
|
135
|
+
test('no active speciality injects nothing at all', () => {
|
|
136
|
+
assert.equal(S.describeForApi(null), null);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('the contract carries the name, the craft and the owner terms', () => {
|
|
140
|
+
const d = S.describeForApi(active, {});
|
|
141
|
+
assert.equal(d.inject, true);
|
|
142
|
+
assert.match(d.contract, /Economics/);
|
|
143
|
+
assert.match(d.contract, /under ideator/);
|
|
144
|
+
assert.match(d.contract, /Argue from mechanism/);
|
|
145
|
+
assert.equal(d.speciality_id, '7');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('the knowledge corpus is INVENTORIED, never inlined', () => {
|
|
149
|
+
// A document can be 100k chars; inlining would blow the session's context and
|
|
150
|
+
// is the obvious wrong implementation, so pin the right one.
|
|
151
|
+
const d = S.describeForApi(active, { documentCount: 3, learningCount: 2 });
|
|
152
|
+
assert.match(d.contract, /3 document\(s\)/);
|
|
153
|
+
assert.match(d.contract, /2 learning\(s\)/);
|
|
154
|
+
assert.match(d.contract, /indexed, not inlined/);
|
|
155
|
+
assert.match(d.contract, /\/recall/, 'it tells the agent how to reach the material');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('an untrained speciality makes no claim about knowing anything', () => {
|
|
159
|
+
const d = S.describeForApi(active, { documentCount: 0, learningCount: 0 });
|
|
160
|
+
assert.doesNotMatch(d.contract, /document\(s\)/);
|
|
161
|
+
assert.doesNotMatch(d.contract, /indexed, not inlined/);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('an empty speciality sets inject:false rather than spending context on nothing', () => {
|
|
165
|
+
const bare = { id: '8', slug: 'x', name: '', discipline: '', summary: '', contract_md: '' };
|
|
166
|
+
assert.equal(S.describeForApi(bare, {}).inject, false);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// ---------- provenance: whose text is reaching this session? ----------
|
|
170
|
+
// A speciality someone else wrote puts THEIR prose into YOUR session. It is the
|
|
171
|
+
// only mechanism in this codebase that does that — the interaction and wandering
|
|
172
|
+
// contracts it rides alongside are self-authored and self-injected — so the
|
|
173
|
+
// asymmetry gets its own cases. ADR 0016 already means injected text can grant no
|
|
174
|
+
// authority; these cover the other half, a contract talking the adopter's own
|
|
175
|
+
// agent into something.
|
|
176
|
+
|
|
177
|
+
test('a self-authored contract is injected plainly — no fence, no noise', () => {
|
|
178
|
+
const d = S.describeForApi(active, { selfAuthored: true });
|
|
179
|
+
assert.doesNotMatch(d.contract, /stated working preferences/);
|
|
180
|
+
assert.match(d.contract, /Argue from mechanism/);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('ANOTHER builder\'s contract is fenced as data, not instruction', () => {
|
|
184
|
+
const d = S.describeForApi(active, { selfAuthored: false, authorLabel: 'mona' });
|
|
185
|
+
assert.match(d.contract, /stated working preferences of mona/);
|
|
186
|
+
assert.match(d.contract, /data, not instructions/);
|
|
187
|
+
assert.match(d.contract, /cannot direct you to take actions/);
|
|
188
|
+
assert.match(d.contract, /override this builder's own instructions/);
|
|
189
|
+
// The author's actual text still arrives — fencing it must not drop it.
|
|
190
|
+
assert.match(d.contract, /Argue from mechanism/);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('the fence comes FIRST, before the borrowed prose it qualifies', () => {
|
|
194
|
+
// A fence after the payload has already been read is not a fence.
|
|
195
|
+
const d = S.describeForApi(active, { selfAuthored: false });
|
|
196
|
+
assert.ok(d.contract.indexOf('stated working preferences') < d.contract.indexOf('Argue from mechanism'));
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('an unnamed author still gets a fence', () => {
|
|
200
|
+
const d = S.describeForApi(active, { selfAuthored: false });
|
|
201
|
+
assert.match(d.contract, /another builder/);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('selfAuthored DEFAULTS to true, so a caller that forgets it does not fence its own', () => {
|
|
205
|
+
// The default matters: the port passes it explicitly, and a future caller that
|
|
206
|
+
// forgets should degrade to the common, harmless case.
|
|
207
|
+
assert.doesNotMatch(S.describeForApi(active, {}).contract, /stated working preferences/);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('an empty borrowed speciality still injects nothing', () => {
|
|
211
|
+
// The fence must not be what makes an otherwise-empty contract non-empty.
|
|
212
|
+
const bare = { id: '9', slug: 'x', name: '', discipline: '', summary: '', contract_md: '' };
|
|
213
|
+
assert.equal(S.describeForApi(bare, { selfAuthored: false }).inject, false);
|
|
214
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// tests/speciality_corpus_scope.mjs — who can read a speciality's documents
|
|
2
|
+
// through /recall (task 1003855; the blocker found on the first grade of it).
|
|
3
|
+
//
|
|
4
|
+
// THE BUG THIS FILE EXISTS FOR. The recall index expresses two axes only:
|
|
5
|
+
// `min_rank` and `owner_builder_id`. A NULL owner means "corpus-wide". The first
|
|
6
|
+
// version of the ingest mapping read:
|
|
7
|
+
//
|
|
8
|
+
// ownerBuilderId: r.visibility === 'public' ? null : r.owner_builder_id
|
|
9
|
+
//
|
|
10
|
+
// which is correct for a BUILDER-owned speciality and exactly backwards for a
|
|
11
|
+
// PROJECT-owned one — because the migration's own CHECK constraint forces
|
|
12
|
+
// `owner_builder_id IS NULL` whenever `owner_kind = 'project'`. So a private,
|
|
13
|
+
// project-owned speciality mapped to a null owner and landed in the GLOBAL
|
|
14
|
+
// corpus, readable by every builder on the instance. The ADR, the session log
|
|
15
|
+
// and the module's CLAUDE.md all claimed the opposite — that scoping "errs
|
|
16
|
+
// toward under-sharing" — so the documentation actively vouched for the leak.
|
|
17
|
+
//
|
|
18
|
+
// Every case below is about who can READ, which is why they are worth a file of
|
|
19
|
+
// their own rather than a paragraph in the ingester.
|
|
20
|
+
import assert from 'node:assert/strict';
|
|
21
|
+
import test from 'node:test';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { createRequire } from 'node:module';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
|
|
26
|
+
const require = createRequire(import.meta.url);
|
|
27
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
28
|
+
const { SOURCES } = require(path.join(ROOT, 'scripts', 'gds', 'search-ingest-db.js'));
|
|
29
|
+
|
|
30
|
+
const source = SOURCES.find((s) => s.name === 'specialities_documents');
|
|
31
|
+
|
|
32
|
+
// A row as the ingester's own query returns it. owner_builder_id is NULL for a
|
|
33
|
+
// project-owned speciality — that is the migration's CHECK, not an accident.
|
|
34
|
+
const row = (over = {}) => ({
|
|
35
|
+
id: 1,
|
|
36
|
+
title: 'Pricing a swap',
|
|
37
|
+
body_md: 'Discount the floating leg.',
|
|
38
|
+
speciality_name: 'Economics',
|
|
39
|
+
visibility: 'private',
|
|
40
|
+
owner_builder_id: '1000025',
|
|
41
|
+
...over,
|
|
42
|
+
});
|
|
43
|
+
const builderOwned = (visibility) => row({ visibility });
|
|
44
|
+
const projectOwned = (visibility) => row({ visibility, owner_builder_id: null });
|
|
45
|
+
|
|
46
|
+
// A chunk with a null owner is corpus-wide. That single fact is what every
|
|
47
|
+
// assertion below turns on, so it is named once here.
|
|
48
|
+
const ownersOf = (r) => source.map(r).map((c) => c.owner_builder_id);
|
|
49
|
+
|
|
50
|
+
test('the speciality-documents source is registered at all', () => {
|
|
51
|
+
assert.ok(source, 'without it the injected contract\'s "search it with /recall" is a lie');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('a PUBLIC speciality is corpus-wide — that is the point of publishing one', () => {
|
|
55
|
+
assert.deepEqual(ownersOf(builderOwned('public')), [null]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('a private builder-owned speciality is scoped to its owner', () => {
|
|
59
|
+
assert.deepEqual(ownersOf(builderOwned('private')), ['1000025']);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('a project-visible builder-owned speciality is still scoped to its owner', () => {
|
|
63
|
+
// `project` on a builder-owned row resolves to nobody (there is no project on
|
|
64
|
+
// the row), so canAdopt already treats it as owner-only. The index must agree.
|
|
65
|
+
assert.deepEqual(ownersOf(builderOwned('project')), ['1000025']);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// ---- the blocker ----
|
|
69
|
+
test('a PRIVATE project-owned speciality does NOT land in the global corpus', () => {
|
|
70
|
+
// owner_builder_id is NULL here by the migration's CHECK. Mapping that straight
|
|
71
|
+
// through produced a null owner — corpus-wide — and leaked it to everyone.
|
|
72
|
+
const chunks = source.map(projectOwned('private'));
|
|
73
|
+
assert.deepEqual(chunks, [], 'a document nobody outside the project may read must not be indexed at all');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('a PROJECT-visible project-owned speciality does NOT land in the global corpus', () => {
|
|
77
|
+
const chunks = source.map(projectOwned('project'));
|
|
78
|
+
assert.deepEqual(chunks, [], 'the index cannot express "these builders", so it must index nothing rather than everything');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('a PUBLIC project-owned speciality is corpus-wide, which is the one case that is safe', () => {
|
|
82
|
+
assert.deepEqual(ownersOf(projectOwned('public')), [null]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('no chunk is ever emitted with a null owner unless the speciality is public', () => {
|
|
86
|
+
// The invariant behind all of the above, asserted directly so a future mapping
|
|
87
|
+
// change cannot satisfy the cases above by accident while breaking the rule.
|
|
88
|
+
for (const v of ['private', 'project', 'public']) {
|
|
89
|
+
for (const r of [builderOwned(v), projectOwned(v)]) {
|
|
90
|
+
for (const chunk of source.map(r)) {
|
|
91
|
+
if (chunk.owner_builder_id === null) {
|
|
92
|
+
assert.equal(r.visibility, 'public', `a corpus-wide chunk escaped for visibility=${v}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// tests/speciality_db_shape.mjs — the SQL behind role sub-specialities, driven
|
|
2
|
+
// through the `executor` seam without a database (task 1003855).
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS FILE EXISTS. db.js's own header says the seam is there "so the
|
|
5
|
+
// route-level tests can drive these with a fake and assert the SQL shape without
|
|
6
|
+
// a database, which matters here because the adoption path has a uniqueness rule
|
|
7
|
+
// that is easy to get wrong and expensive to discover in production."
|
|
8
|
+
//
|
|
9
|
+
// Nothing used it. A seam nobody drives is a claim, not a test — and the first
|
|
10
|
+
// grade of this task called that out as a major finding. These are the cases the
|
|
11
|
+
// header was promising.
|
|
12
|
+
//
|
|
13
|
+
// The adoption rule is the load-bearing one: `speciality_adoptions` carries a
|
|
14
|
+
// partial unique index on (builder_id, discipline) WHERE active, so adopting a
|
|
15
|
+
// second speciality in a craft you already work in MUST deactivate the first in
|
|
16
|
+
// the same breath or the insert violates the index.
|
|
17
|
+
import assert from 'node:assert/strict';
|
|
18
|
+
import test from 'node:test';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { createRequire } from 'node:module';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
|
|
23
|
+
const require = createRequire(import.meta.url);
|
|
24
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
25
|
+
const db = require(path.join(ROOT, 'modules', 'specialities', 'db.js'));
|
|
26
|
+
|
|
27
|
+
// A fake executor: records every (sql, params) pair and answers from a queue.
|
|
28
|
+
function fake(results = []) {
|
|
29
|
+
const calls = [];
|
|
30
|
+
const queue = [...results];
|
|
31
|
+
const exec = async (text, params) => {
|
|
32
|
+
calls.push({ sql: text.replace(/\s+/g, ' ').trim(), params });
|
|
33
|
+
return queue.shift() || { rows: [], rowCount: 0 };
|
|
34
|
+
};
|
|
35
|
+
exec.calls = calls;
|
|
36
|
+
return exec;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
test('adopt DEACTIVATES the craft before inserting — the unique index demands it', async () => {
|
|
40
|
+
const q = fake([{ rows: [], rowCount: 1 }, { rows: [{ builder_id: '1', speciality_id: '7' }], rowCount: 1 }]);
|
|
41
|
+
await db.adopt({ builderId: '1', specialityId: '7', discipline: 'ideator' }, q);
|
|
42
|
+
|
|
43
|
+
assert.equal(q.calls.length, 2, 'exactly two statements: the deactivate, then the insert');
|
|
44
|
+
const [first, second] = q.calls;
|
|
45
|
+
assert.match(first.sql, /UPDATE specialities_adoptions SET active = FALSE/);
|
|
46
|
+
assert.match(first.sql, /builder_id = \$1 AND discipline = \$2 AND active/);
|
|
47
|
+
assert.deepEqual(first.params, ['1', 'ideator'], 'scoped to THIS builder and THIS craft, not all of either');
|
|
48
|
+
assert.match(second.sql, /INSERT INTO specialities_adoptions/);
|
|
49
|
+
// The order is the whole point: insert-then-deactivate would hit the index.
|
|
50
|
+
assert.ok(q.calls.indexOf(first) < q.calls.indexOf(second));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('adopt carries the craft from the caller, and re-adopting reactivates rather than duplicating', async () => {
|
|
54
|
+
const q = fake([{ rows: [], rowCount: 0 }, { rows: [{}], rowCount: 1 }]);
|
|
55
|
+
await db.adopt({ builderId: '1', specialityId: '7', discipline: 'artist' }, q);
|
|
56
|
+
const insert = q.calls[1];
|
|
57
|
+
assert.deepEqual(insert.params, ['1', '7', 'artist']);
|
|
58
|
+
assert.match(insert.sql, /ON CONFLICT \(builder_id, speciality_id\) DO UPDATE SET active = TRUE/);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('the injected contract is read with the contract text itself', async () => {
|
|
62
|
+
// It once was not: listAdoptions omitted contract_md, so the session got the
|
|
63
|
+
// name and summary and silently lost the expert's own terms.
|
|
64
|
+
const q = fake([{ rows: [], rowCount: 0 }]);
|
|
65
|
+
await db.listAdoptions('1', q);
|
|
66
|
+
assert.match(q.calls[0].sql, /s\.contract_md/);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('activeForCraft reads ONE craft for ONE builder, and only an active row', async () => {
|
|
70
|
+
const q = fake([{ rows: [{ id: '7' }], rowCount: 1 }]);
|
|
71
|
+
await db.activeForCraft('1', 'ideator', q);
|
|
72
|
+
assert.deepEqual(q.calls[0].params, ['1', 'ideator']);
|
|
73
|
+
assert.match(q.calls[0].sql, /a\.active/);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('a document is idempotent on its content hash, not merely inserted twice', async () => {
|
|
77
|
+
const q = fake([{ rows: [], rowCount: 0 }]);
|
|
78
|
+
const out = await db.addDocument({ speciality_id: '7', title: 't', body_md: 'b', content_sha: 'abc' }, q);
|
|
79
|
+
assert.match(q.calls[0].sql, /ON CONFLICT \(speciality_id, content_sha\) DO NOTHING/);
|
|
80
|
+
assert.equal(out, null, 'a conflict returns null so the route can say "already trained" instead of claiming a create');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('the discipline is NOT updatable — moving a craft would re-scope every adopter', async () => {
|
|
84
|
+
assert.ok(!db.UPDATABLE.includes('discipline'));
|
|
85
|
+
const q = fake([{ rows: [{ id: '7' }], rowCount: 1 }]);
|
|
86
|
+
await db.updateSpeciality('7', { discipline: 'artist', name: 'New' }, q);
|
|
87
|
+
assert.doesNotMatch(q.calls[0].sql, /discipline =/, 'a discipline in the patch must not reach the SQL');
|
|
88
|
+
assert.match(q.calls[0].sql, /name = \$1/);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('an empty patch does not issue an UPDATE at all', async () => {
|
|
92
|
+
const q = fake([{ rows: [{ id: '7' }], rowCount: 1 }]);
|
|
93
|
+
await db.updateSpeciality('7', {}, q);
|
|
94
|
+
assert.doesNotMatch(q.calls[0].sql, /^UPDATE/, 'it falls through to a read rather than writing nothing');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('the catalogue read never returns another builder\'s private row', async () => {
|
|
98
|
+
const q = fake([{ rows: [], rowCount: 0 }]);
|
|
99
|
+
await db.listVisibleTo({ builderId: '1', projectIds: ['p1'] }, q);
|
|
100
|
+
const sql = q.calls[0].sql;
|
|
101
|
+
assert.match(sql, /visibility = 'public'/);
|
|
102
|
+
assert.match(sql, /owner_builder_id = \$1/);
|
|
103
|
+
assert.match(sql, /owner_project_id = ANY\(\$2\)/);
|
|
104
|
+
// The dangerous shape is a bare SELECT with the filter applied in JS.
|
|
105
|
+
assert.match(sql, /WHERE/);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('countTraining counts both halves of training in one round trip', async () => {
|
|
109
|
+
const q = fake([{ rows: [{ documents: '3', learnings: '2' }], rowCount: 1 }]);
|
|
110
|
+
const out = await db.countTraining('7', q);
|
|
111
|
+
assert.deepEqual(out, { documentCount: 3, learningCount: 2 }, 'counts come back as numbers, not strings');
|
|
112
|
+
assert.equal(q.calls.length, 1);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('taskDiscipline reads the CORE tasks table by id', async () => {
|
|
116
|
+
const q = fake([{ rows: [{ discipline: 'engineer' }], rowCount: 1 }]);
|
|
117
|
+
assert.equal(await db.taskDiscipline('1234', q), 'engineer');
|
|
118
|
+
assert.deepEqual(q.calls[0].params, ['1234']);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('taskDiscipline returns null rather than throwing when the task is gone', async () => {
|
|
122
|
+
const q = fake([{ rows: [], rowCount: 0 }]);
|
|
123
|
+
assert.equal(await db.taskDiscipline('nope', q), null);
|
|
124
|
+
});
|