@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.
@@ -1,32 +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
- CreateCalendarCollectionInput,
9
- ICalendarCollectionRepository,
10
- ICalendarEventIndexRepository,
11
- ICalendarObjectRepository,
12
- ICalendarUnitOfWork,
13
- PutCalendarObjectInput,
14
- UpdateCalendarCollectionInput,
15
7
  } from "@remit/data-ports";
16
- 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";
17
12
  import {
18
- deriveCalendarId,
19
- deriveCalendarObjectId,
20
- normalizeCalendarUrlSegment,
21
- } from "@remit/data-ports/id";
22
- import {
23
- CalendarColor,
24
- CalendarComponentSet,
25
- CalendarSource,
26
- RecurrenceScope,
27
- } from "@remit/domain-enums";
13
+ _resetForTest,
14
+ type RemitClient,
15
+ setClient,
16
+ } from "../service/data-client.js";
28
17
  import {
29
18
  type CalendarDeps,
19
+ CalendarDetailOperations,
20
+ CalendarOperations,
21
+ calendarDepsOf,
30
22
  createCalendarFor,
31
23
  deleteCalendarFor,
32
24
  listCalendarsFor,
@@ -43,325 +35,112 @@ import {
43
35
  readWindow,
44
36
  updateCalendarEventFor,
45
37
  } from "./calendar-event.js";
38
+ import { createCalendarSqliteClient } from "./calendar-sqlite-fixture.js";
46
39
 
47
40
  /**
48
- * The calendar store in memory, behind the same ports the relational one
49
- * implements — one class per port, because a collection and a resource both
50
- * 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.
51
50
  *
52
- * Written against the ports rather than stubbed per test so a handler test
53
- * exercises the real write path: `putCalendarObject` projects, expands and
54
- * bumps here exactly as it does against sqlite, and a handler that stopped
55
- * 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.
56
54
  */
57
- class CalendarState {
58
- readonly collections = new Map<string, CalendarCollectionItem>();
59
- readonly objects = new Map<string, CalendarObjectItem>();
60
- readonly occurrences = new Map<string, CalendarEventIndexItem[]>();
61
- }
62
55
 
63
- class MemoryCollections implements ICalendarCollectionRepository {
64
- constructor(private state: CalendarState) {}
65
-
66
- async create(
67
- input: CreateCalendarCollectionInput,
68
- ): Promise<CalendarCollectionItem> {
69
- const urlSegment = normalizeCalendarUrlSegment(input.urlSegment);
70
- const calendarId = deriveCalendarId(input.accountConfigId, urlSegment);
71
- const existing = this.state.collections.get(calendarId);
72
- if (existing) return existing;
73
-
74
- const created: CalendarCollectionItem = {
75
- calendarId,
76
- accountConfigId: input.accountConfigId,
77
- urlSegment,
78
- displayName: input.displayName,
79
- color: input.color ?? CalendarColor.Cal1,
80
- componentSet: input.componentSet ?? CalendarComponentSet.VeventOnly,
81
- source: input.source ?? CalendarSource.UserCreated,
82
- timezone: input.timezone ?? "",
83
- syncSequence: 0,
84
- createdAt: 0,
85
- updatedAt: 0,
86
- };
87
- this.state.collections.set(calendarId, created);
88
- return created;
89
- }
56
+ let client: RemitClient;
57
+ let cleanup: () => void;
58
+ let mintedAccounts = 0;
90
59
 
91
- async createExclusive(
92
- input: CreateCalendarCollectionInput,
93
- ): Promise<CalendarCollectionItem | null> {
94
- const calendarId = deriveCalendarId(
95
- input.accountConfigId,
96
- normalizeCalendarUrlSegment(input.urlSegment),
97
- );
98
- if (this.state.collections.has(calendarId)) return null;
99
- return this.create(input);
100
- }
60
+ /** One caller's calendars, and the two ways the handlers reach them. */
61
+ class CalendarAccount {
62
+ readonly accountConfigId: string;
101
63
 
102
- async get(
103
- accountConfigId: string,
104
- calendarId: string,
105
- ): Promise<CalendarCollectionItem> {
106
- const found = this.state.collections.get(calendarId);
107
- if (!found || found.accountConfigId !== accountConfigId) {
108
- throw new NotFoundError(`Calendar not found: ${calendarId}`);
109
- }
110
- return found;
111
- }
112
-
113
- async update(
114
- accountConfigId: string,
115
- calendarId: string,
116
- input: UpdateCalendarCollectionInput,
117
- ): Promise<CalendarCollectionItem> {
118
- const updated = {
119
- ...(await this.get(accountConfigId, calendarId)),
120
- ...input,
121
- };
122
- this.state.collections.set(calendarId, updated);
123
- return updated;
64
+ constructor(readonly sub: string) {
65
+ this.accountConfigId = deriveAccountConfigId(sub);
124
66
  }
125
67
 
126
- async delete(_accountConfigId: string, calendarId: string): Promise<void> {
127
- this.state.collections.delete(calendarId);
128
- }
129
-
130
- async listByAccountConfig(
131
- accountConfigId: string,
132
- ): Promise<CalendarCollectionItem[]> {
133
- return [...this.state.collections.values()]
134
- .filter((item) => item.accountConfigId === accountConfigId)
135
- .sort((left, right) => left.urlSegment.localeCompare(right.urlSegment));
136
- }
137
-
138
- async findByUrlSegment(
139
- accountConfigId: string,
140
- urlSegment: string,
141
- ): Promise<CalendarCollectionItem | null> {
142
- return (
143
- this.state.collections.get(
144
- deriveCalendarId(
145
- accountConfigId,
146
- normalizeCalendarUrlSegment(urlSegment),
147
- ),
148
- ) ?? null
149
- );
68
+ /** The request an authenticated caller of this account arrives on. */
69
+ request(): APIGatewayProxyEvent {
70
+ return {
71
+ requestContext: { authorizer: { claims: { sub: this.sub } } },
72
+ } as unknown as APIGatewayProxyEvent;
150
73
  }
151
74
 
152
- async bumpSyncSequence(
153
- accountConfigId: string,
154
- calendarId: string,
155
- ): Promise<number> {
156
- const current = await this.get(accountConfigId, calendarId);
157
- const bumped = { ...current, syncSequence: current.syncSequence + 1 };
158
- this.state.collections.set(calendarId, bumped);
159
- return bumped.syncSequence;
75
+ deps(): CalendarDeps {
76
+ return calendarDepsOf(client);
160
77
  }
161
- }
162
-
163
- class MemoryObjects implements ICalendarObjectRepository {
164
- constructor(private state: CalendarState) {}
165
78
 
166
- async put(input: PutCalendarObjectInput): Promise<CalendarObjectItem> {
167
- const calendarObjectId = deriveCalendarObjectId(
168
- input.calendarId,
169
- input.resourceName,
170
- );
171
- const stored: CalendarObjectItem = {
172
- ...input,
173
- calendarObjectId,
174
- createdAt: 0,
175
- updatedAt: 0,
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"),
176
89
  };
177
- this.state.objects.set(calendarObjectId, stored);
178
- return stored;
179
90
  }
180
91
 
181
- async get(
182
- calendarId: string,
183
- calendarObjectId: string,
184
- ): Promise<CalendarObjectItem> {
185
- const found = await this.find(calendarId, calendarObjectId);
186
- if (!found) {
187
- throw new NotFoundError(`Calendar object not found: ${calendarObjectId}`);
188
- }
189
- return found;
92
+ collections(): Promise<CalendarCollectionItem[]> {
93
+ return client.calendarCollection.listByAccountConfig(this.accountConfigId);
190
94
  }
191
95
 
192
- async find(
193
- calendarId: string,
194
- calendarObjectId: string,
195
- ): Promise<CalendarObjectItem | null> {
196
- const found = this.state.objects.get(calendarObjectId);
197
- return found && found.calendarId === calendarId ? found : null;
198
- }
199
-
200
- async delete(_calendarId: string, calendarObjectId: string): Promise<void> {
201
- this.state.objects.delete(calendarObjectId);
202
- }
203
-
204
- async findByResourceName(
205
- calendarId: string,
206
- resourceName: string,
207
- ): Promise<CalendarObjectItem | null> {
208
- return this.find(
209
- calendarId,
210
- deriveCalendarObjectId(calendarId, resourceName),
211
- );
212
- }
213
-
214
- async findByUid(
215
- calendarId: string,
216
- icalUid: string,
217
- ): Promise<CalendarObjectItem | null> {
96
+ async collection(calendarId: string): Promise<CalendarCollectionItem | null> {
97
+ const held = await this.collections();
218
98
  return (
219
- [...this.state.objects.values()].find(
220
- (object) =>
221
- object.calendarId === calendarId && object.icalUid === icalUid,
222
- ) ?? null
99
+ held.find((collection) => collection.calendarId === calendarId) ?? null
223
100
  );
224
101
  }
225
102
 
226
- async listByCalendar(calendarId: string): Promise<CalendarObjectItem[]> {
227
- return [...this.state.objects.values()]
228
- .filter((object) => object.calendarId === calendarId)
229
- .sort((left, right) =>
230
- left.resourceName.localeCompare(right.resourceName),
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)),
231
109
  );
110
+ }
111
+ return objects;
232
112
  }
233
113
 
234
- async listIncompleteExpansions(
235
- calendarId: string,
236
- instant: string,
237
- ): Promise<CalendarObjectItem[]> {
238
- return (await this.listByCalendar(calendarId)).filter(
239
- (object) =>
240
- object.expandedThrough !== "" && object.expandedThrough < instant,
241
- );
242
- }
243
-
244
- async listChangedSince(
245
- calendarId: string,
246
- syncSequence: number,
247
- ): Promise<CalendarObjectItem[]> {
248
- return (await this.listByCalendar(calendarId))
249
- .filter((object) => object.syncSequence > syncSequence)
250
- .sort((left, right) => left.syncSequence - right.syncSequence);
251
- }
252
- }
253
-
254
- class MemoryOccurrences implements ICalendarEventIndexRepository {
255
- constructor(private state: CalendarState) {}
256
-
257
- async replaceForObject(
114
+ object(
258
115
  calendarId: string,
259
116
  calendarObjectId: string,
260
- occurrences: CalendarOccurrenceInput[],
261
- ): Promise<void> {
262
- this.state.occurrences.set(
263
- calendarObjectId,
264
- occurrences.map((occurrence) => ({
265
- ...occurrence,
266
- calendarId,
267
- calendarObjectId,
268
- createdAt: 0,
269
- updatedAt: 0,
270
- })),
271
- );
272
- }
273
-
274
- async deleteForObject(
275
- _calendarId: string,
276
- calendarObjectId: string,
277
- ): Promise<void> {
278
- this.state.occurrences.delete(calendarObjectId);
279
- }
280
-
281
- async listForObject(
282
- _calendarId: string,
283
- calendarObjectId: string,
284
- ): Promise<CalendarEventIndexItem[]> {
285
- return this.state.occurrences.get(calendarObjectId) ?? [];
286
- }
287
-
288
- async listByStartRange(
289
- calendarId: string,
290
- startAt: string,
291
- endAt: string,
292
- ): Promise<CalendarEventIndexItem[]> {
293
- return [...this.state.occurrences.values()]
294
- .flat()
295
- .filter(
296
- (row) =>
297
- row.calendarId === calendarId &&
298
- row.startAt >= startAt &&
299
- row.startAt < endAt,
300
- )
301
- .sort((left, right) => left.startAt.localeCompare(right.startAt));
302
- }
303
- }
304
-
305
- class InMemoryCalendarStore implements ICalendarUnitOfWork {
306
- readonly state = new CalendarState();
307
- readonly calendarCollection = new MemoryCollections(this.state);
308
- readonly calendarObject = new MemoryObjects(this.state);
309
- readonly calendarEventIndex = new MemoryOccurrences(this.state);
310
-
311
- get collections(): Map<string, CalendarCollectionItem> {
312
- return this.state.collections;
313
- }
314
-
315
- get objects(): Map<string, CalendarObjectItem> {
316
- return this.state.objects;
317
- }
318
-
319
- get occurrences(): Map<string, CalendarEventIndexItem[]> {
320
- return this.state.occurrences;
321
- }
322
-
323
- // No isolation to model: the tests that care about atomicity run against
324
- // sqlite, where the transaction is real.
325
- transaction<T>(
326
- fn: (repos: {
327
- calendarCollection: ICalendarCollectionRepository;
328
- calendarObject: ICalendarObjectRepository;
329
- calendarEventIndex: ICalendarEventIndexRepository;
330
- }) => Promise<T>,
331
- ): Promise<T> {
332
- return fn(this);
117
+ ): Promise<CalendarObjectItem | null> {
118
+ return client.calendarObject.find(calendarId, calendarObjectId);
333
119
  }
334
120
 
335
- deps(): CalendarDeps {
336
- return {
337
- calendarCollection: this.calendarCollection,
338
- calendarObject: this.calendarObject,
339
- calendarEventIndex: this.calendarEventIndex,
340
- calendarUnitOfWork: this,
341
- };
121
+ occurrences(object: {
122
+ calendarId: string;
123
+ calendarObjectId: string;
124
+ }): Promise<CalendarEventIndexItem[]> {
125
+ return client.calendarEventIndex.listForObject(
126
+ object.calendarId,
127
+ object.calendarObjectId,
128
+ );
342
129
  }
343
130
  }
344
131
 
345
- const ACCOUNT = "account-config-1";
346
-
347
- const eventDeps = (store: InMemoryCalendarStore): CalendarEventDeps => {
348
- let minted = 0;
349
- return {
350
- ...store.deps(),
351
- newId: () => {
352
- minted += 1;
353
- return `minted-${minted}`;
354
- },
355
- now: () => new Date("2026-08-29T00:00:00Z"),
356
- };
132
+ const anAccount = (): CalendarAccount => {
133
+ mintedAccounts += 1;
134
+ return new CalendarAccount(`calendar-sub-${mintedAccounts}`);
357
135
  };
358
136
 
359
137
  const seedWeekly = async (
360
138
  deps: CalendarEventDeps,
139
+ accountConfigId: string,
361
140
  calendarId: string,
362
141
  recurrenceRule = "FREQ=WEEKLY;COUNT=5",
363
142
  ) => {
364
- const created = await createCalendarEventFor(deps, ACCOUNT, {
143
+ const created = await createCalendarEventFor(deps, accountConfigId, {
365
144
  calendarId,
366
145
  summary: "Stand-up",
367
146
  start: "2026-09-07T09:00:00Z",
@@ -372,11 +151,25 @@ const seedWeekly = async (
372
151
  return created.value;
373
152
  };
374
153
 
154
+ before(async () => {
155
+ _resetForTest();
156
+ ({ client, cleanup } = await createCalendarSqliteClient());
157
+ setClient(client);
158
+ });
159
+
160
+ after(() => {
161
+ _resetForTest();
162
+ cleanup();
163
+ });
164
+
375
165
  describe("listCalendarsFor", () => {
376
166
  it("provisions the default calendar on a first read", async () => {
377
- const store = new InMemoryCalendarStore();
167
+ const account = anAccount();
378
168
 
379
- const calendars = await listCalendarsFor(store.deps(), ACCOUNT);
169
+ const calendars = await listCalendarsFor(
170
+ account.deps(),
171
+ account.accountConfigId,
172
+ );
380
173
 
381
174
  assert.equal(calendars.length, 1);
382
175
  assert.equal(calendars[0]?.urlSegment, "default");
@@ -384,13 +177,15 @@ describe("listCalendarsFor", () => {
384
177
  });
385
178
 
386
179
  it("provisions it exactly once when several reads arrive together", async () => {
387
- const store = new InMemoryCalendarStore();
180
+ const account = anAccount();
388
181
 
389
182
  const reads = await Promise.all(
390
- Array.from({ length: 8 }, () => listCalendarsFor(store.deps(), ACCOUNT)),
183
+ Array.from({ length: 8 }, () =>
184
+ listCalendarsFor(account.deps(), account.accountConfigId),
185
+ ),
391
186
  );
392
187
 
393
- assert.equal(store.collections.size, 1);
188
+ assert.equal((await account.collections()).length, 1);
394
189
  const ids = new Set(reads.flat().map((calendar) => calendar.calendarId));
395
190
  assert.equal(ids.size, 1);
396
191
  });
@@ -398,35 +193,40 @@ describe("listCalendarsFor", () => {
398
193
 
399
194
  describe("createCalendarFor", () => {
400
195
  it("refuses a url segment the account already uses", async () => {
401
- const store = new InMemoryCalendarStore();
402
- const deps = store.deps();
403
- await createCalendarFor(deps, ACCOUNT, {
196
+ const account = anAccount();
197
+ const deps = account.deps();
198
+ await createCalendarFor(deps, account.accountConfigId, {
404
199
  urlSegment: "work",
405
200
  displayName: "Work",
406
201
  });
407
202
 
408
- const second = await createCalendarFor(deps, ACCOUNT, {
203
+ const second = await createCalendarFor(deps, account.accountConfigId, {
409
204
  urlSegment: "WORK",
410
205
  displayName: "Work again",
411
206
  });
412
207
 
413
208
  assert.ok(!second.ok);
414
209
  assert.equal(second.error.code, "UrlSegmentTaken");
415
- assert.equal(store.collections.size, 1);
210
+ const held = await account.collections();
211
+ assert.equal(held.length, 1);
416
212
  assert.equal(
417
- [...store.collections.values()][0]?.displayName,
213
+ held[0]?.displayName,
418
214
  "Work",
419
215
  "the refused create never wrote over the calendar that holds the segment",
420
216
  );
421
217
  });
422
218
 
423
219
  it("refuses an empty url segment", async () => {
424
- const store = new InMemoryCalendarStore();
220
+ const account = anAccount();
425
221
 
426
- const created = await createCalendarFor(store.deps(), ACCOUNT, {
427
- urlSegment: " ",
428
- displayName: "Nameless",
429
- });
222
+ const created = await createCalendarFor(
223
+ account.deps(),
224
+ account.accountConfigId,
225
+ {
226
+ urlSegment: " ",
227
+ displayName: "Nameless",
228
+ },
229
+ );
430
230
 
431
231
  assert.ok(!created.ok);
432
232
  assert.equal(created.error.code, "InvalidUrlSegment");
@@ -435,49 +235,57 @@ describe("createCalendarFor", () => {
435
235
 
436
236
  describe("deleteCalendarFor", () => {
437
237
  it("refuses to remove the calendar events fall back to", async () => {
438
- const store = new InMemoryCalendarStore();
439
- const deps = store.deps();
440
- const [fallback] = await listCalendarsFor(deps, ACCOUNT);
238
+ const account = anAccount();
239
+ const deps = account.deps();
240
+ const [fallback] = await listCalendarsFor(deps, account.accountConfigId);
441
241
  assert.ok(fallback);
442
242
 
443
- const removed = await deleteCalendarFor(deps, ACCOUNT, fallback.calendarId);
243
+ const removed = await deleteCalendarFor(
244
+ deps,
245
+ account.accountConfigId,
246
+ fallback.calendarId,
247
+ );
444
248
 
445
249
  assert.ok(!removed.ok);
446
250
  assert.equal(removed.error.code, "DefaultCalendarUndeletable");
447
- assert.equal(store.collections.size, 1);
251
+ assert.equal((await account.collections()).length, 1);
448
252
  });
449
253
 
450
254
  it("takes the events and their occurrences with a calendar it does remove", async () => {
451
- const store = new InMemoryCalendarStore();
452
- const deps = eventDeps(store);
453
- const created = await createCalendarFor(deps, ACCOUNT, {
255
+ const account = anAccount();
256
+ const deps = account.eventDeps();
257
+ const created = await createCalendarFor(deps, account.accountConfigId, {
454
258
  urlSegment: "work",
455
259
  displayName: "Work",
456
260
  });
457
261
  assert.ok(created.ok);
458
- await seedWeekly(deps, created.value.calendarId);
459
- 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);
460
268
 
461
269
  const removed = await deleteCalendarFor(
462
270
  deps,
463
- ACCOUNT,
271
+ account.accountConfigId,
464
272
  created.value.calendarId,
465
273
  );
466
274
 
467
275
  assert.ok(removed.ok);
468
- assert.equal(store.objects.size, 0);
469
- assert.equal(store.occurrences.size, 0);
276
+ assert.deepEqual(await account.objects(), []);
277
+ assert.deepEqual(await account.occurrences(event), []);
470
278
  });
471
279
 
472
280
  it("answers not-found for a calendar on another account", async () => {
473
- const store = new InMemoryCalendarStore();
474
- const deps = store.deps();
475
- const [mine] = await listCalendarsFor(deps, ACCOUNT);
281
+ const account = anAccount();
282
+ const deps = account.deps();
283
+ const [mine] = await listCalendarsFor(deps, account.accountConfigId);
476
284
  assert.ok(mine);
477
285
 
478
286
  const removed = await deleteCalendarFor(
479
287
  deps,
480
- "someone-else",
288
+ anAccount().accountConfigId,
481
289
  mine.calendarId,
482
290
  );
483
291
 
@@ -545,15 +353,19 @@ describe("pickEventUpdate", () => {
545
353
 
546
354
  describe("updateCalendarEventFor", () => {
547
355
  it("refuses a write built on an etag the resource no longer carries", async () => {
548
- const store = new InMemoryCalendarStore();
549
- const deps = eventDeps(store);
550
- const [calendar] = await listCalendarsFor(deps, ACCOUNT);
356
+ const account = anAccount();
357
+ const deps = account.eventDeps();
358
+ const [calendar] = await listCalendarsFor(deps, account.accountConfigId);
551
359
  assert.ok(calendar);
552
- const event = await seedWeekly(deps, calendar.calendarId);
360
+ const event = await seedWeekly(
361
+ deps,
362
+ account.accountConfigId,
363
+ calendar.calendarId,
364
+ );
553
365
 
554
366
  const first = await updateCalendarEventFor(
555
367
  deps,
556
- ACCOUNT,
368
+ account.accountConfigId,
557
369
  {
558
370
  calendarId: calendar.calendarId,
559
371
  calendarObjectId: event.calendarObjectId,
@@ -567,7 +379,7 @@ describe("updateCalendarEventFor", () => {
567
379
 
568
380
  const stale = await updateCalendarEventFor(
569
381
  deps,
570
- ACCOUNT,
382
+ account.accountConfigId,
571
383
  {
572
384
  calendarId: calendar.calendarId,
573
385
  calendarObjectId: event.calendarObjectId,
@@ -580,23 +392,31 @@ describe("updateCalendarEventFor", () => {
580
392
 
581
393
  assert.ok(!stale.ok);
582
394
  assert.equal(stale.error.code, "EtagMismatch");
395
+ const survivor = await account.object(
396
+ calendar.calendarId,
397
+ event.calendarObjectId,
398
+ );
583
399
  assert.equal(
584
- store.objects.get(event.calendarObjectId)?.summary,
400
+ survivor?.summary,
585
401
  "Stand-up (renamed)",
586
402
  "the losing write left the resource alone",
587
403
  );
588
404
  });
589
405
 
590
406
  it("writes both resources of a Following split", async () => {
591
- const store = new InMemoryCalendarStore();
592
- const deps = eventDeps(store);
593
- const [calendar] = await listCalendarsFor(deps, ACCOUNT);
407
+ const account = anAccount();
408
+ const deps = account.eventDeps();
409
+ const [calendar] = await listCalendarsFor(deps, account.accountConfigId);
594
410
  assert.ok(calendar);
595
- const event = await seedWeekly(deps, calendar.calendarId);
411
+ const event = await seedWeekly(
412
+ deps,
413
+ account.accountConfigId,
414
+ calendar.calendarId,
415
+ );
596
416
 
597
417
  const split = await updateCalendarEventFor(
598
418
  deps,
599
- ACCOUNT,
419
+ account.accountConfigId,
600
420
  {
601
421
  calendarId: calendar.calendarId,
602
422
  calendarObjectId: event.calendarObjectId,
@@ -608,10 +428,11 @@ describe("updateCalendarEventFor", () => {
608
428
  );
609
429
 
610
430
  assert.ok(split.ok, JSON.stringify(split));
611
- assert.equal(store.objects.size, 2);
612
- const [head, tail] = [...store.objects.values()].sort((left, right) =>
431
+ const objects = (await account.objects()).sort((left, right) =>
613
432
  left.dtStart.localeCompare(right.dtStart),
614
433
  );
434
+ assert.equal(objects.length, 2);
435
+ const [head, tail] = objects;
615
436
  assert.equal(head?.summary, "Stand-up");
616
437
  assert.equal(tail?.summary, "Stand-up (new format)");
617
438
  assert.notEqual(head?.icalUid, tail?.icalUid);
@@ -624,14 +445,14 @@ describe("updateCalendarEventFor", () => {
624
445
  });
625
446
 
626
447
  it("answers not-found for an event the calendar does not hold", async () => {
627
- const store = new InMemoryCalendarStore();
628
- const deps = eventDeps(store);
629
- const [calendar] = await listCalendarsFor(deps, ACCOUNT);
448
+ const account = anAccount();
449
+ const deps = account.eventDeps();
450
+ const [calendar] = await listCalendarsFor(deps, account.accountConfigId);
630
451
  assert.ok(calendar);
631
452
 
632
453
  const updated = await updateCalendarEventFor(
633
454
  deps,
634
- ACCOUNT,
455
+ account.accountConfigId,
635
456
  {
636
457
  calendarId: calendar.calendarId,
637
458
  calendarObjectId: "absent",
@@ -649,44 +470,60 @@ describe("updateCalendarEventFor", () => {
649
470
 
650
471
  describe("deleteCalendarEventFor", () => {
651
472
  it("removes the resource and its occurrences under scope=All", async () => {
652
- const store = new InMemoryCalendarStore();
653
- const deps = eventDeps(store);
654
- const [calendar] = await listCalendarsFor(deps, ACCOUNT);
473
+ const account = anAccount();
474
+ const deps = account.eventDeps();
475
+ const [calendar] = await listCalendarsFor(deps, account.accountConfigId);
655
476
  assert.ok(calendar);
656
- const event = await seedWeekly(deps, calendar.calendarId);
657
-
658
- const removed = await deleteCalendarEventFor(deps, ACCOUNT, {
659
- calendarId: calendar.calendarId,
660
- calendarObjectId: event.calendarObjectId,
661
- scope: RecurrenceScope.All,
662
- recurrenceId: "",
663
- ifMatch: undefined,
664
- });
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
+ );
665
494
 
666
495
  assert.ok(removed.ok);
667
- assert.equal(store.objects.size, 0);
668
- assert.equal(store.occurrences.size, 0);
496
+ assert.deepEqual(await account.objects(), []);
497
+ assert.deepEqual(await account.occurrences(event), []);
669
498
  });
670
499
 
671
500
  it("keeps the series under scope=This and drops one occurrence from it", async () => {
672
- const store = new InMemoryCalendarStore();
673
- const deps = eventDeps(store);
674
- const [calendar] = await listCalendarsFor(deps, ACCOUNT);
501
+ const account = anAccount();
502
+ const deps = account.eventDeps();
503
+ const [calendar] = await listCalendarsFor(deps, account.accountConfigId);
675
504
  assert.ok(calendar);
676
- const event = await seedWeekly(deps, calendar.calendarId);
677
- assert.equal(store.occurrences.get(event.calendarObjectId)?.length, 5);
678
-
679
- const removed = await deleteCalendarEventFor(deps, ACCOUNT, {
680
- calendarId: calendar.calendarId,
681
- calendarObjectId: event.calendarObjectId,
682
- scope: RecurrenceScope.This,
683
- recurrenceId: "2026-09-21T09:00:00Z",
684
- ifMatch: undefined,
685
- });
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
+ );
686
523
 
687
524
  assert.ok(removed.ok);
688
- assert.equal(store.objects.size, 1);
689
- const rows = store.occurrences.get(event.calendarObjectId) ?? [];
525
+ assert.equal((await account.objects()).length, 1);
526
+ const rows = await account.occurrences(event);
690
527
  assert.equal(rows.length, 4);
691
528
  assert.equal(
692
529
  rows.some((row) => row.startAt === "2026-09-21T09:00:00Z"),
@@ -695,58 +532,75 @@ describe("deleteCalendarEventFor", () => {
695
532
  });
696
533
 
697
534
  it("refuses a per-occurrence delete of an event that happens once", async () => {
698
- const store = new InMemoryCalendarStore();
699
- const deps = eventDeps(store);
700
- const [calendar] = await listCalendarsFor(deps, ACCOUNT);
535
+ const account = anAccount();
536
+ const deps = account.eventDeps();
537
+ const [calendar] = await listCalendarsFor(deps, account.accountConfigId);
701
538
  assert.ok(calendar);
702
- const event = await seedWeekly(deps, calendar.calendarId, "");
703
-
704
- const removed = await deleteCalendarEventFor(deps, ACCOUNT, {
705
- calendarId: calendar.calendarId,
706
- calendarObjectId: event.calendarObjectId,
707
- scope: RecurrenceScope.This,
708
- recurrenceId: "2026-09-07T09:00:00Z",
709
- ifMatch: undefined,
710
- });
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
+ );
711
557
 
712
558
  assert.ok(!removed.ok);
713
559
  assert.equal(removed.error.code, "NotRecurring");
714
- assert.equal(store.objects.size, 1);
560
+ assert.equal((await account.objects()).length, 1);
715
561
  });
716
562
  });
717
563
 
718
564
  describe("createCalendarEventFor", () => {
719
565
  it("refuses an event aimed at a calendar the account does not hold", async () => {
720
- const store = new InMemoryCalendarStore();
721
- const deps = eventDeps(store);
722
-
723
- const created = await createCalendarEventFor(deps, ACCOUNT, {
724
- calendarId: "someone-elses-calendar",
725
- summary: "Stand-up",
726
- start: "2026-09-07T09:00:00Z",
727
- end: "2026-09-07T10:00:00Z",
728
- });
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
+ );
729
579
 
730
580
  assert.ok(!created.ok);
731
581
  assert.equal(created.error.code, "NotFound");
732
582
  });
733
583
 
734
584
  it("refuses an event that ends before it starts", async () => {
735
- const store = new InMemoryCalendarStore();
736
- const deps = eventDeps(store);
737
- const [calendar] = await listCalendarsFor(deps, ACCOUNT);
585
+ const account = anAccount();
586
+ const deps = account.eventDeps();
587
+ const [calendar] = await listCalendarsFor(deps, account.accountConfigId);
738
588
  assert.ok(calendar);
739
589
 
740
- const created = await createCalendarEventFor(deps, ACCOUNT, {
741
- calendarId: calendar.calendarId,
742
- summary: "Backwards",
743
- start: "2026-09-07T10:00:00Z",
744
- end: "2026-09-07T09:00:00Z",
745
- });
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
+ );
746
600
 
747
601
  assert.ok(!created.ok);
748
602
  assert.equal(created.error.code, "BackwardsEnd");
749
- assert.equal(store.objects.size, 0);
603
+ assert.deepEqual(await account.objects(), []);
750
604
  });
751
605
  });
752
606
 
@@ -792,53 +646,50 @@ describe("updateCalendarFor", () => {
792
646
  };
793
647
 
794
648
  it("refuses a timezone this server cannot resolve", async () => {
795
- const store = new InMemoryCalendarStore();
796
- const deps = store.deps();
797
- const [calendar] = await listCalendarsFor(deps, ACCOUNT);
649
+ const account = anAccount();
650
+ const deps = account.deps();
651
+ const [calendar] = await listCalendarsFor(deps, account.accountConfigId);
798
652
  assert.ok(calendar);
799
653
 
800
654
  const updated = await updateCalendarFor(
801
655
  deps,
802
- ACCOUNT,
656
+ account.accountConfigId,
803
657
  calendar.calendarId,
804
- {
805
- timezone: "Pacific Standard Time",
806
- },
658
+ { timezone: "Pacific Standard Time" },
807
659
  );
808
660
 
809
661
  assert.ok(!updated.ok);
810
662
  assert.equal(updated.error.code, "UnknownTimeZone");
811
- assert.equal(store.collections.get(calendar.calendarId)?.timezone, "");
663
+ assert.equal((await account.collection(calendar.calendarId))?.timezone, "");
812
664
  });
813
665
 
814
666
  it("re-expands the calendar's events when its timezone changes", async () => {
815
- const store = new InMemoryCalendarStore();
816
- const deps = eventDeps(store);
817
- const [calendar] = await listCalendarsFor(deps, ACCOUNT);
667
+ const account = anAccount();
668
+ const deps = account.eventDeps();
669
+ const [calendar] = await listCalendarsFor(deps, account.accountConfigId);
818
670
  assert.ok(calendar);
819
- const created = await createCalendarEventFor(deps, ACCOUNT, {
820
- calendarId: calendar.calendarId,
821
- ...allDay,
822
- });
671
+ const created = await createCalendarEventFor(
672
+ deps,
673
+ account.accountConfigId,
674
+ { calendarId: calendar.calendarId, ...allDay },
675
+ );
823
676
  assert.ok(created.ok, JSON.stringify(created));
824
677
  assert.equal(
825
- store.occurrences.get(created.value.calendarObjectId)?.[0]?.startAt,
678
+ (await account.occurrences(created.value))[0]?.startAt,
826
679
  "2026-06-01T00:00:00Z",
827
680
  "an all-day event in a calendar with no zone starts at midnight UTC",
828
681
  );
829
682
 
830
683
  const updated = await updateCalendarFor(
831
684
  deps,
832
- ACCOUNT,
685
+ account.accountConfigId,
833
686
  calendar.calendarId,
834
- {
835
- timezone: "America/New_York",
836
- },
687
+ { timezone: "America/New_York" },
837
688
  );
838
689
 
839
690
  assert.ok(updated.ok, JSON.stringify(updated));
840
691
  assert.equal(
841
- store.occurrences.get(created.value.calendarObjectId)?.[0]?.startAt,
692
+ (await account.occurrences(created.value))[0]?.startAt,
842
693
  "2026-06-01T04:00:00Z",
843
694
  "and midnight in the calendar's new zone once it has one",
844
695
  );
@@ -849,24 +700,24 @@ describe("updateCalendarFor", () => {
849
700
  });
850
701
 
851
702
  it("leaves the events alone when only the name changes", async () => {
852
- const store = new InMemoryCalendarStore();
853
- const deps = eventDeps(store);
854
- const [calendar] = await listCalendarsFor(deps, ACCOUNT);
703
+ const account = anAccount();
704
+ const deps = account.eventDeps();
705
+ const [calendar] = await listCalendarsFor(deps, account.accountConfigId);
855
706
  assert.ok(calendar);
856
- const created = await createCalendarEventFor(deps, ACCOUNT, {
857
- calendarId: calendar.calendarId,
858
- ...allDay,
859
- });
707
+ const created = await createCalendarEventFor(
708
+ deps,
709
+ account.accountConfigId,
710
+ { calendarId: calendar.calendarId, ...allDay },
711
+ );
860
712
  assert.ok(created.ok);
861
- const before = store.collections.get(calendar.calendarId)?.syncSequence;
713
+ const before = (await account.collection(calendar.calendarId))
714
+ ?.syncSequence;
862
715
 
863
716
  const updated = await updateCalendarFor(
864
717
  deps,
865
- ACCOUNT,
718
+ account.accountConfigId,
866
719
  calendar.calendarId,
867
- {
868
- displayName: "Renamed",
869
- },
720
+ { displayName: "Renamed" },
870
721
  );
871
722
 
872
723
  assert.ok(updated.ok);
@@ -874,3 +725,119 @@ describe("updateCalendarFor", () => {
874
725
  assert.equal(updated.value.syncSequence, before);
875
726
  });
876
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
+ });