@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.
- package/CELILO_SUBSYSTEMS.md +1 -0
- package/package.json +3 -3
- package/src/cli/commands/module-config.test.ts +77 -1
- package/src/cli/commands/module-config.ts +45 -3
- package/src/cli/commands/module-journal.test.ts +47 -0
- package/src/cli/commands/module-journal.ts +98 -0
- package/src/cli/completion.ts +2 -0
- package/src/cli/generate-zsh-completion.ts +4 -0
- package/src/cli/index.ts +3 -0
- package/src/module/packaging/release-metadata.test.ts +77 -3
- package/src/module/packaging/release-metadata.ts +11 -1
- package/src/services/alerting/inbound-poller.test.ts +191 -0
- package/src/services/alerting/inbound-poller.ts +31 -7
- package/src/services/alerting/inbound.test.ts +213 -2
- package/src/services/alerting/inbound.ts +161 -32
- package/src/services/alerting/interview-responder.test.ts +0 -32
- package/src/services/alerting/interview-responder.ts +6 -17
- package/src/services/alerting/tokens.ts +39 -1
- package/src/services/module-journal.test.ts +302 -0
- package/src/services/module-journal.ts +160 -0
|
@@ -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:
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
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', '
|
|
37
|
-
|
|
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
|
/**
|