@bongos/core 1.21.58 → 1.21.60

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.
@@ -0,0 +1,144 @@
1
+ // tests/filter_views.mjs — saved filter views (task 1004553): the pure storage rules
2
+ // in modules/builder-settings/filter-views.js and the own-scoped /me/filter-views
3
+ // routes, run against a fake prefs store keyed by builder. No DB needed.
4
+ //
5
+ // Run: node --test tests/filter_views.mjs
6
+
7
+ import assert from 'node:assert/strict';
8
+ import { test } from 'node:test';
9
+ import { readFileSync } from 'node:fs';
10
+ import { createRequire } from 'node:module';
11
+
12
+ process.env.NODE_ENV = 'test';
13
+ const require = createRequire(import.meta.url);
14
+ const fv = require('../modules/builder-settings/filter-views.js');
15
+
16
+ // One behavior_prefs blob per builder — the column the routes really write.
17
+ const rows = new Map();
18
+ const db = require('../modules/builder-settings/db.js');
19
+ db.getBuilderBehaviorPrefs = async (id) => rows.get(id) || {};
20
+ db.setBuilderBehaviorPrefs = async (id, prefs) => { rows.set(id, prefs); return prefs; };
21
+ const router = require('../modules/builder-settings/routes/settings.js')();
22
+
23
+ const src = (rel) => readFileSync(new URL('../' + rel, import.meta.url), 'utf8');
24
+
25
+ function handler(method, path) {
26
+ const layer = router.stack.find((l) => l.route && l.route.path === path && l.route.methods[method]);
27
+ assert.ok(layer, `${method} ${path} is registered`);
28
+ return layer.route.stack[layer.route.stack.length - 1].handle;
29
+ }
30
+ async function call(method, path, { builder, body, query = {}, params = {} }) {
31
+ const out = { status: 200, body: null };
32
+ const res = {
33
+ status(s) { out.status = s; return this; },
34
+ json(b) { out.body = b; return this; },
35
+ fail(code, opts) { out.status = (opts && opts.status) || 400; out.body = { error: { code, message: opts && opts.message } }; return this; },
36
+ };
37
+ await handler(method, path)({ builder, body, query, params }, res);
38
+ return out;
39
+ }
40
+ const ALICE = { id: 1, rank: 'thetes' };
41
+ const BOB = { id: 2, rank: 'metic' };
42
+
43
+ test('saving a view keeps sibling prefs and only what was sent', () => {
44
+ const r = fv.saveView({ wandering: 'low' }, 'goals', { name: ' My open goals ', values: { 'f-goal-status': 'open' } });
45
+ assert.equal(r.ok, true);
46
+ assert.equal(r.prefs.wandering, 'low', 'sibling knobs survive');
47
+ assert.deepEqual(r.view, { id: 1, name: 'My open goals', values: { 'f-goal-status': 'open' }, is_default: false });
48
+ });
49
+
50
+ test('saving under an existing name updates that view instead of adding a second', () => {
51
+ let p = fv.saveView({}, 'goals', { name: 'Mine', values: { a: '1' } }).prefs;
52
+ p = fv.saveView(p, 'goals', { name: 'mine', values: { a: '2' } }).prefs;
53
+ const list = fv.listViews(p, 'goals');
54
+ assert.equal(list.length, 1);
55
+ assert.deepEqual(list[0].values, { a: '2' });
56
+ });
57
+
58
+ test('only one view per page is the default', () => {
59
+ let p = fv.saveView({}, 'goals', { name: 'A', values: {}, is_default: true }).prefs;
60
+ p = fv.saveView(p, 'goals', { name: 'B', values: {}, is_default: true }).prefs;
61
+ assert.deepEqual(fv.listViews(p, 'goals').map((v) => [v.name, v.is_default]), [['A', false], ['B', true]]);
62
+ p = fv.updateView(p, 'goals', 1, { is_default: true }).prefs;
63
+ assert.deepEqual(fv.listViews(p, 'goals').map((v) => v.is_default), [true, false]);
64
+ p = fv.updateView(p, 'goals', 1, { is_default: false }).prefs;
65
+ assert.deepEqual(fv.listViews(p, 'goals').map((v) => v.is_default), [false, false]);
66
+ });
67
+
68
+ test('pages keep separate lists, and deleting the last view leaves no empty key behind', () => {
69
+ let p = fv.saveView({}, 'goals', { name: 'G', values: {} }).prefs;
70
+ p = fv.saveView(p, 'work', { name: 'W', values: {} }).prefs;
71
+ assert.deepEqual(fv.listViews(p, 'work').map((v) => v.name), ['W']);
72
+ p = fv.deleteView(p, 'goals', 1).prefs;
73
+ p = fv.deleteView(p, 'work', 1).prefs;
74
+ assert.equal(fv.RESERVED_KEY in p, false);
75
+ });
76
+
77
+ test('bad input is refused with a readable reason', () => {
78
+ assert.equal(fv.saveView({}, 'Goals!', { name: 'x', values: {} }).ok, false);
79
+ assert.equal(fv.saveView({}, 'goals', { name: ' ', values: {} }).ok, false);
80
+ assert.equal(fv.saveView({}, 'goals', { name: 'x'.repeat(61), values: {} }).ok, false);
81
+ assert.equal(fv.saveView({}, 'goals', { name: 'x', values: [] }).ok, false);
82
+ assert.equal(fv.saveView({}, 'goals', { name: 'x', values: { 'f-a': 3 } }).ok, false);
83
+ let p = {};
84
+ for (let i = 0; i < fv.MAX_VIEWS_PER_PAGE; i++) p = fv.saveView(p, 'goals', { name: `v${i}`, values: {} }).prefs;
85
+ assert.match(fv.saveView(p, 'goals', { name: 'one more', values: {} }).errors[0], /at most/);
86
+ let q = fv.saveView({}, 'goals', { name: 'A', values: {} }).prefs;
87
+ q = fv.saveView(q, 'goals', { name: 'B', values: {} }).prefs;
88
+ assert.match(fv.updateView(q, 'goals', 2, { name: 'a' }).errors[0], /already have/);
89
+ });
90
+
91
+ test('a builder saves a view and reads it back from a fresh request (another device)', async () => {
92
+ rows.clear();
93
+ const saved = await call('post', '/me/filter-views', { builder: ALICE, body: { page: 'goals', name: 'Open', values: { 'f-goal-status': 'open' }, is_default: true } });
94
+ assert.equal(saved.status, 201);
95
+ const got = await call('get', '/me/filter-views', { builder: ALICE, query: { page: 'goals' } });
96
+ assert.deepEqual(got.body.views, [{ id: 1, name: 'Open', values: { 'f-goal-status': 'open' }, is_default: true }]);
97
+ });
98
+
99
+ test('another builder cannot see, rename, default or delete it', async () => {
100
+ const seen = await call('get', '/me/filter-views', { builder: BOB, query: { page: 'goals' } });
101
+ assert.deepEqual(seen.body.views, []);
102
+ const patch = await call('patch', '/me/filter-views/:id', { builder: BOB, params: { id: '1' }, body: { page: 'goals', name: 'Hijacked' } });
103
+ assert.equal(patch.status, 404);
104
+ const del = await call('delete', '/me/filter-views/:id', { builder: BOB, params: { id: '1' }, query: { page: 'goals' } });
105
+ assert.equal(del.status, 404);
106
+ const still = await call('get', '/me/filter-views', { builder: ALICE, query: { page: 'goals' } });
107
+ assert.equal(still.body.views[0].name, 'Open', 'Alice\'s view is untouched');
108
+ });
109
+
110
+ test('the owner renames and deletes their own view', async () => {
111
+ const ren = await call('patch', '/me/filter-views/:id', { builder: ALICE, params: { id: '1' }, body: { page: 'goals', name: 'Open goals' } });
112
+ assert.equal(ren.body.view.name, 'Open goals');
113
+ const del = await call('delete', '/me/filter-views/:id', { builder: ALICE, params: { id: '1' }, query: { page: 'goals' } });
114
+ assert.deepEqual(del.body.views, []);
115
+ });
116
+
117
+ test('a missing page is a 400; a wrong-shaped body is refused before it is stored; a bad value is a 422', async () => {
118
+ rows.clear();
119
+ assert.equal((await call('get', '/me/filter-views', { builder: ALICE, query: {} })).status, 400);
120
+ assert.equal((await call('post', '/me/filter-views', { builder: ALICE, body: { page: 'goals', name: 'x' } })).status, 400, 'values missing');
121
+ assert.equal((await call('post', '/me/filter-views', { builder: ALICE, body: { page: 'goals', name: 'x', values: {}, owner: 2 } })).status, 400, 'unknown field');
122
+ assert.equal((await call('post', '/me/filter-views', { builder: ALICE, body: { page: 'goals', name: ' ', values: {} } })).status, 422);
123
+ assert.equal(rows.size, 0, 'nothing was written');
124
+ });
125
+
126
+ test('every route is own-scoped: requireBuilder, and no id from the request picks the builder', () => {
127
+ const routes = src('modules/builder-settings/routes/settings.js');
128
+ for (const m of ['get', 'post', 'patch', 'delete']) {
129
+ assert.match(routes, new RegExp(`router\\.${m}\\('/me/filter-views[^']*', api\\.requireBuilder,`));
130
+ }
131
+ const block = routes.slice(routes.indexOf("'/me/filter-views'"), routes.indexOf('Per-builder DISPLAY NAME'));
132
+ assert.doesNotMatch(block, /BehaviorPrefs\((?!req\.builder\.id)/, 'prefs are only ever read/written for req.builder.id');
133
+ });
134
+
135
+ test('both pages opt in, and apply my default view before the focus version', () => {
136
+ const work = src('modules/hall-ui/public/work.js');
137
+ assert.match(work, /views: \{ page: 'work', client: api, base: `\$\{API\}\/me\/filter-views` \}/);
138
+ assert.match(work, /applyDefault\(\{ fire: true \}\)[\s\S]{0,160}!applied && applyFocusVersion\(focusResp\)/);
139
+ const goals = src('modules/hall-ui/public/goals-page.js');
140
+ assert.match(goals, /views: \{ page: 'goals', client: api, base: `\$\{API\}\/me\/filter-views` \}/);
141
+ assert.match(goals, /if \(!\(views && await views\.applyDefault\(\{ fire: false \}\)\)\) applyFocusVersion\(focusResp\);/);
142
+ const kit = src('modules/hall-ui/public/hall-kit.js');
143
+ assert.match(kit, /if \(!\(await ready\) \|\| urlHasFilter\) return false;/, 'a URL filter beats my default view');
144
+ });
@@ -0,0 +1,48 @@
1
+ // tests/focus_version_filter_default.mjs — the Goals page and Tasks board open their
2
+ // Version filter on the project's focus version (task 1004552). The decision is the
3
+ // pure task-lib helper; the two pages are pinned to calling it after their options fill.
4
+ //
5
+ // Run: node --test tests/focus_version_filter_default.mjs
6
+
7
+ import assert from 'node:assert/strict';
8
+ import { test } from 'node:test';
9
+ import { readFileSync } from 'node:fs';
10
+ import { createRequire } from 'node:module';
11
+
12
+ const require = createRequire(import.meta.url);
13
+ const { focusVersionDefault } = require('../modules/hall-ui/public/task-lib.js');
14
+ const src = (rel) => readFileSync(new URL('../' + rel, import.meta.url), 'utf8');
15
+ const opts = ['', 'BONGOS-V1', 'BONGOS-V2'];
16
+
17
+ test('a starred version is the default the filter opens on', () => {
18
+ assert.equal(focusVersionDefault({ focusId: 'BONGOS-V2', optionValues: opts, current: '' }), 'BONGOS-V2');
19
+ });
20
+
21
+ test('no focus leaves the filter on any — nothing is guessed', () => {
22
+ for (const focusId of [null, undefined, '']) {
23
+ assert.equal(focusVersionDefault({ focusId, optionValues: opts, current: '' }), null);
24
+ }
25
+ });
26
+
27
+ test('a URL filter wins over the focus default', () => {
28
+ assert.equal(focusVersionDefault({ focusId: 'BONGOS-V2', optionValues: opts, current: '', urlValue: 'BONGOS-V1' }), null);
29
+ });
30
+
31
+ test('a choice the builder already made wins over the focus default', () => {
32
+ assert.equal(focusVersionDefault({ focusId: 'BONGOS-V2', optionValues: opts, current: 'BONGOS-V1' }), null);
33
+ });
34
+
35
+ test('a focus the select has no option for is left alone, not selected as a blank', () => {
36
+ assert.equal(focusVersionDefault({ focusId: 'BONGOS-V9', optionValues: opts, current: '' }), null);
37
+ });
38
+
39
+ test('both pages read the focus and apply it after their version options fill', () => {
40
+ const work = src('modules/hall-ui/public/work.js');
41
+ assert.match(work, /\/versions\/focus/);
42
+ // task 1004553 moved the call after the Goal options fill, behind the builder's default view.
43
+ assert.match(work, /restoreUrlFilter\('f-goal'\)[\s\S]{0,600}!applied && applyFocusVersion\(focusResp\)/);
44
+ const goals = src('modules/hall-ui/public/goals-page.js');
45
+ assert.match(goals, /\/versions\/focus/);
46
+ assert.match(goals, /populateVersionFilter\(\);[\s\S]{0,400}applyFocusVersion\(focusResp\);/);
47
+ for (const page of [work, goals]) assert.match(page, /OTBTaskLib\.focusVersionDefault\(/);
48
+ });
@@ -166,6 +166,7 @@ function bootWorkBoard({ signedIn = true, goalsFail = false } = {}) {
166
166
  priorityRank: () => 0, touchesConflict: () => false, conflictPeers: () => [], conflictChipHtml: () => '', rankTagHtml: () => '',
167
167
  creditsPerMinute: () => 0, buildPromptForTask: () => '', friendlyBatchReason: () => '',
168
168
  taskActivityMs: () => 0, STALE_CHOICES: [{ value: '', label: '— any —' }], isStale: () => false,
169
+ focusVersionDefault: () => null,
169
170
  },
170
171
  OTB: {
171
172
  escapeHtml,