domma-cms 0.91.1 → 0.92.1
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/CLAUDE.md +18 -9
- package/admin/js/lib/collection-editor-arrange.js +2 -2
- package/admin/js/lib/view-editor-arrange.js +1 -1
- package/admin/js/templates/collection-editor.html +3 -1
- package/admin/js/templates/docs/api-users.html +8 -8
- package/admin/js/templates/docs/api-views.html +5 -4
- package/admin/js/templates/docs/usage-users-roles.html +28 -48
- package/admin/js/templates/docs/usage-views.html +6 -4
- package/admin/js/views/collection-editor.js +4 -4
- package/admin/js/views/view-editor.js +1 -1
- package/package.json +1 -1
- package/plugins/feedback/CLAUDE.md +96 -0
- package/plugins/feedback/admin/css/index.css +80 -0
- package/plugins/feedback/admin/lib/kit.js +105 -0
- package/plugins/feedback/admin/templates/feedback.html +1 -0
- package/plugins/feedback/admin/views/feedback.js +63 -0
- package/plugins/feedback/admin/views/inbox.js +325 -0
- package/plugins/feedback/admin/views/send.js +223 -0
- package/plugins/feedback/collections/feedback-reports.json +24 -0
- package/plugins/feedback/config.js +15 -0
- package/plugins/feedback/lib/receiver.js +121 -0
- package/plugins/feedback/lib/sender.js +161 -0
- package/plugins/feedback/lib/shape.js +211 -0
- package/plugins/feedback/plugin.js +276 -0
- package/plugins/feedback/plugin.json +48 -0
- package/plugins/feedback/tests/api.test.js +219 -0
- package/plugins/feedback/tests/shape.test.js +87 -0
- package/plugins/free-tier.lock.json +22 -1
- package/server/routes/api/collections.js +8 -1
- package/server/routes/api/search.js +27 -6
- package/server/routes/api/views.js +19 -20
- package/server/routes/public.js +9 -0
- package/server/services/content.js +6 -4
- package/server/services/markdown.js +66 -33
- package/server/services/rowAccess.js +49 -0
- package/server/services/search.js +145 -17
- package/server/services/viewerSession.js +2 -2
- package/server/services/views.js +67 -5
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feedback - site admins tell Domma what is broken, missing or good, and see
|
|
3
|
+
* what became of it. Mounted at /api/plugins/feedback.
|
|
4
|
+
*
|
|
5
|
+
* ONE plugin, TWO sides, chosen by where it runs:
|
|
6
|
+
*
|
|
7
|
+
* sender (any site the manager started: MANAGER_URL + FLEET_TOKEN)
|
|
8
|
+
* GET /mode which side, and whether this site is connected
|
|
9
|
+
* GET /count sidebar badge: replies not yet read
|
|
10
|
+
* GET /sent this site's reports (sends any waiting first)
|
|
11
|
+
* POST /send a new report: kept here, then sent
|
|
12
|
+
* PUT /sent/:id/seen a reply has been read
|
|
13
|
+
*
|
|
14
|
+
* receiver (the manager - Site Manager is installed beside it)
|
|
15
|
+
* POST /fleet/submit a site's report (Bearer fleet token)
|
|
16
|
+
* GET /fleet/mine that site's reports, sender-safe fields only
|
|
17
|
+
* GET /mode · GET /count 'new' reports
|
|
18
|
+
* GET /reports · GET /reports/:id · PUT /reports/:id · DELETE /reports/:id
|
|
19
|
+
* GET /waypoint is Waypoint Pro here, and its projects
|
|
20
|
+
* POST /reports/:id/promote make a Waypoint issue from a report
|
|
21
|
+
*
|
|
22
|
+
* Nothing the browser sends says who sent a report or which site it came from:
|
|
23
|
+
* the user is the session's, the site is the one the fleet token names.
|
|
24
|
+
*/
|
|
25
|
+
import fs from 'node:fs';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import {fileURLToPath} from 'node:url';
|
|
28
|
+
import defaults from './config.js';
|
|
29
|
+
import {
|
|
30
|
+
SEVERITIES, STATUSES, TYPES, buildPayload, cleanSubmission, cleanUpdate, createThrottle, issueDescription, senderView
|
|
31
|
+
} from './lib/shape.js';
|
|
32
|
+
import {createOutbox, flush, managerApi, managerOf, mergeReports, newRef} from './lib/sender.js';
|
|
33
|
+
|
|
34
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
35
|
+
const NAME = 'feedback';
|
|
36
|
+
|
|
37
|
+
/** The CMS this site runs, from the updater's marker (the site's package.json is the SITE's version). */
|
|
38
|
+
function cmsVersion() {
|
|
39
|
+
for (const f of [path.join(process.cwd(), '.domma'), path.join(process.cwd(), '.cmsVersion')]) {
|
|
40
|
+
try {
|
|
41
|
+
const raw = fs.readFileSync(f, 'utf8');
|
|
42
|
+
try { return String(JSON.parse(raw).cmsVersion || ''); } catch { return raw.trim(); }
|
|
43
|
+
} catch { /* try the next */ }
|
|
44
|
+
}
|
|
45
|
+
try { return JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8')).version || ''; } catch { return ''; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export default async function feedbackPlugin(fastify, options) {
|
|
49
|
+
const {authenticate, requirePermission} = options.auth;
|
|
50
|
+
const hooks = options.hooks || {};
|
|
51
|
+
const can = (action) => ({preHandler: [authenticate, requirePermission(NAME, action)]});
|
|
52
|
+
const settings = {...defaults, ...(options.settings || {})};
|
|
53
|
+
const receiverMod = await import('./lib/receiver.js');
|
|
54
|
+
const mode = options.mode || (receiverMod.isManager() ? 'receiver' : 'sender');
|
|
55
|
+
const notifyOk = typeof hooks.notify === 'function';
|
|
56
|
+
if (typeof hooks.registerNotificationSource === 'function') {
|
|
57
|
+
try {
|
|
58
|
+
if (mode === 'receiver') {
|
|
59
|
+
hooks.registerNotificationSource({id: 'new-report', label: 'New feedback from a site',
|
|
60
|
+
description: 'A site admin sent a bug, request or question.', severity: 'info', audience: 'admins'});
|
|
61
|
+
} else {
|
|
62
|
+
hooks.registerNotificationSource({id: 'reply', label: 'Domma replied to your feedback',
|
|
63
|
+
description: 'Domma answered something this site sent.', severity: 'info', audience: 'admins'});
|
|
64
|
+
}
|
|
65
|
+
} catch (err) { fastify.log.warn(`[feedback] notification source: ${err.message}`); }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
fastify.get('/meta', can('send'), async () => ({types: TYPES, statuses: STATUSES, severities: SEVERITIES}));
|
|
69
|
+
|
|
70
|
+
if (mode === 'receiver') await receiverRoutes(fastify, {can, hooks, notifyOk, receiverMod, settings, options});
|
|
71
|
+
else await senderRoutes(fastify, {can, hooks, notifyOk, settings, options});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------------
|
|
75
|
+
// Sender
|
|
76
|
+
// ---------------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
async function senderRoutes(fastify, {can, hooks, notifyOk, settings, options}) {
|
|
79
|
+
const env = options.env || process.env;
|
|
80
|
+
const manager = managerOf(env);
|
|
81
|
+
const outbox = createOutbox(options.dataDir || path.join(HERE, 'data'));
|
|
82
|
+
const api = manager ? managerApi(manager, options.fetch) : null;
|
|
83
|
+
|
|
84
|
+
/** Send what waits, then read the manager's view; tell admins about replies they have not heard of. */
|
|
85
|
+
async function sync() {
|
|
86
|
+
if (!api) return {reports: mergeReports(outbox.all(), null, outbox.seen()), reachable: false, error: ''};
|
|
87
|
+
const pushed = await flush(outbox, api);
|
|
88
|
+
const mine = await api.mine();
|
|
89
|
+
const remote = mine.ok ? (mine.body?.reports || []) : null;
|
|
90
|
+
const reports = mergeReports(outbox.all(), remote, outbox.seen());
|
|
91
|
+
// Keep the last-known reply on the outbox copy: the badge reads that, never the manager.
|
|
92
|
+
if (remote) for (const r of reports) if (!r.local && r.ref) outbox.mark(r.ref, {reply: r.reply ? '1' : '', replyAt: r.replyAt, remoteId: r.id});
|
|
93
|
+
if (remote && notifyOk) {
|
|
94
|
+
for (const r of reports.filter(x => x.unread)) {
|
|
95
|
+
hooks.notify({title: `Domma replied: ${r.title}`, body: r.reply.slice(0, 300), severity: 'info', source: 'reply',
|
|
96
|
+
link: '#/plugins/feedback', dedupeKey: `feedback-reply:${r.id}:${r.replyAt}`});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return {reports, reachable: Boolean(remote), error: mine.ok ? pushed.error : mine.error};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// The hourly sync: waiting reports go, new replies are noticed. Off in tests.
|
|
103
|
+
if (api && options.timer !== false) {
|
|
104
|
+
const t = setInterval(() => { sync().catch(() => {}); }, Math.max(5, Number(settings.syncMinutes) || 60) * 60_000);
|
|
105
|
+
t.unref?.();
|
|
106
|
+
const first = setTimeout(() => { sync().catch(() => {}); }, 60_000);
|
|
107
|
+
first.unref?.();
|
|
108
|
+
fastify.addHook('onClose', async () => { clearInterval(t); clearTimeout(first); });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
fastify.get('/mode', can('send'), async () => ({mode: 'sender', connected: Boolean(manager), site: manager?.slug || ''}));
|
|
112
|
+
|
|
113
|
+
fastify.get('/count', can('send'), async () => {
|
|
114
|
+
const seen = outbox.seen();
|
|
115
|
+
// From the last sync's copy: the badge must not call the manager every minute.
|
|
116
|
+
const unread = outbox.all().filter(i => i.reply && i.replyAt && seen[i.remoteId] !== i.replyAt);
|
|
117
|
+
const waiting = outbox.all().filter(i => i.state === 'waiting').length;
|
|
118
|
+
return {
|
|
119
|
+
count: unread.length,
|
|
120
|
+
label: unread.length ? `${unread.length} new ${unread.length === 1 ? 'reply' : 'replies'} from Domma` : '',
|
|
121
|
+
tone: 'info',
|
|
122
|
+
details: {title: 'Feedback', rows: [{label: 'New replies', value: String(unread.length)}, {label: 'Waiting to send', value: String(waiting)}],
|
|
123
|
+
items: unread.slice(0, 5).map(i => ({text: i.payload?.title || 'A report'})), empty: 'Nothing new from Domma.'}
|
|
124
|
+
};
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
fastify.get('/sent', can('send'), async () => {
|
|
128
|
+
const r = await sync();
|
|
129
|
+
return {connected: Boolean(manager), reachable: r.reachable, error: r.error, reports: r.reports};
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
fastify.post('/send', can('send'), async (request, reply) => {
|
|
133
|
+
if (!manager) return reply.code(409).send({error: 'This site is not connected to Domma, so feedback cannot be sent from here.'});
|
|
134
|
+
const {report, error} = cleanSubmission(request.body || {});
|
|
135
|
+
if (error) return reply.code(400).send({error});
|
|
136
|
+
const payload = buildPayload({report, user: request.user, cmsVersion: cmsVersion(), site: manager.slug, ref: newRef()});
|
|
137
|
+
outbox.add(payload);
|
|
138
|
+
const r = await flush(outbox, api);
|
|
139
|
+
const item = outbox.all().find(i => i.ref === payload.ref);
|
|
140
|
+
return reply.code(201).send({ref: payload.ref, state: item?.state || 'waiting',
|
|
141
|
+
message: item?.state === 'sent' ? 'Sent to Domma. Thank you.' : `Kept here and sent as soon as Domma answers. (${r.error || 'not sent yet'})`});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
fastify.put('/sent/:id/seen', can('send'), async (request) => {
|
|
145
|
+
const {replyAt} = request.body || {};
|
|
146
|
+
outbox.markSeen(String(request.params.id), String(replyAt || ''));
|
|
147
|
+
return {ok: true};
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ---------------------------------------------------------------------------------
|
|
152
|
+
// Receiver (the manager)
|
|
153
|
+
// ---------------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
async function receiverRoutes(fastify, {can, hooks, notifyOk, receiverMod, settings, options}) {
|
|
156
|
+
const rx = receiverMod;
|
|
157
|
+
const slugOf = options.slugFromToken || rx.slugFromToken;
|
|
158
|
+
const names = options.siteNames || rx.siteNames;
|
|
159
|
+
const allowed = createThrottle({max: Number(settings.perSiteMax) || 10, windowMs: (Number(settings.perSiteMinutes) || 10) * 60_000});
|
|
160
|
+
try { await rx.ensureCollection(); } catch (err) { fastify.log.error(`[feedback] collection: ${err.message}`); }
|
|
161
|
+
|
|
162
|
+
// Waypoint Pro, when it is loaded, calls this when a linked issue moves.
|
|
163
|
+
globalThis.dommaFeedback = {
|
|
164
|
+
version: 1,
|
|
165
|
+
async issueUpdated({sourceId, issue} = {}) {
|
|
166
|
+
try { await rx.issueMoved(String(sourceId || ''), issue || {}); } catch { /* the report went */ }
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
fastify.addHook('onClose', async () => { if (globalThis.dommaFeedback?.version === 1) delete globalThis.dommaFeedback; });
|
|
170
|
+
|
|
171
|
+
const waypoint = () => (globalThis.dommaWaypoint?.version === 1 ? globalThis.dommaWaypoint : null);
|
|
172
|
+
|
|
173
|
+
// ---- From the sites (no session: the fleet token IS the identity) ----
|
|
174
|
+
|
|
175
|
+
fastify.post('/fleet/submit', {bodyLimit: 32 * 1024}, async (request, reply) => {
|
|
176
|
+
const slug = await slugOf(request.headers.authorization);
|
|
177
|
+
if (!slug) return reply.code(401).send({error: 'Unknown site.'});
|
|
178
|
+
if (!allowed(slug)) return reply.code(429).send({error: 'That is a lot of feedback at once - try again in a few minutes.'});
|
|
179
|
+
const r = await rx.receive(slug, request.body || {});
|
|
180
|
+
if (r.error) return reply.code(400).send({error: r.error});
|
|
181
|
+
if (r.created && notifyOk) {
|
|
182
|
+
const d = r.entry.data;
|
|
183
|
+
hooks.notify({title: `Feedback from ${names()[slug] || slug}: ${d.title}`, body: d.description.slice(0, 300),
|
|
184
|
+
severity: d.severity === 'blocker' ? 'warning' : 'info', source: 'new-report',
|
|
185
|
+
link: `#/plugins/feedback/${r.entry.id}`, dedupeKey: `feedback:${r.entry.id}`});
|
|
186
|
+
}
|
|
187
|
+
return reply.code(r.created ? 201 : 200).send({id: r.entry.id, status: r.entry.data.status});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
fastify.get('/fleet/mine', async (request, reply) => {
|
|
191
|
+
const slug = await slugOf(request.headers.authorization);
|
|
192
|
+
if (!slug) return reply.code(401).send({error: 'Unknown site.'});
|
|
193
|
+
return {reports: (await rx.reportsOf(slug)).map(senderView)};
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// ---- The inbox ----
|
|
197
|
+
|
|
198
|
+
fastify.get('/mode', can('manage'), async () => ({mode: 'receiver', waypoint: Boolean(waypoint())}));
|
|
199
|
+
|
|
200
|
+
fastify.get('/count', can('manage'), async () => {
|
|
201
|
+
const fresh = (await rx.allReports()).filter(e => e.data?.status === 'new');
|
|
202
|
+
const n = names();
|
|
203
|
+
return {
|
|
204
|
+
count: fresh.length,
|
|
205
|
+
label: fresh.length ? `${fresh.length} new` : '',
|
|
206
|
+
tone: fresh.some(e => e.data?.severity === 'blocker') ? 'danger' : 'info',
|
|
207
|
+
details: {title: 'New feedback', items: fresh.slice(0, 6).map(e => ({text: e.data.title, meta: n[e.data.site] || e.data.site})),
|
|
208
|
+
empty: 'Nothing new.'}
|
|
209
|
+
};
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
fastify.get('/reports', can('manage'), async () => {
|
|
213
|
+
const n = names();
|
|
214
|
+
return {sites: n, reports: (await rx.allReports()).map(e => ({id: e.id, ...e.data, siteName: n[e.data.site] || e.data.site,
|
|
215
|
+
createdAt: e.meta?.createdAt, updatedAt: e.meta?.updatedAt}))};
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
fastify.get('/reports/:id', can('manage'), async (request, reply) => {
|
|
219
|
+
let e = await rx.getReport(request.params.id);
|
|
220
|
+
if (!e) return reply.code(404).send({error: 'No such report.'});
|
|
221
|
+
// Freshen the linked issue's status while we are here.
|
|
222
|
+
const wp = waypoint();
|
|
223
|
+
if (wp && e.data.issueId) {
|
|
224
|
+
try {
|
|
225
|
+
const issue = await wp.getIssue(e.data.issueId);
|
|
226
|
+
if (issue && issue.statusName !== e.data.issueStatus) e = (await rx.issueMoved(e.id, issue)) || e;
|
|
227
|
+
} catch { /* Waypoint could not say */ }
|
|
228
|
+
}
|
|
229
|
+
return {id: e.id, ...e.data, siteName: names()[e.data.site] || e.data.site, createdAt: e.meta?.createdAt, updatedAt: e.meta?.updatedAt};
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
fastify.put('/reports/:id', can('manage'), async (request, reply) => {
|
|
233
|
+
const e = await rx.getReport(request.params.id);
|
|
234
|
+
if (!e) return reply.code(404).send({error: 'No such report.'});
|
|
235
|
+
const {patch, error} = cleanUpdate(request.body || {}, e.data);
|
|
236
|
+
if (error) return reply.code(400).send({error});
|
|
237
|
+
const next = await rx.applyPatch(e.id, patch, request.user?.name || 'Domma');
|
|
238
|
+
return {id: next.id, ...next.data, createdAt: next.meta?.createdAt, updatedAt: next.meta?.updatedAt};
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
fastify.delete('/reports/:id', can('manage'), async (request, reply) => {
|
|
242
|
+
const e = await rx.getReport(request.params.id);
|
|
243
|
+
if (!e) return reply.code(404).send({error: 'No such report.'});
|
|
244
|
+
await rx.removeReport(e.id);
|
|
245
|
+
return {ok: true};
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
fastify.get('/waypoint', can('manage'), async () => {
|
|
249
|
+
const wp = waypoint();
|
|
250
|
+
if (!wp) return {available: false, projects: []};
|
|
251
|
+
try { return {available: true, projects: (await wp.listProjects()) || []}; }
|
|
252
|
+
catch (err) { return {available: false, projects: [], error: err.message}; }
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
fastify.post('/reports/:id/promote', can('manage'), async (request, reply) => {
|
|
256
|
+
const wp = waypoint();
|
|
257
|
+
if (!wp) return reply.code(503).send({error: 'Waypoint Pro is not installed or not enabled on this server.'});
|
|
258
|
+
const e = await rx.getReport(request.params.id);
|
|
259
|
+
if (!e) return reply.code(404).send({error: 'No such report.'});
|
|
260
|
+
if (e.data.issueId) return reply.code(409).send({error: `Already ${e.data.issueKey}.`});
|
|
261
|
+
const {projectId, type, priority} = request.body || {};
|
|
262
|
+
if (!projectId) return reply.code(400).send({error: 'Choose a project.'});
|
|
263
|
+
let issue;
|
|
264
|
+
try {
|
|
265
|
+
issue = await wp.createIssue({projectId: String(projectId), title: e.data.title, description: issueDescription({...e.data, site: names()[e.data.site] || e.data.site}),
|
|
266
|
+
type: String(type || ''), priority: String(priority || ''), reporterName: e.data.userName,
|
|
267
|
+
source: {plugin: 'feedback', id: e.id, site: e.data.site, url: `#/plugins/feedback/${e.id}`}});
|
|
268
|
+
} catch (err) {
|
|
269
|
+
return reply.code(400).send({error: `Waypoint did not make the issue: ${err.message}`});
|
|
270
|
+
}
|
|
271
|
+
const status = ['new', 'acknowledged'].includes(e.data.status) ? 'planned' : e.data.status;
|
|
272
|
+
const next = await rx.applyPatch(e.id, {issueId: String(issue.id), issueKey: String(issue.key || ''), issueStatus: '', status},
|
|
273
|
+
request.user?.name || 'Domma');
|
|
274
|
+
return {id: next.id, ...next.data, issueLink: issue.link || ''};
|
|
275
|
+
});
|
|
276
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "feedback",
|
|
3
|
+
"displayName": "Feedback",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"tier": "free",
|
|
6
|
+
"description": "Tell Domma what is broken, missing or good - straight from your admin - and see what became of it: seen, planned, in progress, done, with Domma's reply.",
|
|
7
|
+
"author": "Domma CMS",
|
|
8
|
+
"date": "2026-09-27",
|
|
9
|
+
"icon": "message-square",
|
|
10
|
+
"permissions": [
|
|
11
|
+
{
|
|
12
|
+
"key": "feedback",
|
|
13
|
+
"label": "Feedback",
|
|
14
|
+
"group": "Plugins",
|
|
15
|
+
"icon": "message-square",
|
|
16
|
+
"description": "Send feedback to Domma and read the replies (send). On the Domma server itself: read, answer and file everything the sites sent (manage).",
|
|
17
|
+
"actions": [
|
|
18
|
+
{"key": "send", "label": "Send", "description": "Send feedback to Domma and see what this site has sent"},
|
|
19
|
+
{"key": "manage", "label": "Manage", "description": "On the Domma server: the inbox of every site's feedback"}
|
|
20
|
+
],
|
|
21
|
+
"grant": ["admin"]
|
|
22
|
+
}
|
|
23
|
+
],
|
|
24
|
+
"admin": {
|
|
25
|
+
"css": ["admin/css/index.css"],
|
|
26
|
+
"sidebar": [
|
|
27
|
+
{
|
|
28
|
+
"id": "feedback",
|
|
29
|
+
"text": "Feedback",
|
|
30
|
+
"icon": "message-square",
|
|
31
|
+
"url": "#/plugins/feedback",
|
|
32
|
+
"section": "#/plugins/feedback",
|
|
33
|
+
"permission": "feedback",
|
|
34
|
+
"countUrl": "/api/plugins/feedback/count"
|
|
35
|
+
}
|
|
36
|
+
],
|
|
37
|
+
"routes": [
|
|
38
|
+
{"path": "/plugins/feedback", "view": "plugin-feedback", "title": "Feedback - Domma CMS"},
|
|
39
|
+
{"path": "/plugins/feedback/:id", "view": "plugin-feedback", "title": "Feedback - Domma CMS"}
|
|
40
|
+
],
|
|
41
|
+
"views": {
|
|
42
|
+
"plugin-feedback": {
|
|
43
|
+
"entry": "feedback/admin/views/feedback.js?v=1.0.0",
|
|
44
|
+
"exportName": "feedbackView"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Both sides at once: a SENDER app (a site) whose fetch is wired straight into
|
|
3
|
+
* a RECEIVER app (the manager) via inject, against the real collection service
|
|
4
|
+
* in the domma-cms checkout `make test` runs in. The collection is made for the
|
|
5
|
+
* test and removed after; config/plugins.json is restored byte for byte.
|
|
6
|
+
*/
|
|
7
|
+
import {test, before, after} from 'node:test';
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import fs from 'node:fs/promises';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import Fastify from 'fastify';
|
|
13
|
+
import feedback from '../plugin.js';
|
|
14
|
+
|
|
15
|
+
const PLUGINS = path.resolve('config/plugins.json');
|
|
16
|
+
const COLL = path.resolve('content/collections/feedback-reports');
|
|
17
|
+
const TOKENS = {'tok-acme': 'acme', 'tok-beta': 'beta', 'tok-flood': 'flood'};
|
|
18
|
+
let savedPlugins;
|
|
19
|
+
let manager;
|
|
20
|
+
let dataDir;
|
|
21
|
+
let managerUp = true;
|
|
22
|
+
const notices = [];
|
|
23
|
+
const siteNotices = [];
|
|
24
|
+
|
|
25
|
+
/** A site's fetch: into the manager app, or a connection failure while it is "restarting". */
|
|
26
|
+
const fetchInto = (app) => async (url, init = {}) => {
|
|
27
|
+
if (!managerUp) throw new Error('connect ECONNREFUSED 127.0.0.1:8192');
|
|
28
|
+
const res = await app.inject({method: init.method || 'GET', url: new URL(url).pathname,
|
|
29
|
+
headers: init.headers, payload: init.body});
|
|
30
|
+
return {status: res.statusCode, json: async () => res.json()};
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
async function site({token, user}) {
|
|
34
|
+
const app = Fastify();
|
|
35
|
+
await app.register(feedback, {
|
|
36
|
+
auth: {authenticate: async (req) => { req.user = user; }, requirePermission: () => async () => {}},
|
|
37
|
+
settings: {}, mode: 'sender', timer: false, dataDir: await fs.mkdtemp(path.join(dataDir, 'site-')),
|
|
38
|
+
env: {MANAGER_URL: 'http://127.0.0.1:8192/', FLEET_TOKEN: token, SITE_SLUG: TOKENS[token] || 'x'},
|
|
39
|
+
fetch: fetchInto(manager),
|
|
40
|
+
hooks: {notify: (n) => siteNotices.push(n), registerNotificationSource() {}}
|
|
41
|
+
});
|
|
42
|
+
return app;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const call = async (app, method, url, body, headers) => {
|
|
46
|
+
const res = await app.inject({method, url, payload: body, headers});
|
|
47
|
+
let json = null;
|
|
48
|
+
try { json = res.json(); } catch { /* not JSON */ }
|
|
49
|
+
return {status: res.statusCode, body: json};
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
before(async () => {
|
|
53
|
+
savedPlugins = await fs.readFile(PLUGINS, 'utf8').catch(() => null);
|
|
54
|
+
await fs.rm(COLL, {recursive: true, force: true});
|
|
55
|
+
dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'feedback-test-'));
|
|
56
|
+
manager = Fastify();
|
|
57
|
+
await manager.register(feedback, {
|
|
58
|
+
auth: {authenticate: async (req) => { req.user = {id: 'v1', name: 'Vendor', role: 'super-admin'}; }, requirePermission: () => async () => {}},
|
|
59
|
+
prefix: '/api/plugins/feedback',
|
|
60
|
+
settings: {perSiteMax: 6, perSiteMinutes: 10}, mode: 'receiver',
|
|
61
|
+
slugFromToken: async (h) => TOKENS[String(h || '').replace(/^Bearer\s+/i, '')] || null,
|
|
62
|
+
siteNames: () => ({acme: 'Acme Ltd', beta: 'Beta Co'}),
|
|
63
|
+
hooks: {notify: (n) => notices.push(n), registerNotificationSource() {}}
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
after(async () => {
|
|
68
|
+
await manager?.close();
|
|
69
|
+
delete globalThis.dommaWaypoint;
|
|
70
|
+
await fs.rm(COLL, {recursive: true, force: true});
|
|
71
|
+
await fs.rm(dataDir, {recursive: true, force: true});
|
|
72
|
+
if (savedPlugins === null) await fs.rm(PLUGINS, {force: true});
|
|
73
|
+
else await fs.writeFile(PLUGINS, savedPlugins);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('a site sends; the manager attributes it to the token and tells the vendor', async () => {
|
|
77
|
+
const acme = await site({token: 'tok-acme', user: {id: 'u1', name: 'Sam', email: 'sam@acme.test', role: 'admin'}});
|
|
78
|
+
let r = await call(acme, 'POST', '/send', {type: 'bug', title: 'Save loses text', description: 'Typed, saved, gone.', severity: 'major',
|
|
79
|
+
user: {name: 'Mallory'}, site: 'beta'});
|
|
80
|
+
assert.equal(r.status, 201, JSON.stringify(r.body));
|
|
81
|
+
assert.equal(r.body.state, 'sent');
|
|
82
|
+
|
|
83
|
+
const inbox = (await call(manager, 'GET', '/api/plugins/feedback/reports')).body.reports;
|
|
84
|
+
assert.equal(inbox.length, 1);
|
|
85
|
+
assert.equal(inbox[0].site, 'acme', 'the token names the site, not the body');
|
|
86
|
+
assert.equal(inbox[0].userName, 'Sam', 'the session names the sender, not the body');
|
|
87
|
+
assert.equal(inbox[0].siteName, 'Acme Ltd');
|
|
88
|
+
assert.ok(notices.some(n => n.source === 'new-report' && n.title.includes('Acme Ltd')));
|
|
89
|
+
assert.equal((await call(manager, 'GET', '/api/plugins/feedback/count')).body.count, 1);
|
|
90
|
+
|
|
91
|
+
r = await call(acme, 'GET', '/sent');
|
|
92
|
+
assert.equal(r.body.reachable, true);
|
|
93
|
+
assert.equal(r.body.reports.length, 1);
|
|
94
|
+
assert.equal(r.body.reports[0].statusLabel, 'New');
|
|
95
|
+
await acme.close();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('an unknown token is refused; each site reads only its own', async () => {
|
|
99
|
+
let r = await call(manager, 'POST', '/api/plugins/feedback/fleet/submit', {title: 't', description: 'd'}, {authorization: 'Bearer nope'});
|
|
100
|
+
assert.equal(r.status, 401);
|
|
101
|
+
r = await call(manager, 'GET', '/api/plugins/feedback/fleet/mine', null, {authorization: 'Bearer nope'});
|
|
102
|
+
assert.equal(r.status, 401);
|
|
103
|
+
|
|
104
|
+
const beta = await site({token: 'tok-beta', user: {id: 'u9', name: 'Bea', email: 'bea@beta.test'}});
|
|
105
|
+
await call(beta, 'POST', '/send', {type: 'feature', title: 'Dark mode', description: 'Please.'});
|
|
106
|
+
const mine = (await call(manager, 'GET', '/api/plugins/feedback/fleet/mine', null, {authorization: 'Bearer tok-beta'})).body.reports;
|
|
107
|
+
assert.deepEqual(mine.map(x => x.title), ['Dark mode'], 'beta never sees acme');
|
|
108
|
+
for (const k of ['notes', 'userEmail', 'site', 'userId', 'history']) assert.equal(mine[0][k], undefined, `${k} stays on the manager`);
|
|
109
|
+
await beta.close();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('a retry of the same report does not make a second one', async () => {
|
|
113
|
+
const body = {ref: 'same-ref', title: 'Twice', description: 'Sent twice.'};
|
|
114
|
+
const h = {authorization: 'Bearer tok-beta'};
|
|
115
|
+
const a = await call(manager, 'POST', '/api/plugins/feedback/fleet/submit', body, h);
|
|
116
|
+
const b = await call(manager, 'POST', '/api/plugins/feedback/fleet/submit', body, h);
|
|
117
|
+
assert.equal(a.status, 201);
|
|
118
|
+
assert.equal(b.status, 200);
|
|
119
|
+
assert.equal(a.body.id, b.body.id);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('the per-site throttle and the body limit', async () => {
|
|
123
|
+
// A site stuck retrying cannot flood: 6 in the window, then 429 - for that site only.
|
|
124
|
+
const h = {authorization: 'Bearer tok-flood'};
|
|
125
|
+
for (let i = 0; i < 6; i++) {
|
|
126
|
+
assert.equal((await call(manager, 'POST', '/api/plugins/feedback/fleet/submit', {title: `n${i}`, description: 'y'}, h)).status, 201);
|
|
127
|
+
}
|
|
128
|
+
assert.equal((await call(manager, 'POST', '/api/plugins/feedback/fleet/submit', {title: 'x', description: 'y'}, h)).status, 429);
|
|
129
|
+
assert.equal((await call(manager, 'POST', '/api/plugins/feedback/fleet/submit', {title: 'x', description: 'y'},
|
|
130
|
+
{authorization: 'Bearer tok-beta'})).status, 201, 'another site is unaffected');
|
|
131
|
+
const big = await manager.inject({method: 'POST', url: '/api/plugins/feedback/fleet/submit',
|
|
132
|
+
headers: {'content-type': 'application/json', authorization: 'Bearer tok-acme'},
|
|
133
|
+
payload: JSON.stringify({title: 't', description: 'z'.repeat(40 * 1024)})});
|
|
134
|
+
assert.equal(big.statusCode, 413);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('the manager restarting: the report waits, then goes', async () => {
|
|
138
|
+
const acme = await site({token: 'tok-acme', user: {id: 'u2', name: 'Al'}});
|
|
139
|
+
managerUp = false;
|
|
140
|
+
let r = await call(acme, 'POST', '/send', {type: 'question', title: 'While down', description: 'Is it?'});
|
|
141
|
+
assert.equal(r.status, 201);
|
|
142
|
+
assert.equal(r.body.state, 'waiting');
|
|
143
|
+
r = await call(acme, 'GET', '/sent');
|
|
144
|
+
assert.equal(r.body.reachable, false);
|
|
145
|
+
assert.equal(r.body.reports.find(x => x.title === 'While down').status, 'waiting');
|
|
146
|
+
managerUp = true;
|
|
147
|
+
r = await call(acme, 'GET', '/sent');
|
|
148
|
+
assert.equal(r.body.reachable, true);
|
|
149
|
+
assert.equal(r.body.reports.find(x => x.title === 'While down').status, 'new', 'sent on the next open');
|
|
150
|
+
await acme.close();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('a reply: the site sees it, is told once, and the badge counts it until read', async () => {
|
|
154
|
+
const acme = await site({token: 'tok-acme', user: {id: 'u1', name: 'Sam'}});
|
|
155
|
+
await call(acme, 'POST', '/send', {type: 'bug', title: 'Needs a reply', description: 'd'});
|
|
156
|
+
const report = (await call(manager, 'GET', '/api/plugins/feedback/reports')).body.reports.find(x => x.title === 'Needs a reply');
|
|
157
|
+
let r = await call(manager, 'PUT', `/api/plugins/feedback/reports/${report.id}`, {status: 'planned', reply: 'On the list.', notes: 'Easy fix'});
|
|
158
|
+
assert.equal(r.status, 200, JSON.stringify(r.body));
|
|
159
|
+
siteNotices.length = 0;
|
|
160
|
+
const sent = (await call(acme, 'GET', '/sent')).body.reports.find(x => x.title === 'Needs a reply');
|
|
161
|
+
assert.equal(sent.reply, 'On the list.');
|
|
162
|
+
assert.equal(sent.statusLabel, 'Planned');
|
|
163
|
+
assert.equal(sent.notes, undefined);
|
|
164
|
+
assert.equal(sent.unread, true);
|
|
165
|
+
assert.ok(siteNotices.some(n => n.source === 'reply'));
|
|
166
|
+
assert.equal((await call(acme, 'GET', '/count')).body.count, 1);
|
|
167
|
+
await call(acme, 'PUT', `/sent/${sent.id}/seen`, {replyAt: sent.replyAt});
|
|
168
|
+
assert.equal((await call(acme, 'GET', '/count')).body.count, 0);
|
|
169
|
+
await acme.close();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('promote to Waypoint, and follow the issue', async () => {
|
|
173
|
+
const report = (await call(manager, 'GET', '/api/plugins/feedback/reports')).body.reports.find(x => x.title === 'Save loses text');
|
|
174
|
+
let r = await call(manager, 'POST', `/api/plugins/feedback/reports/${report.id}/promote`, {projectId: 'p1'});
|
|
175
|
+
assert.equal(r.status, 503, 'no Waypoint yet');
|
|
176
|
+
assert.equal((await call(manager, 'GET', '/api/plugins/feedback/waypoint')).body.available, false);
|
|
177
|
+
|
|
178
|
+
const made = [];
|
|
179
|
+
globalThis.dommaWaypoint = {
|
|
180
|
+
version: 1,
|
|
181
|
+
listProjects: async () => [{id: 'p1', key: 'CMS', name: 'Domma CMS'}],
|
|
182
|
+
createIssue: async (input) => { made.push(input); return {id: 'i1', key: 'CMS-12', link: '#/plugins/waypoint-pro/issue/CMS-12'}; },
|
|
183
|
+
getIssue: async () => ({id: 'i1', key: 'CMS-12', statusName: 'In Review', statusCategory: 'doing'})
|
|
184
|
+
};
|
|
185
|
+
assert.deepEqual((await call(manager, 'GET', '/api/plugins/feedback/waypoint')).body.projects.map(p => p.key), ['CMS']);
|
|
186
|
+
r = await call(manager, 'POST', `/api/plugins/feedback/reports/${report.id}/promote`, {projectId: 'p1', type: 'bug', priority: 'high'});
|
|
187
|
+
assert.equal(r.status, 200, JSON.stringify(r.body));
|
|
188
|
+
assert.equal(r.body.issueKey, 'CMS-12');
|
|
189
|
+
assert.equal(r.body.status, 'planned');
|
|
190
|
+
assert.equal(made[0].source.plugin, 'feedback');
|
|
191
|
+
assert.equal(made[0].source.id, report.id);
|
|
192
|
+
assert.match(made[0].description, /From Sam on Acme Ltd/);
|
|
193
|
+
assert.equal((await call(manager, 'POST', `/api/plugins/feedback/reports/${report.id}/promote`, {projectId: 'p1'})).status, 409);
|
|
194
|
+
|
|
195
|
+
// Waypoint moves the issue: the report follows.
|
|
196
|
+
await globalThis.dommaFeedback.issueUpdated({sourceId: report.id, issue: {id: 'i1', key: 'CMS-12', statusName: 'Done', statusCategory: 'done'}});
|
|
197
|
+
r = await call(manager, 'GET', `/api/plugins/feedback/reports/${report.id}`);
|
|
198
|
+
// Reading it asks Waypoint again, which now says In Review / doing.
|
|
199
|
+
assert.equal(r.body.status, 'in-progress');
|
|
200
|
+
assert.equal(r.body.issueStatus, 'In Review');
|
|
201
|
+
|
|
202
|
+
// Declined by hand stays declined.
|
|
203
|
+
await call(manager, 'PUT', `/api/plugins/feedback/reports/${report.id}`, {status: 'declined'});
|
|
204
|
+
await globalThis.dommaFeedback.issueUpdated({sourceId: report.id, issue: {statusName: 'Done', statusCategory: 'done'}});
|
|
205
|
+
delete globalThis.dommaWaypoint;
|
|
206
|
+
r = await call(manager, 'GET', `/api/plugins/feedback/reports/${report.id}`);
|
|
207
|
+
assert.equal(r.body.status, 'declined');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('a site the manager did not start cannot send, and says so', async () => {
|
|
211
|
+
const app = Fastify();
|
|
212
|
+
await app.register(feedback, {
|
|
213
|
+
auth: {authenticate: async (req) => { req.user = {id: 'u', name: 'U'}; }, requirePermission: () => async () => {}},
|
|
214
|
+
settings: {}, mode: 'sender', timer: false, env: {}, dataDir: await fs.mkdtemp(path.join(dataDir, 'lone-')), hooks: {}
|
|
215
|
+
});
|
|
216
|
+
assert.equal((await call(app, 'GET', '/mode')).body.connected, false);
|
|
217
|
+
assert.equal((await call(app, 'POST', '/send', {title: 't', description: 'd'})).status, 409);
|
|
218
|
+
await app.close();
|
|
219
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import {test} from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {
|
|
4
|
+
buildPayload, cleanSubmission, cleanUpdate, createThrottle, issueDescription, receivedReport, senderView, statusFromIssue
|
|
5
|
+
} from '../lib/shape.js';
|
|
6
|
+
import {mergeReports} from '../lib/sender.js';
|
|
7
|
+
|
|
8
|
+
test('a submission is cleaned, and says nothing about who sent it', () => {
|
|
9
|
+
assert.equal(cleanSubmission({title: ' ', description: 'x'}).error, 'Give it a short title.');
|
|
10
|
+
assert.equal(cleanSubmission({title: 'x', description: ''}).error, 'Say a little more in the description.');
|
|
11
|
+
const {report} = cleanSubmission({type: 'nonsense', title: 'A'.repeat(500), description: 'd', severity: 'blocker', user: {name: 'Mallory'}});
|
|
12
|
+
assert.equal(report.type, 'other');
|
|
13
|
+
assert.equal(report.title.length, 140);
|
|
14
|
+
assert.equal(report.severity, '', 'severity is for bugs only');
|
|
15
|
+
assert.equal(report.user, undefined);
|
|
16
|
+
assert.equal(cleanSubmission({type: 'bug', title: 't', description: 'd', severity: 'blocker'}).report.severity, 'blocker');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('the payload takes the user from the session', () => {
|
|
20
|
+
const p = buildPayload({report: {type: 'bug', title: 't', description: 'd'}, user: {id: 'u1', name: 'Sam', email: 's@x.io', role: 'admin'},
|
|
21
|
+
cmsVersion: '0.91.0', site: 'acme', ref: 'r1'});
|
|
22
|
+
assert.deepEqual(p.user, {id: 'u1', name: 'Sam', email: 's@x.io'});
|
|
23
|
+
assert.equal(p.ref, 'r1');
|
|
24
|
+
assert.equal(p.cmsVersion, '0.91.0');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('the manager attributes a report to the token, never the body', () => {
|
|
28
|
+
const {data} = receivedReport({title: 't', description: 'd', site: 'someone-else', status: 'done', notes: 'sneaky', user: {name: 'Sam'}}, 'acme');
|
|
29
|
+
assert.equal(data.site, 'acme');
|
|
30
|
+
assert.equal(data.status, 'new');
|
|
31
|
+
assert.equal(data.notes, '');
|
|
32
|
+
assert.equal(data.userName, 'Sam');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('a site sees a whitelist of its report', () => {
|
|
36
|
+
const v = senderView({id: 'e1', data: {title: 't', status: 'planned', notes: 'internal', userEmail: 'x@y', site: 'acme', reply: 'Thanks'},
|
|
37
|
+
meta: {createdAt: '2026-09-27T10:00:00Z'}});
|
|
38
|
+
assert.equal(v.notes, undefined);
|
|
39
|
+
assert.equal(v.userEmail, undefined);
|
|
40
|
+
assert.equal(v.site, undefined);
|
|
41
|
+
assert.equal(v.statusLabel, 'Planned');
|
|
42
|
+
assert.equal(v.reply, 'Thanks');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('a Waypoint issue moves the report, but not a declined one', () => {
|
|
46
|
+
assert.equal(statusFromIssue('todo', 'new'), 'planned');
|
|
47
|
+
assert.equal(statusFromIssue('doing', 'planned'), 'in-progress');
|
|
48
|
+
assert.equal(statusFromIssue('done', 'in-progress'), 'done');
|
|
49
|
+
assert.equal(statusFromIssue('done', 'declined'), 'declined');
|
|
50
|
+
assert.equal(statusFromIssue('weird', 'planned'), 'planned');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('updates: known statuses only, an unchanged reply is not a new reply', () => {
|
|
54
|
+
assert.equal(cleanUpdate({status: 'lost'}).error, 'That is not a status.');
|
|
55
|
+
assert.equal(cleanUpdate({}).error, 'Nothing to change.');
|
|
56
|
+
assert.deepEqual(cleanUpdate({status: 'done', reply: 'Same'}, {reply: 'Same'}).patch, {status: 'done'});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('the throttle allows max in the window, then waits', () => {
|
|
60
|
+
const ok = createThrottle({max: 2, windowMs: 1000});
|
|
61
|
+
assert.equal(ok('a', 0), true);
|
|
62
|
+
assert.equal(ok('a', 10), true);
|
|
63
|
+
assert.equal(ok('a', 20), false);
|
|
64
|
+
assert.equal(ok('b', 20), true, 'per site');
|
|
65
|
+
assert.equal(ok('a', 1015), true, 'the window moves on');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('the issue text carries where it came from', () => {
|
|
69
|
+
const t = issueDescription({description: 'It broke', userName: 'Sam', site: 'Acme', cmsVersion: '0.91.0', severity: 'major'});
|
|
70
|
+
assert.match(t, /It broke/);
|
|
71
|
+
assert.match(t, /From Sam on Acme/);
|
|
72
|
+
assert.match(t, /Domma CMS 0\.91\.0/);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('the list merges the manager view with what is still waiting', () => {
|
|
76
|
+
const outbox = [
|
|
77
|
+
{ref: 'a', state: 'sent', payload: {title: 'A'}, createdAt: '2026-09-27T09:00:00Z'},
|
|
78
|
+
{ref: 'b', state: 'waiting', payload: {title: 'B', user: {name: 'Sam'}}, createdAt: '2026-09-27T11:00:00Z', lastError: 'down'}
|
|
79
|
+
];
|
|
80
|
+
const remote = [{id: 'e1', ref: 'a', title: 'A', reply: 'Yes', replyAt: '2026-09-27T12:00:00Z', createdAt: '2026-09-27T09:00:00Z'}];
|
|
81
|
+
const list = mergeReports(outbox, remote, {});
|
|
82
|
+
assert.deepEqual(list.map(r => r.title), ['B', 'A']);
|
|
83
|
+
assert.equal(list[0].statusLabel, 'Waiting to send');
|
|
84
|
+
assert.equal(list[1].unread, true);
|
|
85
|
+
assert.equal(mergeReports(outbox, remote, {e1: '2026-09-27T12:00:00Z'})[1].unread, false, 'read once seen');
|
|
86
|
+
assert.equal(mergeReports(outbox, null, {}).length, 2, 'manager unreachable: the outbox copy stands in');
|
|
87
|
+
});
|