@celilo/cli 0.15.0 → 0.16.2

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.
@@ -118,6 +118,7 @@ describe('pollInbound', () => {
118
118
  });
119
119
 
120
120
  const alertState = () => db.select().from(alerts).where(eq(alerts.id, 'alert-1')).get()?.state;
121
+ const ackedBy = () => db.select().from(alerts).where(eq(alerts.id, 'alert-1')).get()?.ackedBy;
121
122
 
122
123
  test('only transports with routes are polled', () => {
123
124
  expect(transportsWithRoutes(db)).toEqual(['signal']);
@@ -326,9 +327,199 @@ describe('pollInbound', () => {
326
327
  expect(alertState()).toBe('firing');
327
328
  });
328
329
 
330
+ // The seam that was untested. `acknowledgeAlert` returning null was covered
331
+ // (ack.test.ts), and pollInbound was covered — but not what the REPORT says
332
+ // when the two meet. Observed live: a reply produced `1 acked` while every
333
+ // alert still read `ackedBy: null`, because the counter incremented next to
334
+ // the call instead of observing its result.
335
+ describe('a token whose alert no longer exists', () => {
336
+ test('is NOT counted as acked', async () => {
337
+ const delivery = page(peterRoute.id);
338
+ db.delete(alerts).run(); // the alert this token names is gone
339
+
340
+ const report = await pollInbound(db, deps([inbound(PETER, delivery.token)]));
341
+
342
+ expect(report.acked).toBe(0);
343
+ });
344
+
345
+ test('is reported as stale_target rather than silently', async () => {
346
+ const delivery = page(peterRoute.id);
347
+ db.delete(alerts).run();
348
+
349
+ const report = await pollInbound(db, deps([inbound(PETER, delivery.token)]));
350
+
351
+ expect(report.unheard).toEqual([{ senderAddress: PETER, reason: 'stale_target' }]);
352
+ });
353
+
354
+ // The token is still single-use: a dead target must not leave it replayable.
355
+ test('still consumes the token', async () => {
356
+ const delivery = page(peterRoute.id);
357
+ db.delete(alerts).run();
358
+
359
+ await pollInbound(db, deps([inbound(PETER, delivery.token)]));
360
+ const second = await pollInbound(db, deps([inbound(PETER, delivery.token)]));
361
+
362
+ expect(second.acked).toBe(0);
363
+ expect(second.unheard).toEqual([{ senderAddress: PETER, reason: 'unknown_token' }]);
364
+ });
365
+
366
+ // Nobody should be told "someone took it" when nobody did.
367
+ test('broadcasts nothing', async () => {
368
+ const delivery = page(peterRoute.id);
369
+ db.delete(alerts).run();
370
+
371
+ const report = await pollInbound(db, deps([inbound(PETER, delivery.token)]));
372
+
373
+ expect(report.broadcast).toBe(0);
374
+ });
375
+ });
376
+
377
+ // The counter must track the database, not the attempt. If these two ever
378
+ // disagree again, the operator sees a success that did not happen.
379
+ test('report.acked agrees with what the alert row actually says', async () => {
380
+ const delivery = page(peterRoute.id);
381
+
382
+ const report = await pollInbound(db, deps([inbound(PETER, delivery.token)]));
383
+
384
+ expect(report.acked).toBe(1);
385
+ expect(alertState()).toBe('acked');
386
+ expect(ackedBy()).toBe(peterRoute.personId);
387
+ });
388
+
329
389
  test('a route pointing at a transport nobody uses is not polled', () => {
330
390
  db.delete(alerts).run();
331
391
  expect(transportsWithRoutes(db)).toEqual(['signal']);
332
392
  expect(wifeRoute.transportModuleId).toBe('signal');
333
393
  });
394
+
395
+ /**
396
+ * Answering a deploy question, at the SEAM.
397
+ *
398
+ * This is the coverage #533 was missing. `parseInterviewAnswer` was unit
399
+ * tested and correct; `pollInbound` was unit tested and correct; and an
400
+ * answer sent in the shape the page asks for was rejected as `unrecognised`
401
+ * for months, because nothing exercised an interview delivery THROUGH the
402
+ * poller. Two green units, wrong wiring — the same shape as the ack counter
403
+ * above.
404
+ */
405
+ describe('an answer to a deploy question', () => {
406
+ const ask = (routeId: string, eventId = '42') =>
407
+ mintDelivery(db, {
408
+ kind: 'interview',
409
+ targetId: eventId,
410
+ routeId,
411
+ now: NOW,
412
+ ttlMs: 60_000,
413
+ });
414
+
415
+ test('is published against the waiting question and counted as answered', async () => {
416
+ const question = ask(peterRoute.id, '42');
417
+ const answered: { eventId: string; value: string }[] = [];
418
+
419
+ const report = await pollInbound(
420
+ db,
421
+ deps([inbound(PETER, `${question.token} admin@example.org`)], {
422
+ answerInterview: (eventId, value) => answered.push({ eventId, value }),
423
+ }),
424
+ );
425
+
426
+ expect(answered).toEqual([{ eventId: '42', value: 'admin@example.org' }]);
427
+ expect(report.answered).toBe(1);
428
+ // An answer is not an acknowledgement; nothing about an alert moved.
429
+ expect(report.acked).toBe(0);
430
+ expect(report.unheard).toEqual([]);
431
+ });
432
+
433
+ test('a value containing spaces survives verbatim', async () => {
434
+ const question = ask(peterRoute.id);
435
+ const answered: string[] = [];
436
+
437
+ await pollInbound(
438
+ db,
439
+ deps([inbound(PETER, `${question.token} my value: with, punctuation`)], {
440
+ answerInterview: (_id, value) => answered.push(value),
441
+ }),
442
+ );
443
+
444
+ expect(answered).toEqual(['my value: with, punctuation']);
445
+ });
446
+
447
+ test('the token is consumed, so an answer cannot be replayed', async () => {
448
+ const question = ask(peterRoute.id);
449
+ const answered: string[] = [];
450
+ const withResponder = (body: string) =>
451
+ pollInbound(
452
+ db,
453
+ deps([inbound(PETER, body)], { answerInterview: (_id, v) => answered.push(v) }),
454
+ );
455
+
456
+ await withResponder(`${question.token} first`);
457
+ const second = await withResponder(`${question.token} second`);
458
+
459
+ expect(answered).toEqual(['first']);
460
+ expect(second.answered).toBe(0);
461
+ // `unrecognised`, not `unknown_token`: once the token no longer resolves,
462
+ // `<word> <text>` is indistinguishable from an ordinary sentence, so
463
+ // celilo declines to assert the operator mistyped a token.
464
+ expect(second.unheard).toEqual([{ senderAddress: PETER, reason: 'unrecognised' }]);
465
+ });
466
+
467
+ // Naming the question but supplying nothing is its own answer, and the
468
+ // token must survive so the operator's next attempt can work.
469
+ test('a token with no value reports needs_value and leaves the token live', async () => {
470
+ const question = ask(peterRoute.id);
471
+
472
+ const first = await pollInbound(
473
+ db,
474
+ deps([inbound(PETER, question.token)], { answerInterview: () => {} }),
475
+ );
476
+ expect(first.answered).toBe(0);
477
+ expect(first.unheard).toEqual([{ senderAddress: PETER, reason: 'needs_value' }]);
478
+
479
+ const answered: string[] = [];
480
+ const second = await pollInbound(
481
+ db,
482
+ deps([inbound(PETER, `${question.token} admin@example.org`)], {
483
+ answerInterview: (_id, v) => answered.push(v),
484
+ }),
485
+ );
486
+ expect(second.answered).toBe(1);
487
+ expect(answered).toEqual(['admin@example.org']);
488
+ });
489
+
490
+ // With no responder attached there is nothing to publish against, so the
491
+ // question stays unanswered AND the token stays usable.
492
+ test('with no responder attached the token is not burned', async () => {
493
+ const question = ask(peterRoute.id);
494
+
495
+ const report = await pollInbound(db, deps([inbound(PETER, `${question.token} value`)]));
496
+ expect(report.answered).toBe(0);
497
+ expect(report.unheard).toEqual([{ senderAddress: PETER, reason: 'unrecognised' }]);
498
+
499
+ const answered: string[] = [];
500
+ const retry = await pollInbound(
501
+ db,
502
+ deps([inbound(PETER, `${question.token} value`)], {
503
+ answerInterview: (_id, v) => answered.push(v),
504
+ }),
505
+ );
506
+ expect(retry.answered).toBe(1);
507
+ expect(answered).toEqual(['value']);
508
+ });
509
+
510
+ test('an answer from a number the question was not sent to is refused', async () => {
511
+ const question = ask(peterRoute.id);
512
+ const answered: string[] = [];
513
+
514
+ const report = await pollInbound(
515
+ db,
516
+ deps([inbound(WIFE, `${question.token} admin@example.org`)], {
517
+ answerInterview: (_id, v) => answered.push(v),
518
+ }),
519
+ );
520
+
521
+ expect(answered).toEqual([]);
522
+ expect(report.unheard).toEqual([{ senderAddress: WIFE, reason: 'wrong_sender' }]);
523
+ });
524
+ });
334
525
  });
@@ -20,7 +20,6 @@ import { loadCapabilityFunctions } from '../../hooks/capability-loader';
20
20
  import { createCapturingLogger } from '../../hooks/logger';
21
21
  import { type AckResult, acknowledgeAlert } from './ack';
22
22
  import { interpretInbound } from './inbound';
23
- import { parseInterviewAnswer } from './interview-responder';
24
23
  import type { NotificationTransport } from './notifier';
25
24
  import { composeAckBroadcastBody } from './notifier';
26
25
  import { consumeDelivery } from './tokens';
@@ -48,7 +47,16 @@ export interface TransportFailure {
48
47
  /** A message that was read but not acted on, and why. */
49
48
  export interface UnheardMessage {
50
49
  senderAddress: string;
51
- reason: 'unknown_sender' | 'unknown_token' | 'wrong_sender' | 'ambiguous' | 'unrecognised';
50
+ reason:
51
+ | 'unknown_sender'
52
+ | 'unknown_token'
53
+ | 'wrong_sender'
54
+ | 'ambiguous'
55
+ | 'unrecognised'
56
+ /** A deploy question was named, but with no value to answer it with. */
57
+ | 'needs_value'
58
+ /** The token was valid, but the alert it names no longer exists. */
59
+ | 'stale_target';
52
60
  }
53
61
 
54
62
  export interface InboundPollDeps {
@@ -150,14 +158,19 @@ export async function pollInbound(db: DbClient, deps: InboundPollDeps): Promise<
150
158
 
151
159
  // An interview reply IS the answer rather than an acknowledgement —
152
160
  // same token table, same sender check, different meaning for the body.
153
- if (outcome.delivery.kind === 'interview') {
154
- const value = parseInterviewAnswer(message.body, outcome.delivery.token);
155
- if (!value || !deps.answerInterview) {
161
+ // The value was extracted by `interpretInbound`, which is the only place
162
+ // that knows the delivery's kind; re-parsing it here is what left the
163
+ // real grammar unreachable for months (#533).
164
+ if (outcome.action === 'answer') {
165
+ if (!deps.answerInterview) {
166
+ // Nothing is attached to publish the answer against, so the question
167
+ // is still unanswered. Say so rather than consuming the token: the
168
+ // operator's next attempt has to be able to work.
156
169
  report.unheard.push({ senderAddress: message.senderAddress, reason: 'unrecognised' });
157
170
  continue;
158
171
  }
159
172
  consumeDelivery(db, outcome.delivery.id, deps.now());
160
- deps.answerInterview(outcome.delivery.targetId, value);
173
+ deps.answerInterview(outcome.delivery.targetId, outcome.value);
161
174
  report.answered++;
162
175
  continue;
163
176
  }
@@ -171,12 +184,23 @@ export async function pollInbound(db: DbClient, deps: InboundPollDeps): Promise<
171
184
  outcome.route.personId,
172
185
  deps.now(),
173
186
  );
187
+
188
+ // A live token can outlive the alert it names. When the row is gone
189
+ // there is nothing to acknowledge, and saying "1 acked" anyway is a lie
190
+ // the operator has no way to catch — observed live: a reply reported
191
+ // `1 acked` while every alert still read `ackedBy: null`. Count what
192
+ // HAPPENED, never what was attempted.
193
+ if (!ack) {
194
+ report.unheard.push({ senderAddress: message.senderAddress, reason: 'stale_target' });
195
+ continue;
196
+ }
197
+
174
198
  report.acked++;
175
199
 
176
200
  // Everyone else who was paged is still expecting to act. Telling them is
177
201
  // the entire point of a per-delivery token — it is what makes the reply
178
202
  // identify a PERSON rather than just an alert.
179
- if (ack) report.broadcast += await broadcastAck(db, ack, deps);
203
+ report.broadcast += await broadcastAck(db, ack, deps);
180
204
  }
181
205
 
182
206
  deps.writeCursor(transportId, received.cursor);
@@ -33,8 +33,37 @@ describe('parseInbound', () => {
33
33
  expect(parseInbound('K7QM2X ack')).toEqual({ kind: 'ack', token: 'K7QM2X' });
34
34
  });
35
35
 
36
- test.each(['', ' ', 'what is going on', 'K7QM', 'K7QM2XY'])('%p is unrecognised', (body) => {
37
- expect(parseInbound(body)).toEqual({ kind: 'unrecognised' });
36
+ test.each(['', ' ', 'what is going on', 'K7QM2XY', 'ack now please'])(
37
+ '%p is unrecognised',
38
+ (body) => {
39
+ expect(parseInbound(body)).toEqual({ kind: 'unrecognised' });
40
+ },
41
+ );
42
+
43
+ // The bug that started this: the page says "reply BPRJEH", and a human types
44
+ // "ack BPRJEH". The old grammar read the VERB as the token, failed the length
45
+ // check, and rejected a real acknowledgement over word order.
46
+ test.each([
47
+ ['ack K7QM2X', 'K7QM2X'],
48
+ ['ACK K7QM2X', 'K7QM2X'],
49
+ ['ok k7qm2x', 'K7QM2X'],
50
+ ['K7QM2X ack', 'K7QM2X'],
51
+ ['k7qm2x OK', 'K7QM2X'],
52
+ ['👍 K7QM2X', 'K7QM2X'],
53
+ [' ack k7qm2x ', 'K7QM2X'],
54
+ ])('%p yields token %p — the verb may lead, trail, or be absent', (body, token) => {
55
+ expect(parseInbound(body)).toEqual({ kind: 'ack', token });
56
+ });
57
+
58
+ // A prefix is legal INPUT; whether it identifies anything is the delivery
59
+ // table's question, not the parser's.
60
+ test.each([
61
+ ['bp', 'BP'],
62
+ ['ack bp', 'BP'],
63
+ ['BP ack', 'BP'],
64
+ ['b', 'B'],
65
+ ])('%p parses as the prefix %p', (body, token) => {
66
+ expect(parseInbound(body)).toEqual({ kind: 'ack', token });
38
67
  });
39
68
  });
40
69
 
@@ -128,6 +157,69 @@ describe('interpretInbound', () => {
128
157
  expect(interpretInbound(db, late)).toEqual({ action: 'rejected', reason: 'unknown_token' });
129
158
  });
130
159
 
160
+ describe('an abbreviated token', () => {
161
+ // How much an operator must type depends on what is actually outstanding.
162
+ test('resolves when the prefix is unique among live deliveries', () => {
163
+ const delivery = mintFor('r-peter');
164
+ const prefix = delivery.token.slice(0, 2);
165
+ const outcome = interpretInbound(db, context(PETER, `ack ${prefix.toLowerCase()}`));
166
+ expect(outcome).toMatchObject({ action: 'ack' });
167
+ expect(outcome.action === 'ack' && outcome.delivery.id).toBe(delivery.id);
168
+ });
169
+
170
+ test('a single character is enough when only one alert is live', () => {
171
+ const delivery = mintFor('r-peter');
172
+ // One character in 32 is `K`, which is ALSO an ack synonym — so that
173
+ // token parses as a bare ack rather than as a prefix. Either reading
174
+ // must land on the single live delivery, so the harness reflects it as
175
+ // outstanding as well; without this the test failed ~3% of runs with
176
+ // `unknown_token`, because the bare-ack path saw nothing outstanding.
177
+ outstanding.push(delivery);
178
+ const outcome = interpretInbound(db, context(PETER, delivery.token.slice(0, 1)));
179
+ expect(outcome).toMatchObject({ action: 'ack' });
180
+ });
181
+
182
+ // Never a guess: acknowledging the wrong alert is worse than asking again.
183
+ test('is refused as ambiguous when two live deliveries share the prefix', () => {
184
+ const a = mintFor('r-peter', 'alert-a');
185
+ let b = mintFor('r-peter', 'alert-b');
186
+ // Force a shared prefix rather than hoping two random tokens collide.
187
+ for (let i = 0; i < 40 && b.token[0] !== a.token[0]; i++) {
188
+ b = mintFor('r-peter', `alert-b-${i}`);
189
+ }
190
+ if (b.token[0] !== a.token[0]) return; // vanishingly unlikely; do not flake
191
+ // As above: a shared first character of `K` parses as a bare ack, and
192
+ // that path reads `outstandingForRoute` rather than the prefix search.
193
+ // Both readings must answer `ambiguous` here, so both are given two
194
+ // candidates to be ambiguous between.
195
+ outstanding.push(a, b);
196
+ expect(interpretInbound(db, context(PETER, a.token[0]))).toEqual({
197
+ action: 'rejected',
198
+ reason: 'ambiguous',
199
+ });
200
+ });
201
+
202
+ // Uniqueness is scoped to what is LIVE, so yesterday's tokens cannot make
203
+ // today's prefix ambiguous.
204
+ test('an expired delivery does not make a prefix ambiguous', () => {
205
+ const delivery = mintFor('r-peter');
206
+ const late = {
207
+ ...context(PETER, delivery.token.slice(0, 2)),
208
+ now: new Date(NOW.getTime() + TTL_MS + 1),
209
+ };
210
+ expect(interpretInbound(db, late)).toEqual({ action: 'rejected', reason: 'unknown_token' });
211
+ });
212
+
213
+ // The second factor still holds for a prefix.
214
+ test('a prefix from the WRONG sender is still refused', () => {
215
+ const delivery = mintFor('r-peter');
216
+ expect(interpretInbound(db, context(WIFE, delivery.token.slice(0, 3)))).toEqual({
217
+ action: 'rejected',
218
+ reason: 'wrong_sender',
219
+ });
220
+ });
221
+ });
222
+
131
223
  test('gibberish from a known sender is rejected as unrecognised', () => {
132
224
  expect(interpretInbound(db, context(PETER, 'what is going on'))).toEqual({
133
225
  action: 'rejected',
@@ -168,6 +260,125 @@ describe('interpretInbound', () => {
168
260
  });
169
261
  });
170
262
  });
263
+
264
+ /**
265
+ * Answering a deploy question.
266
+ *
267
+ * These ran against `parseInterviewAnswer` in isolation and all passed while
268
+ * the feature was dead in production (#533) — the alert grammar rejected
269
+ * every one of these bodies before the answer path was ever reached. They
270
+ * live here now, against `interpretInbound`, because the wiring is the part
271
+ * that was broken and the part worth guarding.
272
+ */
273
+ describe('a deploy question', () => {
274
+ const askFor = (routeId: string, targetId = 'evt-1') =>
275
+ mintDelivery(db, { kind: 'interview', targetId, routeId, now: NOW, ttlMs: TTL_MS });
276
+
277
+ test('takes everything after the token as the answer', () => {
278
+ const q = askFor('r-peter');
279
+ expect(interpretInbound(db, context(PETER, `${q.token} www.example.com`))).toMatchObject({
280
+ action: 'answer',
281
+ value: 'www.example.com',
282
+ });
283
+ });
284
+
285
+ test('is case-insensitive on the token', () => {
286
+ const q = askFor('r-peter');
287
+ expect(
288
+ interpretInbound(db, context(PETER, `${q.token.toLowerCase()} www.example.com`)),
289
+ ).toMatchObject({ action: 'answer', value: 'www.example.com' });
290
+ });
291
+
292
+ // An answer may legitimately contain spaces and punctuation. Second-guessing
293
+ // it would corrupt exactly the values that are painful to retype. This is
294
+ // the shape that was refused as `unrecognised` for months.
295
+ test('preserves an answer containing spaces and punctuation verbatim', () => {
296
+ const q = askFor('r-peter');
297
+ expect(
298
+ interpretInbound(db, context(PETER, `${q.token} my value: with, punctuation`)),
299
+ ).toMatchObject({ action: 'answer', value: 'my value: with, punctuation' });
300
+ });
301
+
302
+ // The word that must NOT acknowledge an alert is a perfectly good answer to
303
+ // a question. That the same text means different things is the whole reason
304
+ // the token is resolved before the grammar is chosen.
305
+ test('an answer that looks like a terminal verb is still an answer', () => {
306
+ const q = askFor('r-peter');
307
+ expect(interpretInbound(db, context(PETER, `${q.token} resolve`))).toMatchObject({
308
+ action: 'answer',
309
+ value: 'resolve',
310
+ });
311
+ });
312
+
313
+ test('a token with no value says so, rather than being unrecognised', () => {
314
+ const q = askFor('r-peter');
315
+ for (const body of [q.token, `${q.token} `]) {
316
+ expect(interpretInbound(db, context(PETER, body))).toEqual({
317
+ action: 'rejected',
318
+ reason: 'needs_value',
319
+ });
320
+ }
321
+ });
322
+
323
+ // A verb-led message names no value, so it is not an answer.
324
+ test('a leading ack verb is not an answer', () => {
325
+ const q = askFor('r-peter');
326
+ expect(interpretInbound(db, context(PETER, `ack ${q.token}`))).toEqual({
327
+ action: 'rejected',
328
+ reason: 'unrecognised',
329
+ });
330
+ });
331
+
332
+ // Both factors still apply — a question is no less sensitive than a page.
333
+ test('an answer from the wrong number is refused', () => {
334
+ const q = askFor('r-peter');
335
+ expect(interpretInbound(db, context(WIFE, `${q.token} www.example.com`))).toEqual({
336
+ action: 'rejected',
337
+ reason: 'wrong_sender',
338
+ });
339
+ });
340
+
341
+ // Reported as `unrecognised` rather than `unknown_token`, and that is
342
+ // deliberate: `ZZZZZZ value` and `what is going on` are the same shape to
343
+ // celilo — a token-shaped word followed by text — because the token
344
+ // alphabet is most of the Latin one. Nothing corroborates that the leading
345
+ // word was meant as a token, so claiming the operator got a TOKEN wrong
346
+ // would send them hunting for a typo they may not have made.
347
+ test('an unknown leading token is not answerable', () => {
348
+ askFor('r-peter');
349
+ expect(interpretInbound(db, context(PETER, 'ZZZZZZ value'))).toEqual({
350
+ action: 'rejected',
351
+ reason: 'unrecognised',
352
+ });
353
+ });
354
+ });
355
+
356
+ /**
357
+ * The two grammars share a table, so the same body must mean different
358
+ * things depending on what the token names. Asserting both readings side by
359
+ * side is the guard against one of them quietly swallowing the other again.
360
+ */
361
+ describe('the two grammars do not bleed into each other', () => {
362
+ test('`<token> resolve` answers a question and REFUSES an alert', () => {
363
+ const question = mintDelivery(db, {
364
+ kind: 'interview',
365
+ targetId: 'evt-1',
366
+ routeId: 'r-peter',
367
+ now: NOW,
368
+ ttlMs: TTL_MS,
369
+ });
370
+ const alert = mintFor('r-peter');
371
+
372
+ expect(interpretInbound(db, context(PETER, `${question.token} resolve`))).toMatchObject({
373
+ action: 'answer',
374
+ value: 'resolve',
375
+ });
376
+ expect(interpretInbound(db, context(PETER, `${alert.token} resolve`))).toEqual({
377
+ action: 'rejected',
378
+ reason: 'unrecognised',
379
+ });
380
+ });
381
+ });
171
382
  });
172
383
 
173
384
  /**