@remit/calendar-service 0.0.13 → 0.0.14
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/correct.test.ts +173 -0
- package/src/correct.ts +40 -0
- package/src/errors.ts +1 -0
- package/src/index.ts +1 -0
package/package.json
CHANGED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type { CalendarSuggestionItem } from "@remit/data-ports";
|
|
4
|
+
import {
|
|
5
|
+
CalendarInviteAnswer,
|
|
6
|
+
CalendarSuggestionSource,
|
|
7
|
+
CalendarSuggestionState,
|
|
8
|
+
ZoneCertainty,
|
|
9
|
+
} from "@remit/domain-enums";
|
|
10
|
+
import { acceptCalendarSuggestion } from "./accept.js";
|
|
11
|
+
import type { CalendarEventFields } from "./build.js";
|
|
12
|
+
import { correctCalendarSuggestion } from "./correct.js";
|
|
13
|
+
import { ical } from "./fixtures.js";
|
|
14
|
+
import { MemoryCalendarStore } from "./memory-store.js";
|
|
15
|
+
import { provisionDefaultCalendar } from "./put.js";
|
|
16
|
+
import { recordCalendarSuggestion } from "./suggest.js";
|
|
17
|
+
|
|
18
|
+
const ACCOUNT_CONFIG_ID = "account-config-1";
|
|
19
|
+
const ATTENDEE = "user@example.test";
|
|
20
|
+
const UID = "invite-5120@example.test";
|
|
21
|
+
|
|
22
|
+
const invitation = ({
|
|
23
|
+
method = "REQUEST",
|
|
24
|
+
start = "DTSTART:20260901T080000Z",
|
|
25
|
+
end = "DTEND:20260901T090000Z",
|
|
26
|
+
}: {
|
|
27
|
+
method?: string;
|
|
28
|
+
start?: string;
|
|
29
|
+
end?: string;
|
|
30
|
+
}): string =>
|
|
31
|
+
ical(
|
|
32
|
+
"BEGIN:VCALENDAR",
|
|
33
|
+
"VERSION:2.0",
|
|
34
|
+
"PRODID:-//Example Corp//Scheduler//EN",
|
|
35
|
+
`METHOD:${method}`,
|
|
36
|
+
"BEGIN:VEVENT",
|
|
37
|
+
`UID:${UID}`,
|
|
38
|
+
"DTSTAMP:20260801T090000Z",
|
|
39
|
+
"SEQUENCE:2",
|
|
40
|
+
start,
|
|
41
|
+
end,
|
|
42
|
+
"SUMMARY:Quarterly review",
|
|
43
|
+
"LOCATION:Room 4",
|
|
44
|
+
"ORGANIZER:mailto:organizer@example.test",
|
|
45
|
+
"ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:user@example.test",
|
|
46
|
+
"X-EXAMPLE-TICKET:AB-5120",
|
|
47
|
+
"END:VEVENT",
|
|
48
|
+
"END:VCALENDAR",
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const pendingIn = async (icalData: string) => {
|
|
52
|
+
const store = new MemoryCalendarStore();
|
|
53
|
+
const collection = await provisionDefaultCalendar(store, ACCOUNT_CONFIG_ID);
|
|
54
|
+
const recorded = await recordCalendarSuggestion(store.calendarSuggestion, {
|
|
55
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
56
|
+
messageId: "message-1",
|
|
57
|
+
bodyPartId: "body-part-1",
|
|
58
|
+
source: CalendarSuggestionSource.IcalendarPart,
|
|
59
|
+
icalData,
|
|
60
|
+
timezone: "UTC",
|
|
61
|
+
});
|
|
62
|
+
assert.ok(recorded.ok);
|
|
63
|
+
return {
|
|
64
|
+
store,
|
|
65
|
+
calendarId: collection.calendarId,
|
|
66
|
+
suggestion: recorded.value.suggestion,
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const acceptCorrected = async (
|
|
71
|
+
store: MemoryCalendarStore,
|
|
72
|
+
calendarId: string,
|
|
73
|
+
suggestion: CalendarSuggestionItem,
|
|
74
|
+
patch: Partial<CalendarEventFields>,
|
|
75
|
+
) => {
|
|
76
|
+
const corrected = await correctCalendarSuggestion(suggestion, patch, "UTC");
|
|
77
|
+
if (!corrected.ok) return corrected;
|
|
78
|
+
return acceptCalendarSuggestion(store, {
|
|
79
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
80
|
+
calendarId,
|
|
81
|
+
suggestion: corrected.value,
|
|
82
|
+
attendee: ATTENDEE,
|
|
83
|
+
answer: CalendarInviteAnswer.Accepted,
|
|
84
|
+
});
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
describe("correctCalendarSuggestion", () => {
|
|
88
|
+
it("writes the corrected event and settles the card as accepted", async () => {
|
|
89
|
+
const { store, calendarId, suggestion } = await pendingIn(invitation({}));
|
|
90
|
+
|
|
91
|
+
const accepted = await acceptCorrected(store, calendarId, suggestion, {
|
|
92
|
+
summary: "Quarterly review, moved",
|
|
93
|
+
start: "2026-09-01T13:00:00+00:00",
|
|
94
|
+
end: "2026-09-01T14:30:00+00:00",
|
|
95
|
+
allDay: false,
|
|
96
|
+
timeZone: "",
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
assert.ok(accepted.ok);
|
|
100
|
+
const object = accepted.value.object;
|
|
101
|
+
assert.ok(object);
|
|
102
|
+
assert.equal(object.summary, "Quarterly review, moved");
|
|
103
|
+
assert.equal(Date.parse(object.dtStart), Date.parse("2026-09-01T13:00Z"));
|
|
104
|
+
assert.equal(Date.parse(object.dtEnd), Date.parse("2026-09-01T14:30Z"));
|
|
105
|
+
assert.equal(object.icalUid, UID);
|
|
106
|
+
assert.match(object.icalData, /LOCATION:Room 4/);
|
|
107
|
+
assert.match(object.icalData, /X-EXAMPLE-TICKET:AB-5120/);
|
|
108
|
+
assert.match(object.icalData, /PARTSTAT=ACCEPTED/);
|
|
109
|
+
|
|
110
|
+
const settled = await store.calendarSuggestion.get(
|
|
111
|
+
ACCOUNT_CONFIG_ID,
|
|
112
|
+
suggestion.suggestionId,
|
|
113
|
+
);
|
|
114
|
+
assert.equal(settled.state, CalendarSuggestionState.Accepted);
|
|
115
|
+
assert.equal(settled.acceptedCalendarObjectId, object.calendarObjectId);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("places an unresolvable zone on the clock the reader picked", async () => {
|
|
119
|
+
const { store, calendarId, suggestion } = await pendingIn(
|
|
120
|
+
invitation({
|
|
121
|
+
start: "DTSTART;TZID=Nowhere Standard Time:20260901T090000",
|
|
122
|
+
end: "DTEND;TZID=Nowhere Standard Time:20260901T100000",
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
125
|
+
assert.equal(suggestion.zoneCertainty, ZoneCertainty.Ambiguous);
|
|
126
|
+
|
|
127
|
+
const accepted = await acceptCorrected(store, calendarId, suggestion, {
|
|
128
|
+
start: "2026-09-01T09:00:00+02:00",
|
|
129
|
+
end: "2026-09-01T10:00:00+02:00",
|
|
130
|
+
allDay: false,
|
|
131
|
+
timeZone: "Europe/Amsterdam",
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
assert.ok(accepted.ok);
|
|
135
|
+
const object = accepted.value.object;
|
|
136
|
+
assert.ok(object);
|
|
137
|
+
assert.equal(object.zoneCertainty, ZoneCertainty.Explicit);
|
|
138
|
+
assert.equal(Date.parse(object.dtStart), Date.parse("2026-09-01T07:00Z"));
|
|
139
|
+
assert.match(
|
|
140
|
+
object.icalData,
|
|
141
|
+
/DTSTART;TZID=Europe\/Amsterdam:20260901T090000/,
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("refuses a cancellation and leaves the card waiting", async () => {
|
|
146
|
+
const { store, calendarId, suggestion } = await pendingIn(
|
|
147
|
+
invitation({ method: "CANCEL" }),
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
const refused = await acceptCorrected(store, calendarId, suggestion, {
|
|
151
|
+
summary: "Anything",
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
assert.equal(refused.ok, false);
|
|
155
|
+
assert.equal(store.objects.size, 0);
|
|
156
|
+
const untouched = await store.calendarSuggestion.get(
|
|
157
|
+
ACCOUNT_CONFIG_ID,
|
|
158
|
+
suggestion.suggestionId,
|
|
159
|
+
);
|
|
160
|
+
assert.equal(untouched.state, CalendarSuggestionState.Pending);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("refuses a correction the event cannot take and writes nothing", async () => {
|
|
164
|
+
const { store, calendarId, suggestion } = await pendingIn(invitation({}));
|
|
165
|
+
|
|
166
|
+
const refused = await acceptCorrected(store, calendarId, suggestion, {
|
|
167
|
+
start: "2026-09-01T13:00:00",
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
assert.equal(refused.ok, false);
|
|
171
|
+
assert.equal(store.objects.size, 0);
|
|
172
|
+
});
|
|
173
|
+
});
|
package/src/correct.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { CalendarSuggestionItem } from "@remit/data-ports";
|
|
2
|
+
import { CalendarInviteMethod, RecurrenceScope } from "@remit/domain-enums";
|
|
3
|
+
import type { CalendarEventFields } from "./build.js";
|
|
4
|
+
import { type CalendarResult, calendarFailure } from "./errors.js";
|
|
5
|
+
import { parseCalendar } from "./parse.js";
|
|
6
|
+
import { applyScopedUpdate } from "./scope.js";
|
|
7
|
+
|
|
8
|
+
export const correctCalendarSuggestion = async (
|
|
9
|
+
suggestion: CalendarSuggestionItem,
|
|
10
|
+
patch: Partial<CalendarEventFields>,
|
|
11
|
+
collectionTimezone: string,
|
|
12
|
+
): Promise<CalendarResult<CalendarSuggestionItem>> => {
|
|
13
|
+
if (suggestion.method === CalendarInviteMethod.Cancel) {
|
|
14
|
+
return calendarFailure(
|
|
15
|
+
"UneditableCancellation",
|
|
16
|
+
"a cancellation names no event to correct — add it or dismiss it as it is",
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const parsed = await parseCalendar(suggestion.icalData);
|
|
21
|
+
if (!parsed.ok) return parsed;
|
|
22
|
+
|
|
23
|
+
const edited = await applyScopedUpdate(
|
|
24
|
+
parsed.value,
|
|
25
|
+
collectionTimezone,
|
|
26
|
+
{ scope: RecurrenceScope.All, recurrenceId: "", followingUid: "" },
|
|
27
|
+
patch,
|
|
28
|
+
);
|
|
29
|
+
if (!edited.ok) return edited;
|
|
30
|
+
if (edited.value.kind !== "Replace") {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`an edit to the whole invitation resolved to a ${edited.value.kind}`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
ok: true,
|
|
38
|
+
value: { ...suggestion, icalData: edited.value.icalData },
|
|
39
|
+
};
|
|
40
|
+
};
|
package/src/errors.ts
CHANGED
|
@@ -36,6 +36,7 @@ export type CalendarValidationCode =
|
|
|
36
36
|
/** A RECURRENCE-ID naming no occurrence this series produces. */
|
|
37
37
|
| "UnknownOccurrence"
|
|
38
38
|
| "UnmovableRecurrenceRule"
|
|
39
|
+
| "UneditableCancellation"
|
|
39
40
|
/** A subscription address that is not an http, https or webcal URL. */
|
|
40
41
|
| "InvalidSubscriptionUrl";
|
|
41
42
|
|