@remit/backend 0.0.91 → 0.0.93

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,558 @@
1
+ import assert from "node:assert/strict";
2
+ import { after, before, describe, test } from "node:test";
3
+ import type {
4
+ CalendarSuggestionItem,
5
+ CreateFilterInput,
6
+ FilterItem,
7
+ ICalendarSuggestionRepository,
8
+ MessageData,
9
+ PutCalendarSuggestionInput,
10
+ SettleCalendarSuggestionInput,
11
+ } from "@remit/data-ports";
12
+ import {
13
+ CalendarInviteMethod,
14
+ CalendarSuggestionSource,
15
+ CalendarSuggestionState,
16
+ FilterState,
17
+ } from "@remit/domain-enums";
18
+ import type { APIGatewayProxyEvent } from "aws-lambda";
19
+ import type { Context } from "openapi-backend";
20
+ import { deriveAccountConfigId } from "../auth.js";
21
+ import {
22
+ _resetForTest,
23
+ type RemitClient,
24
+ setClient,
25
+ } from "../service/data-client.js";
26
+ import { createCalendarSqliteClient } from "./calendar-sqlite-fixture.js";
27
+ import {
28
+ assertSettleable,
29
+ CalendarSuggestionActionOperations,
30
+ CalendarSuggestionOperations,
31
+ type MuteSenderDeps,
32
+ muteSender,
33
+ settleSuggestion,
34
+ toCalendarSuggestionResponse,
35
+ } from "./calendar-suggestion.js";
36
+
37
+ const ACCOUNT_CONFIG_ID = "cfg-1";
38
+
39
+ const suggestion = (
40
+ overrides: Partial<CalendarSuggestionItem> = {},
41
+ ): CalendarSuggestionItem => ({
42
+ suggestionId: "sug-1",
43
+ accountConfigId: ACCOUNT_CONFIG_ID,
44
+ messageId: "msg-1",
45
+ bodyPartId: "part-1",
46
+ icalUid: "invite@example.test",
47
+ sequence: 0,
48
+ method: CalendarInviteMethod.Request,
49
+ source: CalendarSuggestionSource.IcalendarPart,
50
+ state: CalendarSuggestionState.Pending,
51
+ summary: "Quarterly review",
52
+ dtStart: "2026-09-01T10:00:00+02:00",
53
+ dtEnd: "2026-09-01T11:00:00+02:00",
54
+ allDay: false,
55
+ location: "Room 4",
56
+ organizer: "organizer@example.test",
57
+ zoneCertainty: "Explicit",
58
+ icalData: "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n",
59
+ acceptedCalendarObjectId: "",
60
+ createdAt: 1,
61
+ updatedAt: 1,
62
+ ...overrides,
63
+ });
64
+
65
+ const repoOf = (
66
+ initial: CalendarSuggestionItem,
67
+ ): {
68
+ repo: ICalendarSuggestionRepository;
69
+ settles: SettleCalendarSuggestionInput[];
70
+ } => {
71
+ let row = initial;
72
+ const settles: SettleCalendarSuggestionInput[] = [];
73
+ const repo = {
74
+ get: async () => row,
75
+ settle: async (
76
+ _accountConfigId: string,
77
+ _suggestionId: string,
78
+ input: SettleCalendarSuggestionInput,
79
+ ) => {
80
+ settles.push(input);
81
+ row = { ...row, ...input };
82
+ return row;
83
+ },
84
+ } as unknown as ICalendarSuggestionRepository;
85
+ return { repo, settles };
86
+ };
87
+
88
+ const muteDepsOf = (
89
+ from: string | null,
90
+ existing: FilterItem[] = [],
91
+ ): { deps: MuteSenderDeps; created: CreateFilterInput[] } => {
92
+ const created: CreateFilterInput[] = [];
93
+ const rules = [...existing];
94
+ const deps: MuteSenderDeps = {
95
+ envelope: {
96
+ getMessageData: async () =>
97
+ ({
98
+ envelopeAddress: from
99
+ ? [{ addressRole: "from", normalizedEmail: from }]
100
+ : [{ addressRole: "to", normalizedEmail: "user@example.test" }],
101
+ }) as unknown as MessageData,
102
+ },
103
+ filter: {
104
+ listByAccountAndState: async () => rules,
105
+ create: async (input: CreateFilterInput) => {
106
+ created.push(input);
107
+ const row = {
108
+ ...input,
109
+ actionLabelId: input.actionLabelId ?? "None",
110
+ actionMailboxId: input.actionMailboxId ?? "None",
111
+ } as unknown as FilterItem;
112
+ rules.push(row);
113
+ return row;
114
+ },
115
+ },
116
+ };
117
+ return { deps, created };
118
+ };
119
+
120
+ const muteRule = (sender: string): FilterItem =>
121
+ ({
122
+ filterId: `mute-${sender}`,
123
+ accountConfigId: ACCOUNT_CONFIG_ID,
124
+ name: `Muted invitations from ${sender}`,
125
+ scope: "Standing",
126
+ state: "Active",
127
+ matchOperator: "And",
128
+ literalClauses: [{ field: "From", value: sender }],
129
+ actionLabelId: "None",
130
+ actionMailboxId: "None",
131
+ }) as unknown as FilterItem;
132
+
133
+ describe("toCalendarSuggestionResponse", () => {
134
+ test("keeps the raw invitation bytes on the server", async () => {
135
+ const response = toCalendarSuggestionResponse(suggestion());
136
+
137
+ assert.equal("icalData" in response, false);
138
+ assert.equal(response.summary, "Quarterly review");
139
+ assert.equal(response.organizer, "organizer@example.test");
140
+ });
141
+ });
142
+
143
+ describe("assertSettleable", () => {
144
+ test("lets a pending card be answered", () => {
145
+ assertSettleable(suggestion(), CalendarSuggestionState.Accepted);
146
+ });
147
+
148
+ test("refuses to accept an event a revision already retired", () => {
149
+ assert.throws(
150
+ () =>
151
+ assertSettleable(
152
+ suggestion({ state: CalendarSuggestionState.Superseded }),
153
+ CalendarSuggestionState.Accepted,
154
+ ),
155
+ /already superseded/,
156
+ );
157
+ });
158
+
159
+ test("refuses to decline an event that is already in the calendar", () => {
160
+ // A resource exists. Declining would say no to a meeting the user's
161
+ // calendar still shows, which is a lie the API must not tell.
162
+ assert.throws(
163
+ () =>
164
+ assertSettleable(
165
+ suggestion({ state: CalendarSuggestionState.Accepted }),
166
+ CalendarSuggestionState.Declined,
167
+ ),
168
+ /already accepted/,
169
+ );
170
+ });
171
+
172
+ test("lets a repeat of the same answer through", () => {
173
+ assertSettleable(
174
+ suggestion({ state: CalendarSuggestionState.Declined }),
175
+ CalendarSuggestionState.Declined,
176
+ );
177
+ });
178
+ });
179
+
180
+ describe("settleSuggestion", () => {
181
+ test("records a decline", async () => {
182
+ const { repo, settles } = repoOf(suggestion());
183
+
184
+ const settled = await settleSuggestion(
185
+ repo,
186
+ ACCOUNT_CONFIG_ID,
187
+ "sug-1",
188
+ CalendarSuggestionState.Declined,
189
+ );
190
+
191
+ assert.equal(settled.state, CalendarSuggestionState.Declined);
192
+ assert.deepEqual(settles, [
193
+ {
194
+ state: CalendarSuggestionState.Declined,
195
+ acceptedCalendarObjectId: "",
196
+ },
197
+ ]);
198
+ });
199
+
200
+ test("writes nothing on a repeated decline", async () => {
201
+ const { repo, settles } = repoOf(
202
+ suggestion({ state: CalendarSuggestionState.Declined }),
203
+ );
204
+
205
+ const settled = await settleSuggestion(
206
+ repo,
207
+ ACCOUNT_CONFIG_ID,
208
+ "sug-1",
209
+ CalendarSuggestionState.Declined,
210
+ );
211
+
212
+ assert.equal(settled.state, CalendarSuggestionState.Declined);
213
+ assert.deepEqual(settles, []);
214
+ });
215
+
216
+ test("never names a calendar object on a decision that wrote none", async () => {
217
+ // Dismiss and decline write no resource, so the field that points at one
218
+ // stays the empty sentinel rather than carrying a stale id.
219
+ const { repo, settles } = repoOf(suggestion());
220
+
221
+ await settleSuggestion(
222
+ repo,
223
+ ACCOUNT_CONFIG_ID,
224
+ "sug-1",
225
+ CalendarSuggestionState.Dismissed,
226
+ );
227
+
228
+ assert.deepEqual(
229
+ settles.map((settle) => settle.acceptedCalendarObjectId),
230
+ [""],
231
+ );
232
+ });
233
+ });
234
+
235
+ describe("muteSender", () => {
236
+ test("writes a standing rule on the message's sender", async () => {
237
+ const { deps, created } = muteDepsOf("organizer@example.test");
238
+
239
+ await muteSender(deps, ACCOUNT_CONFIG_ID, "msg-1");
240
+
241
+ assert.equal(created.length, 1);
242
+ assert.equal(created[0]?.accountConfigId, ACCOUNT_CONFIG_ID);
243
+ assert.equal(created[0]?.scope, "Standing");
244
+ assert.deepEqual(created[0]?.literalClauses, [
245
+ { field: "From", value: "organizer@example.test" },
246
+ ]);
247
+ assert.match(created[0]?.name ?? "", /organizer@example\.test/);
248
+ });
249
+
250
+ test("writes one rule however often the dismiss is retried", async () => {
251
+ // A retried dismiss is the same instruction repeated. A second identical
252
+ // rule would only be a second row for the user to find and delete twice.
253
+ const { deps, created } = muteDepsOf("organizer@example.test");
254
+
255
+ await muteSender(deps, ACCOUNT_CONFIG_ID, "msg-1");
256
+ await muteSender(deps, ACCOUNT_CONFIG_ID, "msg-1");
257
+ await muteSender(deps, ACCOUNT_CONFIG_ID, "msg-2");
258
+
259
+ assert.equal(created.length, 1);
260
+ });
261
+
262
+ test("adds nothing when the sender is already muted from another card", async () => {
263
+ const { deps, created } = muteDepsOf("organizer@example.test", [
264
+ muteRule("Organizer@Example.test"),
265
+ ]);
266
+
267
+ await muteSender(deps, ACCOUNT_CONFIG_ID, "msg-1");
268
+
269
+ assert.deepEqual(created, []);
270
+ });
271
+
272
+ test("still writes a rule when the existing one names a different sender", async () => {
273
+ const { deps, created } = muteDepsOf("organizer@example.test", [
274
+ muteRule("someone-else@example.test"),
275
+ ]);
276
+
277
+ await muteSender(deps, ACCOUNT_CONFIG_ID, "msg-1");
278
+
279
+ assert.equal(created.length, 1);
280
+ });
281
+
282
+ test("refuses to mute a message that names no sender", async () => {
283
+ const { deps, created } = muteDepsOf(null);
284
+
285
+ await assert.rejects(
286
+ () => muteSender(deps, ACCOUNT_CONFIG_ID, "msg-1"),
287
+ /nobody to mute/,
288
+ );
289
+ assert.deepEqual(created, []);
290
+ });
291
+ });
292
+
293
+ /**
294
+ * The suggestion wrappers driven the way an HTTP request drives them — through
295
+ * the registered client — against the SQLite store the self-host build ships.
296
+ * The unit tests above pin each half; these pin what one request does.
297
+ */
298
+
299
+ type Handler = (
300
+ context: Context,
301
+ event: APIGatewayProxyEvent,
302
+ ) => Promise<Record<string, unknown>>;
303
+
304
+ const listSuggestions =
305
+ CalendarSuggestionOperations.CalendarSuggestionOperations_listCalendarSuggestions as Handler;
306
+ const acceptSuggestion =
307
+ CalendarSuggestionActionOperations.CalendarSuggestionActionOperations_acceptCalendarSuggestion as Handler;
308
+ const dismissSuggestion =
309
+ CalendarSuggestionActionOperations.CalendarSuggestionActionOperations_dismissCalendarSuggestion as Handler;
310
+
311
+ interface Card {
312
+ suggestionId: string;
313
+ state: string;
314
+ }
315
+
316
+ let client: RemitClient;
317
+ let cleanup: () => void;
318
+ let mintedSubs = 0;
319
+
320
+ const contextOf = (request: {
321
+ params?: Record<string, string>;
322
+ query?: Record<string, unknown>;
323
+ requestBody?: unknown;
324
+ }): Context => ({ request }) as unknown as Context;
325
+
326
+ const anAccount = (): {
327
+ accountConfigId: string;
328
+ event: APIGatewayProxyEvent;
329
+ } => {
330
+ mintedSubs += 1;
331
+ const sub = `calendar-suggestion-sub-${mintedSubs}`;
332
+ return {
333
+ accountConfigId: deriveAccountConfigId(sub),
334
+ event: {
335
+ requestContext: { authorizer: { claims: { sub } } },
336
+ } as unknown as APIGatewayProxyEvent,
337
+ };
338
+ };
339
+
340
+ const INVITATION = [
341
+ "BEGIN:VCALENDAR",
342
+ "VERSION:2.0",
343
+ "METHOD:REQUEST",
344
+ "BEGIN:VEVENT",
345
+ "UID:invite@example.test",
346
+ "DTSTART:20260901T080000Z",
347
+ "DTEND:20260901T090000Z",
348
+ "SUMMARY:Quarterly review",
349
+ "END:VEVENT",
350
+ "END:VCALENDAR",
351
+ "",
352
+ ].join("\r\n");
353
+
354
+ const putSuggestion = (
355
+ accountConfigId: string,
356
+ messageId: string,
357
+ ): Promise<CalendarSuggestionItem> =>
358
+ client.calendarSuggestion.put({
359
+ accountConfigId,
360
+ messageId,
361
+ bodyPartId: "part-1",
362
+ icalUid: "invite@example.test",
363
+ sequence: 0,
364
+ method: CalendarInviteMethod.Request,
365
+ source: CalendarSuggestionSource.IcalendarPart,
366
+ summary: "Quarterly review",
367
+ dtStart: "2026-09-01T10:00:00+02:00",
368
+ dtEnd: "2026-09-01T11:00:00+02:00",
369
+ allDay: false,
370
+ location: "Room 4",
371
+ organizer: "organizer@example.test",
372
+ zoneCertainty: "Explicit",
373
+ icalData: INVITATION,
374
+ } as PutCalendarSuggestionInput);
375
+
376
+ /** A message with a From address, which is all muting a sender reads. */
377
+ const seedMessageFrom = async (
378
+ messageId: string,
379
+ sender: string,
380
+ ): Promise<void> => {
381
+ await client.envelope.createEnvelope({
382
+ envelopeId: "",
383
+ messageId,
384
+ dateValue: Date.parse("2026-08-30T08:00:00Z"),
385
+ dateRaw: "Sun, 30 Aug 2026 08:00:00 +0000",
386
+ subject: "Invitation: Quarterly review",
387
+ messageIdValue: `<${messageId}@example.test>`,
388
+ });
389
+ await client.address.createEnvelopeAddress({
390
+ messageId,
391
+ addressId: `address-${messageId}`,
392
+ displayName: "The organiser",
393
+ normalizedEmail: sender,
394
+ addressRole: "from",
395
+ addressOrder: 0,
396
+ });
397
+ };
398
+
399
+ before(async () => {
400
+ _resetForTest();
401
+ ({ client, cleanup } = await createCalendarSqliteClient());
402
+ setClient(client);
403
+ });
404
+
405
+ after(() => {
406
+ _resetForTest();
407
+ cleanup();
408
+ });
409
+
410
+ describe("GET /calendar-suggestions", () => {
411
+ test("hands the pending set back one page at a time", async () => {
412
+ const { accountConfigId, event } = anAccount();
413
+ const seeded = await Promise.all(
414
+ Array.from({ length: 101 }, (_unused, index) =>
415
+ putSuggestion(accountConfigId, `msg-page-${index}`),
416
+ ),
417
+ );
418
+
419
+ const first = (await listSuggestions(
420
+ contextOf({ query: { state: CalendarSuggestionState.Pending } }),
421
+ event,
422
+ )) as unknown as { items: Card[]; continuationToken?: string };
423
+ assert.equal(first.items.length, 100);
424
+ assert.ok(first.continuationToken, "a full page names where to continue");
425
+
426
+ const second = (await listSuggestions(
427
+ contextOf({
428
+ query: {
429
+ state: CalendarSuggestionState.Pending,
430
+ continuationToken: first.continuationToken,
431
+ },
432
+ }),
433
+ event,
434
+ )) as unknown as { items: Card[]; continuationToken?: string };
435
+
436
+ assert.equal(second.items.length, 1);
437
+ assert.equal(second.continuationToken, undefined);
438
+ const paged = new Set(
439
+ [...first.items, ...second.items].map((card) => card.suggestionId),
440
+ );
441
+ assert.equal(
442
+ paged.size,
443
+ seeded.length,
444
+ "the two pages cover the set once each, with no card in both",
445
+ );
446
+ });
447
+
448
+ test("answers only the state that was asked for, and keeps the raw bytes back", async () => {
449
+ const { accountConfigId, event } = anAccount();
450
+ await putSuggestion(accountConfigId, "msg-pending");
451
+ const dismissed = await putSuggestion(accountConfigId, "msg-dismissed");
452
+ await client.calendarSuggestion.settle(
453
+ accountConfigId,
454
+ dismissed.suggestionId,
455
+ {
456
+ state: CalendarSuggestionState.Dismissed,
457
+ acceptedCalendarObjectId: "",
458
+ },
459
+ );
460
+
461
+ const pending = (await listSuggestions(
462
+ contextOf({ query: { state: CalendarSuggestionState.Pending } }),
463
+ event,
464
+ )) as unknown as { items: Card[] };
465
+
466
+ assert.deepEqual(
467
+ pending.items.map((card) => card.state),
468
+ [CalendarSuggestionState.Pending],
469
+ );
470
+ assert.equal("icalData" in (pending.items[0] ?? {}), false);
471
+ });
472
+ });
473
+
474
+ describe("POST /calendar-suggestions/{suggestionId}/accept", () => {
475
+ test("answers not-found for a calendar on another account, before writing anything", async () => {
476
+ const stranger = anAccount();
477
+ const strangersCalendar = await client.calendarCollection.create({
478
+ accountConfigId: stranger.accountConfigId,
479
+ urlSegment: "default",
480
+ displayName: "Calendar",
481
+ });
482
+ const { accountConfigId, event } = anAccount();
483
+ const card = await putSuggestion(accountConfigId, "msg-cross-account");
484
+
485
+ await assert.rejects(
486
+ () =>
487
+ acceptSuggestion(
488
+ contextOf({
489
+ params: { suggestionId: card.suggestionId },
490
+ requestBody: { calendarId: strangersCalendar.calendarId },
491
+ }),
492
+ event,
493
+ ),
494
+ (error: unknown) => (error as { statusCode?: number }).statusCode === 404,
495
+ );
496
+
497
+ assert.deepEqual(
498
+ await client.calendarObject.listByCalendar(strangersCalendar.calendarId),
499
+ [],
500
+ "nothing was written into the calendar the caller does not hold",
501
+ );
502
+ const untouched = await client.calendarSuggestion.get(
503
+ accountConfigId,
504
+ card.suggestionId,
505
+ );
506
+ assert.equal(untouched.state, CalendarSuggestionState.Pending);
507
+ assert.equal(untouched.acceptedCalendarObjectId, "");
508
+ });
509
+ });
510
+
511
+ describe("POST /calendar-suggestions/{suggestionId}/dismiss", () => {
512
+ test("settles the card and writes the sender's mute rule in one request", async () => {
513
+ const { accountConfigId, event } = anAccount();
514
+ const card = await putSuggestion(accountConfigId, "msg-mute");
515
+ await seedMessageFrom("msg-mute", "organizer@example.test");
516
+
517
+ const dismissed = (await dismissSuggestion(
518
+ contextOf({
519
+ params: { suggestionId: card.suggestionId },
520
+ requestBody: { muteSender: true },
521
+ }),
522
+ event,
523
+ )) as unknown as Card;
524
+
525
+ assert.equal(dismissed.state, CalendarSuggestionState.Dismissed);
526
+ const rules = await client.filter.listByAccountAndState(
527
+ accountConfigId,
528
+ FilterState.Active,
529
+ );
530
+ assert.equal(rules.length, 1);
531
+ assert.deepEqual(rules[0]?.literalClauses, [
532
+ { field: "From", value: "organizer@example.test" },
533
+ ]);
534
+ });
535
+
536
+ test("writes no rule when the request does not ask to mute", async () => {
537
+ const { accountConfigId, event } = anAccount();
538
+ const card = await putSuggestion(accountConfigId, "msg-quiet");
539
+ await seedMessageFrom("msg-quiet", "organizer@example.test");
540
+
541
+ const dismissed = (await dismissSuggestion(
542
+ contextOf({
543
+ params: { suggestionId: card.suggestionId },
544
+ requestBody: {},
545
+ }),
546
+ event,
547
+ )) as unknown as Card;
548
+
549
+ assert.equal(dismissed.state, CalendarSuggestionState.Dismissed);
550
+ assert.deepEqual(
551
+ await client.filter.listByAccountAndState(
552
+ accountConfigId,
553
+ FilterState.Active,
554
+ ),
555
+ [],
556
+ );
557
+ });
558
+ });