@remit/calendar-service 0.0.17 → 0.0.19

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.
Files changed (73) hide show
  1. package/dist/accept.d.ts +71 -0
  2. package/dist/accept.js +159 -0
  3. package/dist/build.d.ts +79 -0
  4. package/dist/build.js +196 -0
  5. package/dist/correct.d.ts +4 -0
  6. package/dist/correct.js +22 -0
  7. package/dist/errors.d.ts +56 -0
  8. package/dist/errors.js +1 -0
  9. package/dist/etag.d.ts +9 -0
  10. package/{src/etag.ts → dist/etag.js} +1 -3
  11. package/dist/expand.d.ts +73 -0
  12. package/dist/expand.js +188 -0
  13. package/dist/feed.d.ts +81 -0
  14. package/dist/feed.js +157 -0
  15. package/dist/fixtures.d.ts +19 -0
  16. package/dist/fixtures.js +37 -0
  17. package/dist/index.d.ts +16 -0
  18. package/dist/index.js +15 -0
  19. package/dist/memory-store.d.ts +105 -0
  20. package/dist/memory-store.js +319 -0
  21. package/dist/parse.d.ts +39 -0
  22. package/dist/parse.js +87 -0
  23. package/dist/project.d.ts +30 -0
  24. package/dist/project.js +57 -0
  25. package/dist/put.d.ts +52 -0
  26. package/dist/put.js +78 -0
  27. package/dist/reopen.d.ts +14 -0
  28. package/dist/reopen.js +92 -0
  29. package/dist/scope.d.ts +70 -0
  30. package/dist/scope.js +687 -0
  31. package/dist/subscribe.d.ts +96 -0
  32. package/dist/subscribe.js +298 -0
  33. package/dist/suggest.d.ts +74 -0
  34. package/dist/suggest.js +138 -0
  35. package/dist/time.d.ts +76 -0
  36. package/dist/time.js +164 -0
  37. package/dist/window.d.ts +85 -0
  38. package/dist/window.js +161 -0
  39. package/package.json +40 -35
  40. package/src/accept.test.ts +0 -403
  41. package/src/accept.ts +0 -229
  42. package/src/build.test.ts +0 -113
  43. package/src/build.ts +0 -311
  44. package/src/correct.test.ts +0 -173
  45. package/src/correct.ts +0 -40
  46. package/src/errors.ts +0 -62
  47. package/src/etag.test.ts +0 -54
  48. package/src/expand.test.ts +0 -361
  49. package/src/expand.ts +0 -303
  50. package/src/feed.test.ts +0 -334
  51. package/src/feed.ts +0 -203
  52. package/src/feeds/google-secret-address.ics +0 -99
  53. package/src/fixtures.ts +0 -52
  54. package/src/index.ts +0 -125
  55. package/src/memory-store.ts +0 -404
  56. package/src/parse.test.ts +0 -264
  57. package/src/parse.ts +0 -138
  58. package/src/project.test.ts +0 -247
  59. package/src/project.ts +0 -122
  60. package/src/put.test.ts +0 -320
  61. package/src/put.ts +0 -123
  62. package/src/reopen.test.ts +0 -531
  63. package/src/reopen.ts +0 -190
  64. package/src/scope.test.ts +0 -1421
  65. package/src/scope.ts +0 -1003
  66. package/src/subscribe.test.ts +0 -578
  67. package/src/subscribe.ts +0 -420
  68. package/src/suggest.test.ts +0 -376
  69. package/src/suggest.ts +0 -244
  70. package/src/time.ts +0 -260
  71. package/src/window.test.ts +0 -648
  72. package/src/window.ts +0 -281
  73. package/tsconfig.json +0 -8
@@ -1,404 +0,0 @@
1
- import type {
2
- CalendarCollectionItem,
3
- CalendarEventIndexItem,
4
- CalendarFeedTokenItem,
5
- CalendarObjectItem,
6
- CalendarOccurrenceInput,
7
- CalendarSuggestionItem,
8
- CreateCalendarCollectionInput,
9
- ICalendarCollectionRepository,
10
- ICalendarEventIndexRepository,
11
- ICalendarFeedTokenRepository,
12
- ICalendarObjectRepository,
13
- ICalendarSuggestionRepository,
14
- ICalendarUnitOfWork,
15
- PutCalendarFeedTokenInput,
16
- PutCalendarObjectInput as PutCalendarObjectRow,
17
- PutCalendarSuggestionInput,
18
- SettleCalendarSuggestionInput,
19
- UpdateCalendarCollectionInput,
20
- } from "@remit/data-ports";
21
- import { NotFoundError } from "@remit/data-ports/errors";
22
- import {
23
- deriveCalendarFeedTokenId,
24
- deriveCalendarId,
25
- deriveCalendarObjectId,
26
- deriveCalendarSuggestionId,
27
- normalizeCalendarUrlSegment,
28
- } from "@remit/data-ports/id";
29
- import { CalendarSuggestionState } from "@remit/domain-enums";
30
-
31
- // The class the port's conformance suite requires a repository to throw for an
32
- // absent row, so a caller that branches on it — the calendar feed answers 404 to
33
- // a token whose collection is gone — behaves the same against this double as
34
- // against a real one.
35
- export class MissingRow extends NotFoundError {}
36
-
37
- /**
38
- * A pass-through unit of work over plain maps — the shape the port documents
39
- * for a backend with no cross-entity transaction. It proves the write path
40
- * calls what it should, in the order it should; that the three writes stand or
41
- * fall together is proven against a real transaction, in
42
- * drizzle-service's calendar-put.sqlite.test.ts.
43
- */
44
- export class MemoryCalendarStore implements ICalendarUnitOfWork {
45
- readonly collections = new Map<string, CalendarCollectionItem>();
46
- readonly objects = new Map<string, CalendarObjectItem>();
47
- readonly occurrences = new Map<string, CalendarEventIndexItem[]>();
48
- readonly suggestions = new Map<string, CalendarSuggestionItem>();
49
- readonly feedTokens = new Map<string, CalendarFeedTokenItem>();
50
-
51
- private readonly collectionRepo: ICalendarCollectionRepository = {
52
- create: async (input: CreateCalendarCollectionInput) => {
53
- const urlSegment = normalizeCalendarUrlSegment(input.urlSegment);
54
- const calendarId = deriveCalendarId(input.accountConfigId, urlSegment);
55
- const existing = this.collections.get(calendarId);
56
- if (existing) return existing;
57
- const now = Date.now();
58
- const collection: CalendarCollectionItem = {
59
- calendarId,
60
- accountConfigId: input.accountConfigId,
61
- urlSegment,
62
- displayName: input.displayName,
63
- color: input.color ?? "Cal1",
64
- componentSet: input.componentSet ?? "VeventOnly",
65
- source: input.source ?? "UserCreated",
66
- timezone: input.timezone ?? "",
67
- syncSequence: 0,
68
- subscriptionUrl: input.subscriptionUrl ?? "",
69
- subscriptionEnabled: input.subscriptionEnabled ?? false,
70
- subscriptionCheckedAt: 0,
71
- subscriptionFetchedAt: 0,
72
- subscriptionError: "",
73
- createdAt: now,
74
- updatedAt: now,
75
- };
76
- this.collections.set(calendarId, collection);
77
- return collection;
78
- },
79
- get: async (_accountConfigId: string, calendarId: string) => {
80
- const collection = this.collections.get(calendarId);
81
- if (!collection) throw new MissingRow(calendarId);
82
- return collection;
83
- },
84
- update: async (
85
- _accountConfigId: string,
86
- calendarId: string,
87
- input: UpdateCalendarCollectionInput,
88
- ) => {
89
- const collection = this.collections.get(calendarId);
90
- if (!collection) throw new MissingRow(calendarId);
91
- const updated = { ...collection, ...input };
92
- this.collections.set(calendarId, updated);
93
- return updated;
94
- },
95
- delete: async (_accountConfigId: string, calendarId: string) => {
96
- this.collections.delete(calendarId);
97
- },
98
- listByAccountConfig: async (accountConfigId: string) =>
99
- [...this.collections.values()].filter(
100
- (collection) => collection.accountConfigId === accountConfigId,
101
- ),
102
- createExclusive: async (input: CreateCalendarCollectionInput) => {
103
- const calendarId = deriveCalendarId(
104
- input.accountConfigId,
105
- input.urlSegment,
106
- );
107
- if (this.collections.has(calendarId)) return null;
108
- return this.collectionRepo.create(input);
109
- },
110
- listEnabledSubscriptions: async () =>
111
- [...this.collections.values()].filter(
112
- (collection) =>
113
- collection.source === "Subscribed" && collection.subscriptionEnabled,
114
- ),
115
- findByUrlSegment: async (accountConfigId: string, urlSegment: string) =>
116
- this.collections.get(deriveCalendarId(accountConfigId, urlSegment)) ??
117
- null,
118
- bumpSyncSequence: async (_accountConfigId: string, calendarId: string) => {
119
- const collection = this.collections.get(calendarId);
120
- if (!collection) throw new MissingRow(calendarId);
121
- const bumped = {
122
- ...collection,
123
- syncSequence: collection.syncSequence + 1,
124
- updatedAt: Date.now(),
125
- };
126
- this.collections.set(calendarId, bumped);
127
- return bumped.syncSequence;
128
- },
129
- };
130
-
131
- private readonly objectRepo: ICalendarObjectRepository = {
132
- put: async (input: PutCalendarObjectRow) => {
133
- const calendarObjectId = deriveCalendarObjectId(
134
- input.calendarId,
135
- input.resourceName,
136
- );
137
- const now = Date.now();
138
- const object: CalendarObjectItem = {
139
- ...input,
140
- calendarObjectId,
141
- createdAt: this.objects.get(calendarObjectId)?.createdAt ?? now,
142
- updatedAt: now,
143
- };
144
- this.objects.set(calendarObjectId, object);
145
- return object;
146
- },
147
- listIncompleteExpansions: async (calendarId: string, instant: string) =>
148
- [...this.objects.values()].filter(
149
- (object) =>
150
- object.calendarId === calendarId &&
151
- object.expandedThrough !== "" &&
152
- object.expandedThrough < instant,
153
- ),
154
- find: async (_calendarId: string, calendarObjectId: string) =>
155
- this.objects.get(calendarObjectId) ?? null,
156
- get: async (_calendarId: string, calendarObjectId: string) => {
157
- const object = this.objects.get(calendarObjectId);
158
- if (!object) throw new MissingRow(calendarObjectId);
159
- return object;
160
- },
161
- delete: async (_calendarId: string, calendarObjectId: string) => {
162
- this.objects.delete(calendarObjectId);
163
- },
164
- findByResourceName: async (calendarId: string, resourceName: string) =>
165
- this.objects.get(deriveCalendarObjectId(calendarId, resourceName)) ??
166
- null,
167
- findByUid: async (calendarId: string, icalUid: string) =>
168
- [...this.objects.values()].find(
169
- (object) =>
170
- object.calendarId === calendarId && object.icalUid === icalUid,
171
- ) ?? null,
172
- listByUidPrefix: async (calendarId: string, prefix: string) =>
173
- [...this.objects.values()].filter(
174
- (object) =>
175
- object.calendarId === calendarId && object.icalUid.startsWith(prefix),
176
- ),
177
- listByCalendar: async (calendarId: string) =>
178
- [...this.objects.values()].filter(
179
- (object) => object.calendarId === calendarId,
180
- ),
181
- listChangedSince: async (calendarId: string, syncSequence: number) =>
182
- [...this.objects.values()]
183
- .filter(
184
- (object) =>
185
- object.calendarId === calendarId &&
186
- object.syncSequence > syncSequence,
187
- )
188
- .sort((left, right) => left.syncSequence - right.syncSequence),
189
- };
190
-
191
- private readonly eventIndexRepo: ICalendarEventIndexRepository = {
192
- replaceForObject: async (
193
- calendarId: string,
194
- calendarObjectId: string,
195
- occurrences: CalendarOccurrenceInput[],
196
- ) => {
197
- const now = Date.now();
198
- this.occurrences.set(
199
- calendarObjectId,
200
- occurrences.map((occurrence) => ({
201
- ...occurrence,
202
- calendarId,
203
- calendarObjectId,
204
- createdAt: now,
205
- updatedAt: now,
206
- })),
207
- );
208
- },
209
- deleteForObject: async (_calendarId: string, calendarObjectId: string) => {
210
- this.occurrences.delete(calendarObjectId);
211
- },
212
- listForObject: async (_calendarId: string, calendarObjectId: string) =>
213
- this.occurrences.get(calendarObjectId) ?? [],
214
- listByStartRange: async (
215
- calendarId: string,
216
- startAt: string,
217
- endAt: string,
218
- ) =>
219
- [...this.occurrences.values()]
220
- .flat()
221
- .filter(
222
- (row) =>
223
- row.calendarId === calendarId &&
224
- row.startAt >= startAt &&
225
- row.startAt < endAt,
226
- )
227
- .sort((left, right) => left.startAt.localeCompare(right.startAt)),
228
- };
229
-
230
- private readonly suggestionRepo: ICalendarSuggestionRepository = {
231
- put: async (input: PutCalendarSuggestionInput) => {
232
- const suggestionId = deriveCalendarSuggestionId(
233
- input.messageId,
234
- input.bodyPartId,
235
- input.icalUid,
236
- );
237
- const now = Date.now();
238
- const existing = this.suggestions.get(suggestionId);
239
- const suggestion: CalendarSuggestionItem = {
240
- ...input,
241
- suggestionId,
242
- state: existing?.state ?? CalendarSuggestionState.Pending,
243
- acceptedCalendarObjectId: existing?.acceptedCalendarObjectId ?? "",
244
- supersededByMessageId: existing?.supersededByMessageId ?? "",
245
- createdAt: existing?.createdAt ?? now,
246
- updatedAt: now,
247
- };
248
- this.suggestions.set(suggestionId, suggestion);
249
- return suggestion;
250
- },
251
- get: async (accountConfigId: string, suggestionId: string) => {
252
- const suggestion = this.suggestions.get(suggestionId);
253
- if (!suggestion || suggestion.accountConfigId !== accountConfigId) {
254
- throw new MissingRow(suggestionId);
255
- }
256
- return suggestion;
257
- },
258
- listByAcceptedCalendarObjects: async (
259
- accountConfigId: string,
260
- calendarObjectIds: string[],
261
- ) =>
262
- [...this.suggestions.values()].filter(
263
- (suggestion) =>
264
- suggestion.accountConfigId === accountConfigId &&
265
- calendarObjectIds.includes(suggestion.acceptedCalendarObjectId),
266
- ),
267
- listByMessage: async (accountConfigId: string, messageId: string) =>
268
- [...this.suggestions.values()].filter(
269
- (suggestion) =>
270
- suggestion.accountConfigId === accountConfigId &&
271
- suggestion.messageId === messageId,
272
- ),
273
- listByState: async (
274
- accountConfigId: string,
275
- state: CalendarSuggestionItem["state"],
276
- options: {
277
- endsAfter: string;
278
- limit?: number;
279
- continuationToken?: string;
280
- },
281
- ) => ({
282
- items: [...this.suggestions.values()].filter(
283
- (suggestion) =>
284
- suggestion.accountConfigId === accountConfigId &&
285
- suggestion.state === state &&
286
- suggestion.endsAtUtc >= options.endsAfter,
287
- ),
288
- continuationToken: undefined,
289
- }),
290
- settle: async (
291
- accountConfigId: string,
292
- suggestionId: string,
293
- input: SettleCalendarSuggestionInput,
294
- ) => {
295
- const suggestion = this.suggestions.get(suggestionId);
296
- if (!suggestion || suggestion.accountConfigId !== accountConfigId) {
297
- throw new MissingRow(suggestionId);
298
- }
299
- const settled = { ...suggestion, ...input, updatedAt: Date.now() };
300
- this.suggestions.set(suggestionId, settled);
301
- return settled;
302
- },
303
- supersedeIfPending: async (
304
- accountConfigId: string,
305
- suggestionId: string,
306
- supersededByMessageId: string,
307
- ) => {
308
- const suggestion = this.suggestions.get(suggestionId);
309
- if (
310
- !suggestion ||
311
- suggestion.accountConfigId !== accountConfigId ||
312
- suggestion.state !== CalendarSuggestionState.Pending
313
- ) {
314
- return null;
315
- }
316
- const retired = {
317
- ...suggestion,
318
- state: CalendarSuggestionState.Superseded,
319
- acceptedCalendarObjectId: "",
320
- supersededByMessageId,
321
- updatedAt: Date.now(),
322
- };
323
- this.suggestions.set(suggestionId, retired);
324
- return retired;
325
- },
326
- repointSuperseded: async (
327
- accountConfigId: string,
328
- suggestionId: string,
329
- supersededByMessageId: string,
330
- ) => {
331
- const suggestion = this.suggestions.get(suggestionId);
332
- if (
333
- !suggestion ||
334
- suggestion.accountConfigId !== accountConfigId ||
335
- suggestion.state !== CalendarSuggestionState.Superseded
336
- ) {
337
- return null;
338
- }
339
- const repointed = {
340
- ...suggestion,
341
- supersededByMessageId,
342
- updatedAt: Date.now(),
343
- };
344
- this.suggestions.set(suggestionId, repointed);
345
- return repointed;
346
- },
347
- };
348
-
349
- private readonly feedTokenRepo: ICalendarFeedTokenRepository = {
350
- put: async (input: PutCalendarFeedTokenInput) => {
351
- const feedTokenId = deriveCalendarFeedTokenId(input.calendarId);
352
- const now = Date.now();
353
- const existing = this.feedTokens.get(feedTokenId);
354
- const token: CalendarFeedTokenItem = {
355
- feedTokenId,
356
- accountConfigId: input.accountConfigId,
357
- calendarId: input.calendarId,
358
- tokenHash: input.tokenHash,
359
- createdAt: existing?.createdAt ?? now,
360
- rotatedAt: existing ? now : 0,
361
- updatedAt: now,
362
- };
363
- this.feedTokens.set(feedTokenId, token);
364
- return token;
365
- },
366
- findByCalendar: async (accountConfigId: string, calendarId: string) => {
367
- const token = this.feedTokens.get(deriveCalendarFeedTokenId(calendarId));
368
- if (!token || token.accountConfigId !== accountConfigId) return null;
369
- return token;
370
- },
371
- findByTokenHash: async (tokenHash: string) =>
372
- [...this.feedTokens.values()].find(
373
- (token) => token.tokenHash === tokenHash,
374
- ) ?? null,
375
- delete: async (accountConfigId: string, calendarId: string) => {
376
- const feedTokenId = deriveCalendarFeedTokenId(calendarId);
377
- const token = this.feedTokens.get(feedTokenId);
378
- if (!token || token.accountConfigId !== accountConfigId) return;
379
- this.feedTokens.delete(feedTokenId);
380
- },
381
- };
382
-
383
- get calendarSuggestion(): ICalendarSuggestionRepository {
384
- return this.suggestionRepo;
385
- }
386
-
387
- transaction<T>(
388
- fn: (repos: {
389
- calendarCollection: ICalendarCollectionRepository;
390
- calendarObject: ICalendarObjectRepository;
391
- calendarEventIndex: ICalendarEventIndexRepository;
392
- calendarSuggestion: ICalendarSuggestionRepository;
393
- calendarFeedToken: ICalendarFeedTokenRepository;
394
- }) => Promise<T>,
395
- ): Promise<T> {
396
- return fn({
397
- calendarCollection: this.collectionRepo,
398
- calendarObject: this.objectRepo,
399
- calendarEventIndex: this.eventIndexRepo,
400
- calendarSuggestion: this.suggestionRepo,
401
- calendarFeedToken: this.feedTokenRepo,
402
- });
403
- }
404
- }
package/src/parse.test.ts DELETED
@@ -1,264 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { describe, it } from "node:test";
3
- import { asLf, ical, singleEvent } from "./fixtures.js";
4
- import { parseCalendar, serializeCalendar } from "./parse.js";
5
-
6
- const parsed = async (icalData: string) => {
7
- const result = await parseCalendar(icalData);
8
- assert.ok(result.ok, `expected a parse, got ${JSON.stringify(result)}`);
9
- return result.value;
10
- };
11
-
12
- const refusalCode = async (icalData: string) => {
13
- const result = await parseCalendar(icalData);
14
- assert.ok(!result.ok, "expected a refusal");
15
- return result.error;
16
- };
17
-
18
- describe("parseCalendar", () => {
19
- it("reads the master event and its UID", async () => {
20
- const calendar = await parsed(
21
- singleEvent("DTSTART:20260826T090000Z", "DTEND:20260826T100000Z"),
22
- );
23
-
24
- assert.equal(calendar.uid, "fixture@example.com");
25
- assert.equal(calendar.master.name, "vevent");
26
- assert.deepEqual(calendar.overrides, []);
27
- });
28
-
29
- it("separates the overrides from the master", async () => {
30
- const calendar = await parsed(
31
- ical(
32
- "BEGIN:VCALENDAR",
33
- "VERSION:2.0",
34
- "BEGIN:VEVENT",
35
- "UID:series@example.com",
36
- "DTSTART:20260826T090000Z",
37
- "RRULE:FREQ=WEEKLY;COUNT=3",
38
- "END:VEVENT",
39
- "BEGIN:VEVENT",
40
- "UID:series@example.com",
41
- "RECURRENCE-ID:20260902T090000Z",
42
- "DTSTART:20260902T110000Z",
43
- "END:VEVENT",
44
- "END:VCALENDAR",
45
- ),
46
- );
47
-
48
- assert.equal(calendar.overrides.length, 1);
49
- assert.ok(!calendar.master.hasProperty("recurrence-id"));
50
- });
51
-
52
- it("refuses bytes that are not iCalendar", async () => {
53
- assert.equal(
54
- (await refusalCode("this is not a calendar")).code,
55
- "MalformedIcalendar",
56
- );
57
- });
58
-
59
- it("refuses empty bytes as a value, naming nothing rather than crashing", async () => {
60
- // A suggestion read out of prose carries no iCalendar at all, and the
61
- // accept path reaches this with `""`. ical.js reads empty bytes as a
62
- // component with no name, so naming what arrived would throw here — a
63
- // crash on ordinary input rather than the refusal every caller renders.
64
- const refusal = await refusalCode("");
65
-
66
- assert.equal(refusal.code, "NotACalendar");
67
- assert.match(refusal.message, /found nothing/);
68
- });
69
-
70
- it("refuses a document whose root is not a VCALENDAR", async () => {
71
- assert.equal(
72
- (
73
- await refusalCode(
74
- ical("BEGIN:VCARD", "VERSION:4.0", "FN:Someone", "END:VCARD"),
75
- )
76
- ).code,
77
- "NotACalendar",
78
- );
79
- });
80
-
81
- it("refuses a component this collection does not store, naming it", async () => {
82
- const error = await refusalCode(
83
- ical(
84
- "BEGIN:VCALENDAR",
85
- "VERSION:2.0",
86
- "BEGIN:VTODO",
87
- "UID:todo@example.com",
88
- "SUMMARY:Buy milk",
89
- "END:VTODO",
90
- "END:VCALENDAR",
91
- ),
92
- );
93
-
94
- assert.equal(error.code, "UnsupportedComponent");
95
- assert.match(error.message, /VTODO/);
96
- });
97
-
98
- it("refuses a VCALENDAR with no VEVENT", async () => {
99
- assert.equal(
100
- (
101
- await refusalCode(
102
- ical("BEGIN:VCALENDAR", "VERSION:2.0", "END:VCALENDAR"),
103
- )
104
- ).code,
105
- "NoEvent",
106
- );
107
- });
108
-
109
- it("refuses overrides with no event to override", async () => {
110
- const error = await refusalCode(
111
- ical(
112
- "BEGIN:VCALENDAR",
113
- "VERSION:2.0",
114
- "BEGIN:VEVENT",
115
- "UID:orphan@example.com",
116
- "RECURRENCE-ID:20260902T090000Z",
117
- "DTSTART:20260902T110000Z",
118
- "END:VEVENT",
119
- "END:VCALENDAR",
120
- ),
121
- );
122
-
123
- assert.equal(error.code, "NoMasterEvent");
124
- });
125
-
126
- it("refuses two unrelated events in one resource", async () => {
127
- const error = await refusalCode(
128
- ical(
129
- "BEGIN:VCALENDAR",
130
- "VERSION:2.0",
131
- "BEGIN:VEVENT",
132
- "UID:one@example.com",
133
- "DTSTART:20260826T090000Z",
134
- "END:VEVENT",
135
- "BEGIN:VEVENT",
136
- "UID:two@example.com",
137
- "DTSTART:20260827T090000Z",
138
- "END:VEVENT",
139
- "END:VCALENDAR",
140
- ),
141
- );
142
-
143
- assert.equal(error.code, "MultipleMasterEvents");
144
- });
145
-
146
- it("refuses a VEVENT with no UID", async () => {
147
- const error = await refusalCode(
148
- ical(
149
- "BEGIN:VCALENDAR",
150
- "VERSION:2.0",
151
- "BEGIN:VEVENT",
152
- "DTSTART:20260826T090000Z",
153
- "END:VEVENT",
154
- "END:VCALENDAR",
155
- ),
156
- );
157
-
158
- assert.equal(error.code, "MissingUid");
159
- });
160
-
161
- it("refuses an override that belongs to a different event", async () => {
162
- const error = await refusalCode(
163
- ical(
164
- "BEGIN:VCALENDAR",
165
- "VERSION:2.0",
166
- "BEGIN:VEVENT",
167
- "UID:series@example.com",
168
- "DTSTART:20260826T090000Z",
169
- "RRULE:FREQ=WEEKLY;COUNT=3",
170
- "END:VEVENT",
171
- "BEGIN:VEVENT",
172
- "UID:stranger@example.com",
173
- "RECURRENCE-ID:20260902T090000Z",
174
- "DTSTART:20260902T110000Z",
175
- "END:VEVENT",
176
- "END:VCALENDAR",
177
- ),
178
- );
179
-
180
- assert.equal(error.code, "MismatchedUid");
181
- });
182
-
183
- it("refuses a VEVENT with no DTSTART", async () => {
184
- assert.equal(
185
- (await refusalCode(singleEvent("SUMMARY:Undated"))).code,
186
- "MissingDtStart",
187
- );
188
- });
189
- });
190
-
191
- describe("serializeCalendar", () => {
192
- it("keeps the line endings RFC 5545 requires", async () => {
193
- const source = singleEvent(
194
- "DTSTART:20260826T090000Z",
195
- "DTEND:20260826T100000Z",
196
- );
197
-
198
- const serialized = serializeCalendar((await parsed(source)).component);
199
-
200
- assert.ok(serialized.includes("\r\n"));
201
- assert.doesNotMatch(
202
- serialized,
203
- /(?<!\r)\n/,
204
- "every line break is a CRLF, with no bare LF left behind",
205
- );
206
- });
207
-
208
- it("reads an LF-only resource and writes it back as CRLF", async () => {
209
- // RFC 5545 says CRLF; real files arrive both ways, and a parser that only
210
- // recognises CRLF reads an LF-only resource as one enormous line.
211
- const source = asLf(
212
- singleEvent("DTSTART:20260826T090000Z", "DTEND:20260826T100000Z"),
213
- );
214
- assert.ok(!source.includes("\r"), "the fixture is LF-only");
215
-
216
- const calendar = await parsed(source);
217
- const serialized = serializeCalendar(calendar.component);
218
-
219
- assert.equal(calendar.uid, "fixture@example.com");
220
- assert.ok(calendar.master.hasProperty("dtstart"));
221
- assert.doesNotMatch(serialized, /(?<!\r)\n/);
222
- });
223
-
224
- it("round-trips a resource losslessly, unknown properties included", async () => {
225
- const source = singleEvent(
226
- "DTSTART:20260826T090000Z",
227
- "DTEND:20260826T100000Z",
228
- "SUMMARY:Quarterly review",
229
- "X-APPLE-TRAVEL-ADVISORY-BEHAVIOR:AUTOMATIC",
230
- "X-MICROSOFT-CDO-BUSYSTATUS:BUSY",
231
- "ATTENDEE;CN=Someone;X-NUM-GUESTS=0:mailto:someone@example.com",
232
- );
233
-
234
- const once = serializeCalendar((await parsed(source)).component);
235
- const twice = serializeCalendar((await parsed(once)).component);
236
-
237
- assert.equal(twice, once);
238
- for (const line of [
239
- "X-APPLE-TRAVEL-ADVISORY-BEHAVIOR:AUTOMATIC",
240
- "X-MICROSOFT-CDO-BUSYSTATUS:BUSY",
241
- "X-NUM-GUESTS=0",
242
- "SUMMARY:Quarterly review",
243
- ]) {
244
- assert.ok(once.includes(line), `${line} did not survive the round trip`);
245
- }
246
- });
247
-
248
- it("keeps a whole unknown subcomponent of the event", async () => {
249
- const source = singleEvent(
250
- "DTSTART:20260826T090000Z",
251
- "DTEND:20260826T100000Z",
252
- "BEGIN:VALARM",
253
- "ACTION:DISPLAY",
254
- "DESCRIPTION:Reminder",
255
- "TRIGGER:-PT15M",
256
- "END:VALARM",
257
- );
258
-
259
- const serialized = serializeCalendar((await parsed(source)).component);
260
-
261
- assert.ok(serialized.includes("BEGIN:VALARM"));
262
- assert.ok(serialized.includes("TRIGGER:-PT15M"));
263
- });
264
- });