@remit/backend 0.0.92 → 0.0.94

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.
@@ -1,40 +1,24 @@
1
1
  import assert from "node:assert/strict";
2
- import { describe, it } from "node:test";
2
+ import { after, before, describe, it } from "node:test";
3
3
  import type {
4
4
  CalendarCollectionItem,
5
5
  CalendarEventIndexItem,
6
6
  CalendarObjectItem,
7
- CalendarOccurrenceInput,
8
- CalendarSuggestionItem,
9
- CalendarUnitOfWorkRepositories,
10
- CreateCalendarCollectionInput,
11
- ICalendarCollectionRepository,
12
- ICalendarEventIndexRepository,
13
- ICalendarObjectRepository,
14
- ICalendarSuggestionRepository,
15
- ICalendarUnitOfWork,
16
- PutCalendarObjectInput,
17
- PutCalendarSuggestionInput,
18
- ResultList,
19
- SettleCalendarSuggestionInput,
20
- UpdateCalendarCollectionInput,
21
7
  } from "@remit/data-ports";
22
- import { NotFoundError } from "@remit/data-ports/errors";
8
+ import { CalendarSource, RecurrenceScope } from "@remit/domain-enums";
9
+ import type { APIGatewayProxyEvent } from "aws-lambda";
10
+ import type { Context } from "openapi-backend";
11
+ import { deriveAccountConfigId } from "../auth.js";
23
12
  import {
24
- deriveCalendarId,
25
- deriveCalendarObjectId,
26
- deriveCalendarSuggestionId,
27
- normalizeCalendarUrlSegment,
28
- } from "@remit/data-ports/id";
29
- import {
30
- CalendarColor,
31
- CalendarComponentSet,
32
- CalendarSource,
33
- CalendarSuggestionState,
34
- RecurrenceScope,
35
- } from "@remit/domain-enums";
13
+ _resetForTest,
14
+ type RemitClient,
15
+ setClient,
16
+ } from "../service/data-client.js";
36
17
  import {
37
18
  type CalendarDeps,
19
+ CalendarDetailOperations,
20
+ CalendarOperations,
21
+ calendarDepsOf,
38
22
  createCalendarFor,
39
23
  deleteCalendarFor,
40
24
  listCalendarsFor,
@@ -51,423 +35,112 @@ import {
51
35
  readWindow,
52
36
  updateCalendarEventFor,
53
37
  } from "./calendar-event.js";
38
+ import { createCalendarSqliteClient } from "./calendar-sqlite-fixture.js";
54
39
 
55
40
  /**
56
- * The calendar store in memory, behind the same ports the relational one
57
- * implements — one class per port, because a collection and a resource both
58
- * answer to `get` and `delete` with different arguments.
41
+ * The calendar handlers against the store the self-host build ships.
42
+ *
43
+ * This file used to run on a set of in-memory port implementations that claimed
44
+ * to behave exactly as sqlite. Nothing checked the claim, so it was worth
45
+ * nothing: `putCalendarObject` projects, expands and bumps the sequence, and a
46
+ * memory twin of that is a second implementation of the write path that can
47
+ * drift from the real one silently. Every test here now writes through the
48
+ * drizzle repositories, so the projection, the expansion and the transaction
49
+ * are the shipped ones.
59
50
  *
60
- * Written against the ports rather than stubbed per test so a handler test
61
- * exercises the real write path: `putCalendarObject` projects, expands and
62
- * bumps here exactly as it does against sqlite, and a handler that stopped
63
- * going through it would fail these tests rather than pass them.
51
+ * One database serves the file. Every calendar row is scoped by account config
52
+ * and `calendarId` is derived from it, so a test that mints its own account
53
+ * sees only what it wrote.
64
54
  */
65
- class CalendarState {
66
- readonly collections = new Map<string, CalendarCollectionItem>();
67
- readonly objects = new Map<string, CalendarObjectItem>();
68
- readonly occurrences = new Map<string, CalendarEventIndexItem[]>();
69
- readonly suggestions = new Map<string, CalendarSuggestionItem>();
70
- }
71
55
 
72
- /**
73
- * Suggestions are bound to the same unit of work so accepting a card and
74
- * writing its resource commit together (issue #1033). Nothing in this file
75
- * exercises them; they are here so the store answers the whole port.
76
- */
77
- class MemorySuggestions implements ICalendarSuggestionRepository {
78
- constructor(private state: CalendarState) {}
79
-
80
- async put(
81
- input: PutCalendarSuggestionInput,
82
- ): Promise<CalendarSuggestionItem> {
83
- const suggestionId = deriveCalendarSuggestionId(
84
- input.messageId,
85
- input.bodyPartId,
86
- input.icalUid,
87
- );
88
- const existing = this.state.suggestions.get(suggestionId);
89
- const now = Date.now();
90
- const suggestion: CalendarSuggestionItem = {
91
- ...input,
92
- suggestionId,
93
- state: existing?.state ?? CalendarSuggestionState.Pending,
94
- acceptedCalendarObjectId: existing?.acceptedCalendarObjectId ?? "",
95
- createdAt: existing?.createdAt ?? now,
96
- updatedAt: now,
97
- };
98
- this.state.suggestions.set(suggestionId, suggestion);
99
- return suggestion;
100
- }
56
+ let client: RemitClient;
57
+ let cleanup: () => void;
58
+ let mintedAccounts = 0;
101
59
 
102
- async get(
103
- accountConfigId: string,
104
- suggestionId: string,
105
- ): Promise<CalendarSuggestionItem> {
106
- const suggestion = this.state.suggestions.get(suggestionId);
107
- if (!suggestion || suggestion.accountConfigId !== accountConfigId) {
108
- throw new NotFoundError(`Calendar suggestion not found: ${suggestionId}`);
109
- }
110
- return suggestion;
111
- }
60
+ /** One caller's calendars, and the two ways the handlers reach them. */
61
+ class CalendarAccount {
62
+ readonly accountConfigId: string;
112
63
 
113
- async listByMessage(
114
- accountConfigId: string,
115
- messageId: string,
116
- ): Promise<CalendarSuggestionItem[]> {
117
- return [...this.state.suggestions.values()].filter(
118
- (suggestion) =>
119
- suggestion.accountConfigId === accountConfigId &&
120
- suggestion.messageId === messageId,
121
- );
64
+ constructor(readonly sub: string) {
65
+ this.accountConfigId = deriveAccountConfigId(sub);
122
66
  }
123
67
 
124
- async listByState(
125
- accountConfigId: string,
126
- state: CalendarSuggestionItem["state"],
127
- ): Promise<ResultList<CalendarSuggestionItem>> {
68
+ /** The request an authenticated caller of this account arrives on. */
69
+ request(): APIGatewayProxyEvent {
128
70
  return {
129
- items: [...this.state.suggestions.values()].filter(
130
- (suggestion) =>
131
- suggestion.accountConfigId === accountConfigId &&
132
- suggestion.state === state,
133
- ),
134
- continuationToken: undefined,
135
- };
136
- }
137
-
138
- async settle(
139
- accountConfigId: string,
140
- suggestionId: string,
141
- input: SettleCalendarSuggestionInput,
142
- ): Promise<CalendarSuggestionItem> {
143
- const suggestion = await this.get(accountConfigId, suggestionId);
144
- const settled = { ...suggestion, ...input, updatedAt: Date.now() };
145
- this.state.suggestions.set(suggestionId, settled);
146
- return settled;
71
+ requestContext: { authorizer: { claims: { sub: this.sub } } },
72
+ } as unknown as APIGatewayProxyEvent;
147
73
  }
148
74
 
149
- async supersedeIfPending(
150
- accountConfigId: string,
151
- suggestionId: string,
152
- ): Promise<CalendarSuggestionItem | null> {
153
- const suggestion = this.state.suggestions.get(suggestionId);
154
- if (
155
- !suggestion ||
156
- suggestion.accountConfigId !== accountConfigId ||
157
- suggestion.state !== CalendarSuggestionState.Pending
158
- ) {
159
- return null;
160
- }
161
- const retired = {
162
- ...suggestion,
163
- state: CalendarSuggestionState.Superseded,
164
- acceptedCalendarObjectId: "",
165
- updatedAt: Date.now(),
166
- };
167
- this.state.suggestions.set(suggestionId, retired);
168
- return retired;
169
- }
170
- }
171
-
172
- class MemoryCollections implements ICalendarCollectionRepository {
173
- constructor(private state: CalendarState) {}
174
-
175
- async create(
176
- input: CreateCalendarCollectionInput,
177
- ): Promise<CalendarCollectionItem> {
178
- const urlSegment = normalizeCalendarUrlSegment(input.urlSegment);
179
- const calendarId = deriveCalendarId(input.accountConfigId, urlSegment);
180
- const existing = this.state.collections.get(calendarId);
181
- if (existing) return existing;
182
-
183
- const created: CalendarCollectionItem = {
184
- calendarId,
185
- accountConfigId: input.accountConfigId,
186
- urlSegment,
187
- displayName: input.displayName,
188
- color: input.color ?? CalendarColor.Cal1,
189
- componentSet: input.componentSet ?? CalendarComponentSet.VeventOnly,
190
- source: input.source ?? CalendarSource.UserCreated,
191
- timezone: input.timezone ?? "",
192
- syncSequence: 0,
193
- createdAt: 0,
194
- updatedAt: 0,
195
- };
196
- this.state.collections.set(calendarId, created);
197
- return created;
198
- }
199
-
200
- async createExclusive(
201
- input: CreateCalendarCollectionInput,
202
- ): Promise<CalendarCollectionItem | null> {
203
- const calendarId = deriveCalendarId(
204
- input.accountConfigId,
205
- normalizeCalendarUrlSegment(input.urlSegment),
206
- );
207
- if (this.state.collections.has(calendarId)) return null;
208
- return this.create(input);
209
- }
210
-
211
- async get(
212
- accountConfigId: string,
213
- calendarId: string,
214
- ): Promise<CalendarCollectionItem> {
215
- const found = this.state.collections.get(calendarId);
216
- if (!found || found.accountConfigId !== accountConfigId) {
217
- throw new NotFoundError(`Calendar not found: ${calendarId}`);
218
- }
219
- return found;
75
+ deps(): CalendarDeps {
76
+ return calendarDepsOf(client);
220
77
  }
221
78
 
222
- async update(
223
- accountConfigId: string,
224
- calendarId: string,
225
- input: UpdateCalendarCollectionInput,
226
- ): Promise<CalendarCollectionItem> {
227
- const updated = {
228
- ...(await this.get(accountConfigId, calendarId)),
229
- ...input,
79
+ /** The same deps with id minting and the clock pinned. */
80
+ eventDeps(): CalendarEventDeps {
81
+ let minted = 0;
82
+ return {
83
+ ...this.deps(),
84
+ newId: () => {
85
+ minted += 1;
86
+ return `${this.accountConfigId}-minted-${minted}`;
87
+ },
88
+ now: () => new Date("2026-08-29T00:00:00Z"),
230
89
  };
231
- this.state.collections.set(calendarId, updated);
232
- return updated;
233
90
  }
234
91
 
235
- async delete(_accountConfigId: string, calendarId: string): Promise<void> {
236
- this.state.collections.delete(calendarId);
92
+ collections(): Promise<CalendarCollectionItem[]> {
93
+ return client.calendarCollection.listByAccountConfig(this.accountConfigId);
237
94
  }
238
95
 
239
- async listByAccountConfig(
240
- accountConfigId: string,
241
- ): Promise<CalendarCollectionItem[]> {
242
- return [...this.state.collections.values()]
243
- .filter((item) => item.accountConfigId === accountConfigId)
244
- .sort((left, right) => left.urlSegment.localeCompare(right.urlSegment));
245
- }
246
-
247
- async findByUrlSegment(
248
- accountConfigId: string,
249
- urlSegment: string,
250
- ): Promise<CalendarCollectionItem | null> {
96
+ async collection(calendarId: string): Promise<CalendarCollectionItem | null> {
97
+ const held = await this.collections();
251
98
  return (
252
- this.state.collections.get(
253
- deriveCalendarId(
254
- accountConfigId,
255
- normalizeCalendarUrlSegment(urlSegment),
256
- ),
257
- ) ?? null
258
- );
259
- }
260
-
261
- async bumpSyncSequence(
262
- accountConfigId: string,
263
- calendarId: string,
264
- ): Promise<number> {
265
- const current = await this.get(accountConfigId, calendarId);
266
- const bumped = { ...current, syncSequence: current.syncSequence + 1 };
267
- this.state.collections.set(calendarId, bumped);
268
- return bumped.syncSequence;
269
- }
270
- }
271
-
272
- class MemoryObjects implements ICalendarObjectRepository {
273
- constructor(private state: CalendarState) {}
274
-
275
- async put(input: PutCalendarObjectInput): Promise<CalendarObjectItem> {
276
- const calendarObjectId = deriveCalendarObjectId(
277
- input.calendarId,
278
- input.resourceName,
99
+ held.find((collection) => collection.calendarId === calendarId) ?? null
279
100
  );
280
- const stored: CalendarObjectItem = {
281
- ...input,
282
- calendarObjectId,
283
- createdAt: 0,
284
- updatedAt: 0,
285
- };
286
- this.state.objects.set(calendarObjectId, stored);
287
- return stored;
288
101
  }
289
102
 
290
- async get(
291
- calendarId: string,
292
- calendarObjectId: string,
293
- ): Promise<CalendarObjectItem> {
294
- const found = await this.find(calendarId, calendarObjectId);
295
- if (!found) {
296
- throw new NotFoundError(`Calendar object not found: ${calendarObjectId}`);
103
+ async objects(): Promise<CalendarObjectItem[]> {
104
+ const held = await this.collections();
105
+ const objects: CalendarObjectItem[] = [];
106
+ for (const collection of held) {
107
+ objects.push(
108
+ ...(await client.calendarObject.listByCalendar(collection.calendarId)),
109
+ );
297
110
  }
298
- return found;
111
+ return objects;
299
112
  }
300
113
 
301
- async find(
114
+ object(
302
115
  calendarId: string,
303
116
  calendarObjectId: string,
304
117
  ): Promise<CalendarObjectItem | null> {
305
- const found = this.state.objects.get(calendarObjectId);
306
- return found && found.calendarId === calendarId ? found : null;
118
+ return client.calendarObject.find(calendarId, calendarObjectId);
307
119
  }
308
120
 
309
- async delete(_calendarId: string, calendarObjectId: string): Promise<void> {
310
- this.state.objects.delete(calendarObjectId);
311
- }
312
-
313
- async findByResourceName(
314
- calendarId: string,
315
- resourceName: string,
316
- ): Promise<CalendarObjectItem | null> {
317
- return this.find(
318
- calendarId,
319
- deriveCalendarObjectId(calendarId, resourceName),
320
- );
321
- }
322
-
323
- async findByUid(
324
- calendarId: string,
325
- icalUid: string,
326
- ): Promise<CalendarObjectItem | null> {
327
- return (
328
- [...this.state.objects.values()].find(
329
- (object) =>
330
- object.calendarId === calendarId && object.icalUid === icalUid,
331
- ) ?? null
121
+ occurrences(object: {
122
+ calendarId: string;
123
+ calendarObjectId: string;
124
+ }): Promise<CalendarEventIndexItem[]> {
125
+ return client.calendarEventIndex.listForObject(
126
+ object.calendarId,
127
+ object.calendarObjectId,
332
128
  );
333
129
  }
334
-
335
- async listByCalendar(calendarId: string): Promise<CalendarObjectItem[]> {
336
- return [...this.state.objects.values()]
337
- .filter((object) => object.calendarId === calendarId)
338
- .sort((left, right) =>
339
- left.resourceName.localeCompare(right.resourceName),
340
- );
341
- }
342
-
343
- async listIncompleteExpansions(
344
- calendarId: string,
345
- instant: string,
346
- ): Promise<CalendarObjectItem[]> {
347
- return (await this.listByCalendar(calendarId)).filter(
348
- (object) =>
349
- object.expandedThrough !== "" && object.expandedThrough < instant,
350
- );
351
- }
352
-
353
- async listChangedSince(
354
- calendarId: string,
355
- syncSequence: number,
356
- ): Promise<CalendarObjectItem[]> {
357
- return (await this.listByCalendar(calendarId))
358
- .filter((object) => object.syncSequence > syncSequence)
359
- .sort((left, right) => left.syncSequence - right.syncSequence);
360
- }
361
- }
362
-
363
- class MemoryOccurrences implements ICalendarEventIndexRepository {
364
- constructor(private state: CalendarState) {}
365
-
366
- async replaceForObject(
367
- calendarId: string,
368
- calendarObjectId: string,
369
- occurrences: CalendarOccurrenceInput[],
370
- ): Promise<void> {
371
- this.state.occurrences.set(
372
- calendarObjectId,
373
- occurrences.map((occurrence) => ({
374
- ...occurrence,
375
- calendarId,
376
- calendarObjectId,
377
- createdAt: 0,
378
- updatedAt: 0,
379
- })),
380
- );
381
- }
382
-
383
- async deleteForObject(
384
- _calendarId: string,
385
- calendarObjectId: string,
386
- ): Promise<void> {
387
- this.state.occurrences.delete(calendarObjectId);
388
- }
389
-
390
- async listForObject(
391
- _calendarId: string,
392
- calendarObjectId: string,
393
- ): Promise<CalendarEventIndexItem[]> {
394
- return this.state.occurrences.get(calendarObjectId) ?? [];
395
- }
396
-
397
- async listByStartRange(
398
- calendarId: string,
399
- startAt: string,
400
- endAt: string,
401
- ): Promise<CalendarEventIndexItem[]> {
402
- return [...this.state.occurrences.values()]
403
- .flat()
404
- .filter(
405
- (row) =>
406
- row.calendarId === calendarId &&
407
- row.startAt >= startAt &&
408
- row.startAt < endAt,
409
- )
410
- .sort((left, right) => left.startAt.localeCompare(right.startAt));
411
- }
412
- }
413
-
414
- class InMemoryCalendarStore implements ICalendarUnitOfWork {
415
- readonly state = new CalendarState();
416
- readonly calendarCollection = new MemoryCollections(this.state);
417
- readonly calendarObject = new MemoryObjects(this.state);
418
- readonly calendarEventIndex = new MemoryOccurrences(this.state);
419
- readonly calendarSuggestion = new MemorySuggestions(this.state);
420
-
421
- get collections(): Map<string, CalendarCollectionItem> {
422
- return this.state.collections;
423
- }
424
-
425
- get objects(): Map<string, CalendarObjectItem> {
426
- return this.state.objects;
427
- }
428
-
429
- get occurrences(): Map<string, CalendarEventIndexItem[]> {
430
- return this.state.occurrences;
431
- }
432
-
433
- // No isolation to model: the tests that care about atomicity run against
434
- // sqlite, where the transaction is real.
435
- transaction<T>(
436
- fn: (repos: CalendarUnitOfWorkRepositories) => Promise<T>,
437
- ): Promise<T> {
438
- return fn(this);
439
- }
440
-
441
- deps(): CalendarDeps {
442
- return {
443
- calendarCollection: this.calendarCollection,
444
- calendarObject: this.calendarObject,
445
- calendarEventIndex: this.calendarEventIndex,
446
- calendarUnitOfWork: this,
447
- };
448
- }
449
130
  }
450
131
 
451
- const ACCOUNT = "account-config-1";
452
-
453
- const eventDeps = (store: InMemoryCalendarStore): CalendarEventDeps => {
454
- let minted = 0;
455
- return {
456
- ...store.deps(),
457
- newId: () => {
458
- minted += 1;
459
- return `minted-${minted}`;
460
- },
461
- now: () => new Date("2026-08-29T00:00:00Z"),
462
- };
132
+ const anAccount = (): CalendarAccount => {
133
+ mintedAccounts += 1;
134
+ return new CalendarAccount(`calendar-sub-${mintedAccounts}`);
463
135
  };
464
136
 
465
137
  const seedWeekly = async (
466
138
  deps: CalendarEventDeps,
139
+ accountConfigId: string,
467
140
  calendarId: string,
468
141
  recurrenceRule = "FREQ=WEEKLY;COUNT=5",
469
142
  ) => {
470
- const created = await createCalendarEventFor(deps, ACCOUNT, {
143
+ const created = await createCalendarEventFor(deps, accountConfigId, {
471
144
  calendarId,
472
145
  summary: "Stand-up",
473
146
  start: "2026-09-07T09:00:00Z",
@@ -478,11 +151,25 @@ const seedWeekly = async (
478
151
  return created.value;
479
152
  };
480
153
 
154
+ before(async () => {
155
+ _resetForTest();
156
+ ({ client, cleanup } = await createCalendarSqliteClient());
157
+ setClient(client);
158
+ });
159
+
160
+ after(() => {
161
+ _resetForTest();
162
+ cleanup();
163
+ });
164
+
481
165
  describe("listCalendarsFor", () => {
482
166
  it("provisions the default calendar on a first read", async () => {
483
- const store = new InMemoryCalendarStore();
167
+ const account = anAccount();
484
168
 
485
- const calendars = await listCalendarsFor(store.deps(), ACCOUNT);
169
+ const calendars = await listCalendarsFor(
170
+ account.deps(),
171
+ account.accountConfigId,
172
+ );
486
173
 
487
174
  assert.equal(calendars.length, 1);
488
175
  assert.equal(calendars[0]?.urlSegment, "default");
@@ -490,13 +177,15 @@ describe("listCalendarsFor", () => {
490
177
  });
491
178
 
492
179
  it("provisions it exactly once when several reads arrive together", async () => {
493
- const store = new InMemoryCalendarStore();
180
+ const account = anAccount();
494
181
 
495
182
  const reads = await Promise.all(
496
- Array.from({ length: 8 }, () => listCalendarsFor(store.deps(), ACCOUNT)),
183
+ Array.from({ length: 8 }, () =>
184
+ listCalendarsFor(account.deps(), account.accountConfigId),
185
+ ),
497
186
  );
498
187
 
499
- assert.equal(store.collections.size, 1);
188
+ assert.equal((await account.collections()).length, 1);
500
189
  const ids = new Set(reads.flat().map((calendar) => calendar.calendarId));
501
190
  assert.equal(ids.size, 1);
502
191
  });
@@ -504,35 +193,40 @@ describe("listCalendarsFor", () => {
504
193
 
505
194
  describe("createCalendarFor", () => {
506
195
  it("refuses a url segment the account already uses", async () => {
507
- const store = new InMemoryCalendarStore();
508
- const deps = store.deps();
509
- await createCalendarFor(deps, ACCOUNT, {
196
+ const account = anAccount();
197
+ const deps = account.deps();
198
+ await createCalendarFor(deps, account.accountConfigId, {
510
199
  urlSegment: "work",
511
200
  displayName: "Work",
512
201
  });
513
202
 
514
- const second = await createCalendarFor(deps, ACCOUNT, {
203
+ const second = await createCalendarFor(deps, account.accountConfigId, {
515
204
  urlSegment: "WORK",
516
205
  displayName: "Work again",
517
206
  });
518
207
 
519
208
  assert.ok(!second.ok);
520
209
  assert.equal(second.error.code, "UrlSegmentTaken");
521
- assert.equal(store.collections.size, 1);
210
+ const held = await account.collections();
211
+ assert.equal(held.length, 1);
522
212
  assert.equal(
523
- [...store.collections.values()][0]?.displayName,
213
+ held[0]?.displayName,
524
214
  "Work",
525
215
  "the refused create never wrote over the calendar that holds the segment",
526
216
  );
527
217
  });
528
218
 
529
219
  it("refuses an empty url segment", async () => {
530
- const store = new InMemoryCalendarStore();
220
+ const account = anAccount();
531
221
 
532
- const created = await createCalendarFor(store.deps(), ACCOUNT, {
533
- urlSegment: " ",
534
- displayName: "Nameless",
535
- });
222
+ const created = await createCalendarFor(
223
+ account.deps(),
224
+ account.accountConfigId,
225
+ {
226
+ urlSegment: " ",
227
+ displayName: "Nameless",
228
+ },
229
+ );
536
230
 
537
231
  assert.ok(!created.ok);
538
232
  assert.equal(created.error.code, "InvalidUrlSegment");
@@ -541,49 +235,57 @@ describe("createCalendarFor", () => {
541
235
 
542
236
  describe("deleteCalendarFor", () => {
543
237
  it("refuses to remove the calendar events fall back to", async () => {
544
- const store = new InMemoryCalendarStore();
545
- const deps = store.deps();
546
- const [fallback] = await listCalendarsFor(deps, ACCOUNT);
238
+ const account = anAccount();
239
+ const deps = account.deps();
240
+ const [fallback] = await listCalendarsFor(deps, account.accountConfigId);
547
241
  assert.ok(fallback);
548
242
 
549
- const removed = await deleteCalendarFor(deps, ACCOUNT, fallback.calendarId);
243
+ const removed = await deleteCalendarFor(
244
+ deps,
245
+ account.accountConfigId,
246
+ fallback.calendarId,
247
+ );
550
248
 
551
249
  assert.ok(!removed.ok);
552
250
  assert.equal(removed.error.code, "DefaultCalendarUndeletable");
553
- assert.equal(store.collections.size, 1);
251
+ assert.equal((await account.collections()).length, 1);
554
252
  });
555
253
 
556
254
  it("takes the events and their occurrences with a calendar it does remove", async () => {
557
- const store = new InMemoryCalendarStore();
558
- const deps = eventDeps(store);
559
- const created = await createCalendarFor(deps, ACCOUNT, {
255
+ const account = anAccount();
256
+ const deps = account.eventDeps();
257
+ const created = await createCalendarFor(deps, account.accountConfigId, {
560
258
  urlSegment: "work",
561
259
  displayName: "Work",
562
260
  });
563
261
  assert.ok(created.ok);
564
- await seedWeekly(deps, created.value.calendarId);
565
- assert.equal(store.objects.size, 1);
262
+ const event = await seedWeekly(
263
+ deps,
264
+ account.accountConfigId,
265
+ created.value.calendarId,
266
+ );
267
+ assert.equal((await account.objects()).length, 1);
566
268
 
567
269
  const removed = await deleteCalendarFor(
568
270
  deps,
569
- ACCOUNT,
271
+ account.accountConfigId,
570
272
  created.value.calendarId,
571
273
  );
572
274
 
573
275
  assert.ok(removed.ok);
574
- assert.equal(store.objects.size, 0);
575
- assert.equal(store.occurrences.size, 0);
276
+ assert.deepEqual(await account.objects(), []);
277
+ assert.deepEqual(await account.occurrences(event), []);
576
278
  });
577
279
 
578
280
  it("answers not-found for a calendar on another account", async () => {
579
- const store = new InMemoryCalendarStore();
580
- const deps = store.deps();
581
- const [mine] = await listCalendarsFor(deps, ACCOUNT);
281
+ const account = anAccount();
282
+ const deps = account.deps();
283
+ const [mine] = await listCalendarsFor(deps, account.accountConfigId);
582
284
  assert.ok(mine);
583
285
 
584
286
  const removed = await deleteCalendarFor(
585
287
  deps,
586
- "someone-else",
288
+ anAccount().accountConfigId,
587
289
  mine.calendarId,
588
290
  );
589
291
 
@@ -651,15 +353,19 @@ describe("pickEventUpdate", () => {
651
353
 
652
354
  describe("updateCalendarEventFor", () => {
653
355
  it("refuses a write built on an etag the resource no longer carries", async () => {
654
- const store = new InMemoryCalendarStore();
655
- const deps = eventDeps(store);
656
- const [calendar] = await listCalendarsFor(deps, ACCOUNT);
356
+ const account = anAccount();
357
+ const deps = account.eventDeps();
358
+ const [calendar] = await listCalendarsFor(deps, account.accountConfigId);
657
359
  assert.ok(calendar);
658
- const event = await seedWeekly(deps, calendar.calendarId);
360
+ const event = await seedWeekly(
361
+ deps,
362
+ account.accountConfigId,
363
+ calendar.calendarId,
364
+ );
659
365
 
660
366
  const first = await updateCalendarEventFor(
661
367
  deps,
662
- ACCOUNT,
368
+ account.accountConfigId,
663
369
  {
664
370
  calendarId: calendar.calendarId,
665
371
  calendarObjectId: event.calendarObjectId,
@@ -673,7 +379,7 @@ describe("updateCalendarEventFor", () => {
673
379
 
674
380
  const stale = await updateCalendarEventFor(
675
381
  deps,
676
- ACCOUNT,
382
+ account.accountConfigId,
677
383
  {
678
384
  calendarId: calendar.calendarId,
679
385
  calendarObjectId: event.calendarObjectId,
@@ -686,23 +392,31 @@ describe("updateCalendarEventFor", () => {
686
392
 
687
393
  assert.ok(!stale.ok);
688
394
  assert.equal(stale.error.code, "EtagMismatch");
395
+ const survivor = await account.object(
396
+ calendar.calendarId,
397
+ event.calendarObjectId,
398
+ );
689
399
  assert.equal(
690
- store.objects.get(event.calendarObjectId)?.summary,
400
+ survivor?.summary,
691
401
  "Stand-up (renamed)",
692
402
  "the losing write left the resource alone",
693
403
  );
694
404
  });
695
405
 
696
406
  it("writes both resources of a Following split", async () => {
697
- const store = new InMemoryCalendarStore();
698
- const deps = eventDeps(store);
699
- const [calendar] = await listCalendarsFor(deps, ACCOUNT);
407
+ const account = anAccount();
408
+ const deps = account.eventDeps();
409
+ const [calendar] = await listCalendarsFor(deps, account.accountConfigId);
700
410
  assert.ok(calendar);
701
- const event = await seedWeekly(deps, calendar.calendarId);
411
+ const event = await seedWeekly(
412
+ deps,
413
+ account.accountConfigId,
414
+ calendar.calendarId,
415
+ );
702
416
 
703
417
  const split = await updateCalendarEventFor(
704
418
  deps,
705
- ACCOUNT,
419
+ account.accountConfigId,
706
420
  {
707
421
  calendarId: calendar.calendarId,
708
422
  calendarObjectId: event.calendarObjectId,
@@ -714,10 +428,11 @@ describe("updateCalendarEventFor", () => {
714
428
  );
715
429
 
716
430
  assert.ok(split.ok, JSON.stringify(split));
717
- assert.equal(store.objects.size, 2);
718
- const [head, tail] = [...store.objects.values()].sort((left, right) =>
431
+ const objects = (await account.objects()).sort((left, right) =>
719
432
  left.dtStart.localeCompare(right.dtStart),
720
433
  );
434
+ assert.equal(objects.length, 2);
435
+ const [head, tail] = objects;
721
436
  assert.equal(head?.summary, "Stand-up");
722
437
  assert.equal(tail?.summary, "Stand-up (new format)");
723
438
  assert.notEqual(head?.icalUid, tail?.icalUid);
@@ -730,14 +445,14 @@ describe("updateCalendarEventFor", () => {
730
445
  });
731
446
 
732
447
  it("answers not-found for an event the calendar does not hold", async () => {
733
- const store = new InMemoryCalendarStore();
734
- const deps = eventDeps(store);
735
- const [calendar] = await listCalendarsFor(deps, ACCOUNT);
448
+ const account = anAccount();
449
+ const deps = account.eventDeps();
450
+ const [calendar] = await listCalendarsFor(deps, account.accountConfigId);
736
451
  assert.ok(calendar);
737
452
 
738
453
  const updated = await updateCalendarEventFor(
739
454
  deps,
740
- ACCOUNT,
455
+ account.accountConfigId,
741
456
  {
742
457
  calendarId: calendar.calendarId,
743
458
  calendarObjectId: "absent",
@@ -755,44 +470,60 @@ describe("updateCalendarEventFor", () => {
755
470
 
756
471
  describe("deleteCalendarEventFor", () => {
757
472
  it("removes the resource and its occurrences under scope=All", async () => {
758
- const store = new InMemoryCalendarStore();
759
- const deps = eventDeps(store);
760
- const [calendar] = await listCalendarsFor(deps, ACCOUNT);
473
+ const account = anAccount();
474
+ const deps = account.eventDeps();
475
+ const [calendar] = await listCalendarsFor(deps, account.accountConfigId);
761
476
  assert.ok(calendar);
762
- const event = await seedWeekly(deps, calendar.calendarId);
763
-
764
- const removed = await deleteCalendarEventFor(deps, ACCOUNT, {
765
- calendarId: calendar.calendarId,
766
- calendarObjectId: event.calendarObjectId,
767
- scope: RecurrenceScope.All,
768
- recurrenceId: "",
769
- ifMatch: undefined,
770
- });
477
+ const event = await seedWeekly(
478
+ deps,
479
+ account.accountConfigId,
480
+ calendar.calendarId,
481
+ );
482
+
483
+ const removed = await deleteCalendarEventFor(
484
+ deps,
485
+ account.accountConfigId,
486
+ {
487
+ calendarId: calendar.calendarId,
488
+ calendarObjectId: event.calendarObjectId,
489
+ scope: RecurrenceScope.All,
490
+ recurrenceId: "",
491
+ ifMatch: undefined,
492
+ },
493
+ );
771
494
 
772
495
  assert.ok(removed.ok);
773
- assert.equal(store.objects.size, 0);
774
- assert.equal(store.occurrences.size, 0);
496
+ assert.deepEqual(await account.objects(), []);
497
+ assert.deepEqual(await account.occurrences(event), []);
775
498
  });
776
499
 
777
500
  it("keeps the series under scope=This and drops one occurrence from it", async () => {
778
- const store = new InMemoryCalendarStore();
779
- const deps = eventDeps(store);
780
- const [calendar] = await listCalendarsFor(deps, ACCOUNT);
501
+ const account = anAccount();
502
+ const deps = account.eventDeps();
503
+ const [calendar] = await listCalendarsFor(deps, account.accountConfigId);
781
504
  assert.ok(calendar);
782
- const event = await seedWeekly(deps, calendar.calendarId);
783
- assert.equal(store.occurrences.get(event.calendarObjectId)?.length, 5);
784
-
785
- const removed = await deleteCalendarEventFor(deps, ACCOUNT, {
786
- calendarId: calendar.calendarId,
787
- calendarObjectId: event.calendarObjectId,
788
- scope: RecurrenceScope.This,
789
- recurrenceId: "2026-09-21T09:00:00Z",
790
- ifMatch: undefined,
791
- });
505
+ const event = await seedWeekly(
506
+ deps,
507
+ account.accountConfigId,
508
+ calendar.calendarId,
509
+ );
510
+ assert.equal((await account.occurrences(event)).length, 5);
511
+
512
+ const removed = await deleteCalendarEventFor(
513
+ deps,
514
+ account.accountConfigId,
515
+ {
516
+ calendarId: calendar.calendarId,
517
+ calendarObjectId: event.calendarObjectId,
518
+ scope: RecurrenceScope.This,
519
+ recurrenceId: "2026-09-21T09:00:00Z",
520
+ ifMatch: undefined,
521
+ },
522
+ );
792
523
 
793
524
  assert.ok(removed.ok);
794
- assert.equal(store.objects.size, 1);
795
- const rows = store.occurrences.get(event.calendarObjectId) ?? [];
525
+ assert.equal((await account.objects()).length, 1);
526
+ const rows = await account.occurrences(event);
796
527
  assert.equal(rows.length, 4);
797
528
  assert.equal(
798
529
  rows.some((row) => row.startAt === "2026-09-21T09:00:00Z"),
@@ -801,58 +532,75 @@ describe("deleteCalendarEventFor", () => {
801
532
  });
802
533
 
803
534
  it("refuses a per-occurrence delete of an event that happens once", async () => {
804
- const store = new InMemoryCalendarStore();
805
- const deps = eventDeps(store);
806
- const [calendar] = await listCalendarsFor(deps, ACCOUNT);
535
+ const account = anAccount();
536
+ const deps = account.eventDeps();
537
+ const [calendar] = await listCalendarsFor(deps, account.accountConfigId);
807
538
  assert.ok(calendar);
808
- const event = await seedWeekly(deps, calendar.calendarId, "");
809
-
810
- const removed = await deleteCalendarEventFor(deps, ACCOUNT, {
811
- calendarId: calendar.calendarId,
812
- calendarObjectId: event.calendarObjectId,
813
- scope: RecurrenceScope.This,
814
- recurrenceId: "2026-09-07T09:00:00Z",
815
- ifMatch: undefined,
816
- });
539
+ const event = await seedWeekly(
540
+ deps,
541
+ account.accountConfigId,
542
+ calendar.calendarId,
543
+ "",
544
+ );
545
+
546
+ const removed = await deleteCalendarEventFor(
547
+ deps,
548
+ account.accountConfigId,
549
+ {
550
+ calendarId: calendar.calendarId,
551
+ calendarObjectId: event.calendarObjectId,
552
+ scope: RecurrenceScope.This,
553
+ recurrenceId: "2026-09-07T09:00:00Z",
554
+ ifMatch: undefined,
555
+ },
556
+ );
817
557
 
818
558
  assert.ok(!removed.ok);
819
559
  assert.equal(removed.error.code, "NotRecurring");
820
- assert.equal(store.objects.size, 1);
560
+ assert.equal((await account.objects()).length, 1);
821
561
  });
822
562
  });
823
563
 
824
564
  describe("createCalendarEventFor", () => {
825
565
  it("refuses an event aimed at a calendar the account does not hold", async () => {
826
- const store = new InMemoryCalendarStore();
827
- const deps = eventDeps(store);
828
-
829
- const created = await createCalendarEventFor(deps, ACCOUNT, {
830
- calendarId: "someone-elses-calendar",
831
- summary: "Stand-up",
832
- start: "2026-09-07T09:00:00Z",
833
- end: "2026-09-07T10:00:00Z",
834
- });
566
+ const account = anAccount();
567
+ const deps = account.eventDeps();
568
+
569
+ const created = await createCalendarEventFor(
570
+ deps,
571
+ account.accountConfigId,
572
+ {
573
+ calendarId: "someone-elses-calendar",
574
+ summary: "Stand-up",
575
+ start: "2026-09-07T09:00:00Z",
576
+ end: "2026-09-07T10:00:00Z",
577
+ },
578
+ );
835
579
 
836
580
  assert.ok(!created.ok);
837
581
  assert.equal(created.error.code, "NotFound");
838
582
  });
839
583
 
840
584
  it("refuses an event that ends before it starts", async () => {
841
- const store = new InMemoryCalendarStore();
842
- const deps = eventDeps(store);
843
- const [calendar] = await listCalendarsFor(deps, ACCOUNT);
585
+ const account = anAccount();
586
+ const deps = account.eventDeps();
587
+ const [calendar] = await listCalendarsFor(deps, account.accountConfigId);
844
588
  assert.ok(calendar);
845
589
 
846
- const created = await createCalendarEventFor(deps, ACCOUNT, {
847
- calendarId: calendar.calendarId,
848
- summary: "Backwards",
849
- start: "2026-09-07T10:00:00Z",
850
- end: "2026-09-07T09:00:00Z",
851
- });
590
+ const created = await createCalendarEventFor(
591
+ deps,
592
+ account.accountConfigId,
593
+ {
594
+ calendarId: calendar.calendarId,
595
+ summary: "Backwards",
596
+ start: "2026-09-07T10:00:00Z",
597
+ end: "2026-09-07T09:00:00Z",
598
+ },
599
+ );
852
600
 
853
601
  assert.ok(!created.ok);
854
602
  assert.equal(created.error.code, "BackwardsEnd");
855
- assert.equal(store.objects.size, 0);
603
+ assert.deepEqual(await account.objects(), []);
856
604
  });
857
605
  });
858
606
 
@@ -898,53 +646,50 @@ describe("updateCalendarFor", () => {
898
646
  };
899
647
 
900
648
  it("refuses a timezone this server cannot resolve", async () => {
901
- const store = new InMemoryCalendarStore();
902
- const deps = store.deps();
903
- const [calendar] = await listCalendarsFor(deps, ACCOUNT);
649
+ const account = anAccount();
650
+ const deps = account.deps();
651
+ const [calendar] = await listCalendarsFor(deps, account.accountConfigId);
904
652
  assert.ok(calendar);
905
653
 
906
654
  const updated = await updateCalendarFor(
907
655
  deps,
908
- ACCOUNT,
656
+ account.accountConfigId,
909
657
  calendar.calendarId,
910
- {
911
- timezone: "Pacific Standard Time",
912
- },
658
+ { timezone: "Pacific Standard Time" },
913
659
  );
914
660
 
915
661
  assert.ok(!updated.ok);
916
662
  assert.equal(updated.error.code, "UnknownTimeZone");
917
- assert.equal(store.collections.get(calendar.calendarId)?.timezone, "");
663
+ assert.equal((await account.collection(calendar.calendarId))?.timezone, "");
918
664
  });
919
665
 
920
666
  it("re-expands the calendar's events when its timezone changes", async () => {
921
- const store = new InMemoryCalendarStore();
922
- const deps = eventDeps(store);
923
- const [calendar] = await listCalendarsFor(deps, ACCOUNT);
667
+ const account = anAccount();
668
+ const deps = account.eventDeps();
669
+ const [calendar] = await listCalendarsFor(deps, account.accountConfigId);
924
670
  assert.ok(calendar);
925
- const created = await createCalendarEventFor(deps, ACCOUNT, {
926
- calendarId: calendar.calendarId,
927
- ...allDay,
928
- });
671
+ const created = await createCalendarEventFor(
672
+ deps,
673
+ account.accountConfigId,
674
+ { calendarId: calendar.calendarId, ...allDay },
675
+ );
929
676
  assert.ok(created.ok, JSON.stringify(created));
930
677
  assert.equal(
931
- store.occurrences.get(created.value.calendarObjectId)?.[0]?.startAt,
678
+ (await account.occurrences(created.value))[0]?.startAt,
932
679
  "2026-06-01T00:00:00Z",
933
680
  "an all-day event in a calendar with no zone starts at midnight UTC",
934
681
  );
935
682
 
936
683
  const updated = await updateCalendarFor(
937
684
  deps,
938
- ACCOUNT,
685
+ account.accountConfigId,
939
686
  calendar.calendarId,
940
- {
941
- timezone: "America/New_York",
942
- },
687
+ { timezone: "America/New_York" },
943
688
  );
944
689
 
945
690
  assert.ok(updated.ok, JSON.stringify(updated));
946
691
  assert.equal(
947
- store.occurrences.get(created.value.calendarObjectId)?.[0]?.startAt,
692
+ (await account.occurrences(created.value))[0]?.startAt,
948
693
  "2026-06-01T04:00:00Z",
949
694
  "and midnight in the calendar's new zone once it has one",
950
695
  );
@@ -955,24 +700,24 @@ describe("updateCalendarFor", () => {
955
700
  });
956
701
 
957
702
  it("leaves the events alone when only the name changes", async () => {
958
- const store = new InMemoryCalendarStore();
959
- const deps = eventDeps(store);
960
- const [calendar] = await listCalendarsFor(deps, ACCOUNT);
703
+ const account = anAccount();
704
+ const deps = account.eventDeps();
705
+ const [calendar] = await listCalendarsFor(deps, account.accountConfigId);
961
706
  assert.ok(calendar);
962
- const created = await createCalendarEventFor(deps, ACCOUNT, {
963
- calendarId: calendar.calendarId,
964
- ...allDay,
965
- });
707
+ const created = await createCalendarEventFor(
708
+ deps,
709
+ account.accountConfigId,
710
+ { calendarId: calendar.calendarId, ...allDay },
711
+ );
966
712
  assert.ok(created.ok);
967
- const before = store.collections.get(calendar.calendarId)?.syncSequence;
713
+ const before = (await account.collection(calendar.calendarId))
714
+ ?.syncSequence;
968
715
 
969
716
  const updated = await updateCalendarFor(
970
717
  deps,
971
- ACCOUNT,
718
+ account.accountConfigId,
972
719
  calendar.calendarId,
973
- {
974
- displayName: "Renamed",
975
- },
720
+ { displayName: "Renamed" },
976
721
  );
977
722
 
978
723
  assert.ok(updated.ok);
@@ -980,3 +725,119 @@ describe("updateCalendarFor", () => {
980
725
  assert.equal(updated.value.syncSequence, before);
981
726
  });
982
727
  });
728
+
729
+ /**
730
+ * The collection wrappers, driven the way an HTTP request drives them. The
731
+ * suites above hold the inner functions; these hold what the API answers with.
732
+ */
733
+
734
+ type Handler = (
735
+ context: Context,
736
+ event: APIGatewayProxyEvent,
737
+ ) => Promise<Record<string, unknown>>;
738
+
739
+ const createCalendar =
740
+ CalendarOperations.CalendarOperations_createCalendar as Handler;
741
+ const getCalendar =
742
+ CalendarDetailOperations.CalendarDetailOperations_getCalendar as Handler;
743
+ const updateCalendar =
744
+ CalendarDetailOperations.CalendarDetailOperations_updateCalendar as Handler;
745
+ const deleteCalendar =
746
+ CalendarDetailOperations.CalendarDetailOperations_deleteCalendar as Handler;
747
+
748
+ const contextOf = (request: {
749
+ params?: Record<string, string>;
750
+ requestBody?: unknown;
751
+ }): Context => ({ request }) as unknown as Context;
752
+
753
+ describe("the calendar collection wrappers", () => {
754
+ it("answers not-found for a collection on another account", async () => {
755
+ const stranger = anAccount();
756
+ const [theirs] = await listCalendarsFor(
757
+ stranger.deps(),
758
+ stranger.accountConfigId,
759
+ );
760
+ assert.ok(theirs);
761
+ const event = anAccount().request();
762
+
763
+ const read = await getCalendar(
764
+ contextOf({ params: { calendarId: theirs.calendarId } }),
765
+ event,
766
+ );
767
+
768
+ assert.equal(read.statusCode, 404);
769
+ assert.equal((read.body as { code: string }).code, "NotFound");
770
+ });
771
+
772
+ it("refuses a second calendar under a segment the account already uses", async () => {
773
+ const event = anAccount().request();
774
+ await createCalendar(
775
+ contextOf({ requestBody: { urlSegment: "work", displayName: "Work" } }),
776
+ event,
777
+ );
778
+
779
+ const second = await createCalendar(
780
+ contextOf({
781
+ requestBody: { urlSegment: "Work", displayName: "Work again" },
782
+ }),
783
+ event,
784
+ );
785
+
786
+ assert.equal(second.statusCode, 400);
787
+ assert.equal((second.body as { code: string }).code, "UrlSegmentTaken");
788
+ });
789
+
790
+ it("refuses a rename that names a zone this server cannot resolve", async () => {
791
+ const account = anAccount();
792
+ const event = account.request();
793
+ const [calendar] = await listCalendarsFor(
794
+ account.deps(),
795
+ account.accountConfigId,
796
+ );
797
+ assert.ok(calendar);
798
+
799
+ const updated = await updateCalendar(
800
+ contextOf({
801
+ params: { calendarId: calendar.calendarId },
802
+ requestBody: { timezone: "Pacific Standard Time" },
803
+ }),
804
+ event,
805
+ );
806
+
807
+ assert.equal(updated.statusCode, 400);
808
+ assert.equal((updated.body as { code: string }).code, "UnknownTimeZone");
809
+ });
810
+
811
+ it("answers 204 for a calendar it removed and 400 for the default one", async () => {
812
+ const account = anAccount();
813
+ const event = account.request();
814
+ const held = await listCalendarsFor(
815
+ account.deps(),
816
+ account.accountConfigId,
817
+ );
818
+ const fallback = held.find(
819
+ (collection) => collection.source === CalendarSource.Default,
820
+ );
821
+ assert.ok(fallback);
822
+ const created = (await createCalendar(
823
+ contextOf({ requestBody: { urlSegment: "work", displayName: "Work" } }),
824
+ event,
825
+ )) as unknown as { calendarId: string };
826
+
827
+ const removed = await deleteCalendar(
828
+ contextOf({ params: { calendarId: created.calendarId } }),
829
+ event,
830
+ );
831
+ const refused = await deleteCalendar(
832
+ contextOf({ params: { calendarId: fallback.calendarId } }),
833
+ event,
834
+ );
835
+
836
+ assert.equal(removed.statusCode, 204);
837
+ assert.equal(refused.statusCode, 400);
838
+ assert.equal(
839
+ (refused.body as { code: string }).code,
840
+ "DefaultCalendarUndeletable",
841
+ );
842
+ });
843
+ });