@oneuptime/common 12.0.29 → 12.0.30
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/Models/DatabaseModels/Index.ts +11 -0
- package/Models/DatabaseModels/OnCallDutyPolicySchedule.ts +43 -0
- package/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.ts +662 -0
- package/Models/DatabaseModels/ProjectOnCallCalendarFeed.ts +584 -0
- package/Models/DatabaseModels/UserOnCallCalendarFeed.ts +546 -0
- package/Models/DatabaseModels/UserOnCallShiftReminder.ts +231 -0
- package/Models/DatabaseModels/UserOnCallShiftReminderLog.ts +331 -0
- package/Server/API/OnCallCalendarAPI.ts +2194 -0
- package/Server/EnvironmentConfig.ts +138 -0
- package/Server/Infrastructure/OnCallCalendarFeedCache.ts +1050 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.ts +253 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
- package/Server/Middleware/OnCallCalendarFeedRateLimit.ts +487 -0
- package/Server/Services/Index.ts +12 -0
- package/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.ts +32 -0
- package/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.ts +260 -0
- package/Server/Services/OnCallDutyPolicyScheduleLayerService.ts +70 -3
- package/Server/Services/OnCallDutyPolicyScheduleLayerUserService.ts +138 -3
- package/Server/Services/OnCallDutyPolicyScheduleService.ts +1465 -134
- package/Server/Services/OnCallDutyPolicyUserOverrideService.ts +167 -31
- package/Server/Services/ProjectOnCallCalendarFeedService.ts +192 -0
- package/Server/Services/TeamMemberService.ts +489 -1
- package/Server/Services/TeamService.ts +50 -0
- package/Server/Services/UserNotificationSettingService.ts +44 -1
- package/Server/Services/UserOnCallCalendarFeedService.ts +159 -0
- package/Server/Services/UserOnCallShiftReminderLogService.ts +151 -0
- package/Server/Services/UserOnCallShiftReminderService.ts +112 -0
- package/Server/Utils/OnCall/CalendarFeedToken.ts +241 -0
- package/Server/Utils/OnCall/OnCallCalendarFeedRenderer.ts +1488 -0
- package/Server/Utils/OnCall/OnCallCalendarFeedUrls.ts +230 -0
- package/Server/Utils/OnCall/OnCallShiftChangeListeners.ts +227 -0
- package/Server/Utils/OnCall/OnCallShiftMaterializer.ts +1127 -0
- package/Server/Utils/OnCall/OnCallShiftReminderListener.ts +174 -0
- package/Server/Utils/OnCall/OnCallShiftReminderRunner.ts +2584 -0
- package/Server/Utils/Response.ts +134 -0
- package/Server/Utils/StartServer.ts +10 -0
- package/Server/Utils/Telemetry/AppMetrics.ts +51 -0
- package/Tests/App/Dashboard/OnCallCalendarFeedLinks.test.tsx +229 -0
- package/Tests/App/Dashboard/OnCallCalendarFeedPlanGate.test.tsx +489 -0
- package/Tests/App/Dashboard/OnCallCalendarFeedSideMenus.test.tsx +149 -0
- package/Tests/App/Dashboard/OnCallCalendarFeedUtil.test.ts +855 -0
- package/Tests/App/Dashboard/OnCallPersonalCalendarFeed.test.tsx +1209 -0
- package/Tests/App/Dashboard/OnCallSharedCalendarFeedCard.test.tsx +1019 -0
- package/Tests/App/Dashboard/OnCallShiftRemindersAndUpcomingShifts.test.tsx +655 -0
- package/Tests/App/Dashboard/UserSettingsSetupChecklistModel.test.ts +73 -0
- package/Tests/Models/OnCallCalendarFeedModels.test.ts +642 -0
- package/Tests/Models/OnCallDutyPolicyScheduleCalendarFeed.test.ts +288 -0
- package/Tests/Models/OnCallDutyPolicyScheduleShiftConfigVersion.test.ts +88 -0
- package/Tests/Models/UserOnCallShiftReminderModels.test.ts +368 -0
- package/Tests/Server/API/Helpers.ts +8 -0
- package/Tests/Server/API/OnCallCalendarAPI.test.ts +4461 -0
- package/Tests/Server/EnvironmentConfigOnCallCalendarFeed.test.ts +357 -0
- package/Tests/Server/Infrastructure/OnCallCalendarFeedCache.test.ts +1589 -0
- package/Tests/Server/Infrastructure/Postgres/AddOnCallCalendarFeedsMigration.test.ts +369 -0
- package/Tests/Server/Middleware/OnCallCalendarFeedRateLimit.test.ts +1365 -0
- package/Tests/Server/Services/FeedRetentionConsistency.test.ts +70 -0
- package/Tests/Server/Services/OnCallCalendarFeedServices.test.ts +1103 -0
- package/Tests/Server/Services/OnCallDutyPolicyScheduleResolver.test.ts +1037 -0
- package/Tests/Server/Services/OnCallShiftConfigPropagation.test.ts +1270 -0
- package/Tests/Server/Services/TeamDeleteMemberCleanup.test.ts +156 -0
- package/Tests/Server/Services/TeamMemberOnCallCleanup.test.ts +755 -0
- package/Tests/Server/Services/UserNotificationSettingShiftReminderDefaults.test.ts +267 -0
- package/Tests/Server/Services/UserOnCallShiftReminderServices.test.ts +583 -0
- package/Tests/Server/Types/Database/Permissions/OnCallCalendarFeedPermissions.test.ts +254 -0
- package/Tests/Server/Utils/OnCall/CalendarFeedToken.test.ts +467 -0
- package/Tests/Server/Utils/OnCall/OnCallCalendarFeedRenderer.test.ts +2142 -0
- package/Tests/Server/Utils/OnCall/OnCallCalendarFeedUrls.test.ts +278 -0
- package/Tests/Server/Utils/OnCall/OnCallResolverTestHarness.ts +440 -0
- package/Tests/Server/Utils/OnCall/OnCallShiftChangeListeners.test.ts +314 -0
- package/Tests/Server/Utils/OnCall/OnCallShiftMaterializer.test.ts +1502 -0
- package/Tests/Server/Utils/OnCall/OnCallShiftReminderListener.test.ts +491 -0
- package/Tests/Server/Utils/OnCall/OnCallShiftReminderRunner.test.ts +3088 -0
- package/Tests/Server/Utils/OnCall/OnCallShiftReminderTestHarness.ts +910 -0
- package/Tests/Server/Utils/ResponseSendCalendarResponse.test.ts +561 -0
- package/Tests/Server/Utils/StartServerEncryptionSecretWarning.test.ts +64 -0
- package/Tests/Server/Utils/Telemetry/AppMetrics.test.ts +29 -0
- package/Tests/Types/Calendar/ICalendar.test.ts +488 -0
- package/Tests/Types/NotificationSetting/NotificationSettingEventType.test.ts +53 -0
- package/Tests/Types/OnCallDutyPolicy/CalendarFeedTestFixtures.ts +310 -0
- package/Tests/Types/OnCallDutyPolicy/CalendarFeedWindow.test.ts +205 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilLayerMetaAndIterationCap.test.ts +458 -0
- package/Tests/Types/OnCallDutyPolicy/MaterializedShift.test.ts +197 -0
- package/Tests/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.test.ts +1986 -0
- package/Tests/Types/OnCallDutyPolicy/ScheduleShiftUtilGroupKey.test.ts +678 -0
- package/Tests/Types/OnCallDutyPolicy/ShiftSeamUtil.test.ts +417 -0
- package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +10 -0
- package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +2 -0
- package/Types/Calendar/ICalendar.ts +442 -0
- package/Types/Email/EmailTemplateType.ts +2 -0
- package/Types/NotificationSetting/NotificationSettingEventType.ts +4 -0
- package/Types/OnCallDutyPolicy/CalendarFeedWindow.ts +140 -0
- package/Types/OnCallDutyPolicy/Layer.ts +190 -35
- package/Types/OnCallDutyPolicy/MaterializedShift.ts +274 -0
- package/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.ts +1415 -0
- package/Types/OnCallDutyPolicy/ScheduleShiftUtil.ts +159 -4
- package/Types/OnCallDutyPolicy/ShiftSeamUtil.ts +106 -0
- package/build/dist/Models/DatabaseModels/Index.js +11 -0
- package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js +44 -0
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js.map +1 -1
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.js +699 -0
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.js.map +1 -0
- package/build/dist/Models/DatabaseModels/ProjectOnCallCalendarFeed.js +620 -0
- package/build/dist/Models/DatabaseModels/ProjectOnCallCalendarFeed.js.map +1 -0
- package/build/dist/Models/DatabaseModels/UserOnCallCalendarFeed.js +585 -0
- package/build/dist/Models/DatabaseModels/UserOnCallCalendarFeed.js.map +1 -0
- package/build/dist/Models/DatabaseModels/UserOnCallShiftReminder.js +247 -0
- package/build/dist/Models/DatabaseModels/UserOnCallShiftReminder.js.map +1 -0
- package/build/dist/Models/DatabaseModels/UserOnCallShiftReminderLog.js +355 -0
- package/build/dist/Models/DatabaseModels/UserOnCallShiftReminderLog.js.map +1 -0
- package/build/dist/Server/API/OnCallCalendarAPI.js +1371 -0
- package/build/dist/Server/API/OnCallCalendarAPI.js.map +1 -0
- package/build/dist/Server/EnvironmentConfig.js +102 -0
- package/build/dist/Server/EnvironmentConfig.js.map +1 -1
- package/build/dist/Server/Infrastructure/OnCallCalendarFeedCache.js +663 -0
- package/build/dist/Server/Infrastructure/OnCallCalendarFeedCache.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.js +103 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Middleware/OnCallCalendarFeedRateLimit.js +342 -0
- package/build/dist/Server/Middleware/OnCallCalendarFeedRateLimit.js.map +1 -0
- package/build/dist/Server/Services/Index.js +12 -0
- package/build/dist/Server/Services/Index.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js +20 -0
- package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.js +219 -0
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.js.map +1 -0
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerService.js +47 -3
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerUserService.js +112 -3
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerUserService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js +955 -9
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyUserOverrideService.js +93 -6
- package/build/dist/Server/Services/OnCallDutyPolicyUserOverrideService.js.map +1 -1
- package/build/dist/Server/Services/ProjectOnCallCalendarFeedService.js +157 -0
- package/build/dist/Server/Services/ProjectOnCallCalendarFeedService.js.map +1 -0
- package/build/dist/Server/Services/TeamMemberService.js +396 -1
- package/build/dist/Server/Services/TeamMemberService.js.map +1 -1
- package/build/dist/Server/Services/TeamService.js +43 -0
- package/build/dist/Server/Services/TeamService.js.map +1 -1
- package/build/dist/Server/Services/UserNotificationSettingService.js +26 -1
- package/build/dist/Server/Services/UserNotificationSettingService.js.map +1 -1
- package/build/dist/Server/Services/UserOnCallCalendarFeedService.js +140 -0
- package/build/dist/Server/Services/UserOnCallCalendarFeedService.js.map +1 -0
- package/build/dist/Server/Services/UserOnCallShiftReminderLogService.js +116 -0
- package/build/dist/Server/Services/UserOnCallShiftReminderLogService.js.map +1 -0
- package/build/dist/Server/Services/UserOnCallShiftReminderService.js +92 -0
- package/build/dist/Server/Services/UserOnCallShiftReminderService.js.map +1 -0
- package/build/dist/Server/Utils/OnCall/CalendarFeedToken.js +161 -0
- package/build/dist/Server/Utils/OnCall/CalendarFeedToken.js.map +1 -0
- package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedRenderer.js +983 -0
- package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedRenderer.js.map +1 -0
- package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedUrls.js +155 -0
- package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedUrls.js.map +1 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftChangeListeners.js +145 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftChangeListeners.js.map +1 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftMaterializer.js +752 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftMaterializer.js.map +1 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftReminderListener.js +92 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftReminderListener.js.map +1 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftReminderRunner.js +1786 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftReminderRunner.js.map +1 -0
- package/build/dist/Server/Utils/Response.js +97 -0
- package/build/dist/Server/Utils/Response.js.map +1 -1
- package/build/dist/Server/Utils/StartServer.js +9 -1
- package/build/dist/Server/Utils/StartServer.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/AppMetrics.js +41 -0
- package/build/dist/Server/Utils/Telemetry/AppMetrics.js.map +1 -1
- package/build/dist/Types/Calendar/ICalendar.js +290 -0
- package/build/dist/Types/Calendar/ICalendar.js.map +1 -0
- package/build/dist/Types/Email/EmailTemplateType.js +2 -0
- package/build/dist/Types/Email/EmailTemplateType.js.map +1 -1
- package/build/dist/Types/NotificationSetting/NotificationSettingEventType.js +3 -0
- package/build/dist/Types/NotificationSetting/NotificationSettingEventType.js.map +1 -1
- package/build/dist/Types/OnCallDutyPolicy/CalendarFeedWindow.js +97 -0
- package/build/dist/Types/OnCallDutyPolicy/CalendarFeedWindow.js.map +1 -0
- package/build/dist/Types/OnCallDutyPolicy/Layer.js +113 -19
- package/build/dist/Types/OnCallDutyPolicy/Layer.js.map +1 -1
- package/build/dist/Types/OnCallDutyPolicy/MaterializedShift.js +134 -0
- package/build/dist/Types/OnCallDutyPolicy/MaterializedShift.js.map +1 -0
- package/build/dist/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.js +800 -0
- package/build/dist/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.js.map +1 -0
- package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js +97 -3
- package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js.map +1 -1
- package/build/dist/Types/OnCallDutyPolicy/ShiftSeamUtil.js +77 -0
- package/build/dist/Types/OnCallDutyPolicy/ShiftSeamUtil.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
import ICalendar, {
|
|
2
|
+
ICALENDAR_LINE_BREAK,
|
|
3
|
+
ICALENDAR_MAX_LINE_OCTETS,
|
|
4
|
+
ICalendarCalendar,
|
|
5
|
+
ICalendarDocument,
|
|
6
|
+
ICalendarEvent,
|
|
7
|
+
ICalendarEventStatus,
|
|
8
|
+
ICalendarTransparency,
|
|
9
|
+
} from "../../../Types/Calendar/ICalendar";
|
|
10
|
+
import OneUptimeDate from "../../../Types/Date";
|
|
11
|
+
import BadDataException from "../../../Types/Exception/BadDataException";
|
|
12
|
+
|
|
13
|
+
const at: (iso: string) => Date = (iso: string): Date => {
|
|
14
|
+
return OneUptimeDate.fromString(iso);
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const CRLF: string = "\r\n";
|
|
18
|
+
|
|
19
|
+
function physicalLines(body: string): Array<string> {
|
|
20
|
+
const lines: Array<string> = body.split(CRLF);
|
|
21
|
+
if (lines[lines.length - 1] === "") {
|
|
22
|
+
lines.pop();
|
|
23
|
+
}
|
|
24
|
+
return lines;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function octets(text: string): number {
|
|
28
|
+
return Buffer.byteLength(text, "utf8");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// A lone surrogate means a UTF-16 pair (= one 4-octet UTF-8 char) was split.
|
|
32
|
+
const LONE_SURROGATE: RegExp =
|
|
33
|
+
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:^|[^\uD800-\uDBFF])[\uDC00-\uDFFF]/;
|
|
34
|
+
|
|
35
|
+
function baseCalendar(): ICalendarCalendar {
|
|
36
|
+
return {
|
|
37
|
+
productId: "-//Test//Feed//EN",
|
|
38
|
+
name: "Team",
|
|
39
|
+
description: "Desc",
|
|
40
|
+
timezone: "UTC",
|
|
41
|
+
refreshInterval: "PT1H",
|
|
42
|
+
lastModified: at("2026-08-01T10:00:00Z"),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function baseEvent(): ICalendarEvent {
|
|
47
|
+
return {
|
|
48
|
+
uid: "u1@test",
|
|
49
|
+
dtStamp: at("2026-08-01T10:00:00Z"),
|
|
50
|
+
lastModified: at("2026-08-01T10:00:00Z"),
|
|
51
|
+
sequence: 2,
|
|
52
|
+
start: at("2026-09-01T07:00:00Z"),
|
|
53
|
+
end: at("2026-09-01T15:00:00Z"),
|
|
54
|
+
summary: "On-call · Team",
|
|
55
|
+
description: "Line 1\nLine 2; a, b",
|
|
56
|
+
url: "https://x.test/a",
|
|
57
|
+
status: ICalendarEventStatus.Confirmed,
|
|
58
|
+
transparency: ICalendarTransparency.Transparent,
|
|
59
|
+
categories: ["On-Call"],
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe("ICalendar.serialize", () => {
|
|
64
|
+
test("byte-exact snapshot of a small calendar", () => {
|
|
65
|
+
const body: string = ICalendar.serialize({
|
|
66
|
+
calendar: baseCalendar(),
|
|
67
|
+
events: [baseEvent()],
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const expected: string =
|
|
71
|
+
[
|
|
72
|
+
"BEGIN:VCALENDAR",
|
|
73
|
+
"VERSION:2.0",
|
|
74
|
+
"PRODID:-//Test//Feed//EN",
|
|
75
|
+
"CALSCALE:GREGORIAN",
|
|
76
|
+
"NAME:Team",
|
|
77
|
+
"X-WR-CALNAME:Team",
|
|
78
|
+
"X-WR-CALDESC:Desc",
|
|
79
|
+
"X-WR-TIMEZONE:UTC",
|
|
80
|
+
"REFRESH-INTERVAL;VALUE=DURATION:PT1H",
|
|
81
|
+
"X-PUBLISHED-TTL:PT1H",
|
|
82
|
+
"LAST-MODIFIED:20260801T100000Z",
|
|
83
|
+
"BEGIN:VEVENT",
|
|
84
|
+
"UID:u1@test",
|
|
85
|
+
"DTSTAMP:20260801T100000Z",
|
|
86
|
+
"LAST-MODIFIED:20260801T100000Z",
|
|
87
|
+
"SEQUENCE:2",
|
|
88
|
+
"DTSTART:20260901T070000Z",
|
|
89
|
+
"DTEND:20260901T150000Z",
|
|
90
|
+
"SUMMARY:On-call · Team",
|
|
91
|
+
"DESCRIPTION:Line 1\\nLine 2\\; a\\, b",
|
|
92
|
+
"URL:https://x.test/a",
|
|
93
|
+
"STATUS:CONFIRMED",
|
|
94
|
+
"TRANSP:TRANSPARENT",
|
|
95
|
+
"CATEGORIES:On-Call",
|
|
96
|
+
"END:VEVENT",
|
|
97
|
+
"END:VCALENDAR",
|
|
98
|
+
].join(CRLF) + CRLF;
|
|
99
|
+
|
|
100
|
+
expect(body).toBe(expected);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("every line break is CRLF and the body ends with one", () => {
|
|
104
|
+
const body: string = ICalendar.serialize({
|
|
105
|
+
calendar: baseCalendar(),
|
|
106
|
+
events: [baseEvent()],
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
expect(body.endsWith(CRLF)).toBe(true);
|
|
110
|
+
expect(body.replace(/\r\n/g, "")).not.toContain("\n");
|
|
111
|
+
expect(body.replace(/\r\n/g, "")).not.toContain("\r");
|
|
112
|
+
expect(ICALENDAR_LINE_BREAK).toBe(CRLF);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("serializing the same document twice gives identical bytes, whatever the key order", () => {
|
|
116
|
+
const first: string = ICalendar.serialize({
|
|
117
|
+
calendar: baseCalendar(),
|
|
118
|
+
events: [baseEvent()],
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const shuffledEvent: ICalendarEvent = {
|
|
122
|
+
categories: ["On-Call"],
|
|
123
|
+
transparency: ICalendarTransparency.Transparent,
|
|
124
|
+
status: ICalendarEventStatus.Confirmed,
|
|
125
|
+
url: "https://x.test/a",
|
|
126
|
+
description: "Line 1\nLine 2; a, b",
|
|
127
|
+
summary: "On-call · Team",
|
|
128
|
+
end: at("2026-09-01T15:00:00Z"),
|
|
129
|
+
start: at("2026-09-01T07:00:00Z"),
|
|
130
|
+
sequence: 2,
|
|
131
|
+
lastModified: at("2026-08-01T10:00:00Z"),
|
|
132
|
+
dtStamp: at("2026-08-01T10:00:00Z"),
|
|
133
|
+
uid: "u1@test",
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const second: string = ICalendar.serialize({
|
|
137
|
+
calendar: baseCalendar(),
|
|
138
|
+
events: [shuffledEvent],
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
expect(second).toBe(first);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("never emits METHOD, CLASS, VALARM, RRULE, VTIMEZONE, ORGANIZER or ATTENDEE", () => {
|
|
145
|
+
const body: string = ICalendar.serialize({
|
|
146
|
+
calendar: baseCalendar(),
|
|
147
|
+
events: [baseEvent(), { ...baseEvent(), uid: "u2@test" }],
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
for (const forbidden of [
|
|
151
|
+
"METHOD",
|
|
152
|
+
"CLASS",
|
|
153
|
+
"VALARM",
|
|
154
|
+
"RRULE",
|
|
155
|
+
"VTIMEZONE",
|
|
156
|
+
"ORGANIZER",
|
|
157
|
+
"ATTENDEE",
|
|
158
|
+
"TZID",
|
|
159
|
+
]) {
|
|
160
|
+
expect(body).not.toMatch(new RegExp(`^${forbidden}[:;]`, "m"));
|
|
161
|
+
expect(body).not.toContain(`BEGIN:${forbidden}`);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("an empty calendar is just the header", () => {
|
|
166
|
+
const body: string = ICalendar.serialize({
|
|
167
|
+
calendar: baseCalendar(),
|
|
168
|
+
events: [],
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
expect(body).not.toContain("BEGIN:VEVENT");
|
|
172
|
+
expect(physicalLines(body)[0]).toBe("BEGIN:VCALENDAR");
|
|
173
|
+
expect(physicalLines(body)[physicalLines(body).length - 1]).toBe(
|
|
174
|
+
"END:VCALENDAR",
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("optional header properties are omitted when absent or empty; X-WR-CALNAME defaults to NAME", () => {
|
|
179
|
+
const body: string = ICalendar.serialize({
|
|
180
|
+
calendar: {
|
|
181
|
+
productId: "-//Test//Feed//EN",
|
|
182
|
+
name: "Only Name",
|
|
183
|
+
description: "",
|
|
184
|
+
},
|
|
185
|
+
events: [],
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
expect(body).toContain("NAME:Only Name\r\n");
|
|
189
|
+
expect(body).toContain("X-WR-CALNAME:Only Name\r\n");
|
|
190
|
+
expect(body).not.toContain("X-WR-CALDESC");
|
|
191
|
+
expect(body).not.toContain("X-WR-TIMEZONE");
|
|
192
|
+
expect(body).not.toContain("REFRESH-INTERVAL");
|
|
193
|
+
expect(body).not.toContain("X-PUBLISHED-TTL");
|
|
194
|
+
expect(body).not.toContain("LAST-MODIFIED");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("displayName overrides X-WR-CALNAME while NAME keeps the full name", () => {
|
|
198
|
+
const body: string = ICalendar.serialize({
|
|
199
|
+
calendar: {
|
|
200
|
+
productId: "-//Test//Feed//EN",
|
|
201
|
+
name: "A very long schedule name indeed",
|
|
202
|
+
displayName: "A very long schedule…",
|
|
203
|
+
},
|
|
204
|
+
events: [],
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
expect(body).toContain("NAME:A very long schedule name indeed\r\n");
|
|
208
|
+
expect(body).toContain("X-WR-CALNAME:A very long schedule…\r\n");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("optional event properties are omitted when absent", () => {
|
|
212
|
+
const lines: Array<string> = ICalendar.serializeEvent({
|
|
213
|
+
uid: "bare@test",
|
|
214
|
+
dtStamp: at("2026-08-01T10:00:00Z"),
|
|
215
|
+
start: at("2026-09-01T07:00:00Z"),
|
|
216
|
+
end: at("2026-09-01T15:00:00Z"),
|
|
217
|
+
summary: "Bare",
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
expect(lines).toEqual([
|
|
221
|
+
"BEGIN:VEVENT",
|
|
222
|
+
"UID:bare@test",
|
|
223
|
+
"DTSTAMP:20260801T100000Z",
|
|
224
|
+
"DTSTART:20260901T070000Z",
|
|
225
|
+
"DTEND:20260901T150000Z",
|
|
226
|
+
"SUMMARY:Bare",
|
|
227
|
+
"END:VEVENT",
|
|
228
|
+
]);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("REFRESH-INTERVAL and X-PUBLISHED-TTL carry the same normalised duration", () => {
|
|
232
|
+
const body: string = ICalendar.serialize({
|
|
233
|
+
calendar: { ...baseCalendar(), refreshInterval: " pt15m " },
|
|
234
|
+
events: [],
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
expect(body).toContain("REFRESH-INTERVAL;VALUE=DURATION:PT15M\r\n");
|
|
238
|
+
expect(body).toContain("X-PUBLISHED-TTL:PT15M\r\n");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("an invalid refresh interval throws", () => {
|
|
242
|
+
for (const bad of ["1h", "P", "PT", "PT1X", ""]) {
|
|
243
|
+
expect(() => {
|
|
244
|
+
return ICalendar.serialize({
|
|
245
|
+
calendar: { ...baseCalendar(), refreshInterval: bad },
|
|
246
|
+
events: [],
|
|
247
|
+
});
|
|
248
|
+
}).toThrow(BadDataException);
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("an empty UID, an inverted event or a bad SEQUENCE throws", () => {
|
|
253
|
+
expect(() => {
|
|
254
|
+
return ICalendar.serializeEvent({ ...baseEvent(), uid: " " });
|
|
255
|
+
}).toThrow(BadDataException);
|
|
256
|
+
|
|
257
|
+
expect(() => {
|
|
258
|
+
return ICalendar.serializeEvent({
|
|
259
|
+
...baseEvent(),
|
|
260
|
+
end: at("2026-09-01T07:00:00Z"),
|
|
261
|
+
});
|
|
262
|
+
}).toThrow(BadDataException);
|
|
263
|
+
|
|
264
|
+
expect(() => {
|
|
265
|
+
return ICalendar.serializeEvent({ ...baseEvent(), sequence: -1 });
|
|
266
|
+
}).toThrow(BadDataException);
|
|
267
|
+
|
|
268
|
+
expect(() => {
|
|
269
|
+
return ICalendar.serializeEvent({ ...baseEvent(), sequence: 1.5 });
|
|
270
|
+
}).toThrow(BadDataException);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("CATEGORIES escapes each entry and joins with commas; URL is not TEXT-escaped", () => {
|
|
274
|
+
const lines: Array<string> = ICalendar.serializeEvent({
|
|
275
|
+
...baseEvent(),
|
|
276
|
+
categories: ["On-Call", "a,b", "c;d"],
|
|
277
|
+
url: "https://x.test/a?b=1,2;3\nrest",
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
expect(lines).toContain("CATEGORIES:On-Call,a\\,b,c\\;d");
|
|
281
|
+
expect(lines).toContain("URL:https://x.test/a?b=1,2;3rest");
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
describe("ICalendar.escapeText", () => {
|
|
286
|
+
test("escapes backslash, semicolon, comma and newline in that order of precedence", () => {
|
|
287
|
+
expect(ICalendar.escapeText("a;b,c\\d\ne")).toBe("a\\;b\\,c\\\\d\\ne");
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("drops carriage returns and other control characters but keeps tabs", () => {
|
|
291
|
+
expect(ICalendar.escapeText("x\r\nyz\tw")).toBe("x\\nyz\tw");
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("leaves multi-byte characters untouched", () => {
|
|
295
|
+
expect(ICalendar.escapeText("Ærø · 東京 · 😀")).toBe("Ærø · 東京 · 😀");
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("escaping is idempotent on already-plain text and round-trips through a client-style unescape", () => {
|
|
299
|
+
const original: string = "Who: A\nSchedule: B (C); D, E\\F";
|
|
300
|
+
const escaped: string = ICalendar.escapeText(original);
|
|
301
|
+
|
|
302
|
+
const unescaped: string = escaped.replace(
|
|
303
|
+
/\\([\\;,nN])/g,
|
|
304
|
+
(_match: string, char: string): string => {
|
|
305
|
+
return char === "n" || char === "N" ? "\n" : char;
|
|
306
|
+
},
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
expect(unescaped).toBe(original);
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
describe("ICalendar.formatUtcDateTime", () => {
|
|
314
|
+
test("formats as YYYYMMDDTHHMMSSZ in UTC, ignoring milliseconds", () => {
|
|
315
|
+
expect(ICalendar.formatUtcDateTime(at("2026-03-08T06:30:05.999Z"))).toBe(
|
|
316
|
+
"20260308T063005Z",
|
|
317
|
+
);
|
|
318
|
+
expect(ICalendar.formatUtcDateTime(at("2026-12-31T23:59:59Z"))).toBe(
|
|
319
|
+
"20261231T235959Z",
|
|
320
|
+
);
|
|
321
|
+
expect(ICalendar.formatUtcDateTime(at("2026-01-01T00:00:00Z"))).toBe(
|
|
322
|
+
"20260101T000000Z",
|
|
323
|
+
);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test("zero-pads every component", () => {
|
|
327
|
+
expect(ICalendar.formatUtcDateTime(at("2026-02-03T04:05:06Z"))).toBe(
|
|
328
|
+
"20260203T040506Z",
|
|
329
|
+
);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test("throws on an invalid date", () => {
|
|
333
|
+
expect(() => {
|
|
334
|
+
return ICalendar.formatUtcDateTime(new Date("not a date"));
|
|
335
|
+
}).toThrow(BadDataException);
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
describe("ICalendar.foldLine", () => {
|
|
340
|
+
test("a line of at most 75 octets is returned unchanged", () => {
|
|
341
|
+
const line: string = `SUMMARY:${"a".repeat(67)}`;
|
|
342
|
+
expect(octets(line)).toBe(75);
|
|
343
|
+
expect(ICalendar.foldLine(line)).toBe(line);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test("a long ASCII line folds into 75-octet pieces with a leading space and unfolds losslessly", () => {
|
|
347
|
+
const line: string = `DESCRIPTION:${"a".repeat(300)}`;
|
|
348
|
+
const folded: string = ICalendar.foldLine(line);
|
|
349
|
+
const pieces: Array<string> = folded.split(CRLF);
|
|
350
|
+
|
|
351
|
+
expect(pieces.length).toBeGreaterThan(1);
|
|
352
|
+
expect(octets(pieces[0]!)).toBe(75);
|
|
353
|
+
|
|
354
|
+
for (let i: number = 1; i < pieces.length; i++) {
|
|
355
|
+
expect(pieces[i]!.startsWith(" ")).toBe(true);
|
|
356
|
+
expect(octets(pieces[i]!)).toBeLessThanOrEqual(75);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Every continuation but the last is packed to the limit.
|
|
360
|
+
for (let i: number = 1; i < pieces.length - 1; i++) {
|
|
361
|
+
expect(octets(pieces[i]!)).toBe(75);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
expect(ICalendar.unfold(folded)).toBe(line);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("never splits a 2-octet character", () => {
|
|
368
|
+
// "SUMMARY:" is 8 octets; 33 x "é" (66) fits, the 34th would make 76.
|
|
369
|
+
const line: string = `SUMMARY:${"é".repeat(120)}`;
|
|
370
|
+
const folded: string = ICalendar.foldLine(line);
|
|
371
|
+
const pieces: Array<string> = folded.split(CRLF);
|
|
372
|
+
|
|
373
|
+
expect(pieces[0]).toBe(`SUMMARY:${"é".repeat(33)}`);
|
|
374
|
+
expect(octets(pieces[0]!)).toBe(74);
|
|
375
|
+
|
|
376
|
+
for (const piece of pieces) {
|
|
377
|
+
expect(octets(piece)).toBeLessThanOrEqual(ICALENDAR_MAX_LINE_OCTETS);
|
|
378
|
+
expect(piece).not.toMatch(LONE_SURROGATE);
|
|
379
|
+
expect(Buffer.from(piece, "utf8").toString("utf8")).toBe(piece);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
expect(ICalendar.unfold(folded)).toBe(line);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test("never splits a 3-octet or 4-octet character", () => {
|
|
386
|
+
for (const char of ["東", "😀"]) {
|
|
387
|
+
const line: string = `X:${char.repeat(90)}`;
|
|
388
|
+
const folded: string = ICalendar.foldLine(line);
|
|
389
|
+
|
|
390
|
+
for (const piece of folded.split(CRLF)) {
|
|
391
|
+
expect(octets(piece)).toBeLessThanOrEqual(ICALENDAR_MAX_LINE_OCTETS);
|
|
392
|
+
expect(piece).not.toMatch(LONE_SURROGATE);
|
|
393
|
+
expect(Buffer.from(piece, "utf8").toString("utf8")).toBe(piece);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
expect(ICalendar.unfold(folded)).toBe(line);
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test("a mixed-width line packs each physical line as full as the boundary allows", () => {
|
|
401
|
+
const line: string = `DESCRIPTION:${"ab😀é東".repeat(40)}`;
|
|
402
|
+
const folded: string = ICalendar.foldLine(line);
|
|
403
|
+
const pieces: Array<string> = folded.split(CRLF);
|
|
404
|
+
|
|
405
|
+
expect(pieces.length).toBeGreaterThan(2);
|
|
406
|
+
|
|
407
|
+
for (let i: number = 0; i < pieces.length; i++) {
|
|
408
|
+
const piece: string = pieces[i]!;
|
|
409
|
+
expect(octets(piece)).toBeLessThanOrEqual(75);
|
|
410
|
+
// Every piece but the last is packed to within one character of the limit.
|
|
411
|
+
if (i < pieces.length - 1) {
|
|
412
|
+
expect(octets(piece)).toBeGreaterThan(70);
|
|
413
|
+
}
|
|
414
|
+
expect(piece).not.toMatch(LONE_SURROGATE);
|
|
415
|
+
if (i > 0) {
|
|
416
|
+
expect(piece.startsWith(" ")).toBe(true);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
expect(ICalendar.unfold(folded)).toBe(line);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test("a serialized document has no physical line over 75 octets and unfolds to the logical lines", () => {
|
|
424
|
+
const document: ICalendarDocument = {
|
|
425
|
+
calendar: {
|
|
426
|
+
...baseCalendar(),
|
|
427
|
+
description: "Ærø ".repeat(60),
|
|
428
|
+
},
|
|
429
|
+
events: [
|
|
430
|
+
{
|
|
431
|
+
...baseEvent(),
|
|
432
|
+
summary: "😀".repeat(50),
|
|
433
|
+
description: "東京".repeat(100),
|
|
434
|
+
},
|
|
435
|
+
],
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
const body: string = ICalendar.serialize(document);
|
|
439
|
+
|
|
440
|
+
for (const line of physicalLines(body)) {
|
|
441
|
+
expect(octets(line)).toBeLessThanOrEqual(ICALENDAR_MAX_LINE_OCTETS);
|
|
442
|
+
expect(line).not.toMatch(LONE_SURROGATE);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const unfolded: string = ICalendar.unfold(body);
|
|
446
|
+
expect(unfolded).toContain(`SUMMARY:${"😀".repeat(50)}\r\n`);
|
|
447
|
+
expect(unfolded).toContain(`DESCRIPTION:${"東京".repeat(100)}\r\n`);
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
describe("ICalendar.getUtf8OctetLength", () => {
|
|
452
|
+
test("counts 1/2/3/4 octet characters", () => {
|
|
453
|
+
expect(ICalendar.getUtf8OctetLength("abc")).toBe(3);
|
|
454
|
+
expect(ICalendar.getUtf8OctetLength("é")).toBe(2);
|
|
455
|
+
expect(ICalendar.getUtf8OctetLength("東")).toBe(3);
|
|
456
|
+
expect(ICalendar.getUtf8OctetLength("😀")).toBe(4);
|
|
457
|
+
expect(ICalendar.getUtf8OctetLength("aé東😀")).toBe(
|
|
458
|
+
Buffer.byteLength("aé東😀", "utf8"),
|
|
459
|
+
);
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
describe("ICalendar.contentLine / textLine", () => {
|
|
464
|
+
test("upper-cases the name and parameter names", () => {
|
|
465
|
+
expect(ICalendar.contentLine("x-thing", "v", { value: "duration" })).toBe(
|
|
466
|
+
"X-THING;VALUE=duration:v",
|
|
467
|
+
);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
test("quotes parameter values containing reserved characters and strips double quotes", () => {
|
|
471
|
+
expect(ICalendar.contentLine("P", "v", { cn: 'Doe, "John"' })).toBe(
|
|
472
|
+
'P;CN="Doe, John":v',
|
|
473
|
+
);
|
|
474
|
+
expect(ICalendar.contentLine("P", "v", { cn: "a:b" })).toBe('P;CN="a:b":v');
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
test("strips line breaks from any value", () => {
|
|
478
|
+
expect(ICalendar.contentLine("URL", "https://x\r\n.test")).toBe(
|
|
479
|
+
"URL:https://x.test",
|
|
480
|
+
);
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
test("textLine escapes the value", () => {
|
|
484
|
+
expect(ICalendar.textLine("SUMMARY", "a, b; c")).toBe(
|
|
485
|
+
"SUMMARY:a\\, b\\; c",
|
|
486
|
+
);
|
|
487
|
+
});
|
|
488
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, expect, test } from "@jest/globals";
|
|
2
|
+
import NotificationSettingEventType from "../../../Types/NotificationSetting/NotificationSettingEventType";
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* The enum VALUES are what is stored in user_notification_setting.eventType
|
|
6
|
+
* and what the dashboard shows as the row label, so a value is a contract
|
|
7
|
+
* with every existing row and every translation. These pin the two on-call
|
|
8
|
+
* shift reminder members exactly.
|
|
9
|
+
*/
|
|
10
|
+
describe("NotificationSettingEventType - on-call shift reminders", () => {
|
|
11
|
+
test("has the 'before shift starts' member with its stored value", () => {
|
|
12
|
+
expect(
|
|
13
|
+
NotificationSettingEventType.SEND_BEFORE_USER_ON_CALL_SHIFT_STARTS,
|
|
14
|
+
).toBe("Before user's on-call shift starts");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("has the 'shift reassigned' member with its stored value", () => {
|
|
18
|
+
expect(
|
|
19
|
+
NotificationSettingEventType.SEND_WHEN_USER_ON_CALL_SHIFT_IS_REASSIGNED,
|
|
20
|
+
).toBe("User's upcoming on-call shift is reassigned");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("every value is unique, so no two settings rows can mean two things", () => {
|
|
24
|
+
const values: Array<string> = Object.values(NotificationSettingEventType);
|
|
25
|
+
|
|
26
|
+
expect(new Set(values).size).toBe(values.length);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("every value is non-empty and free of surrounding whitespace", () => {
|
|
30
|
+
for (const value of Object.values(NotificationSettingEventType)) {
|
|
31
|
+
expect(value.trim()).toBe(value);
|
|
32
|
+
expect(value.length).toBeGreaterThan(0);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("the existing on-call members are untouched", () => {
|
|
37
|
+
expect(NotificationSettingEventType.SEND_WHEN_USER_IS_ON_CALL_ROSTER).toBe(
|
|
38
|
+
"When user is on-call roster",
|
|
39
|
+
);
|
|
40
|
+
expect(
|
|
41
|
+
NotificationSettingEventType.SEND_WHEN_USER_IS_NEXT_ON_CALL_ROSTER,
|
|
42
|
+
).toBe("When user is next on-call roster");
|
|
43
|
+
expect(
|
|
44
|
+
NotificationSettingEventType.SEND_WHEN_USER_IS_ADDED_TO_ON_CALL_POLICY,
|
|
45
|
+
).toBe("When user is added to on-call policy");
|
|
46
|
+
expect(
|
|
47
|
+
NotificationSettingEventType.SEND_WHEN_USER_IS_REMOVED_FROM_ON_CALL_POLICY,
|
|
48
|
+
).toBe("When user is removed from on-call policy");
|
|
49
|
+
expect(
|
|
50
|
+
NotificationSettingEventType.SEND_WHEN_USER_IS_NO_LONGER_ACTIVE_ON_ON_CALL_ROSTER,
|
|
51
|
+
).toBe("When user is no longer active on on-call roster");
|
|
52
|
+
});
|
|
53
|
+
});
|