@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.
- package/package.json +1 -1
- package/src/handlers/calendar-event.test.ts +568 -0
- package/src/handlers/calendar-sqlite-fixture.ts +64 -0
- package/src/handlers/calendar-suggestion.test.ts +281 -2
- package/src/handlers/calendar.test.ts +409 -548
- package/src/handlers/system-update.test.ts +34 -1
- package/src/openapi-spec.test.ts +44 -0
|
@@ -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 {
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
57
|
-
*
|
|
58
|
-
*
|
|
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
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
114
|
-
accountConfigId
|
|
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
|
-
|
|
125
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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
|
-
|
|
150
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
236
|
-
|
|
92
|
+
collections(): Promise<CalendarCollectionItem[]> {
|
|
93
|
+
return client.calendarCollection.listByAccountConfig(this.accountConfigId);
|
|
237
94
|
}
|
|
238
95
|
|
|
239
|
-
async
|
|
240
|
-
|
|
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
|
-
|
|
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
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
|
111
|
+
return objects;
|
|
299
112
|
}
|
|
300
113
|
|
|
301
|
-
|
|
114
|
+
object(
|
|
302
115
|
calendarId: string,
|
|
303
116
|
calendarObjectId: string,
|
|
304
117
|
): Promise<CalendarObjectItem | null> {
|
|
305
|
-
|
|
306
|
-
return found && found.calendarId === calendarId ? found : null;
|
|
118
|
+
return client.calendarObject.find(calendarId, calendarObjectId);
|
|
307
119
|
}
|
|
308
120
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
|
452
|
-
|
|
453
|
-
|
|
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,
|
|
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
|
|
167
|
+
const account = anAccount();
|
|
484
168
|
|
|
485
|
-
const calendars = await listCalendarsFor(
|
|
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
|
|
180
|
+
const account = anAccount();
|
|
494
181
|
|
|
495
182
|
const reads = await Promise.all(
|
|
496
|
-
Array.from({ length: 8 }, () =>
|
|
183
|
+
Array.from({ length: 8 }, () =>
|
|
184
|
+
listCalendarsFor(account.deps(), account.accountConfigId),
|
|
185
|
+
),
|
|
497
186
|
);
|
|
498
187
|
|
|
499
|
-
assert.equal(
|
|
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
|
|
508
|
-
const deps =
|
|
509
|
-
await createCalendarFor(deps,
|
|
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,
|
|
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
|
-
|
|
210
|
+
const held = await account.collections();
|
|
211
|
+
assert.equal(held.length, 1);
|
|
522
212
|
assert.equal(
|
|
523
|
-
[
|
|
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
|
|
220
|
+
const account = anAccount();
|
|
531
221
|
|
|
532
|
-
const created = await createCalendarFor(
|
|
533
|
-
|
|
534
|
-
|
|
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
|
|
545
|
-
const deps =
|
|
546
|
-
const [fallback] = await listCalendarsFor(deps,
|
|
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(
|
|
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(
|
|
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
|
|
558
|
-
const deps = eventDeps(
|
|
559
|
-
const created = await createCalendarFor(deps,
|
|
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(
|
|
565
|
-
|
|
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
|
-
|
|
271
|
+
account.accountConfigId,
|
|
570
272
|
created.value.calendarId,
|
|
571
273
|
);
|
|
572
274
|
|
|
573
275
|
assert.ok(removed.ok);
|
|
574
|
-
assert.
|
|
575
|
-
assert.
|
|
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
|
|
580
|
-
const deps =
|
|
581
|
-
const [mine] = await listCalendarsFor(deps,
|
|
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
|
-
|
|
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
|
|
655
|
-
const deps = eventDeps(
|
|
656
|
-
const [calendar] = await listCalendarsFor(deps,
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
698
|
-
const deps = eventDeps(
|
|
699
|
-
const [calendar] = await listCalendarsFor(deps,
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
734
|
-
const deps = eventDeps(
|
|
735
|
-
const [calendar] = await listCalendarsFor(deps,
|
|
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
|
-
|
|
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
|
|
759
|
-
const deps = eventDeps(
|
|
760
|
-
const [calendar] = await listCalendarsFor(deps,
|
|
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(
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
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.
|
|
774
|
-
assert.
|
|
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
|
|
779
|
-
const deps = eventDeps(
|
|
780
|
-
const [calendar] = await listCalendarsFor(deps,
|
|
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(
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
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(
|
|
795
|
-
const rows =
|
|
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
|
|
805
|
-
const deps = eventDeps(
|
|
806
|
-
const [calendar] = await listCalendarsFor(deps,
|
|
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(
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
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(
|
|
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
|
|
827
|
-
const deps = eventDeps(
|
|
828
|
-
|
|
829
|
-
const created = await createCalendarEventFor(
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
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
|
|
842
|
-
const deps = eventDeps(
|
|
843
|
-
const [calendar] = await listCalendarsFor(deps,
|
|
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(
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
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.
|
|
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
|
|
902
|
-
const deps =
|
|
903
|
-
const [calendar] = await listCalendarsFor(deps,
|
|
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
|
-
|
|
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(
|
|
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
|
|
922
|
-
const deps = eventDeps(
|
|
923
|
-
const [calendar] = await listCalendarsFor(deps,
|
|
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(
|
|
926
|
-
|
|
927
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
959
|
-
const deps = eventDeps(
|
|
960
|
-
const [calendar] = await listCalendarsFor(deps,
|
|
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(
|
|
963
|
-
|
|
964
|
-
|
|
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 =
|
|
713
|
+
const before = (await account.collection(calendar.calendarId))
|
|
714
|
+
?.syncSequence;
|
|
968
715
|
|
|
969
716
|
const updated = await updateCalendarFor(
|
|
970
717
|
deps,
|
|
971
|
-
|
|
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
|
+
});
|