domma-cms 0.112.1 → 0.114.0

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,265 @@
1
+ /**
2
+ * Both sides at once: SITE apps whose fetch is wired straight into a MANAGER app
3
+ * via inject, against the real collection service in the domma-cms checkout
4
+ * `make test` runs in. The collection is made for the test and removed after.
5
+ * Mail goes to an array, never out.
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 support from '../plugin.js';
14
+ import {getEntry, updateEntry} from '../../../server/services/collections.js';
15
+
16
+ const COLL = path.resolve('content/collections/support-tickets');
17
+ const TOKENS = {'tok-acme': 'acme', 'tok-beta': 'beta', 'tok-flood': 'flood'};
18
+ const SITES = {acme: {name: 'Acme Ltd', host: 'acme.example.test', cmsVersion: '0.113.0'}, beta: {name: 'Beta Co', host: 'beta.example.test'}};
19
+ let manager;
20
+ let dataDir;
21
+ let managerUp = true;
22
+ const mails = [];
23
+ const notices = [];
24
+ const siteNotices = [];
25
+ const mail = {send: async (m) => { mails.push(m); return true; }, siteUrl: () => 'https://dcm.example.test'};
26
+
27
+ let hang = false;
28
+ const fetchInto = (app) => async (url, init = {}) => {
29
+ if (hang) return new Promise(() => {}); // a manager that never answers
30
+ if (!managerUp) throw new Error('connect ECONNREFUSED 127.0.0.1:8192');
31
+ const res = await app.inject({method: init.method || 'GET', url: new URL(url).pathname, headers: init.headers, payload: init.body});
32
+ return {status: res.statusCode, json: async () => res.json()};
33
+ };
34
+
35
+ const ANN = {id: 'u1', name: 'Ann', email: 'ann@acme.test'};
36
+
37
+ async function site({token, user = ANN, settings = {}, env, screenWaitMs} = {}) {
38
+ const app = Fastify();
39
+ await app.register(support, {
40
+ ...(screenWaitMs && {screenWaitMs}),
41
+ auth: {authenticate: async (req) => { req.user = user; }, requirePermission: () => async () => {}},
42
+ settings, mode: 'sender', timer: false, dataDir: await fs.mkdtemp(path.join(dataDir, 'site-')),
43
+ env: env ?? {MANAGER_URL: 'http://127.0.0.1:8192/', FLEET_TOKEN: token, SITE_SLUG: TOKENS[token] || 'x'},
44
+ fetch: fetchInto(manager),
45
+ hooks: {notify: (n) => siteNotices.push(n), registerNotificationSource() {}}
46
+ });
47
+ return app;
48
+ }
49
+
50
+ const call = async (app, method, url, body, headers) => {
51
+ const res = await app.inject({method, url, payload: body, headers});
52
+ let json = null;
53
+ try { json = res.json(); } catch { /* not JSON */ }
54
+ return {status: res.statusCode, body: json};
55
+ };
56
+
57
+ before(async () => {
58
+ await fs.rm(COLL, {recursive: true, force: true});
59
+ dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'support-test-'));
60
+ manager = Fastify();
61
+ await manager.register(support, {
62
+ auth: {authenticate: async (req) => { req.user = {id: 'v1', name: 'Darryl'}; }, requirePermission: () => async () => {}},
63
+ prefix: '/api/plugins/support', mode: 'receiver', mail, dataDir: await fs.mkdtemp(path.join(dataDir, 'manager-')),
64
+ settings: {perSiteMax: 30, perSiteMinutes: 10, phone: '+44 1234 567890', hours: 'Always', notifyEmail: 'support@dommajs.test'},
65
+ slugFromToken: async (h) => TOKENS[String(h || '').replace(/^Bearer\s+/i, '')] || null,
66
+ siteDirectory: () => SITES,
67
+ hooks: {notify: (n) => notices.push(n), registerNotificationSource() {}}
68
+ });
69
+ });
70
+
71
+ after(async () => {
72
+ await manager?.close();
73
+ await fs.rm(COLL, {recursive: true, force: true});
74
+ await fs.rm(dataDir, {recursive: true, force: true});
75
+ });
76
+
77
+ test('a request travels: site -> manager inbox, Domma emailed, urgent shows red', async () => {
78
+ const acme = await site({token: 'tok-acme'});
79
+ const sent = await call(acme, 'POST', '/tickets', {subject: 'Checkout broken', body: 'Customers cannot pay.', priority: 'urgent', category: 'problem'});
80
+ assert.equal(sent.status, 201);
81
+ assert.equal(sent.body.state, 'sent');
82
+
83
+ const inbox = await call(manager, 'GET', '/api/plugins/support/tickets');
84
+ const t = inbox.body.tickets.find(x => x.ref === sent.body.ref);
85
+ assert.equal(t.site, 'acme');
86
+ assert.equal(t.siteName, 'Acme Ltd');
87
+ assert.equal(t.userEmail, 'ann@acme.test');
88
+ assert.equal(t.status, 'open');
89
+ assert.equal(t.cmsVersion, '0.113.0', 'the registry says which engine the site runs');
90
+
91
+ const m = mails.find(x => x.to === 'support@dommajs.test' && x.subject.includes('Checkout broken'));
92
+ assert.match(m.subject, /^\[URGENT\] Support request from Acme Ltd/);
93
+ assert.equal(m.replyTo, 'ann@acme.test');
94
+ assert.ok(m.text.includes(`https://dcm.example.test/admin/#/plugins/support/${t.id}`));
95
+ assert.ok(notices.some(n => n.title.startsWith('URGENT')));
96
+
97
+ const count = await call(manager, 'GET', '/api/plugins/support/count');
98
+ assert.equal(count.body.tone, 'danger');
99
+ assert.equal(count.body.count, 1);
100
+ });
101
+
102
+ test('Domma replies: the customer is emailed a link to their own admin, and the site sees it unread', async () => {
103
+ const acme = await site({token: 'tok-acme'});
104
+ const {body: s} = await call(acme, 'POST', '/tickets', {subject: 'How do I add a page?', body: 'New here.'});
105
+ const t = (await call(manager, 'GET', '/api/plugins/support/tickets')).body.tickets.find(x => x.ref === s.ref);
106
+ mails.length = 0;
107
+ const r = await call(manager, 'POST', `/api/plugins/support/tickets/${t.id}/reply`, {body: 'Pages > New page.'});
108
+ assert.equal(r.body.status, 'answered');
109
+ const toAnn = mails.find(x => x.to === 'ann@acme.test');
110
+ assert.equal(toAnn.subject, 'Re: How do I add a page?');
111
+ assert.ok(toAnn.text.includes(`https://acme.example.test/admin/#/plugins/support/${s.ref}`));
112
+
113
+ const list = await call(acme, 'GET', '/tickets');
114
+ const mine = list.body.tickets.find(x => x.ref === s.ref);
115
+ assert.equal(mine.unread, true);
116
+ assert.equal(mine.messages.at(-1).body, 'Pages > New page.');
117
+ assert.ok(siteNotices.some(n => n.link === `#/plugins/support/${s.ref}`));
118
+ assert.equal((await call(acme, 'GET', '/count')).body.count, 1);
119
+ // Read it: the badge goes calm.
120
+ await call(acme, 'PUT', `/tickets/${s.ref}/seen`, {at: mine.messages.at(-1).at});
121
+ assert.equal((await call(acme, 'GET', '/count')).body.inventory, true);
122
+
123
+ // The customer writes again: open, and Domma hears.
124
+ mails.length = 0;
125
+ const again = await call(acme, 'POST', `/tickets/${s.ref}/messages`, {body: 'Thanks! And menus?'});
126
+ assert.equal(again.body.state, 'sent');
127
+ assert.equal((await call(manager, 'GET', `/api/plugins/support/tickets/${t.id}`)).body.status, 'open');
128
+ assert.ok(mails.some(x => x.to === 'support@dommajs.test' && x.subject.startsWith('Reply from Acme Ltd')));
129
+
130
+ // They resolve it themselves.
131
+ await call(acme, 'POST', `/tickets/${s.ref}/close`);
132
+ assert.equal((await call(manager, 'GET', `/api/plugins/support/tickets/${t.id}`)).body.status, 'closed');
133
+ });
134
+
135
+ test('a site never sees another site\'s requests, nor internal notes', async () => {
136
+ const acme = await site({token: 'tok-acme'});
137
+ const beta = await site({token: 'tok-beta', user: {id: 'b1', name: 'Bob', email: 'bob@beta.test'}});
138
+ const {body: s} = await call(beta, 'POST', '/tickets', {subject: 'Beta only', body: 'private'});
139
+ const t = (await call(manager, 'GET', '/api/plugins/support/tickets')).body.tickets.find(x => x.ref === s.ref);
140
+ await call(manager, 'PUT', `/api/plugins/support/tickets/${t.id}`, {notes: 'secret note'});
141
+ assert.ok(!(await call(acme, 'GET', '/tickets')).body.tickets.some(x => x.ref === s.ref));
142
+ const mine = (await call(beta, 'GET', '/tickets')).body.tickets.find(x => x.ref === s.ref);
143
+ assert.equal(mine.notes, undefined);
144
+ // Acme cannot write into Beta's request by guessing its ref.
145
+ const bad = await call(manager, 'POST', '/api/plugins/support/fleet/messages', {ticketRef: s.ref, ref: 'x', body: 'hijack'},
146
+ {authorization: 'Bearer tok-acme'});
147
+ assert.equal(bad.status, 404);
148
+ assert.equal((await call(manager, 'POST', '/api/plugins/support/fleet/tickets', {}, {authorization: 'Bearer nope'})).status, 401);
149
+ });
150
+
151
+ test('while the manager restarts, a request waits on the site and goes later - once', async () => {
152
+ const acme = await site({token: 'tok-acme'});
153
+ managerUp = false;
154
+ const s = await call(acme, 'POST', '/tickets', {subject: 'Sent while down', body: 'hello'});
155
+ assert.equal(s.body.state, 'waiting');
156
+ const follow = await call(acme, 'POST', `/tickets/${s.body.ref}/messages`, {body: 'and more'});
157
+ assert.equal(follow.body.state, 'waiting');
158
+ const offline = await call(acme, 'GET', '/tickets');
159
+ assert.equal(offline.body.tickets.find(x => x.ref === s.body.ref).statusLabel, 'Waiting to send');
160
+ managerUp = true;
161
+ const list = await call(acme, 'GET', '/tickets');
162
+ const t = list.body.tickets.find(x => x.ref === s.body.ref);
163
+ assert.equal(t.status, 'open');
164
+ assert.deepEqual(t.messages.map(m => m.body), ['hello', 'and more'], 'the follow-up went after its request');
165
+ await call(acme, 'GET', '/tickets');
166
+ const all = (await call(manager, 'GET', '/api/plugins/support/tickets')).body.tickets.filter(x => x.ref === s.body.ref);
167
+ assert.equal(all.length, 1);
168
+ assert.equal(all[0].messages.length, 2);
169
+ });
170
+
171
+ test('the contact card: the manager\'s reaches every site; a standalone site still has one', async () => {
172
+ const acme = await site({token: 'tok-acme', settings: {hours: 'site default'}});
173
+ assert.equal((await call(acme, 'GET', '/mode')).body.contact.hours, 'site default', 'before the first sync');
174
+ await call(acme, 'GET', '/tickets');
175
+ const card = (await call(acme, 'GET', '/mode')).body.contact;
176
+ assert.equal(card.hours, 'Always');
177
+ assert.equal(card.phone, '+44 1234 567890');
178
+
179
+ const alone = await site({env: {}});
180
+ const mode = (await call(alone, 'GET', '/mode')).body;
181
+ assert.equal(mode.connected, false);
182
+ assert.equal(mode.contact.email, 'support@dommajs.org');
183
+ assert.equal((await call(alone, 'POST', '/tickets', {subject: 's', body: 'b'})).status, 409);
184
+ });
185
+
186
+ test('one site cannot flood the inbox', async () => {
187
+ const flood = await site({token: 'tok-flood'});
188
+ const codes = [];
189
+ for (let i = 0; i < 32; i++) {
190
+ const r = await call(manager, 'POST', '/api/plugins/support/fleet/tickets', {ref: `f${i}`, subject: 's', body: 'b'},
191
+ {authorization: 'Bearer tok-flood'});
192
+ codes.push(r.status);
193
+ }
194
+ assert.deepEqual(codes.filter(c => c === 429).length, 2);
195
+ void flood;
196
+ });
197
+
198
+
199
+ // ---- Review findings -------------------------------------------------------------
200
+
201
+ test('review: two syncs at once send a waiting request once', async () => {
202
+ const acme = await site({token: 'tok-acme'});
203
+ managerUp = false;
204
+ const s = (await call(acme, 'POST', '/tickets', {subject: 'Race me', body: 'once'})).body;
205
+ managerUp = true;
206
+ await Promise.all([call(acme, 'GET', '/tickets'), call(acme, 'GET', '/tickets'), call(acme, 'GET', '/count')]);
207
+ const all = (await call(manager, 'GET', '/api/plugins/support/tickets')).body.tickets.filter(x => x.ref === s.ref);
208
+ assert.equal(all.length, 1);
209
+ });
210
+
211
+ test('review: a customer message lost on the manager is sent again', async () => {
212
+ const acme = await site({token: 'tok-acme'});
213
+ const s = (await call(acme, 'POST', '/tickets', {subject: 'Lost one', body: 'first'})).body;
214
+ await call(acme, 'POST', `/tickets/${s.ref}/messages`, {body: 'second'});
215
+ const t = (await call(manager, 'GET', '/api/plugins/support/tickets')).body.tickets.find(x => x.ref === s.ref);
216
+ // Simulate a concurrent write that dropped it.
217
+ const e = await getEntry('support-tickets', t.id);
218
+ await updateEntry('support-tickets', t.id, {...e.data, messages: e.data.messages.slice(0, 1)});
219
+ await call(acme, 'GET', '/tickets'); // notices it is missing, marks it waiting
220
+ await call(acme, 'GET', '/tickets'); // sends it again
221
+ const after = (await call(manager, 'GET', `/api/plugins/support/tickets/${t.id}`)).body;
222
+ assert.deepEqual(after.messages.map(m => m.body), ['first', 'second']);
223
+ });
224
+
225
+ test('review: concurrent turns on one request are all kept', async () => {
226
+ const acme = await site({token: 'tok-acme'});
227
+ const s = (await call(acme, 'POST', '/tickets', {subject: 'Busy', body: 'open'})).body;
228
+ const t = (await call(manager, 'GET', '/api/plugins/support/tickets')).body.tickets.find(x => x.ref === s.ref);
229
+ await Promise.all([
230
+ call(manager, 'POST', `/api/plugins/support/tickets/${t.id}/reply`, {body: 'from Domma'}),
231
+ call(manager, 'POST', '/api/plugins/support/fleet/messages', {ticketRef: s.ref, ref: 'c-x', body: 'from Ann'}, {authorization: 'Bearer tok-acme'}),
232
+ call(manager, 'PUT', `/api/plugins/support/tickets/${t.id}`, {notes: 'n'})
233
+ ]);
234
+ const after = (await call(manager, 'GET', `/api/plugins/support/tickets/${t.id}`)).body;
235
+ assert.deepEqual(after.messages.map(m => m.body).sort(), ['from Ann', 'from Domma', 'open']);
236
+ assert.equal(after.notes, 'n');
237
+ });
238
+
239
+ test('review: control characters are stripped, so nothing is too big to ever send', async () => {
240
+ const acme = await site({token: 'tok-acme'});
241
+ const r = await call(acme, 'POST', '/tickets', {subject: 'ctl', body: 'ok' + '\u0001'.repeat(8000)});
242
+ assert.equal(r.body.state, 'sent');
243
+ });
244
+
245
+ test('review: a deleted request does not come back on a retry', async () => {
246
+ const payload = {ref: 'zombie-1', subject: 'Zombie', body: 'b'};
247
+ const first = await call(manager, 'POST', '/api/plugins/support/fleet/tickets', payload, {authorization: 'Bearer tok-beta'});
248
+ await call(manager, 'DELETE', `/api/plugins/support/tickets/${first.body.id}`);
249
+ const retry = await call(manager, 'POST', '/api/plugins/support/fleet/tickets', payload, {authorization: 'Bearer tok-beta'});
250
+ assert.equal(retry.status, 404);
251
+ });
252
+
253
+ test('review: a manager that never answers does not hang the screen', async () => {
254
+ const acme = await site({token: 'tok-acme', screenWaitMs: 300});
255
+ hang = true;
256
+ try {
257
+ const t0 = Date.now();
258
+ const r = await call(acme, 'GET', '/tickets');
259
+ assert.ok(Date.now() - t0 < 2000);
260
+ assert.equal(r.body.reachable, false);
261
+ const s = await call(acme, 'POST', '/tickets', {subject: 'slow', body: 'b'});
262
+ assert.equal(s.status, 201);
263
+ assert.equal(s.body.state, 'waiting');
264
+ } finally { hang = false; }
265
+ });
@@ -0,0 +1,122 @@
1
+ /** Support - the pure half (lib/shape.js) and the site's merge (lib/sender.js mergeTickets). */
2
+ import {test} from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import {
5
+ addTurn, buildMessagePayload, buildTicketPayload, cleanRequest, cleanUpdate, contactCard, createThrottle, lastDommaAt,
6
+ receivedTicket, senderView, telHref, toCustomerEmail, toDommaEmail
7
+ } from '../lib/shape.js';
8
+ import {mergeTickets} from '../lib/sender.js';
9
+
10
+ const T0 = '2026-10-04T10:00:00.000Z';
11
+ const T1 = '2026-10-04T11:00:00.000Z';
12
+ const T2 = '2026-10-04T12:00:00.000Z';
13
+
14
+ test('a request needs a subject and a message; unknown choices fall back', () => {
15
+ assert.equal(cleanRequest({body: 'x'}).error, 'Give it a short subject.');
16
+ assert.equal(cleanRequest({subject: 'x'}).error, 'Tell us what you need.');
17
+ const {request} = cleanRequest({subject: ' Site\n down ', body: ' help\r\nplease ', category: 'nope', priority: 'panic', where: '/shop'});
18
+ assert.deepEqual(request, {category: 'help', priority: 'normal', subject: 'Site down', body: 'help\nplease', where: '/shop'});
19
+ assert.equal(cleanRequest({subject: 's', body: 'b', priority: 'urgent'}).request.priority, 'urgent');
20
+ });
21
+
22
+ test('who sent it comes from the session user, never from the form', () => {
23
+ const {request} = cleanRequest({subject: 's', body: 'b', user: {name: 'Mallory'}});
24
+ const p = buildTicketPayload({request, user: {id: 'u1', name: 'Ann', email: 'ann@x.test', password: 'no'}, ref: 'r1', site: 'acme'});
25
+ assert.deepEqual(p.user, {id: 'u1', name: 'Ann', email: 'ann@x.test'});
26
+ assert.equal(buildMessagePayload({ticketRef: 'r1', ref: 'm1', body: 'hi', user: {name: 'Ann'}}).user.name, 'Ann');
27
+ });
28
+
29
+ test('the manager files a request under the token\'s site, with the opening message as the first turn', () => {
30
+ const {data} = receivedTicket({ref: 'r1', subject: 's', body: 'b', site: 'evil', user: {name: 'Ann', email: 'not-an-email'}}, 'acme', T0);
31
+ assert.equal(data.site, 'acme');
32
+ assert.equal(data.status, 'open');
33
+ assert.equal(data.userEmail, '', 'a malformed email is never mailed');
34
+ assert.deepEqual(data.messages, [{id: 'r1', from: 'customer', name: 'Ann', body: 'b', at: T0}]);
35
+ });
36
+
37
+ test('turns move the status to whoever has to act next', () => {
38
+ let {data} = receivedTicket({ref: 'r1', subject: 's', body: 'b', user: {name: 'Ann'}}, 'acme', T0);
39
+ ({data} = addTurn(data, {from: 'domma', name: 'Darryl', body: 'Try this', id: 'd1', at: T1}));
40
+ assert.equal(data.status, 'answered');
41
+ assert.equal(lastDommaAt(data), T1);
42
+ ({data} = addTurn(data, {from: 'customer', name: 'Ann', body: 'Still broken', id: 'c2', at: T2}));
43
+ assert.equal(data.status, 'open');
44
+ ({data} = addTurn(data, {from: 'domma', name: 'Darryl', body: 'Fixed', id: 'd2', at: T2, status: 'closed'}));
45
+ assert.equal(data.status, 'closed');
46
+ ({data} = addTurn(data, {from: 'customer', name: 'Ann', body: 'Back again', id: 'c3', at: T2}));
47
+ assert.equal(data.status, 'open', 'writing reopens');
48
+ assert.equal(data.history.at(-1).what, 'Reopened');
49
+ ({data} = addTurn(data, {from: 'customer', name: 'Ann', id: 'c4', at: T2, close: true}));
50
+ assert.equal(data.status, 'closed');
51
+ assert.equal(data.messages.length, 5, 'closing adds no message');
52
+ });
53
+
54
+ test('a turn delivered twice is added once', () => {
55
+ let {data} = receivedTicket({ref: 'r1', subject: 's', body: 'b'}, 'acme', T0);
56
+ ({data} = addTurn(data, {from: 'customer', name: 'Ann', body: 'again', id: 'c2', at: T1}));
57
+ const again = addTurn(data, {from: 'customer', name: 'Ann', body: 'again', id: 'c2', at: T1});
58
+ assert.equal(again.added, false);
59
+ assert.equal(again.data.messages.length, 2);
60
+ });
61
+
62
+ test('a site sees its conversation, never notes or history', () => {
63
+ const {data} = receivedTicket({ref: 'r1', subject: 's', body: 'b'}, 'acme', T0);
64
+ const v = senderView({id: 'e1', data: {...data, notes: 'awkward customer'}, meta: {createdAt: T0}});
65
+ assert.equal(v.notes, undefined);
66
+ assert.equal(v.history, undefined);
67
+ assert.equal(v.statusLabel, 'Waiting on Domma');
68
+ });
69
+
70
+ test('inbox updates are checked', () => {
71
+ assert.equal(cleanUpdate({}).error, 'Nothing to change.');
72
+ assert.equal(cleanUpdate({status: 'gone'}).error, 'That is not a status.');
73
+ assert.deepEqual(cleanUpdate({status: 'closed', notes: ' n ', priority: 'urgent'}).patch, {status: 'closed', notes: 'n', priority: 'urgent'});
74
+ });
75
+
76
+ test('the contact card lets through only what is well-formed', () => {
77
+ assert.deepEqual(contactCard({email: 'support@dommajs.org', phone: '+44 (0)1234 567890', hours: 'Mon\nFri', responseTime: 'Soon'}),
78
+ {email: 'support@dommajs.org', phone: '+44 (0)1234 567890', hours: 'Mon Fri', responseTime: 'Soon'});
79
+ assert.deepEqual(contactCard({email: 'javascript:alert(1)', phone: 'call me'}), {email: '', phone: '', hours: '', responseTime: ''});
80
+ assert.equal(telHref('+44 (0)1234 567890'), 'tel:+4401234567890');
81
+ });
82
+
83
+ test('emails are escaped, flag urgency, and reply to the customer', () => {
84
+ const {data} = receivedTicket({ref: 'r1', subject: '<b>Down</b>', body: '<script>x</script>', priority: 'urgent',
85
+ user: {name: 'Ann', email: 'ann@x.test'}}, 'acme', T0);
86
+ const e = toDommaEmail({ticket: data, siteName: 'Acme', link: 'https://dcm/admin/#/plugins/support/e1', body: data.messages[0].body, isNew: true});
87
+ assert.match(e.subject, /^\[URGENT\] Support request from Acme: <b>Down<\/b>$/);
88
+ assert.equal(e.replyTo, 'ann@x.test');
89
+ assert.ok(!e.html.includes('<script>'));
90
+ assert.ok(e.html.includes('&lt;script&gt;'));
91
+ const c = toCustomerEmail({ticket: data, siteName: 'Acme', link: 'https://acme/admin/#/plugins/support/r1', body: 'Fixed', resolved: true});
92
+ assert.equal(c.subject, 'Re: <b>Down</b>');
93
+ assert.match(c.text, /marked it as resolved/);
94
+ });
95
+
96
+ test('the throttle allows max per window, per key', () => {
97
+ const ok = createThrottle({max: 2, windowMs: 1000});
98
+ assert.deepEqual([ok('a', 0), ok('a', 1), ok('a', 2), ok('b', 2), ok('a', 1001)], [true, true, false, true, true]);
99
+ });
100
+
101
+ test('the site\'s list: the manager\'s view, plus what is still waiting here', () => {
102
+ const remote = [{ref: 'r1', subject: 'One', status: 'answered', lastAt: T1,
103
+ messages: [{id: 'r1', from: 'customer', body: 'b', at: T0}, {id: 'd1', from: 'domma', body: 'Try', at: T1}]}];
104
+ const items = [
105
+ {kind: 'ticket', ref: 'r2', state: 'waiting', createdAt: T2, payload: {subject: 'Two', body: 'b2', user: {name: 'Ann'}}},
106
+ {kind: 'message', ref: 'm1', ticketRef: 'r1', state: 'waiting', createdAt: T2, payload: {body: 'thanks', user: {name: 'Ann'}}},
107
+ {kind: 'message', ref: 'm0', ticketRef: 'r1', state: 'waiting', createdAt: T2, payload: {body: '', close: true}}
108
+ ];
109
+ const list = mergeTickets(items, remote, {});
110
+ assert.deepEqual(list.map(t => t.ref), ['r2', 'r1']);
111
+ assert.equal(list[0].statusLabel, 'Waiting to send');
112
+ const r1 = list[1];
113
+ assert.equal(r1.unread, true);
114
+ assert.equal(r1.messages.at(-1).body, 'thanks');
115
+ assert.equal(r1.messages.at(-1).pending, true);
116
+ assert.equal(r1.messages.length, 3, 'a waiting close adds no message');
117
+ assert.equal(mergeTickets(items, remote, {r1: T1})[1].unread, false);
118
+ // Offline: the cached list stands in.
119
+ const offline = mergeTickets([], null, {}, [{ref: 'r1', subject: 'One', status: 'answered', dommaAt: T1}]);
120
+ assert.equal(offline[0].stale, true);
121
+ assert.equal(offline[0].unread, true);
122
+ });