@remit/backend 0.0.90 → 0.0.91
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/src/handlers/calendar-event.ts +668 -0
- package/src/handlers/calendar.test.ts +876 -0
- package/src/handlers/calendar.ts +384 -0
- package/src/handlers/index.ts +11 -0
- package/src/service/compose-sqlite.ts +8 -0
- package/src/service/create-remit-client.ts +23 -0
- package/src/types.ts +36 -0
|
@@ -0,0 +1,876 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type {
|
|
4
|
+
CalendarCollectionItem,
|
|
5
|
+
CalendarEventIndexItem,
|
|
6
|
+
CalendarObjectItem,
|
|
7
|
+
CalendarOccurrenceInput,
|
|
8
|
+
CreateCalendarCollectionInput,
|
|
9
|
+
ICalendarCollectionRepository,
|
|
10
|
+
ICalendarEventIndexRepository,
|
|
11
|
+
ICalendarObjectRepository,
|
|
12
|
+
ICalendarUnitOfWork,
|
|
13
|
+
PutCalendarObjectInput,
|
|
14
|
+
UpdateCalendarCollectionInput,
|
|
15
|
+
} from "@remit/data-ports";
|
|
16
|
+
import { NotFoundError } from "@remit/data-ports/errors";
|
|
17
|
+
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";
|
|
28
|
+
import {
|
|
29
|
+
type CalendarDeps,
|
|
30
|
+
createCalendarFor,
|
|
31
|
+
deleteCalendarFor,
|
|
32
|
+
listCalendarsFor,
|
|
33
|
+
readCollectionTimezone,
|
|
34
|
+
updateCalendarFor,
|
|
35
|
+
} from "./calendar.js";
|
|
36
|
+
import {
|
|
37
|
+
type CalendarEventDeps,
|
|
38
|
+
createCalendarEventFor,
|
|
39
|
+
deleteCalendarEventFor,
|
|
40
|
+
etagMatches,
|
|
41
|
+
pickEventUpdate,
|
|
42
|
+
readScope,
|
|
43
|
+
readWindow,
|
|
44
|
+
updateCalendarEventFor,
|
|
45
|
+
} from "./calendar-event.js";
|
|
46
|
+
|
|
47
|
+
/**
|
|
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.
|
|
51
|
+
*
|
|
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.
|
|
56
|
+
*/
|
|
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
|
+
|
|
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
|
+
}
|
|
90
|
+
|
|
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
|
+
}
|
|
101
|
+
|
|
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;
|
|
124
|
+
}
|
|
125
|
+
|
|
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
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
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;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
class MemoryObjects implements ICalendarObjectRepository {
|
|
164
|
+
constructor(private state: CalendarState) {}
|
|
165
|
+
|
|
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,
|
|
176
|
+
};
|
|
177
|
+
this.state.objects.set(calendarObjectId, stored);
|
|
178
|
+
return stored;
|
|
179
|
+
}
|
|
180
|
+
|
|
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;
|
|
190
|
+
}
|
|
191
|
+
|
|
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> {
|
|
218
|
+
return (
|
|
219
|
+
[...this.state.objects.values()].find(
|
|
220
|
+
(object) =>
|
|
221
|
+
object.calendarId === calendarId && object.icalUid === icalUid,
|
|
222
|
+
) ?? null
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
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),
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
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(
|
|
258
|
+
calendarId: string,
|
|
259
|
+
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);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
deps(): CalendarDeps {
|
|
336
|
+
return {
|
|
337
|
+
calendarCollection: this.calendarCollection,
|
|
338
|
+
calendarObject: this.calendarObject,
|
|
339
|
+
calendarEventIndex: this.calendarEventIndex,
|
|
340
|
+
calendarUnitOfWork: this,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
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
|
+
};
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
const seedWeekly = async (
|
|
360
|
+
deps: CalendarEventDeps,
|
|
361
|
+
calendarId: string,
|
|
362
|
+
recurrenceRule = "FREQ=WEEKLY;COUNT=5",
|
|
363
|
+
) => {
|
|
364
|
+
const created = await createCalendarEventFor(deps, ACCOUNT, {
|
|
365
|
+
calendarId,
|
|
366
|
+
summary: "Stand-up",
|
|
367
|
+
start: "2026-09-07T09:00:00Z",
|
|
368
|
+
end: "2026-09-07T10:00:00Z",
|
|
369
|
+
recurrenceRule,
|
|
370
|
+
});
|
|
371
|
+
assert.ok(created.ok, JSON.stringify(created));
|
|
372
|
+
return created.value;
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
describe("listCalendarsFor", () => {
|
|
376
|
+
it("provisions the default calendar on a first read", async () => {
|
|
377
|
+
const store = new InMemoryCalendarStore();
|
|
378
|
+
|
|
379
|
+
const calendars = await listCalendarsFor(store.deps(), ACCOUNT);
|
|
380
|
+
|
|
381
|
+
assert.equal(calendars.length, 1);
|
|
382
|
+
assert.equal(calendars[0]?.urlSegment, "default");
|
|
383
|
+
assert.equal(calendars[0]?.source, CalendarSource.Default);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("provisions it exactly once when several reads arrive together", async () => {
|
|
387
|
+
const store = new InMemoryCalendarStore();
|
|
388
|
+
|
|
389
|
+
const reads = await Promise.all(
|
|
390
|
+
Array.from({ length: 8 }, () => listCalendarsFor(store.deps(), ACCOUNT)),
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
assert.equal(store.collections.size, 1);
|
|
394
|
+
const ids = new Set(reads.flat().map((calendar) => calendar.calendarId));
|
|
395
|
+
assert.equal(ids.size, 1);
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
describe("createCalendarFor", () => {
|
|
400
|
+
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, {
|
|
404
|
+
urlSegment: "work",
|
|
405
|
+
displayName: "Work",
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
const second = await createCalendarFor(deps, ACCOUNT, {
|
|
409
|
+
urlSegment: "WORK",
|
|
410
|
+
displayName: "Work again",
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
assert.ok(!second.ok);
|
|
414
|
+
assert.equal(second.error.code, "UrlSegmentTaken");
|
|
415
|
+
assert.equal(store.collections.size, 1);
|
|
416
|
+
assert.equal(
|
|
417
|
+
[...store.collections.values()][0]?.displayName,
|
|
418
|
+
"Work",
|
|
419
|
+
"the refused create never wrote over the calendar that holds the segment",
|
|
420
|
+
);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("refuses an empty url segment", async () => {
|
|
424
|
+
const store = new InMemoryCalendarStore();
|
|
425
|
+
|
|
426
|
+
const created = await createCalendarFor(store.deps(), ACCOUNT, {
|
|
427
|
+
urlSegment: " ",
|
|
428
|
+
displayName: "Nameless",
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
assert.ok(!created.ok);
|
|
432
|
+
assert.equal(created.error.code, "InvalidUrlSegment");
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
describe("deleteCalendarFor", () => {
|
|
437
|
+
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);
|
|
441
|
+
assert.ok(fallback);
|
|
442
|
+
|
|
443
|
+
const removed = await deleteCalendarFor(deps, ACCOUNT, fallback.calendarId);
|
|
444
|
+
|
|
445
|
+
assert.ok(!removed.ok);
|
|
446
|
+
assert.equal(removed.error.code, "DefaultCalendarUndeletable");
|
|
447
|
+
assert.equal(store.collections.size, 1);
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
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, {
|
|
454
|
+
urlSegment: "work",
|
|
455
|
+
displayName: "Work",
|
|
456
|
+
});
|
|
457
|
+
assert.ok(created.ok);
|
|
458
|
+
await seedWeekly(deps, created.value.calendarId);
|
|
459
|
+
assert.equal(store.objects.size, 1);
|
|
460
|
+
|
|
461
|
+
const removed = await deleteCalendarFor(
|
|
462
|
+
deps,
|
|
463
|
+
ACCOUNT,
|
|
464
|
+
created.value.calendarId,
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
assert.ok(removed.ok);
|
|
468
|
+
assert.equal(store.objects.size, 0);
|
|
469
|
+
assert.equal(store.occurrences.size, 0);
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
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);
|
|
476
|
+
assert.ok(mine);
|
|
477
|
+
|
|
478
|
+
const removed = await deleteCalendarFor(
|
|
479
|
+
deps,
|
|
480
|
+
"someone-else",
|
|
481
|
+
mine.calendarId,
|
|
482
|
+
);
|
|
483
|
+
|
|
484
|
+
assert.ok(!removed.ok);
|
|
485
|
+
assert.equal(removed.error.code, "NotFound");
|
|
486
|
+
});
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
describe("readWindow", () => {
|
|
490
|
+
it("refuses a window that is missing, backwards or wider than a year", () => {
|
|
491
|
+
assert.equal(readWindow(undefined, "2026-09-08T00:00:00Z").ok, false);
|
|
492
|
+
assert.equal(readWindow("not a date", "2026-09-08T00:00:00Z").ok, false);
|
|
493
|
+
assert.equal(
|
|
494
|
+
readWindow("2026-09-08T00:00:00Z", "2026-09-07T00:00:00Z").ok,
|
|
495
|
+
false,
|
|
496
|
+
);
|
|
497
|
+
assert.equal(
|
|
498
|
+
readWindow("2026-01-01T00:00:00Z", "2027-06-01T00:00:00Z").ok,
|
|
499
|
+
false,
|
|
500
|
+
);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it("normalises both ends to UTC instants", () => {
|
|
504
|
+
const window = readWindow(
|
|
505
|
+
"2026-09-07T02:00:00+02:00",
|
|
506
|
+
"2026-09-08T02:00:00+02:00",
|
|
507
|
+
);
|
|
508
|
+
|
|
509
|
+
assert.ok(window.ok);
|
|
510
|
+
assert.deepEqual(window.value, {
|
|
511
|
+
from: "2026-09-07T00:00:00Z",
|
|
512
|
+
to: "2026-09-08T00:00:00Z",
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
describe("etagMatches", () => {
|
|
518
|
+
it("lets a write through with no precondition, a wildcard or the tag it read", () => {
|
|
519
|
+
assert.equal(etagMatches(undefined, "abc"), true);
|
|
520
|
+
assert.equal(etagMatches("*", "abc"), true);
|
|
521
|
+
assert.equal(etagMatches('"abc"', "abc"), true);
|
|
522
|
+
assert.equal(etagMatches('W/"abc"', "abc"), true);
|
|
523
|
+
assert.equal(etagMatches('"other", "abc"', "abc"), true);
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it("refuses a tag the resource no longer carries", () => {
|
|
527
|
+
assert.equal(etagMatches('"stale"', "abc"), false);
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
describe("pickEventUpdate", () => {
|
|
532
|
+
it("keeps absence, so a rename touches nothing else", () => {
|
|
533
|
+
assert.deepEqual(pickEventUpdate({ summary: "Renamed" }), {
|
|
534
|
+
summary: "Renamed",
|
|
535
|
+
});
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
it("drops a field the API does not define", () => {
|
|
539
|
+
assert.deepEqual(
|
|
540
|
+
pickEventUpdate({ summary: "Renamed", icalData: "smuggled" } as never),
|
|
541
|
+
{ summary: "Renamed" },
|
|
542
|
+
);
|
|
543
|
+
});
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
describe("updateCalendarEventFor", () => {
|
|
547
|
+
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);
|
|
551
|
+
assert.ok(calendar);
|
|
552
|
+
const event = await seedWeekly(deps, calendar.calendarId);
|
|
553
|
+
|
|
554
|
+
const first = await updateCalendarEventFor(
|
|
555
|
+
deps,
|
|
556
|
+
ACCOUNT,
|
|
557
|
+
{
|
|
558
|
+
calendarId: calendar.calendarId,
|
|
559
|
+
calendarObjectId: event.calendarObjectId,
|
|
560
|
+
scope: RecurrenceScope.All,
|
|
561
|
+
recurrenceId: "",
|
|
562
|
+
ifMatch: `"${event.etag}"`,
|
|
563
|
+
},
|
|
564
|
+
{ summary: "Stand-up (renamed)" },
|
|
565
|
+
);
|
|
566
|
+
assert.ok(first.ok);
|
|
567
|
+
|
|
568
|
+
const stale = await updateCalendarEventFor(
|
|
569
|
+
deps,
|
|
570
|
+
ACCOUNT,
|
|
571
|
+
{
|
|
572
|
+
calendarId: calendar.calendarId,
|
|
573
|
+
calendarObjectId: event.calendarObjectId,
|
|
574
|
+
scope: RecurrenceScope.All,
|
|
575
|
+
recurrenceId: "",
|
|
576
|
+
ifMatch: `"${event.etag}"`,
|
|
577
|
+
},
|
|
578
|
+
{ summary: "Stand-up (renamed again)" },
|
|
579
|
+
);
|
|
580
|
+
|
|
581
|
+
assert.ok(!stale.ok);
|
|
582
|
+
assert.equal(stale.error.code, "EtagMismatch");
|
|
583
|
+
assert.equal(
|
|
584
|
+
store.objects.get(event.calendarObjectId)?.summary,
|
|
585
|
+
"Stand-up (renamed)",
|
|
586
|
+
"the losing write left the resource alone",
|
|
587
|
+
);
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
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);
|
|
594
|
+
assert.ok(calendar);
|
|
595
|
+
const event = await seedWeekly(deps, calendar.calendarId);
|
|
596
|
+
|
|
597
|
+
const split = await updateCalendarEventFor(
|
|
598
|
+
deps,
|
|
599
|
+
ACCOUNT,
|
|
600
|
+
{
|
|
601
|
+
calendarId: calendar.calendarId,
|
|
602
|
+
calendarObjectId: event.calendarObjectId,
|
|
603
|
+
scope: RecurrenceScope.Following,
|
|
604
|
+
recurrenceId: "2026-09-21T09:00:00Z",
|
|
605
|
+
ifMatch: undefined,
|
|
606
|
+
},
|
|
607
|
+
{ summary: "Stand-up (new format)" },
|
|
608
|
+
);
|
|
609
|
+
|
|
610
|
+
assert.ok(split.ok, JSON.stringify(split));
|
|
611
|
+
assert.equal(store.objects.size, 2);
|
|
612
|
+
const [head, tail] = [...store.objects.values()].sort((left, right) =>
|
|
613
|
+
left.dtStart.localeCompare(right.dtStart),
|
|
614
|
+
);
|
|
615
|
+
assert.equal(head?.summary, "Stand-up");
|
|
616
|
+
assert.equal(tail?.summary, "Stand-up (new format)");
|
|
617
|
+
assert.notEqual(head?.icalUid, tail?.icalUid);
|
|
618
|
+
assert.equal(
|
|
619
|
+
split.value?.calendarObjectId,
|
|
620
|
+
tail?.calendarObjectId,
|
|
621
|
+
"the caller is handed the remainder, which is where their edit landed",
|
|
622
|
+
);
|
|
623
|
+
assert.notEqual(split.value?.calendarObjectId, event.calendarObjectId);
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
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);
|
|
630
|
+
assert.ok(calendar);
|
|
631
|
+
|
|
632
|
+
const updated = await updateCalendarEventFor(
|
|
633
|
+
deps,
|
|
634
|
+
ACCOUNT,
|
|
635
|
+
{
|
|
636
|
+
calendarId: calendar.calendarId,
|
|
637
|
+
calendarObjectId: "absent",
|
|
638
|
+
scope: RecurrenceScope.All,
|
|
639
|
+
recurrenceId: "",
|
|
640
|
+
ifMatch: undefined,
|
|
641
|
+
},
|
|
642
|
+
{ summary: "Renamed" },
|
|
643
|
+
);
|
|
644
|
+
|
|
645
|
+
assert.ok(!updated.ok);
|
|
646
|
+
assert.equal(updated.error.code, "NotFound");
|
|
647
|
+
});
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
describe("deleteCalendarEventFor", () => {
|
|
651
|
+
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);
|
|
655
|
+
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
|
+
});
|
|
665
|
+
|
|
666
|
+
assert.ok(removed.ok);
|
|
667
|
+
assert.equal(store.objects.size, 0);
|
|
668
|
+
assert.equal(store.occurrences.size, 0);
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
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);
|
|
675
|
+
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
|
+
});
|
|
686
|
+
|
|
687
|
+
assert.ok(removed.ok);
|
|
688
|
+
assert.equal(store.objects.size, 1);
|
|
689
|
+
const rows = store.occurrences.get(event.calendarObjectId) ?? [];
|
|
690
|
+
assert.equal(rows.length, 4);
|
|
691
|
+
assert.equal(
|
|
692
|
+
rows.some((row) => row.startAt === "2026-09-21T09:00:00Z"),
|
|
693
|
+
false,
|
|
694
|
+
);
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
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);
|
|
701
|
+
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
|
+
});
|
|
711
|
+
|
|
712
|
+
assert.ok(!removed.ok);
|
|
713
|
+
assert.equal(removed.error.code, "NotRecurring");
|
|
714
|
+
assert.equal(store.objects.size, 1);
|
|
715
|
+
});
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
describe("createCalendarEventFor", () => {
|
|
719
|
+
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
|
+
});
|
|
729
|
+
|
|
730
|
+
assert.ok(!created.ok);
|
|
731
|
+
assert.equal(created.error.code, "NotFound");
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
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);
|
|
738
|
+
assert.ok(calendar);
|
|
739
|
+
|
|
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
|
+
});
|
|
746
|
+
|
|
747
|
+
assert.ok(!created.ok);
|
|
748
|
+
assert.equal(created.error.code, "BackwardsEnd");
|
|
749
|
+
assert.equal(store.objects.size, 0);
|
|
750
|
+
});
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
describe("readScope", () => {
|
|
754
|
+
it("reads an absent scope as the whole series", () => {
|
|
755
|
+
const scope = readScope(undefined);
|
|
756
|
+
assert.ok(scope.ok);
|
|
757
|
+
assert.equal(scope.value, RecurrenceScope.All);
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
it("refuses a scope it does not recognise rather than widening it", () => {
|
|
761
|
+
const scope = readScope("Everything");
|
|
762
|
+
assert.ok(!scope.ok);
|
|
763
|
+
assert.equal(scope.error.code, "InvalidScope");
|
|
764
|
+
});
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
describe("readCollectionTimezone", () => {
|
|
768
|
+
it("accepts an IANA name and an absent one", () => {
|
|
769
|
+
assert.deepEqual(readCollectionTimezone("Europe/Amsterdam"), {
|
|
770
|
+
ok: true,
|
|
771
|
+
value: "Europe/Amsterdam",
|
|
772
|
+
});
|
|
773
|
+
assert.deepEqual(readCollectionTimezone(undefined), {
|
|
774
|
+
ok: true,
|
|
775
|
+
value: "",
|
|
776
|
+
});
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
it("refuses a zone this server cannot resolve", () => {
|
|
780
|
+
const timezone = readCollectionTimezone("Pacific Standard Time");
|
|
781
|
+
assert.ok(!timezone.ok);
|
|
782
|
+
assert.equal(timezone.error.code, "UnknownTimeZone");
|
|
783
|
+
});
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
describe("updateCalendarFor", () => {
|
|
787
|
+
const allDay = {
|
|
788
|
+
summary: "Leave",
|
|
789
|
+
start: "2026-06-01",
|
|
790
|
+
end: "2026-06-02",
|
|
791
|
+
allDay: true,
|
|
792
|
+
};
|
|
793
|
+
|
|
794
|
+
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);
|
|
798
|
+
assert.ok(calendar);
|
|
799
|
+
|
|
800
|
+
const updated = await updateCalendarFor(
|
|
801
|
+
deps,
|
|
802
|
+
ACCOUNT,
|
|
803
|
+
calendar.calendarId,
|
|
804
|
+
{
|
|
805
|
+
timezone: "Pacific Standard Time",
|
|
806
|
+
},
|
|
807
|
+
);
|
|
808
|
+
|
|
809
|
+
assert.ok(!updated.ok);
|
|
810
|
+
assert.equal(updated.error.code, "UnknownTimeZone");
|
|
811
|
+
assert.equal(store.collections.get(calendar.calendarId)?.timezone, "");
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
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);
|
|
818
|
+
assert.ok(calendar);
|
|
819
|
+
const created = await createCalendarEventFor(deps, ACCOUNT, {
|
|
820
|
+
calendarId: calendar.calendarId,
|
|
821
|
+
...allDay,
|
|
822
|
+
});
|
|
823
|
+
assert.ok(created.ok, JSON.stringify(created));
|
|
824
|
+
assert.equal(
|
|
825
|
+
store.occurrences.get(created.value.calendarObjectId)?.[0]?.startAt,
|
|
826
|
+
"2026-06-01T00:00:00Z",
|
|
827
|
+
"an all-day event in a calendar with no zone starts at midnight UTC",
|
|
828
|
+
);
|
|
829
|
+
|
|
830
|
+
const updated = await updateCalendarFor(
|
|
831
|
+
deps,
|
|
832
|
+
ACCOUNT,
|
|
833
|
+
calendar.calendarId,
|
|
834
|
+
{
|
|
835
|
+
timezone: "America/New_York",
|
|
836
|
+
},
|
|
837
|
+
);
|
|
838
|
+
|
|
839
|
+
assert.ok(updated.ok, JSON.stringify(updated));
|
|
840
|
+
assert.equal(
|
|
841
|
+
store.occurrences.get(created.value.calendarObjectId)?.[0]?.startAt,
|
|
842
|
+
"2026-06-01T04:00:00Z",
|
|
843
|
+
"and midnight in the calendar's new zone once it has one",
|
|
844
|
+
);
|
|
845
|
+
assert.ok(
|
|
846
|
+
updated.value.syncSequence > calendar.syncSequence,
|
|
847
|
+
"a syncing client is told the calendar changed",
|
|
848
|
+
);
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
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);
|
|
855
|
+
assert.ok(calendar);
|
|
856
|
+
const created = await createCalendarEventFor(deps, ACCOUNT, {
|
|
857
|
+
calendarId: calendar.calendarId,
|
|
858
|
+
...allDay,
|
|
859
|
+
});
|
|
860
|
+
assert.ok(created.ok);
|
|
861
|
+
const before = store.collections.get(calendar.calendarId)?.syncSequence;
|
|
862
|
+
|
|
863
|
+
const updated = await updateCalendarFor(
|
|
864
|
+
deps,
|
|
865
|
+
ACCOUNT,
|
|
866
|
+
calendar.calendarId,
|
|
867
|
+
{
|
|
868
|
+
displayName: "Renamed",
|
|
869
|
+
},
|
|
870
|
+
);
|
|
871
|
+
|
|
872
|
+
assert.ok(updated.ok);
|
|
873
|
+
assert.equal(updated.value.displayName, "Renamed");
|
|
874
|
+
assert.equal(updated.value.syncSequence, before);
|
|
875
|
+
});
|
|
876
|
+
});
|