@remit/calendar-service 0.0.18 → 0.0.20

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 -250
  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 -128
  55. package/src/memory-store.ts +0 -485
  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 -318
  61. package/src/put.ts +0 -133
  62. package/src/reopen.test.ts +0 -529
  63. package/src/reopen.ts +0 -199
  64. package/src/scope.test.ts +0 -1421
  65. package/src/scope.ts +0 -1003
  66. package/src/subscribe.test.ts +0 -582
  67. package/src/subscribe.ts +0 -433
  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
package/src/expand.ts DELETED
@@ -1,303 +0,0 @@
1
- import type { CalendarOccurrenceInput } from "@remit/data-ports";
2
- import ICAL from "ical.js";
3
- import type { ParsedCalendar } from "./parse.js";
4
- import { hasRecurrence, projectEventDisplay } from "./project.js";
5
- import {
6
- dtEndTzid,
7
- dtStartTzid,
8
- resolveTime,
9
- toUtcIso,
10
- tzidOf,
11
- } from "./time.js";
12
-
13
- /**
14
- * How far past a series' own start its occurrences are written out.
15
- *
16
- * A recurring event may have no end at all, so something has to bound the
17
- * write. Two years covers every view a client asks for today and keeps the
18
- * index one bounded write rather than an unbounded one; a resource whose series
19
- * runs past it is marked with `expandedThrough` and stays the business of the
20
- * live expansion that reads it.
21
- */
22
- export const CALENDAR_EXPANSION_HORIZON_DAYS = 730;
23
-
24
- /**
25
- * Ceiling on occurrences written for one resource, independent of the horizon.
26
- * A per-minute RRULE fits three quarters of a million instances inside two
27
- * years; the ceiling turns that into a marked, truncated index instead of a
28
- * write that takes the process down.
29
- */
30
- export const CALENDAR_EXPANSION_MAX_OCCURRENCES = 1000;
31
-
32
- /**
33
- * Ceiling on iterator steps a live window expansion may take.
34
- *
35
- * A window expansion walks the series from its own start, because a rule is
36
- * only meaningful from where it was anchored. A daily event written in 2005 is
37
- * a few thousand steps away from today, which is cheap; a per-minute one is
38
- * millions, which is a read that never returns. The cap turns the second case
39
- * into a short answer rather than a hung request.
40
- */
41
- export const CALENDAR_WINDOW_MAX_STEPS = 50_000;
42
-
43
- const HORIZON_MS = CALENDAR_EXPANSION_HORIZON_DAYS * 24 * 60 * 60 * 1000;
44
-
45
- export interface CalendarExpansion {
46
- occurrences: CalendarOccurrenceInput[];
47
- /**
48
- * The instant through which `occurrences` is complete, or `""` when it holds
49
- * the whole series.
50
- */
51
- expandedThrough: string;
52
- }
53
-
54
- const occurrenceOf = (
55
- collectionTimezone: string,
56
- recurrenceId: string,
57
- source: ICAL.Component,
58
- startDate: ICAL.Time,
59
- endDate: ICAL.Time,
60
- startTzid: string,
61
- endTzid: string,
62
- ): CalendarOccurrenceInput => {
63
- const start = resolveTime(startDate, startTzid, collectionTimezone);
64
- const end = resolveTime(endDate, endTzid, collectionTimezone);
65
- return {
66
- recurrenceId,
67
- startAt: start.isoUtc,
68
- endAt: end.isoUtc,
69
- allDay: start.isDate,
70
- ...projectEventDisplay(source),
71
- };
72
- };
73
-
74
- /**
75
- * The slot each override claims, canonicalized the way the iterator's slots
76
- * are, so the two can be compared at all.
77
- */
78
- export const overridesBySlot = (
79
- calendar: ParsedCalendar,
80
- collectionTimezone: string,
81
- ): Map<string, ICAL.Component> => {
82
- const bySlot = new Map<string, ICAL.Component>();
83
- for (const override of calendar.overrides) {
84
- const recurrenceId = override.getFirstPropertyValue("recurrence-id");
85
- if (!(recurrenceId instanceof ICAL.Time)) continue;
86
- const slot = resolveTime(
87
- recurrenceId,
88
- tzidOf(override.getFirstProperty("recurrence-id")),
89
- collectionTimezone,
90
- ).isoUtc;
91
- bySlot.set(slot, override);
92
- }
93
- return bySlot;
94
- };
95
-
96
- /** The window a walk collects occurrences in, and what bounds the walk. */
97
- interface WalkBounds {
98
- /** Occurrences starting before this are stepped past, not collected. */
99
- fromMs: number;
100
- /** The walk stops once a slot starts after this. */
101
- throughMs: number;
102
- maxOccurrences: number;
103
- maxSteps: number;
104
- }
105
-
106
- interface Walk {
107
- occurrences: CalendarOccurrenceInput[];
108
- /** Whether the walk stopped on a bound rather than on the series ending. */
109
- truncated: boolean;
110
- /** Start of the last slot the walk collected, or `""` when it collected none. */
111
- lastIteratedStart: string;
112
- }
113
-
114
- /**
115
- * Walks a recurring resource's occurrences between the bounds it is given.
116
- *
117
- * EXDATEs are ical.js's business — `ICAL.Event` skips them. Overrides are not
118
- * left entirely to it: the iterator yields the master's own RRULE and RDATE
119
- * slots, so an override whose RECURRENCE-ID names a slot the rule never
120
- * produces — the shape a client writes after moving one instance and then
121
- * editing the rule — would never be reached. Those are walked explicitly, so
122
- * every VEVENT in the resource is an occurrence somebody can find.
123
- */
124
- const walkOccurrences = (
125
- calendar: ParsedCalendar,
126
- collectionTimezone: string,
127
- bounds: WalkBounds,
128
- ): Walk => {
129
- const event = new ICAL.Event(calendar.master);
130
- for (const override of calendar.overrides) {
131
- event.relateException(override);
132
- }
133
-
134
- const overrideBySlot = overridesBySlot(calendar, collectionTimezone);
135
- const masterStartTzid = dtStartTzid(calendar.master);
136
- const occurrences: CalendarOccurrenceInput[] = [];
137
- const claimed = new Set<string>();
138
- const iterator = event.iterator();
139
- let truncated = false;
140
- let steps = 0;
141
- let lastIteratedStart = "";
142
-
143
- let next = iterator.next();
144
- while (next) {
145
- const slot = resolveTime(next, masterStartTzid, collectionTimezone);
146
- if (
147
- slot.instantMs > bounds.throughMs ||
148
- occurrences.length >= bounds.maxOccurrences ||
149
- steps >= bounds.maxSteps
150
- ) {
151
- truncated = true;
152
- break;
153
- }
154
- steps += 1;
155
- claimed.add(slot.isoUtc);
156
-
157
- if (slot.instantMs >= bounds.fromMs) {
158
- // An overridden slot hands back the override's own DTSTART and DTEND, so
159
- // the zones to read them in are the override's. A plain slot hands back
160
- // the rule's start and an end derived from the master's duration, both in
161
- // the master's start zone.
162
- const source = overrideBySlot.get(slot.isoUtc);
163
- const details = event.getOccurrenceDetails(next);
164
- occurrences.push(
165
- occurrenceOf(
166
- collectionTimezone,
167
- slot.isoUtc,
168
- source ?? calendar.master,
169
- details.startDate,
170
- details.endDate,
171
- dtStartTzid(source ?? calendar.master),
172
- source ? dtEndTzid(source) : masterStartTzid,
173
- ),
174
- );
175
- lastIteratedStart = occurrences[occurrences.length - 1]?.startAt ?? "";
176
- }
177
- next = iterator.next();
178
- }
179
-
180
- // Overrides the rule never reached. Kept regardless of the horizon: they are
181
- // a bounded, explicit list, and one moved instance is exactly the thing a
182
- // user goes looking for.
183
- for (const [slot, override] of overrideBySlot) {
184
- if (claimed.has(slot)) continue;
185
- const overrideEvent = new ICAL.Event(override);
186
- const occurrence = occurrenceOf(
187
- collectionTimezone,
188
- slot,
189
- override,
190
- overrideEvent.startDate,
191
- overrideEvent.endDate,
192
- dtStartTzid(override),
193
- dtEndTzid(override),
194
- );
195
- const startMs = Date.parse(occurrence.startAt);
196
- if (startMs < bounds.fromMs || startMs > bounds.throughMs) continue;
197
- occurrences.push(occurrence);
198
- }
199
-
200
- occurrences.sort((left, right) => left.startAt.localeCompare(right.startAt));
201
-
202
- return { occurrences, truncated, lastIteratedStart };
203
- };
204
-
205
- /** The one occurrence a resource that does not recur produces. */
206
- const singleOccurrence = (
207
- calendar: ParsedCalendar,
208
- collectionTimezone: string,
209
- ): CalendarOccurrenceInput => {
210
- const event = new ICAL.Event(calendar.master);
211
- return occurrenceOf(
212
- collectionTimezone,
213
- "",
214
- calendar.master,
215
- event.startDate,
216
- event.endDate,
217
- dtStartTzid(calendar.master),
218
- dtEndTzid(calendar.master),
219
- );
220
- };
221
-
222
- /**
223
- * Flattens a resource into the occurrence rows a date-range read returns.
224
- *
225
- * A non-recurring resource is one row under an empty `recurrenceId`. A
226
- * recurring one is a row per occurrence, each keyed by its RECURRENCE-ID slot
227
- * as a UTC instant — the same canonical form an override's own RECURRENCE-ID
228
- * resolves to, so an override lands on the occurrence it replaces instead of
229
- * beside it.
230
- *
231
- * The horizon is anchored at the series' own start, not at today: two years of
232
- * a series is a bounded write whatever a caller's clock says. A series running
233
- * past it comes back marked with `expandedThrough`, which is what tells a later
234
- * read to expand that resource live instead of trusting the index.
235
- */
236
- export const expandCalendar = (
237
- calendar: ParsedCalendar,
238
- collectionTimezone: string,
239
- ): CalendarExpansion => {
240
- if (!hasRecurrence(calendar)) {
241
- return {
242
- occurrences: [singleOccurrence(calendar, collectionTimezone)],
243
- expandedThrough: "",
244
- };
245
- }
246
-
247
- const event = new ICAL.Event(calendar.master);
248
- const seriesStart = resolveTime(
249
- event.startDate,
250
- dtStartTzid(calendar.master),
251
- collectionTimezone,
252
- ).instantMs;
253
-
254
- const walk = walkOccurrences(calendar, collectionTimezone, {
255
- fromMs: Number.NEGATIVE_INFINITY,
256
- throughMs: seriesStart + HORIZON_MS,
257
- maxOccurrences: CALENDAR_EXPANSION_MAX_OCCURRENCES,
258
- maxSteps: Number.POSITIVE_INFINITY,
259
- });
260
-
261
- // Every truncated walk is marked, including one that collected nothing — a
262
- // series whose first occurrence lands past the horizon. Its index is empty
263
- // from the series start, which is the honest floor to write; `""` would
264
- // claim the whole series is held and the live expansion would never run.
265
- return {
266
- occurrences: walk.occurrences,
267
- expandedThrough: walk.truncated
268
- ? toUtcIso(
269
- walk.lastIteratedStart
270
- ? Date.parse(walk.lastIteratedStart)
271
- : seriesStart,
272
- )
273
- : "",
274
- };
275
- };
276
-
277
- /**
278
- * The occurrences of a resource that fall inside one window, computed rather
279
- * than read.
280
- *
281
- * This is what serves a view of a series whose stored index stops before the
282
- * window — an open-ended weekly meeting written years ago, whose horizon ran
283
- * out long before today. The alternative is extending the index on read, which
284
- * turns every view of an old series into an unbounded write; a bounded
285
- * computation that persists nothing is the cheaper half of that trade, and the
286
- * one that cannot leave anything behind to be wrong later.
287
- */
288
- export const expandCalendarWindow = (
289
- calendar: ParsedCalendar,
290
- collectionTimezone: string,
291
- window: { fromMs: number; toMs: number },
292
- ): CalendarOccurrenceInput[] => {
293
- if (!hasRecurrence(calendar)) {
294
- return [singleOccurrence(calendar, collectionTimezone)];
295
- }
296
-
297
- return walkOccurrences(calendar, collectionTimezone, {
298
- fromMs: window.fromMs,
299
- throughMs: window.toMs,
300
- maxOccurrences: CALENDAR_EXPANSION_MAX_OCCURRENCES,
301
- maxSteps: CALENDAR_WINDOW_MAX_STEPS,
302
- }).occurrences;
303
- };
package/src/feed.test.ts DELETED
@@ -1,334 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { describe, it } from "node:test";
3
- import type {
4
- CalendarCollectionItem,
5
- CalendarObjectItem,
6
- } from "@remit/data-ports";
7
- import {
8
- buildCalendarFeed,
9
- CALENDAR_FEED_TOKEN_BYTES,
10
- calendarFeedIsUnchanged,
11
- calendarFeedIsUnmodifiedSince,
12
- hashCalendarFeedToken,
13
- isCalendarFeedToken,
14
- mintCalendarFeedToken,
15
- readCalendarFeedToken,
16
- redactCalendarFeedPath,
17
- } from "./feed.js";
18
- import { AMSTERDAM_VTIMEZONE, ical, singleEvent } from "./fixtures.js";
19
-
20
- const collection = (
21
- overrides: Partial<CalendarCollectionItem> = {},
22
- ): CalendarCollectionItem =>
23
- ({
24
- calendarId: "cal-1",
25
- accountConfigId: "acc-1",
26
- urlSegment: "work",
27
- displayName: "Work",
28
- color: "Cal1",
29
- componentSet: "VeventOnly",
30
- source: "UserCreated",
31
- timezone: "",
32
- syncSequence: 3,
33
- createdAt: 1_700_000_000_000,
34
- updatedAt: 1_700_000_000_000,
35
- ...overrides,
36
- }) as CalendarCollectionItem;
37
-
38
- const object = (
39
- resourceName: string,
40
- icalData: string,
41
- updatedAt = 1_700_000_100_000,
42
- ): CalendarObjectItem =>
43
- ({
44
- calendarObjectId: `obj-${resourceName}`,
45
- calendarId: "cal-1",
46
- resourceName,
47
- icalData,
48
- updatedAt,
49
- }) as CalendarObjectItem;
50
-
51
- /** Content lines as a subscriber reads them, with RFC 5545 3.1 folding undone. */
52
- const unfolded = (icalData: string): string =>
53
- icalData.replace(/\r\n[ \t]/g, "");
54
-
55
- describe("a feed token", () => {
56
- it("is base64url over the declared number of random bytes", () => {
57
- const minted = mintCalendarFeedToken();
58
-
59
- assert.equal(
60
- Buffer.from(minted.token, "base64url").length,
61
- CALENDAR_FEED_TOKEN_BYTES,
62
- );
63
- assert.ok(isCalendarFeedToken(minted.token));
64
- assert.equal(minted.tokenHash, hashCalendarFeedToken(minted.token));
65
- assert.equal(minted.tokenHash.length, 64);
66
- assert.equal(
67
- minted.tokenHash.includes(minted.token),
68
- false,
69
- "the stored value is a digest, not the secret",
70
- );
71
- });
72
-
73
- it("is never the same twice", () => {
74
- const minted = new Set(
75
- Array.from({ length: 64 }, () => mintCalendarFeedToken().token),
76
- );
77
-
78
- assert.equal(minted.size, 64);
79
- });
80
-
81
- it("refuses anything that is not the shape of one", () => {
82
- for (const candidate of [
83
- "",
84
- "short",
85
- "a".repeat(42),
86
- "a".repeat(44),
87
- `${"a".repeat(42)}/`,
88
- `${"a".repeat(42)}.`,
89
- "../../etc/passwd",
90
- ]) {
91
- assert.equal(isCalendarFeedToken(candidate), false, candidate);
92
- }
93
- });
94
- });
95
-
96
- describe("a feed address", () => {
97
- it("reads back the token it was written from", () => {
98
- const minted = mintCalendarFeedToken();
99
-
100
- assert.equal(
101
- readCalendarFeedToken(`/feeds/calendar/${minted.token}.ics`),
102
- minted.token,
103
- );
104
- });
105
-
106
- it("is not a feed address without the path and the suffix", () => {
107
- assert.equal(readCalendarFeedToken("/feeds/calendar/abc"), null);
108
- assert.equal(readCalendarFeedToken("/calendars/abc.ics"), null);
109
- assert.equal(readCalendarFeedToken("/feeds/calendar/a/b.ics"), null);
110
- });
111
- });
112
-
113
- describe("the calendar a feed serves", () => {
114
- it("names itself from the collection and terminates its last line", () => {
115
- const feed = buildCalendarFeed(collection({ displayName: "Team" }), [
116
- object(
117
- "a.ics",
118
- singleEvent("SUMMARY:Stand-up", "DTSTART:20260907T090000Z"),
119
- ),
120
- ]);
121
-
122
- assert.match(feed.icalData, /^BEGIN:VCALENDAR\r\n/);
123
- assert.match(feed.icalData, /X-WR-CALNAME:Team\r\n/);
124
- assert.match(feed.icalData, /END:VCALENDAR\r\n$/);
125
- assert.match(feed.icalData, /SUMMARY:Stand-up/);
126
- });
127
-
128
- it("escapes the separators a name may carry", () => {
129
- const feed = buildCalendarFeed(
130
- collection({ displayName: "Work, Home; C:\\shared" }),
131
- [],
132
- );
133
-
134
- assert.match(
135
- unfolded(feed.icalData),
136
- /X-WR-CALNAME:Work\\, Home\\; C:\\\\shared\r\n/,
137
- );
138
- });
139
-
140
- it("cannot be made to smuggle a component through its name", () => {
141
- const feed = buildCalendarFeed(
142
- collection({
143
- displayName:
144
- "Work\r\nBEGIN:VEVENT\r\nUID:injected\r\nDTSTART:20250101T000000Z\r\nSUMMARY:pwned\r\nEND:VEVENT",
145
- }),
146
- [
147
- object(
148
- "a.ics",
149
- singleEvent("SUMMARY:Stand-up", "DTSTART:20260907T090000Z"),
150
- ),
151
- ],
152
- );
153
- const served = unfolded(feed.icalData);
154
-
155
- assert.equal(served.match(/^BEGIN:VEVENT\r\n/gm)?.length, 1);
156
- assert.doesNotMatch(served, /^UID:injected/m);
157
- assert.doesNotMatch(served, /^SUMMARY:pwned/m);
158
- assert.match(served, /X-WR-CALNAME:Work\\nBEGIN:VEVENT\\nUID:injected/);
159
- });
160
-
161
- it("leaves no bare carriage return in what it serves", () => {
162
- const feed = buildCalendarFeed(
163
- collection({ displayName: "Work\rHome" }),
164
- [],
165
- );
166
-
167
- assert.match(unfolded(feed.icalData), /X-WR-CALNAME:Work\\nHome\r\n/);
168
- assert.doesNotMatch(feed.icalData, /\r(?!\n)/);
169
- });
170
-
171
- it("carries the collection's zone so a client reads floating times the same way", () => {
172
- const feed = buildCalendarFeed(
173
- collection({ timezone: "Europe/Amsterdam" }),
174
- [],
175
- );
176
-
177
- assert.match(feed.icalData, /X-WR-TIMEZONE:Europe\/Amsterdam\r\n/);
178
- });
179
-
180
- it("keeps the rule rather than the occurrences it would produce", () => {
181
- const feed = buildCalendarFeed(collection(), [
182
- object(
183
- "a.ics",
184
- singleEvent(
185
- "SUMMARY:Weekly",
186
- "DTSTART:20260907T090000Z",
187
- "RRULE:FREQ=WEEKLY;COUNT=5",
188
- ),
189
- ),
190
- ]);
191
-
192
- assert.equal(feed.icalData.match(/BEGIN:VEVENT/g)?.length, 1);
193
- assert.match(feed.icalData, /RRULE:FREQ=WEEKLY;COUNT=5/);
194
- });
195
-
196
- it("carries one copy of a VTIMEZONE two resources both declare", () => {
197
- const withZone = (uid: string) =>
198
- ical(
199
- "BEGIN:VCALENDAR",
200
- "VERSION:2.0",
201
- "PRODID:-//Remit//Calendar Tests//EN",
202
- ...AMSTERDAM_VTIMEZONE,
203
- "BEGIN:VEVENT",
204
- `UID:${uid}`,
205
- "DTSTAMP:20260801T090000Z",
206
- "DTSTART;TZID=Europe/Amsterdam:20260907T090000",
207
- "END:VEVENT",
208
- "END:VCALENDAR",
209
- );
210
-
211
- const feed = buildCalendarFeed(collection(), [
212
- object("a.ics", withZone("a@example.com")),
213
- object("b.ics", withZone("b@example.com")),
214
- ]);
215
-
216
- assert.equal(feed.icalData.match(/BEGIN:VTIMEZONE/g)?.length, 1);
217
- assert.equal(feed.icalData.match(/BEGIN:VEVENT/g)?.length, 2);
218
- });
219
-
220
- it("declares one VERSION however many resources it gathered", () => {
221
- const feed = buildCalendarFeed(collection(), [
222
- object("a.ics", singleEvent("DTSTART:20260907T090000Z")),
223
- object("b.ics", singleEvent("DTSTART:20260908T090000Z")),
224
- ]);
225
-
226
- assert.equal(feed.icalData.match(/^VERSION:/gm)?.length, 1);
227
- assert.equal(feed.icalData.match(/^PRODID:/gm)?.length, 1);
228
- });
229
-
230
- it("moves its tag when the bytes change and holds it when they do not", () => {
231
- const one = object("a.ics", singleEvent("DTSTART:20260907T090000Z"));
232
- const two = object("b.ics", singleEvent("DTSTART:20260908T090000Z"));
233
-
234
- const first = buildCalendarFeed(collection(), [one]);
235
- const again = buildCalendarFeed(collection(), [one]);
236
- const grown = buildCalendarFeed(collection(), [one, two]);
237
- const renamed = buildCalendarFeed(collection({ displayName: "Team" }), [
238
- one,
239
- ]);
240
-
241
- assert.equal(again.etag, first.etag);
242
- assert.notEqual(grown.etag, first.etag);
243
- assert.notEqual(renamed.etag, first.etag);
244
- });
245
-
246
- it("carries no modification time of its own", () => {
247
- // The newest surviving event is not when the calendar last changed: a
248
- // delete removes the newest one and leaves every survivor older than the
249
- // change. The collection's own timestamp is what the feed serves, and it
250
- // belongs to the store rather than to these bytes (issue #1067).
251
- const feed = buildCalendarFeed(collection({ updatedAt: 500 }), [
252
- object("a.ics", singleEvent("DTSTART:20260907T090000Z"), 900),
253
- ]);
254
-
255
- assert.deepEqual(Object.keys(feed).sort(), ["etag", "icalData"]);
256
- });
257
- });
258
-
259
- describe("a conditional poll", () => {
260
- it("is unchanged for the tag it holds, quoted, weak or in a list", () => {
261
- for (const header of ['"abc"', "abc", 'W/"abc"', '"other", "abc"', "*"]) {
262
- assert.ok(calendarFeedIsUnchanged(header, "abc"), String(header));
263
- }
264
- });
265
-
266
- it("is changed with no header, an empty one, or somebody else's tag", () => {
267
- for (const header of [undefined, "", '"other"', 'W/"other"']) {
268
- assert.equal(
269
- calendarFeedIsUnchanged(header, "abc"),
270
- false,
271
- String(header),
272
- );
273
- }
274
- });
275
- });
276
-
277
- describe("a feed path in a log line", () => {
278
- it("keeps the route and drops the token", () => {
279
- const minted = mintCalendarFeedToken();
280
-
281
- const redacted = redactCalendarFeedPath(
282
- `/feeds/calendar/${minted.token}.ics`,
283
- );
284
-
285
- assert.equal(redacted.includes(minted.token), false);
286
- assert.equal(redacted, "/feeds/calendar/<redacted>.ics");
287
- });
288
-
289
- it("leaves a path that carries no token alone", () => {
290
- for (const path of [
291
- "/calendars/abc",
292
- "/feeds/calendar/no-suffix",
293
- "/health",
294
- ]) {
295
- assert.equal(redactCalendarFeedPath(path), path, path);
296
- }
297
- });
298
- });
299
-
300
- describe("a poll carrying a date", () => {
301
- const lastModified = Date.UTC(2026, 8, 7, 9, 0, 0);
302
-
303
- it("is unmodified for the second it was last written in", () => {
304
- // Last-Modified went out truncated to the second, so the value that comes
305
- // back is 750ms behind the stored timestamp and still means "the copy I
306
- // have is the one you served".
307
- assert.ok(
308
- calendarFeedIsUnmodifiedSince(
309
- new Date(lastModified).toUTCString(),
310
- lastModified + 750,
311
- ),
312
- );
313
- });
314
-
315
- it("is modified once the calendar moves into a later second", () => {
316
- assert.equal(
317
- calendarFeedIsUnmodifiedSince(
318
- new Date(lastModified).toUTCString(),
319
- lastModified + 1000,
320
- ),
321
- false,
322
- );
323
- });
324
-
325
- it("is not a condition when the date is absent or unreadable", () => {
326
- for (const header of [undefined, "", "whenever"]) {
327
- assert.equal(
328
- calendarFeedIsUnmodifiedSince(header, lastModified),
329
- false,
330
- String(header),
331
- );
332
- }
333
- });
334
- });