@bongos/core 1.21.90 → 1.21.91

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.
@@ -196,6 +196,64 @@
196
196
  if (mark) mark.textContent = look;
197
197
  }
198
198
 
199
+ // ---------- Governing's header tallies (task 1004563) ----------
200
+ // "needs you N · moving M" beside the eyebrow, as on the four-mode prototype.
201
+ // Needs you is the open-blocker total GET /blockers already carries; moving is
202
+ // tasks being built or waiting to land (active + confirmed, owner 2026-10-08),
203
+ // counted by the server at GET /moving. Read once per page. A figure that cannot
204
+ // be read is LEFT OUT, never shown as 0: zero would say nothing waits on you.
205
+ var TALLY_API = '/api/bongos';
206
+ var tallyAsk = null;
207
+ function readCount(path, pick) {
208
+ if (typeof fetch !== 'function') return Promise.resolve(null);
209
+ return fetch(TALLY_API + path, { credentials: 'same-origin', headers: { Accept: 'application/json' } })
210
+ .then(function (r) { return r.ok ? r.json() : null; })
211
+ .then(function (j) {
212
+ var n = j ? pick(j) : null;
213
+ return (typeof n === 'number' && isFinite(n) && n >= 0) ? n : null;
214
+ }, function () { return null; });
215
+ }
216
+ function askTallies() {
217
+ if (!tallyAsk) {
218
+ tallyAsk = Promise.all([
219
+ readCount('/blockers?limit=1', function (j) { return j.page && j.page.total; }),
220
+ readCount('/moving', function (j) { return j.moving; }),
221
+ ]).then(function (v) { return { needs: v[0], moving: v[1] }; });
222
+ }
223
+ return tallyAsk;
224
+ }
225
+ function tallyHtml(t) {
226
+ var parts = [];
227
+ function one(label, n, due) {
228
+ return '<span class="craft-tally' + (due ? ' craft-tally--due' : '') + '">'
229
+ + '<span class="craft-tally__label">' + label + '</span> '
230
+ + '<span class="craft-tally__n">' + n + '</span></span>';
231
+ }
232
+ if (t && t.needs != null) parts.push(one('needs you', t.needs, t.needs > 0));
233
+ if (t && t.moving != null) parts.push(one('moving', t.moving, false));
234
+ return parts.join(' <span class="craft-tallies__sep" aria-hidden="true">·</span> ');
235
+ }
236
+ function applyTallies(mode) {
237
+ var box = document.getElementById('craft-tallies');
238
+ if (mode !== 'governing') { remove(box); return; }
239
+ var mark = document.getElementById('craft-eyebrow');
240
+ if (!box && mark && mark.parentNode) {
241
+ box = document.createElement('span');
242
+ box.id = 'craft-tallies';
243
+ box.className = 'craft-tallies';
244
+ box.setAttribute('hidden', '');
245
+ mark.parentNode.insertBefore(box, mark.nextSibling);
246
+ }
247
+ if (!box) return;
248
+ askTallies().then(function (t) {
249
+ var live = document.getElementById('craft-tallies');
250
+ if (!live || document.documentElement.dataset.craft !== 'governing') return;
251
+ var html = tallyHtml(t);
252
+ live.innerHTML = html;
253
+ if (html) live.removeAttribute('hidden'); else live.setAttribute('hidden', '');
254
+ });
255
+ }
256
+
199
257
  // ---------- each mode's tools (task 1004496 onward) ----------
200
258
  // Artistry has two: the palette (a feedback doc, page-notes.js) and the
201
259
  // paintbrush (the page's words as a wording change, paintbrush.js). Ideating
@@ -311,6 +369,7 @@
311
369
  if (sw) sw.classList.toggle('craft-switch--unset', !mode);
312
370
  applyNav();
313
371
  applyLook(mode);
372
+ applyTallies(mode);
314
373
  applyTools(mode);
315
374
  try { document.dispatchEvent(new CustomEvent('otb:craft', { detail: { mode: mode } })); } catch (_) { /* old engine */ }
316
375
  }
@@ -521,6 +580,7 @@
521
580
  applyNav: applyNav,
522
581
  LOOKS: LOOKS,
523
582
  artHtml: artHtml,
583
+ tallyHtml: tallyHtml,
524
584
  TOOLS: TOOLS,
525
585
  switchHtml: switchHtml,
526
586
  mount: mount,
@@ -1472,6 +1472,38 @@ html:is([data-craft="artistry"], [data-craft="ideating"], [data-craft="governing
1472
1472
  flex-shrink: 0;
1473
1473
  }
1474
1474
  @media (max-width: 640px) { .craft-eyebrow { display: none; } }
1475
+ /* Governing's header tallies (craft-mode.js applyTallies, task 1004563):
1476
+ "needs you N · moving M" after the eyebrow. The warm dot marks needs-you only
1477
+ while something waits. Hidden at <=960px, earlier than the eyebrow: below that
1478
+ they squeeze the jump button under its own label (measured at 800px). */
1479
+ .craft-tallies {
1480
+ display: inline-flex;
1481
+ align-items: baseline;
1482
+ gap: 10px;
1483
+ white-space: nowrap;
1484
+ flex-shrink: 0;
1485
+ font-size: 12px;
1486
+ }
1487
+ .craft-tallies[hidden] { display: none; }
1488
+ .craft-tally { display: inline-flex; align-items: baseline; gap: 6px; }
1489
+ .craft-tally__label {
1490
+ font-family: var(--label-face);
1491
+ font-size: 11px;
1492
+ letter-spacing: .12em;
1493
+ text-transform: uppercase;
1494
+ color: var(--ink-soft);
1495
+ }
1496
+ .craft-tally__n { color: var(--ink); font-variant-numeric: tabular-nums; font-weight: 500; }
1497
+ .craft-tally--due::before {
1498
+ content: '';
1499
+ align-self: center;
1500
+ width: 7px;
1501
+ height: 7px;
1502
+ border-radius: 999px;
1503
+ background: var(--accent);
1504
+ }
1505
+ .craft-tallies__sep { color: var(--ink-faint); }
1506
+ @media (max-width: 960px) { .craft-tallies { display: none; } }
1475
1507
  /* The signature: the mode's door scene (craft-mode.js artHtml, the same art the
1476
1508
  picker draws) framed under the brand, 40px in the closed rail and 64px open.
1477
1509
  The 110px-tall scene is scaled to the plate's height and widened to fill it,
@@ -34,7 +34,7 @@ Covers: every shelled hall page (`craft-mode.js`, `shell.js`, `style.css`), and
34
34
  - **The accent stays the action colour.** `--craft` paints only the switch's current pill, the current tab (tint, icon, label, marker) and the top bar's hairline.
35
35
  - **Two pieces are drawn by `craft-mode.js` `applyLook()`, on every mode change:** the eyebrow beside `#topbar-title` (`LOOKS`: "the studio · night", "the sky", "the city"; hidden at <=640px, where it overflowed phones) and the scene plate under `.app-sidebar__brand` (40px closed rail, 64px open). The plate's markup IS `artHtml(mode)`, the picker's door art, exported rather than copied; light mode recolours it to each scene's day (the city is the dark one through `invert(1) hue-rotate(180deg)`). It does not animate.
36
36
  - **Always-present tabs widened** (owner, 2026-10-03): `ALWAYS_TABS` is Builders, Collab and Profile, so every mode keeps the You and Community groups.
37
- - **Governing's header tallies** ("needs you · moving") are task 1004563, not this look.
37
+ - **Governing's header tallies** (task 1004563): `applyTallies()` draws "needs you N · moving M" right after the eyebrow, Governing only, hidden at <=960px (below that they squeezed the jump button under its own label, measured at 800px). Needs you is `GET /blockers?limit=1`'s `page.total`; moving is `GET /moving` (lifecycle, tasks `active` + `confirmed`, owner 2026-10-08), a count in SQL so the page never pulls task rows. Read once per page; a figure that cannot be read is LEFT OUT, never drawn as 0. The warm dot marks needs-you only above zero. It toggles the `hidden` ATTRIBUTE, never `.hidden =` (the nav gate owns that property; `tests/craft_mode_nav.mjs`). Pinned by `tests/craft_mode_tallies.mjs`.
38
38
 
39
39
  ## The paintbrush (task 1004497)
40
40
 
@@ -32,7 +32,8 @@
32
32
  "artist-gate",
33
33
  "removal-proposals",
34
34
  "sky",
35
- "goal-task-relevance"
35
+ "goal-task-relevance",
36
+ "moving"
36
37
  ],
37
38
  "pollers": [
38
39
  "publish-reconciler"
@@ -0,0 +1,55 @@
1
+ 'use strict';
2
+
3
+ // modules/lifecycle/moving.js — how much of the project is MOVING right now, as one
4
+ // count (task 1004563, goal 1000095 WA6).
5
+ //
6
+ // WHY. Governing mode's top bar reads "needs you N · moving M" (the four-mode
7
+ // prototype the owner named the standard on 2026-10-03). "Needs you" already had
8
+ // a source — the public open-blocker list carries its own total — but "moving" had
9
+ // none, and the only way to get one from the browser was GET /tasks?status=…, which
10
+ // returns every row with its whole description to produce a single number. That is
11
+ // the read the hall's bounded-reads rule forbids (modules/hall-ui/CLAUDE.md: a Home
12
+ // card asks the server for what it draws). So the number is counted here, in SQL.
13
+ //
14
+ // WHAT COUNTS (owner decision, 2026-10-08): a task being built right now plus a task
15
+ // waiting to land — status `active` or `confirmed`. Not `completed` (a ship waiting on
16
+ // its grade is neither being built nor queued to land) and nothing terminal. The two
17
+ // halves come back separately as well as summed, so a caller can say which is which.
18
+ //
19
+ // MODULE BOUNDARY: reaches core only through the published doorway.
20
+
21
+ const api = require('../../src/module-api');
22
+
23
+ const MOVING_STATUSES = Object.freeze(['active', 'confirmed']);
24
+
25
+ // → { moving, active, confirmed }. `deps.pool` is the #538 test seam, honoured only
26
+ // under NODE_ENV==='test' like its siblings in this module.
27
+ async function countMoving(deps = {}) {
28
+ const pool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : api.pool;
29
+ const { rows } = await pool.query(
30
+ `SELECT status, count(*)::int AS n
31
+ FROM tasks
32
+ WHERE status = ANY($1::text[])
33
+ GROUP BY status`,
34
+ [MOVING_STATUSES],
35
+ );
36
+ const by = Object.fromEntries(rows.map((r) => [r.status, Number(r.n) || 0]));
37
+ const active = by.active || 0;
38
+ const confirmed = by.confirmed || 0;
39
+ return { moving: active + confirmed, active, confirmed };
40
+ }
41
+
42
+ // The GET /moving handler, injectable so a test drives it with no express.
43
+ function movingHandler({ log, count = countMoving } = {}) {
44
+ return async function movingRoute(_req, res) {
45
+ try {
46
+ res.set('Cache-Control', 'private, max-age=30');
47
+ res.json(await count());
48
+ } catch (err) {
49
+ if (log) log.error('GET /moving', err);
50
+ res.fail('moving_failed', { status: 500, message: 'internal error' });
51
+ }
52
+ };
53
+ }
54
+
55
+ module.exports = { MOVING_STATUSES, countMoving, movingHandler };
@@ -0,0 +1,30 @@
1
+ 'use strict';
2
+
3
+ // modules/lifecycle/routes/moving.js — GET /moving, how many tasks are being built or
4
+ // waiting to land (task 1004563, goal 1000095 WA6).
5
+ //
6
+ // WHY ITS OWN ROUTE FILE. routes/tasks.js sits at the 1,500-line ratchet, and the path
7
+ // is top-level `/moving`, never `/tasks/moving`: the manifest mounts `tasks` first and
8
+ // its `/tasks/:id` would swallow a later literal segment (the `/inbox/awaiting-nod`
9
+ // trap). The count and the handler live in ../moving.js so a test drives them with no
10
+ // express and no database.
11
+ //
12
+ // MODULE BOUNDARY: reaches core only through the published doorway
13
+ // `../../../src/module-api`; `../moving` is this module's own read.
14
+
15
+ const express = require('express');
16
+ const api = require('../../../src/module-api');
17
+ const moving = require('../moving');
18
+
19
+ const log = api.logger('lifecycle');
20
+
21
+ module.exports = function buildMovingRouter() {
22
+ const router = express.Router();
23
+
24
+ // rank: any-builder — GET /moving, the count behind Governing mode's "moving" tally.
25
+ // A count of rows every signed-in builder can already list on the Tasks board, so no
26
+ // floor above sign-in; nothing here is writable.
27
+ router.get('/moving', api.requireBuilder, moving.movingHandler({ log }));
28
+
29
+ return router;
30
+ };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.90",
3
+ "version": "1.21.91",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.90",
9
+ "version": "1.21.91",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.90",
3
+ "version": "1.21.91",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -9370,5 +9370,11 @@
9370
9370
  "id": "1004655",
9371
9371
  "text": "Three government actions now have tests that check them the way the site really answers: choosing a form of government, taking back your own proposal before anyone votes, and seeing who each founding stage is still waiting on."
9372
9372
  }
9373
+ ],
9374
+ "1.21.91": [
9375
+ {
9376
+ "id": "1004563",
9377
+ "text": "In Governing mode the top bar now shows two live numbers: how many things are waiting on the owner (\"needs you\") and how many tasks are being built or waiting to go live (\"moving\"). If a number can't be read it is simply left"
9378
+ }
9373
9379
  ]
9374
9380
  }
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.90'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.91'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -0,0 +1,149 @@
1
+ // tests/craft_mode_tallies.mjs — Governing mode's header tallies (task 1004563, goal
2
+ // 1000095 WA6): "needs you N · moving M" beside the eyebrow.
3
+ //
4
+ // Runs the REAL craft-mode.js over a small DOM with a stubbed fetch, and the REAL
5
+ // server count (modules/lifecycle/moving.js) over a fake pool, so the two halves are
6
+ // pinned where they actually decide: which statuses "moving" counts (owner decision
7
+ // 2026-10-08: active + confirmed), and that a figure the page could not read is left
8
+ // out rather than drawn as a zero that says nothing waits on you.
9
+ import assert from 'node:assert/strict';
10
+ import { test } from 'node:test';
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import vm from 'node:vm';
14
+ import { createRequire } from 'node:module';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
18
+ const CRAFT = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/craft-mode.js'), 'utf8');
19
+ const require = createRequire(import.meta.url);
20
+ process.env.NODE_ENV = 'test';
21
+ const { countMoving, movingHandler, MOVING_STATUSES } = require('../modules/lifecycle/moving.js');
22
+
23
+ function node(tag, id, cls) {
24
+ const attrs = {};
25
+ return {
26
+ tagName: tag, id: id || '', className: cls || '', dataset: {}, children: [], parentNode: null,
27
+ textContent: '', innerHTML: '',
28
+ setAttribute(k, v) { attrs[k] = String(v); }, getAttribute(k) { return k in attrs ? attrs[k] : null; },
29
+ removeAttribute(k) { delete attrs[k]; },
30
+ get nextSibling() { const sib = this.parentNode && this.parentNode.children; return sib ? sib[sib.indexOf(this) + 1] || null : null; },
31
+ appendChild(c) { c.parentNode = this; this.children.push(c); return c; },
32
+ insertBefore(c, ref) {
33
+ c.parentNode = this;
34
+ const at = ref ? this.children.indexOf(ref) : -1;
35
+ if (at === -1) this.children.push(c); else this.children.splice(at, 0, c);
36
+ return c;
37
+ },
38
+ removeChild(c) { this.children.splice(this.children.indexOf(c), 1); c.parentNode = null; return c; },
39
+ querySelector(sel) { return all(this).find((n) => sel === '.' + n.className) || null; },
40
+ querySelectorAll() { return []; },
41
+ addEventListener() {}, classList: { toggle() {} },
42
+ };
43
+ }
44
+ function all(root) { return root.children.flatMap((c) => [c, ...all(c)]); }
45
+
46
+ // answers: path → JSON body, or a number (an HTTP failure status), or 'throw'.
47
+ function boot(mode, answers) {
48
+ const sidebar = node('NAV', 'app-sidebar');
49
+ sidebar.appendChild(node('DIV', '', 'app-sidebar__brand'));
50
+ const topbar = node('DIV', 'app-topbar');
51
+ topbar.appendChild(node('H2', 'topbar-title'));
52
+ const root = node('HTML');
53
+ const asked = [];
54
+ const document = {
55
+ documentElement: root, body: node('BODY'), activeElement: null,
56
+ getElementById: (id) => [sidebar, topbar, ...all(sidebar), ...all(topbar)].find((n) => n.id === id) || null,
57
+ querySelectorAll: () => [],
58
+ createElement: (t) => node(t.toUpperCase()),
59
+ addEventListener() {}, removeEventListener() {}, dispatchEvent() { return true; },
60
+ };
61
+ const mk = (obj) => ({ getItem: (k) => (k in obj ? obj[k] : null), setItem: (k, v) => { obj[k] = String(v); }, removeItem: (k) => { delete obj[k]; } });
62
+ const window = { localStorage: mk({}), sessionStorage: mk(mode ? { 'hall-craft': mode } : {}), matchMedia: () => ({ matches: false }) };
63
+ const fetch = (url) => {
64
+ asked.push(url);
65
+ const key = Object.keys(answers || {}).find((k) => url.endsWith(k));
66
+ const a = key ? answers[key] : 404;
67
+ if (a === 'throw') return Promise.reject(new Error('offline'));
68
+ if (typeof a === 'number') return Promise.resolve({ ok: false, status: a, json: () => Promise.resolve({}) });
69
+ return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(a) });
70
+ };
71
+ const sandbox = { window, document, fetch, CustomEvent: class { constructor(t, i) { this.type = t; Object.assign(this, i); } } };
72
+ vm.createContext(sandbox);
73
+ vm.runInContext(CRAFT, sandbox, { filename: 'craft-mode.js' });
74
+ const craft = window.OTBCraftMode;
75
+ craft.mount();
76
+ const box = () => all(topbar).find((n) => n.id === 'craft-tallies') || null;
77
+ return { craft, topbar, asked, box };
78
+ }
79
+ const settle = () => new Promise((r) => setImmediate(r));
80
+ const text = (html) => html.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
81
+
82
+ const BOTH = { '/blockers?limit=1': { blockers: [{ id: 1 }], page: { limit: 1, offset: 0, total: 3 } }, '/moving': { moving: 12, active: 9, confirmed: 3 } };
83
+
84
+ test('Governing reads both counts and draws "needs you 3 · moving 12" right after the eyebrow', async () => {
85
+ const { topbar, box, asked } = boot('governing', BOTH);
86
+ await settle();
87
+ const b = box();
88
+ assert.ok(b, 'the tallies are drawn');
89
+ const eyebrow = all(topbar).find((n) => n.id === 'craft-eyebrow');
90
+ assert.equal(eyebrow.nextSibling, b, 'they sit directly after the mode words');
91
+ assert.equal(b.getAttribute('hidden'), null);
92
+ assert.equal(text(b.innerHTML), 'needs you 3 · moving 12');
93
+ assert.match(b.innerHTML, /craft-tally craft-tally--due/, 'something waits, so needs-you carries the warm dot');
94
+ assert.deepEqual(asked.map((u) => u.replace('/api/bongos', '')).sort(), ['/blockers?limit=1', '/moving'], 'one page of one blocker for the total, one count — never a task list');
95
+ });
96
+
97
+ test('nothing waiting draws "needs you 0" without the dot', async () => {
98
+ const { box } = boot('governing', { ...BOTH, '/blockers?limit=1': { blockers: [], page: { limit: 1, offset: 0, total: 0 } } });
99
+ await settle();
100
+ assert.equal(text(box().innerHTML), 'needs you 0 · moving 12');
101
+ assert.doesNotMatch(box().innerHTML, /craft-tally--due/);
102
+ });
103
+
104
+ test('a count that cannot be read is LEFT OUT, never drawn as zero', async () => {
105
+ const noMoving = boot('governing', { ...BOTH, '/moving': 401 });
106
+ await settle();
107
+ assert.equal(text(noMoving.box().innerHTML), 'needs you 3', 'a signed-out or failed /moving drops only its own figure');
108
+
109
+ const offline = boot('governing', { '/blockers?limit=1': 'throw', '/moving': { moving: 'lots' } });
110
+ await settle();
111
+ assert.equal(offline.box().getAttribute('hidden'), '', 'nothing readable: the slot stays hidden, no zeros');
112
+ assert.equal(offline.box().innerHTML, '');
113
+ });
114
+
115
+ test('only Governing wears the tallies, and leaving it removes them', async () => {
116
+ for (const mode of ['building', 'artistry', 'ideating', null]) {
117
+ const { box, asked } = boot(mode, BOTH);
118
+ await settle();
119
+ assert.equal(box(), null, `${mode || 'no mode'} draws no tallies`);
120
+ assert.deepEqual(asked, [], `${mode || 'no mode'} asks the server for nothing`);
121
+ }
122
+ const { craft, box } = boot('governing', BOTH);
123
+ await settle();
124
+ assert.ok(box());
125
+ craft.choose('building');
126
+ assert.equal(box(), null, 'switching away takes them down');
127
+ });
128
+
129
+ test('the server counts active + confirmed only, and sums them', async () => {
130
+ assert.deepEqual([...MOVING_STATUSES], ['active', 'confirmed']);
131
+ let seen;
132
+ const pool = { query: async (sql, params) => { seen = { sql, params }; return { rows: [{ status: 'active', n: 9 }, { status: 'confirmed', n: '3' }] }; } };
133
+ assert.deepEqual(await countMoving({ pool }), { moving: 12, active: 9, confirmed: 3 });
134
+ assert.deepEqual(seen.params, [['active', 'confirmed']]);
135
+ assert.match(seen.sql, /count\(\*\)/, 'a count in SQL, never rows to count in JS');
136
+ const empty = { query: async () => ({ rows: [] }) };
137
+ assert.deepEqual(await countMoving({ pool: empty }), { moving: 0, active: 0, confirmed: 0 }, 'a quiet board is a real zero');
138
+ });
139
+
140
+ test('GET /moving answers the count, and a failed read is a 500, not a zero', async () => {
141
+ const res = () => { const r = { headers: {} }; r.set = (k, v) => { r.headers[k] = v; }; r.json = (b) => { r.body = b; }; r.fail = (code, o) => { r.failed = { code, status: o.status }; }; return r; };
142
+ const ok = res();
143
+ await movingHandler({ count: async () => ({ moving: 4, active: 1, confirmed: 3 }) })({}, ok);
144
+ assert.deepEqual(ok.body, { moving: 4, active: 1, confirmed: 3 });
145
+ const bad = res();
146
+ await movingHandler({ count: async () => { throw new Error('db down'); }, log: { error() {} } })({}, bad);
147
+ assert.deepEqual(bad.failed, { code: 'moving_failed', status: 500 });
148
+ assert.equal(bad.body, undefined);
149
+ });