@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.
Files changed (189) hide show
  1. package/Models/DatabaseModels/Index.ts +11 -0
  2. package/Models/DatabaseModels/OnCallDutyPolicySchedule.ts +43 -0
  3. package/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.ts +662 -0
  4. package/Models/DatabaseModels/ProjectOnCallCalendarFeed.ts +584 -0
  5. package/Models/DatabaseModels/UserOnCallCalendarFeed.ts +546 -0
  6. package/Models/DatabaseModels/UserOnCallShiftReminder.ts +231 -0
  7. package/Models/DatabaseModels/UserOnCallShiftReminderLog.ts +331 -0
  8. package/Server/API/OnCallCalendarAPI.ts +2194 -0
  9. package/Server/EnvironmentConfig.ts +138 -0
  10. package/Server/Infrastructure/OnCallCalendarFeedCache.ts +1050 -0
  11. package/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.ts +253 -0
  12. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  13. package/Server/Middleware/OnCallCalendarFeedRateLimit.ts +487 -0
  14. package/Server/Services/Index.ts +12 -0
  15. package/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.ts +32 -0
  16. package/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.ts +260 -0
  17. package/Server/Services/OnCallDutyPolicyScheduleLayerService.ts +70 -3
  18. package/Server/Services/OnCallDutyPolicyScheduleLayerUserService.ts +138 -3
  19. package/Server/Services/OnCallDutyPolicyScheduleService.ts +1465 -134
  20. package/Server/Services/OnCallDutyPolicyUserOverrideService.ts +167 -31
  21. package/Server/Services/ProjectOnCallCalendarFeedService.ts +192 -0
  22. package/Server/Services/TeamMemberService.ts +489 -1
  23. package/Server/Services/TeamService.ts +50 -0
  24. package/Server/Services/UserNotificationSettingService.ts +44 -1
  25. package/Server/Services/UserOnCallCalendarFeedService.ts +159 -0
  26. package/Server/Services/UserOnCallShiftReminderLogService.ts +151 -0
  27. package/Server/Services/UserOnCallShiftReminderService.ts +112 -0
  28. package/Server/Utils/OnCall/CalendarFeedToken.ts +241 -0
  29. package/Server/Utils/OnCall/OnCallCalendarFeedRenderer.ts +1488 -0
  30. package/Server/Utils/OnCall/OnCallCalendarFeedUrls.ts +230 -0
  31. package/Server/Utils/OnCall/OnCallShiftChangeListeners.ts +227 -0
  32. package/Server/Utils/OnCall/OnCallShiftMaterializer.ts +1127 -0
  33. package/Server/Utils/OnCall/OnCallShiftReminderListener.ts +174 -0
  34. package/Server/Utils/OnCall/OnCallShiftReminderRunner.ts +2584 -0
  35. package/Server/Utils/Response.ts +134 -0
  36. package/Server/Utils/StartServer.ts +10 -0
  37. package/Server/Utils/Telemetry/AppMetrics.ts +51 -0
  38. package/Tests/App/Dashboard/OnCallCalendarFeedLinks.test.tsx +229 -0
  39. package/Tests/App/Dashboard/OnCallCalendarFeedPlanGate.test.tsx +489 -0
  40. package/Tests/App/Dashboard/OnCallCalendarFeedSideMenus.test.tsx +149 -0
  41. package/Tests/App/Dashboard/OnCallCalendarFeedUtil.test.ts +855 -0
  42. package/Tests/App/Dashboard/OnCallPersonalCalendarFeed.test.tsx +1209 -0
  43. package/Tests/App/Dashboard/OnCallSharedCalendarFeedCard.test.tsx +1019 -0
  44. package/Tests/App/Dashboard/OnCallShiftRemindersAndUpcomingShifts.test.tsx +655 -0
  45. package/Tests/App/Dashboard/UserSettingsSetupChecklistModel.test.ts +73 -0
  46. package/Tests/Models/OnCallCalendarFeedModels.test.ts +642 -0
  47. package/Tests/Models/OnCallDutyPolicyScheduleCalendarFeed.test.ts +288 -0
  48. package/Tests/Models/OnCallDutyPolicyScheduleShiftConfigVersion.test.ts +88 -0
  49. package/Tests/Models/UserOnCallShiftReminderModels.test.ts +368 -0
  50. package/Tests/Server/API/Helpers.ts +8 -0
  51. package/Tests/Server/API/OnCallCalendarAPI.test.ts +4461 -0
  52. package/Tests/Server/EnvironmentConfigOnCallCalendarFeed.test.ts +357 -0
  53. package/Tests/Server/Infrastructure/OnCallCalendarFeedCache.test.ts +1589 -0
  54. package/Tests/Server/Infrastructure/Postgres/AddOnCallCalendarFeedsMigration.test.ts +369 -0
  55. package/Tests/Server/Middleware/OnCallCalendarFeedRateLimit.test.ts +1365 -0
  56. package/Tests/Server/Services/FeedRetentionConsistency.test.ts +70 -0
  57. package/Tests/Server/Services/OnCallCalendarFeedServices.test.ts +1103 -0
  58. package/Tests/Server/Services/OnCallDutyPolicyScheduleResolver.test.ts +1037 -0
  59. package/Tests/Server/Services/OnCallShiftConfigPropagation.test.ts +1270 -0
  60. package/Tests/Server/Services/TeamDeleteMemberCleanup.test.ts +156 -0
  61. package/Tests/Server/Services/TeamMemberOnCallCleanup.test.ts +755 -0
  62. package/Tests/Server/Services/UserNotificationSettingShiftReminderDefaults.test.ts +267 -0
  63. package/Tests/Server/Services/UserOnCallShiftReminderServices.test.ts +583 -0
  64. package/Tests/Server/Types/Database/Permissions/OnCallCalendarFeedPermissions.test.ts +254 -0
  65. package/Tests/Server/Utils/OnCall/CalendarFeedToken.test.ts +467 -0
  66. package/Tests/Server/Utils/OnCall/OnCallCalendarFeedRenderer.test.ts +2142 -0
  67. package/Tests/Server/Utils/OnCall/OnCallCalendarFeedUrls.test.ts +278 -0
  68. package/Tests/Server/Utils/OnCall/OnCallResolverTestHarness.ts +440 -0
  69. package/Tests/Server/Utils/OnCall/OnCallShiftChangeListeners.test.ts +314 -0
  70. package/Tests/Server/Utils/OnCall/OnCallShiftMaterializer.test.ts +1502 -0
  71. package/Tests/Server/Utils/OnCall/OnCallShiftReminderListener.test.ts +491 -0
  72. package/Tests/Server/Utils/OnCall/OnCallShiftReminderRunner.test.ts +3088 -0
  73. package/Tests/Server/Utils/OnCall/OnCallShiftReminderTestHarness.ts +910 -0
  74. package/Tests/Server/Utils/ResponseSendCalendarResponse.test.ts +561 -0
  75. package/Tests/Server/Utils/StartServerEncryptionSecretWarning.test.ts +64 -0
  76. package/Tests/Server/Utils/Telemetry/AppMetrics.test.ts +29 -0
  77. package/Tests/Types/Calendar/ICalendar.test.ts +488 -0
  78. package/Tests/Types/NotificationSetting/NotificationSettingEventType.test.ts +53 -0
  79. package/Tests/Types/OnCallDutyPolicy/CalendarFeedTestFixtures.ts +310 -0
  80. package/Tests/Types/OnCallDutyPolicy/CalendarFeedWindow.test.ts +205 -0
  81. package/Tests/Types/OnCallDutyPolicy/LayerUtilLayerMetaAndIterationCap.test.ts +458 -0
  82. package/Tests/Types/OnCallDutyPolicy/MaterializedShift.test.ts +197 -0
  83. package/Tests/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.test.ts +1986 -0
  84. package/Tests/Types/OnCallDutyPolicy/ScheduleShiftUtilGroupKey.test.ts +678 -0
  85. package/Tests/Types/OnCallDutyPolicy/ShiftSeamUtil.test.ts +417 -0
  86. package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +10 -0
  87. package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +2 -0
  88. package/Types/Calendar/ICalendar.ts +442 -0
  89. package/Types/Email/EmailTemplateType.ts +2 -0
  90. package/Types/NotificationSetting/NotificationSettingEventType.ts +4 -0
  91. package/Types/OnCallDutyPolicy/CalendarFeedWindow.ts +140 -0
  92. package/Types/OnCallDutyPolicy/Layer.ts +190 -35
  93. package/Types/OnCallDutyPolicy/MaterializedShift.ts +274 -0
  94. package/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.ts +1415 -0
  95. package/Types/OnCallDutyPolicy/ScheduleShiftUtil.ts +159 -4
  96. package/Types/OnCallDutyPolicy/ShiftSeamUtil.ts +106 -0
  97. package/build/dist/Models/DatabaseModels/Index.js +11 -0
  98. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  99. package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js +44 -0
  100. package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js.map +1 -1
  101. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.js +699 -0
  102. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.js.map +1 -0
  103. package/build/dist/Models/DatabaseModels/ProjectOnCallCalendarFeed.js +620 -0
  104. package/build/dist/Models/DatabaseModels/ProjectOnCallCalendarFeed.js.map +1 -0
  105. package/build/dist/Models/DatabaseModels/UserOnCallCalendarFeed.js +585 -0
  106. package/build/dist/Models/DatabaseModels/UserOnCallCalendarFeed.js.map +1 -0
  107. package/build/dist/Models/DatabaseModels/UserOnCallShiftReminder.js +247 -0
  108. package/build/dist/Models/DatabaseModels/UserOnCallShiftReminder.js.map +1 -0
  109. package/build/dist/Models/DatabaseModels/UserOnCallShiftReminderLog.js +355 -0
  110. package/build/dist/Models/DatabaseModels/UserOnCallShiftReminderLog.js.map +1 -0
  111. package/build/dist/Server/API/OnCallCalendarAPI.js +1371 -0
  112. package/build/dist/Server/API/OnCallCalendarAPI.js.map +1 -0
  113. package/build/dist/Server/EnvironmentConfig.js +102 -0
  114. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  115. package/build/dist/Server/Infrastructure/OnCallCalendarFeedCache.js +663 -0
  116. package/build/dist/Server/Infrastructure/OnCallCalendarFeedCache.js.map +1 -0
  117. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.js +103 -0
  118. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.js.map +1 -0
  119. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  120. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  121. package/build/dist/Server/Middleware/OnCallCalendarFeedRateLimit.js +342 -0
  122. package/build/dist/Server/Middleware/OnCallCalendarFeedRateLimit.js.map +1 -0
  123. package/build/dist/Server/Services/Index.js +12 -0
  124. package/build/dist/Server/Services/Index.js.map +1 -1
  125. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js +20 -0
  126. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js.map +1 -1
  127. package/build/dist/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.js +219 -0
  128. package/build/dist/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.js.map +1 -0
  129. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerService.js +47 -3
  130. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerService.js.map +1 -1
  131. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerUserService.js +112 -3
  132. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerUserService.js.map +1 -1
  133. package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js +955 -9
  134. package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js.map +1 -1
  135. package/build/dist/Server/Services/OnCallDutyPolicyUserOverrideService.js +93 -6
  136. package/build/dist/Server/Services/OnCallDutyPolicyUserOverrideService.js.map +1 -1
  137. package/build/dist/Server/Services/ProjectOnCallCalendarFeedService.js +157 -0
  138. package/build/dist/Server/Services/ProjectOnCallCalendarFeedService.js.map +1 -0
  139. package/build/dist/Server/Services/TeamMemberService.js +396 -1
  140. package/build/dist/Server/Services/TeamMemberService.js.map +1 -1
  141. package/build/dist/Server/Services/TeamService.js +43 -0
  142. package/build/dist/Server/Services/TeamService.js.map +1 -1
  143. package/build/dist/Server/Services/UserNotificationSettingService.js +26 -1
  144. package/build/dist/Server/Services/UserNotificationSettingService.js.map +1 -1
  145. package/build/dist/Server/Services/UserOnCallCalendarFeedService.js +140 -0
  146. package/build/dist/Server/Services/UserOnCallCalendarFeedService.js.map +1 -0
  147. package/build/dist/Server/Services/UserOnCallShiftReminderLogService.js +116 -0
  148. package/build/dist/Server/Services/UserOnCallShiftReminderLogService.js.map +1 -0
  149. package/build/dist/Server/Services/UserOnCallShiftReminderService.js +92 -0
  150. package/build/dist/Server/Services/UserOnCallShiftReminderService.js.map +1 -0
  151. package/build/dist/Server/Utils/OnCall/CalendarFeedToken.js +161 -0
  152. package/build/dist/Server/Utils/OnCall/CalendarFeedToken.js.map +1 -0
  153. package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedRenderer.js +983 -0
  154. package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedRenderer.js.map +1 -0
  155. package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedUrls.js +155 -0
  156. package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedUrls.js.map +1 -0
  157. package/build/dist/Server/Utils/OnCall/OnCallShiftChangeListeners.js +145 -0
  158. package/build/dist/Server/Utils/OnCall/OnCallShiftChangeListeners.js.map +1 -0
  159. package/build/dist/Server/Utils/OnCall/OnCallShiftMaterializer.js +752 -0
  160. package/build/dist/Server/Utils/OnCall/OnCallShiftMaterializer.js.map +1 -0
  161. package/build/dist/Server/Utils/OnCall/OnCallShiftReminderListener.js +92 -0
  162. package/build/dist/Server/Utils/OnCall/OnCallShiftReminderListener.js.map +1 -0
  163. package/build/dist/Server/Utils/OnCall/OnCallShiftReminderRunner.js +1786 -0
  164. package/build/dist/Server/Utils/OnCall/OnCallShiftReminderRunner.js.map +1 -0
  165. package/build/dist/Server/Utils/Response.js +97 -0
  166. package/build/dist/Server/Utils/Response.js.map +1 -1
  167. package/build/dist/Server/Utils/StartServer.js +9 -1
  168. package/build/dist/Server/Utils/StartServer.js.map +1 -1
  169. package/build/dist/Server/Utils/Telemetry/AppMetrics.js +41 -0
  170. package/build/dist/Server/Utils/Telemetry/AppMetrics.js.map +1 -1
  171. package/build/dist/Types/Calendar/ICalendar.js +290 -0
  172. package/build/dist/Types/Calendar/ICalendar.js.map +1 -0
  173. package/build/dist/Types/Email/EmailTemplateType.js +2 -0
  174. package/build/dist/Types/Email/EmailTemplateType.js.map +1 -1
  175. package/build/dist/Types/NotificationSetting/NotificationSettingEventType.js +3 -0
  176. package/build/dist/Types/NotificationSetting/NotificationSettingEventType.js.map +1 -1
  177. package/build/dist/Types/OnCallDutyPolicy/CalendarFeedWindow.js +97 -0
  178. package/build/dist/Types/OnCallDutyPolicy/CalendarFeedWindow.js.map +1 -0
  179. package/build/dist/Types/OnCallDutyPolicy/Layer.js +113 -19
  180. package/build/dist/Types/OnCallDutyPolicy/Layer.js.map +1 -1
  181. package/build/dist/Types/OnCallDutyPolicy/MaterializedShift.js +134 -0
  182. package/build/dist/Types/OnCallDutyPolicy/MaterializedShift.js.map +1 -0
  183. package/build/dist/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.js +800 -0
  184. package/build/dist/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.js.map +1 -0
  185. package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js +97 -3
  186. package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js.map +1 -1
  187. package/build/dist/Types/OnCallDutyPolicy/ShiftSeamUtil.js +77 -0
  188. package/build/dist/Types/OnCallDutyPolicy/ShiftSeamUtil.js.map +1 -0
  189. package/package.json +1 -1
@@ -0,0 +1,1589 @@
1
+ import {
2
+ afterEach,
3
+ beforeEach,
4
+ describe,
5
+ expect,
6
+ it,
7
+ jest,
8
+ } from "@jest/globals";
9
+
10
+ /*
11
+ * The caches behind the on-call calendar feeds.
12
+ *
13
+ * What matters here is not that a value comes back after being put in -- it
14
+ * is the invalidation rules (a purge for one user must not reach another
15
+ * user's feed, a purge for one schedule must reach every feed rendered from
16
+ * it), the stale-while-error tier surviving the purges that the fresh tiers do
17
+ * not, the behaviour when Redis is gone (everything keeps working, nothing
18
+ * throws, purges still take effect locally), and the per-process render cap.
19
+ */
20
+
21
+ jest.mock("../../../Server/Infrastructure/Redis", () => {
22
+ return {
23
+ __esModule: true,
24
+ default: {
25
+ getClient: jest.fn(),
26
+ isConnected: jest.fn(),
27
+ },
28
+ };
29
+ });
30
+
31
+ jest.mock("../../../Server/Utils/Logger", () => {
32
+ return {
33
+ __esModule: true,
34
+ default: {
35
+ debug: jest.fn(),
36
+ info: jest.fn(),
37
+ warn: jest.fn(),
38
+ error: jest.fn(),
39
+ },
40
+ };
41
+ });
42
+
43
+ import Redis from "../../../Server/Infrastructure/Redis";
44
+ import logger from "../../../Server/Utils/Logger";
45
+ import OnCallCalendarFeedCache, {
46
+ CachedCalendarBody,
47
+ DEFAULT_RENDER_CONCURRENCY,
48
+ LAST_GOOD_TTL_SECONDS,
49
+ MAX_CACHED_BODY_BYTES,
50
+ MAX_CACHED_LAST_GOOD_BYTES,
51
+ MAX_CACHED_SEGMENT_BYTES,
52
+ MEMORY_MAX_BYTES,
53
+ ON_CALL_CALENDAR_FEED_CACHE_NAMESPACE,
54
+ OnCallCalendarFeedCacheScope,
55
+ } from "../../../Server/Infrastructure/OnCallCalendarFeedCache";
56
+
57
+ type MockedFn = ReturnType<typeof jest.fn>;
58
+
59
+ const getClientMock: MockedFn = Redis.getClient as unknown as MockedFn;
60
+ const isConnectedMock: MockedFn = Redis.isConnected as unknown as MockedFn;
61
+ const loggerWarnMock: MockedFn = logger.warn as unknown as MockedFn;
62
+ const loggerErrorMock: MockedFn = logger.error as unknown as MockedFn;
63
+
64
+ /*
65
+ * A fake ioredis client that stores strings with a TTL, the only surface
66
+ * GlobalCache.getString / setString use. `failWrites` / `failReads` simulate a
67
+ * half-broken connection (client present, commands throwing), which is a
68
+ * different failure from "not connected" and must be handled the same way.
69
+ */
70
+ class FakeRedis {
71
+ public store: Map<string, { value: string; ttl: number }> = new Map();
72
+ public failReads: boolean = false;
73
+ public failWrites: boolean = false;
74
+ public getCalls: number = 0;
75
+ public setCalls: number = 0;
76
+
77
+ public async get(key: string): Promise<string | null> {
78
+ this.getCalls++;
79
+
80
+ if (this.failReads) {
81
+ throw new Error("read failed");
82
+ }
83
+
84
+ return this.store.get(key)?.value ?? null;
85
+ }
86
+
87
+ public async set(
88
+ key: string,
89
+ value: string,
90
+ _mode: string,
91
+ ttl: number,
92
+ ): Promise<string> {
93
+ this.setCalls++;
94
+
95
+ if (this.failWrites) {
96
+ throw new Error("write failed");
97
+ }
98
+
99
+ this.store.set(key, { value, ttl });
100
+
101
+ return "OK";
102
+ }
103
+
104
+ public keys(): Array<string> {
105
+ return Array.from(this.store.keys());
106
+ }
107
+
108
+ public keysWithPrefix(prefix: string): Array<string> {
109
+ return this.keys().filter((key: string) => {
110
+ return key.startsWith(
111
+ `${ON_CALL_CALENDAR_FEED_CACHE_NAMESPACE}-${prefix}`,
112
+ );
113
+ });
114
+ }
115
+ }
116
+
117
+ const PROJECT_A: string = "11111111-1111-4111-8111-111111111111";
118
+ const PROJECT_B: string = "22222222-2222-4222-8222-222222222222";
119
+ const USER_1: string = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
120
+ const USER_2: string = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
121
+ const SCHEDULE_X: string = "cccccccc-cccc-4ccc-8ccc-cccccccccccc";
122
+ const SCHEDULE_Y: string = "dddddddd-dddd-4ddd-8ddd-dddddddddddd";
123
+
124
+ function body(text: string): CachedCalendarBody {
125
+ return {
126
+ body: `BEGIN:VCALENDAR\r\n${text}\r\nEND:VCALENDAR\r\n`,
127
+ etag: `"${text}"`,
128
+ lastModified: new Date("2026-08-30T10:11:12.000Z"),
129
+ };
130
+ }
131
+
132
+ describe("OnCallCalendarFeedCache", () => {
133
+ let redis: FakeRedis;
134
+
135
+ beforeEach(() => {
136
+ jest.clearAllMocks();
137
+ redis = new FakeRedis();
138
+ getClientMock.mockReturnValue(redis);
139
+ isConnectedMock.mockReturnValue(true);
140
+ OnCallCalendarFeedCache.clearInProcessState();
141
+ });
142
+
143
+ afterEach(() => {
144
+ OnCallCalendarFeedCache.clearInProcessState();
145
+ });
146
+
147
+ // -- Schedule segments -------------------------------------------------
148
+
149
+ describe("getOrRenderScheduleSegments", () => {
150
+ const render: () => Promise<
151
+ Array<{ start: string; user: string }>
152
+ > = async () => {
153
+ return [{ start: "2026-09-01T00:00:00.000Z", user: USER_1 }];
154
+ };
155
+
156
+ it("renders on a miss and returns the rendered value", async () => {
157
+ const renderFn: MockedFn = jest.fn(render);
158
+
159
+ const result: unknown =
160
+ await OnCallCalendarFeedCache.getOrRenderScheduleSegments({
161
+ scheduleId: SCHEDULE_X,
162
+ key: "v1:day1:2:90",
163
+ ttlSeconds: 3600,
164
+ render: renderFn as unknown as () => Promise<unknown>,
165
+ });
166
+
167
+ expect(result).toEqual(await render());
168
+ expect(renderFn).toHaveBeenCalledTimes(1);
169
+ });
170
+
171
+ it("serves the second read from Redis without rendering again", async () => {
172
+ const renderFn: MockedFn = jest.fn(render);
173
+
174
+ const read: () => Promise<unknown> = () => {
175
+ return OnCallCalendarFeedCache.getOrRenderScheduleSegments({
176
+ scheduleId: SCHEDULE_X,
177
+ key: "v1:day1:2:90",
178
+ ttlSeconds: 3600,
179
+ render: renderFn as unknown as () => Promise<unknown>,
180
+ });
181
+ };
182
+
183
+ const first: unknown = await read();
184
+ const second: unknown = await read();
185
+
186
+ expect(second).toEqual(first);
187
+ expect(renderFn).toHaveBeenCalledTimes(1);
188
+ });
189
+
190
+ it("stores the segments under the namespace with the requested TTL", async () => {
191
+ await OnCallCalendarFeedCache.getOrRenderScheduleSegments({
192
+ scheduleId: SCHEDULE_X,
193
+ key: "v1",
194
+ ttlSeconds: 3600,
195
+ render,
196
+ });
197
+
198
+ const keys: Array<string> = redis.keysWithPrefix("seg:");
199
+
200
+ expect(keys).toHaveLength(1);
201
+ expect(redis.store.get(keys[0]!)?.ttl).toBe(3600);
202
+ });
203
+
204
+ it("keys on the caller's key, so a different window is a different entry", async () => {
205
+ const renderFn: MockedFn = jest.fn(render);
206
+
207
+ for (const key of ["v1:day1", "v1:day2", "v2:day1"]) {
208
+ await OnCallCalendarFeedCache.getOrRenderScheduleSegments({
209
+ scheduleId: SCHEDULE_X,
210
+ key,
211
+ ttlSeconds: 3600,
212
+ render: renderFn as unknown as () => Promise<unknown>,
213
+ });
214
+ }
215
+
216
+ expect(renderFn).toHaveBeenCalledTimes(3);
217
+ expect(redis.keysWithPrefix("seg:")).toHaveLength(3);
218
+ });
219
+
220
+ it("keys on the schedule, so two schedules with the same key do not collide", async () => {
221
+ const first: unknown =
222
+ await OnCallCalendarFeedCache.getOrRenderScheduleSegments({
223
+ scheduleId: SCHEDULE_X,
224
+ key: "same",
225
+ ttlSeconds: 3600,
226
+ render: async () => {
227
+ return "x";
228
+ },
229
+ });
230
+ const second: unknown =
231
+ await OnCallCalendarFeedCache.getOrRenderScheduleSegments({
232
+ scheduleId: SCHEDULE_Y,
233
+ key: "same",
234
+ ttlSeconds: 3600,
235
+ render: async () => {
236
+ return "y";
237
+ },
238
+ });
239
+
240
+ expect(first).toBe("x");
241
+ expect(second).toBe("y");
242
+ });
243
+
244
+ it("never puts the raw key or schedule id into the Redis key", async () => {
245
+ await OnCallCalendarFeedCache.getOrRenderScheduleSegments({
246
+ scheduleId: SCHEDULE_X,
247
+ key: "secret-ish:v1",
248
+ ttlSeconds: 3600,
249
+ render,
250
+ });
251
+
252
+ for (const key of redis.keysWithPrefix("seg:")) {
253
+ expect(key).not.toContain("secret-ish");
254
+ expect(key).not.toContain(SCHEDULE_X);
255
+ }
256
+ });
257
+
258
+ it("re-renders after purgeForSchedule for that schedule", async () => {
259
+ const renderFn: MockedFn = jest.fn(render);
260
+
261
+ const read: () => Promise<unknown> = () => {
262
+ return OnCallCalendarFeedCache.getOrRenderScheduleSegments({
263
+ scheduleId: SCHEDULE_X,
264
+ key: "v1",
265
+ ttlSeconds: 3600,
266
+ render: renderFn as unknown as () => Promise<unknown>,
267
+ });
268
+ };
269
+
270
+ await read();
271
+ await OnCallCalendarFeedCache.purgeForSchedule(SCHEDULE_X);
272
+ await read();
273
+
274
+ expect(renderFn).toHaveBeenCalledTimes(2);
275
+ });
276
+
277
+ it("is not purged by purgeForSchedule on a different schedule", async () => {
278
+ const renderFn: MockedFn = jest.fn(render);
279
+
280
+ const read: () => Promise<unknown> = () => {
281
+ return OnCallCalendarFeedCache.getOrRenderScheduleSegments({
282
+ scheduleId: SCHEDULE_X,
283
+ key: "v1",
284
+ ttlSeconds: 3600,
285
+ render: renderFn as unknown as () => Promise<unknown>,
286
+ });
287
+ };
288
+
289
+ await read();
290
+ await OnCallCalendarFeedCache.purgeForSchedule(SCHEDULE_Y);
291
+ await read();
292
+
293
+ expect(renderFn).toHaveBeenCalledTimes(1);
294
+ });
295
+
296
+ it("does not cache and propagates the error when the render throws", async () => {
297
+ const failing: () => Promise<unknown> = async () => {
298
+ throw new Error("iteration cap");
299
+ };
300
+
301
+ await expect(
302
+ OnCallCalendarFeedCache.getOrRenderScheduleSegments({
303
+ scheduleId: SCHEDULE_X,
304
+ key: "v1",
305
+ ttlSeconds: 3600,
306
+ render: failing,
307
+ }),
308
+ ).rejects.toThrow("iteration cap");
309
+
310
+ expect(redis.keysWithPrefix("seg:")).toHaveLength(0);
311
+
312
+ /* And a later successful render is not blocked by the failed one. */
313
+ const renderFn: MockedFn = jest.fn(render);
314
+
315
+ await OnCallCalendarFeedCache.getOrRenderScheduleSegments({
316
+ scheduleId: SCHEDULE_X,
317
+ key: "v1",
318
+ ttlSeconds: 3600,
319
+ render: renderFn as unknown as () => Promise<unknown>,
320
+ });
321
+
322
+ expect(renderFn).toHaveBeenCalledTimes(1);
323
+ });
324
+
325
+ it("treats a corrupt cached entry as a miss and re-renders", async () => {
326
+ await OnCallCalendarFeedCache.getOrRenderScheduleSegments({
327
+ scheduleId: SCHEDULE_X,
328
+ key: "v1",
329
+ ttlSeconds: 3600,
330
+ render,
331
+ });
332
+
333
+ const [key] = redis.keysWithPrefix("seg:");
334
+ redis.store.set(key!, { value: "{not json", ttl: 3600 });
335
+
336
+ const renderFn: MockedFn = jest.fn(render);
337
+
338
+ const result: unknown =
339
+ await OnCallCalendarFeedCache.getOrRenderScheduleSegments({
340
+ scheduleId: SCHEDULE_X,
341
+ key: "v1",
342
+ ttlSeconds: 3600,
343
+ render: renderFn as unknown as () => Promise<unknown>,
344
+ });
345
+
346
+ expect(result).toEqual(await render());
347
+ expect(renderFn).toHaveBeenCalledTimes(1);
348
+ });
349
+
350
+ it("shares one in-flight render between concurrent misses for the same key", async () => {
351
+ let resolveRender: (value: unknown) => void = () => {};
352
+ let renderCalls: number = 0;
353
+
354
+ const slowRender: () => Promise<unknown> = () => {
355
+ renderCalls++;
356
+ return new Promise<unknown>((resolve: (value: unknown) => void) => {
357
+ resolveRender = resolve;
358
+ });
359
+ };
360
+
361
+ const read: () => Promise<unknown> = () => {
362
+ return OnCallCalendarFeedCache.getOrRenderScheduleSegments({
363
+ scheduleId: SCHEDULE_X,
364
+ key: "v1",
365
+ ttlSeconds: 3600,
366
+ render: slowRender,
367
+ });
368
+ };
369
+
370
+ const first: Promise<unknown> = read();
371
+ const second: Promise<unknown> = read();
372
+ const third: Promise<unknown> = read();
373
+
374
+ /* Let both reads get past their cache miss and register the in-flight. */
375
+ await new Promise<void>((resolve: () => void) => {
376
+ setTimeout(resolve, 0);
377
+ });
378
+
379
+ resolveRender(["shared"]);
380
+
381
+ expect(await first).toEqual(["shared"]);
382
+ expect(await second).toEqual(["shared"]);
383
+ expect(await third).toEqual(["shared"]);
384
+ expect(renderCalls).toBe(1);
385
+ });
386
+
387
+ it("hands back plain JSON, so a Date in the rendered value comes back as a string", async () => {
388
+ const withDate: () => Promise<{ at: Date }> = async () => {
389
+ return { at: new Date("2026-09-01T00:00:00.000Z") };
390
+ };
391
+
392
+ await OnCallCalendarFeedCache.getOrRenderScheduleSegments({
393
+ scheduleId: SCHEDULE_X,
394
+ key: "v1",
395
+ ttlSeconds: 3600,
396
+ render: withDate,
397
+ });
398
+
399
+ const cached: { at: unknown } =
400
+ await OnCallCalendarFeedCache.getOrRenderScheduleSegments({
401
+ scheduleId: SCHEDULE_X,
402
+ key: "v1",
403
+ ttlSeconds: 3600,
404
+ render: withDate,
405
+ });
406
+
407
+ expect(cached.at).toBe("2026-09-01T00:00:00.000Z");
408
+ });
409
+ });
410
+
411
+ // -- Batched schedule segments -----------------------------------------
412
+
413
+ /*
414
+ * A feed render reads EVERY schedule it depends on. Reading them one at a
415
+ * time meant one resolver call per schedule -- seven queries each, the same
416
+ * user and project rows re-read every time -- so the batch exists to make
417
+ * one call for every schedule that missed.
418
+ */
419
+ describe("getOrRenderScheduleSegmentsBatch", () => {
420
+ function segmentsFor(ids: Array<string>): Map<string, { id: string }> {
421
+ const out: Map<string, { id: string }> = new Map();
422
+
423
+ for (const id of ids) {
424
+ out.set(id, { id });
425
+ }
426
+
427
+ return out;
428
+ }
429
+
430
+ it("renders every miss in ONE call and caches each under its own key", async () => {
431
+ const renderMissing: MockedFn = jest.fn(
432
+ async (missing: Array<{ scheduleId: string; key: string }>) => {
433
+ return segmentsFor(
434
+ missing.map((entry: { scheduleId: string }) => {
435
+ return entry.scheduleId;
436
+ }),
437
+ );
438
+ },
439
+ );
440
+
441
+ const first: Map<string, { id: string }> =
442
+ (await OnCallCalendarFeedCache.getOrRenderScheduleSegmentsBatch({
443
+ entries: [
444
+ { scheduleId: SCHEDULE_X, key: "v1" },
445
+ { scheduleId: SCHEDULE_Y, key: "v1" },
446
+ ],
447
+ ttlSeconds: 3600,
448
+ renderMissing: renderMissing as never,
449
+ })) as Map<string, { id: string }>;
450
+
451
+ expect(renderMissing).toHaveBeenCalledTimes(1);
452
+ expect(
453
+ (renderMissing.mock.calls[0]?.[0] as Array<{ scheduleId: string }>).map(
454
+ (entry: { scheduleId: string }) => {
455
+ return entry.scheduleId;
456
+ },
457
+ ),
458
+ ).toEqual([SCHEDULE_X, SCHEDULE_Y]);
459
+ expect(first.get(SCHEDULE_X)).toEqual({ id: SCHEDULE_X });
460
+ expect(first.get(SCHEDULE_Y)).toEqual({ id: SCHEDULE_Y });
461
+ expect(redis.keysWithPrefix("seg:")).toHaveLength(2);
462
+
463
+ /* Both are hits now, so renderMissing is not called again at all. */
464
+ const second: Map<string, { id: string }> =
465
+ (await OnCallCalendarFeedCache.getOrRenderScheduleSegmentsBatch({
466
+ entries: [
467
+ { scheduleId: SCHEDULE_X, key: "v1" },
468
+ { scheduleId: SCHEDULE_Y, key: "v1" },
469
+ ],
470
+ ttlSeconds: 3600,
471
+ renderMissing: renderMissing as never,
472
+ })) as Map<string, { id: string }>;
473
+
474
+ expect(renderMissing).toHaveBeenCalledTimes(1);
475
+ expect(second.get(SCHEDULE_Y)).toEqual({ id: SCHEDULE_Y });
476
+ });
477
+
478
+ it("asks only for the schedules that missed", async () => {
479
+ const renderMissing: MockedFn = jest.fn(
480
+ async (missing: Array<{ scheduleId: string; key: string }>) => {
481
+ return segmentsFor(
482
+ missing.map((entry: { scheduleId: string }) => {
483
+ return entry.scheduleId;
484
+ }),
485
+ );
486
+ },
487
+ );
488
+
489
+ await OnCallCalendarFeedCache.getOrRenderScheduleSegmentsBatch({
490
+ entries: [{ scheduleId: SCHEDULE_X, key: "v1" }],
491
+ ttlSeconds: 3600,
492
+ renderMissing: renderMissing as never,
493
+ });
494
+
495
+ await OnCallCalendarFeedCache.getOrRenderScheduleSegmentsBatch({
496
+ entries: [
497
+ { scheduleId: SCHEDULE_X, key: "v1" },
498
+ { scheduleId: SCHEDULE_Y, key: "v1" },
499
+ ],
500
+ ttlSeconds: 3600,
501
+ renderMissing: renderMissing as never,
502
+ });
503
+
504
+ expect(
505
+ (renderMissing.mock.calls[1]?.[0] as Array<{ scheduleId: string }>).map(
506
+ (entry: { scheduleId: string }) => {
507
+ return entry.scheduleId;
508
+ },
509
+ ),
510
+ ).toEqual([SCHEDULE_Y]);
511
+ });
512
+
513
+ it("a repeated schedule id is one read and one render", async () => {
514
+ const renderMissing: MockedFn = jest.fn(
515
+ async (missing: Array<{ scheduleId: string }>) => {
516
+ return segmentsFor(
517
+ missing.map((entry: { scheduleId: string }) => {
518
+ return entry.scheduleId;
519
+ }),
520
+ );
521
+ },
522
+ );
523
+
524
+ await OnCallCalendarFeedCache.getOrRenderScheduleSegmentsBatch({
525
+ entries: [
526
+ { scheduleId: SCHEDULE_X, key: "v1" },
527
+ { scheduleId: SCHEDULE_X, key: "v1" },
528
+ ],
529
+ ttlSeconds: 3600,
530
+ renderMissing: renderMissing as never,
531
+ });
532
+
533
+ expect(renderMissing.mock.calls[0]?.[0] as Array<unknown>).toHaveLength(
534
+ 1,
535
+ );
536
+ });
537
+
538
+ it("joins a render another caller already has in flight", async () => {
539
+ let release: (value: unknown) => void = () => {};
540
+ let calls: number = 0;
541
+
542
+ const slow: MockedFn = jest.fn(
543
+ async (missing: Array<{ scheduleId: string }>) => {
544
+ calls++;
545
+
546
+ await new Promise<unknown>((resolve: (value: unknown) => void) => {
547
+ release = resolve;
548
+ });
549
+
550
+ return segmentsFor(
551
+ missing.map((entry: { scheduleId: string }) => {
552
+ return entry.scheduleId;
553
+ }),
554
+ );
555
+ },
556
+ );
557
+
558
+ const read: () => Promise<unknown> = () => {
559
+ return OnCallCalendarFeedCache.getOrRenderScheduleSegmentsBatch({
560
+ entries: [{ scheduleId: SCHEDULE_X, key: "v1" }],
561
+ ttlSeconds: 3600,
562
+ renderMissing: slow as never,
563
+ });
564
+ };
565
+
566
+ const a: Promise<unknown> = read();
567
+ const b: Promise<unknown> = read();
568
+
569
+ await new Promise<void>((resolve: () => void) => {
570
+ setTimeout(resolve, 0);
571
+ });
572
+
573
+ release(undefined);
574
+
575
+ expect(
576
+ ((await a) as Map<string, { id: string }>).get(SCHEDULE_X),
577
+ ).toEqual({ id: SCHEDULE_X });
578
+ expect(
579
+ ((await b) as Map<string, { id: string }>).get(SCHEDULE_X),
580
+ ).toEqual({ id: SCHEDULE_X });
581
+ expect(calls).toBe(1);
582
+ });
583
+
584
+ it("propagates a failure to every caller and caches nothing", async () => {
585
+ const failing: MockedFn = jest.fn(async () => {
586
+ throw new Error("resolver exploded");
587
+ });
588
+
589
+ await expect(
590
+ OnCallCalendarFeedCache.getOrRenderScheduleSegmentsBatch({
591
+ entries: [
592
+ { scheduleId: SCHEDULE_X, key: "v1" },
593
+ { scheduleId: SCHEDULE_Y, key: "v1" },
594
+ ],
595
+ ttlSeconds: 3600,
596
+ renderMissing: failing as never,
597
+ }),
598
+ ).rejects.toThrow("resolver exploded");
599
+
600
+ expect(redis.keysWithPrefix("seg:")).toHaveLength(0);
601
+ });
602
+
603
+ /*
604
+ * A schedule's segments carry every user's shifts over the window. An
605
+ * hourly rotation over the widest window is megabytes, and the compose
606
+ * Redis has no maxmemory: the value is returned to the caller, it is just
607
+ * not stored.
608
+ */
609
+ it("returns but does not store a segment over the size cap", async () => {
610
+ const huge: MockedFn = jest.fn(async () => {
611
+ return new Map<string, { blob: string }>([
612
+ [SCHEDULE_X, { blob: "x".repeat(MAX_CACHED_SEGMENT_BYTES + 1) }],
613
+ ]);
614
+ });
615
+
616
+ const result: Map<string, { blob: string }> =
617
+ (await OnCallCalendarFeedCache.getOrRenderScheduleSegmentsBatch({
618
+ entries: [{ scheduleId: SCHEDULE_X, key: "v1" }],
619
+ ttlSeconds: 3600,
620
+ renderMissing: huge as never,
621
+ })) as Map<string, { blob: string }>;
622
+
623
+ expect(result.get(SCHEDULE_X)?.blob).toHaveLength(
624
+ MAX_CACHED_SEGMENT_BYTES + 1,
625
+ );
626
+ expect(redis.keysWithPrefix("seg:")).toHaveLength(0);
627
+ });
628
+ });
629
+
630
+ // -- Body cache ---------------------------------------------------------
631
+
632
+ describe("body cache", () => {
633
+ const scope: OnCallCalendarFeedCacheScope = {
634
+ projectId: PROJECT_A,
635
+ userId: USER_1,
636
+ scheduleIds: [SCHEDULE_X, SCHEDULE_Y],
637
+ };
638
+
639
+ it("returns null on a miss", async () => {
640
+ expect(
641
+ await OnCallCalendarFeedCache.getBody({ key: "tokenhash:day1", scope }),
642
+ ).toBeNull();
643
+ });
644
+
645
+ it("round-trips a body with its ETag and Last-Modified as a Date", async () => {
646
+ const stored: boolean = await OnCallCalendarFeedCache.setBody({
647
+ key: "tokenhash:day1",
648
+ scope,
649
+ value: body("personal"),
650
+ ttlSeconds: 300,
651
+ });
652
+
653
+ expect(stored).toBe(true);
654
+
655
+ const cached: CachedCalendarBody | null =
656
+ await OnCallCalendarFeedCache.getBody({ key: "tokenhash:day1", scope });
657
+
658
+ expect(cached).not.toBeNull();
659
+ expect(cached?.body).toBe(body("personal").body);
660
+ expect(cached?.etag).toBe('"personal"');
661
+ expect(cached?.lastModified).toBeInstanceOf(Date);
662
+ expect(cached?.lastModified.toISOString()).toBe(
663
+ "2026-08-30T10:11:12.000Z",
664
+ );
665
+ });
666
+
667
+ it("stores the body with the requested TTL", async () => {
668
+ await OnCallCalendarFeedCache.setBody({
669
+ key: "k",
670
+ scope,
671
+ value: body("x"),
672
+ ttlSeconds: 300,
673
+ });
674
+
675
+ const keys: Array<string> = redis.keysWithPrefix("body:");
676
+
677
+ expect(keys).toHaveLength(1);
678
+ expect(redis.store.get(keys[0]!)?.ttl).toBe(300);
679
+ });
680
+
681
+ it("never puts the caller's key (which carries the token hash) into the Redis key", async () => {
682
+ await OnCallCalendarFeedCache.setBody({
683
+ key: "deadbeefcafe:day1",
684
+ scope,
685
+ value: body("x"),
686
+ ttlSeconds: 300,
687
+ });
688
+
689
+ for (const key of redis.keysWithPrefix("body:")) {
690
+ expect(key).not.toContain("deadbeefcafe");
691
+ }
692
+ });
693
+
694
+ it("keeps different keys apart", async () => {
695
+ await OnCallCalendarFeedCache.setBody({
696
+ key: "one",
697
+ scope,
698
+ value: body("one"),
699
+ ttlSeconds: 300,
700
+ });
701
+ await OnCallCalendarFeedCache.setBody({
702
+ key: "two",
703
+ scope,
704
+ value: body("two"),
705
+ ttlSeconds: 300,
706
+ });
707
+
708
+ expect(
709
+ (await OnCallCalendarFeedCache.getBody({ key: "one", scope }))?.etag,
710
+ ).toBe('"one"');
711
+ expect(
712
+ (await OnCallCalendarFeedCache.getBody({ key: "two", scope }))?.etag,
713
+ ).toBe('"two"');
714
+ });
715
+
716
+ it("refuses to cache a body over the size cap, without throwing", async () => {
717
+ const huge: CachedCalendarBody = {
718
+ ...body("huge"),
719
+ body: "x".repeat(MAX_CACHED_BODY_BYTES + 1),
720
+ };
721
+
722
+ const stored: boolean = await OnCallCalendarFeedCache.setBody({
723
+ key: "k",
724
+ scope,
725
+ value: huge,
726
+ ttlSeconds: 300,
727
+ });
728
+
729
+ expect(stored).toBe(false);
730
+ expect(redis.keysWithPrefix("body:")).toHaveLength(0);
731
+ expect(await OnCallCalendarFeedCache.getBody({ key: "k", scope })).toBe(
732
+ null,
733
+ );
734
+ });
735
+
736
+ it("measures the cap in bytes, not characters", async () => {
737
+ /* Two-byte characters: half the cap in characters is over it in bytes. */
738
+ const twoByte: CachedCalendarBody = {
739
+ ...body("bytes"),
740
+ body: "Å".repeat(MAX_CACHED_BODY_BYTES / 2 + 1),
741
+ };
742
+
743
+ expect(
744
+ await OnCallCalendarFeedCache.setBody({
745
+ key: "k",
746
+ scope,
747
+ value: twoByte,
748
+ ttlSeconds: 300,
749
+ }),
750
+ ).toBe(false);
751
+ });
752
+
753
+ it("caches a body exactly at the cap", async () => {
754
+ const atCap: CachedCalendarBody = {
755
+ ...body("cap"),
756
+ body: "x".repeat(MAX_CACHED_BODY_BYTES),
757
+ };
758
+
759
+ expect(
760
+ await OnCallCalendarFeedCache.setBody({
761
+ key: "k",
762
+ scope,
763
+ value: atCap,
764
+ ttlSeconds: 300,
765
+ }),
766
+ ).toBe(true);
767
+ });
768
+
769
+ it("treats a corrupt cached body as a miss", async () => {
770
+ await OnCallCalendarFeedCache.setBody({
771
+ key: "k",
772
+ scope,
773
+ value: body("x"),
774
+ ttlSeconds: 300,
775
+ });
776
+
777
+ const [key] = redis.keysWithPrefix("body:");
778
+
779
+ for (const corrupt of [
780
+ "{not json",
781
+ JSON.stringify({ body: "x" }),
782
+ JSON.stringify({ body: "x", etag: 1, lastModified: "2026-01-01" }),
783
+ JSON.stringify({ body: "x", etag: "e", lastModified: "not a date" }),
784
+ JSON.stringify([]),
785
+ "null",
786
+ ]) {
787
+ redis.store.set(key!, { value: corrupt, ttl: 300 });
788
+
789
+ expect(
790
+ await OnCallCalendarFeedCache.getBody({ key: "k", scope }),
791
+ ).toBeNull();
792
+ }
793
+ });
794
+
795
+ it("orders schedule ids canonically, so the same set in a different order is one entry", async () => {
796
+ await OnCallCalendarFeedCache.setBody({
797
+ key: "k",
798
+ scope: {
799
+ projectId: PROJECT_A,
800
+ userId: USER_1,
801
+ scheduleIds: [SCHEDULE_X, SCHEDULE_Y, SCHEDULE_X],
802
+ },
803
+ value: body("x"),
804
+ ttlSeconds: 300,
805
+ });
806
+
807
+ const cached: CachedCalendarBody | null =
808
+ await OnCallCalendarFeedCache.getBody({
809
+ key: "k",
810
+ scope: {
811
+ projectId: PROJECT_A,
812
+ userId: USER_1,
813
+ scheduleIds: [SCHEDULE_Y, SCHEDULE_X],
814
+ },
815
+ });
816
+
817
+ expect(cached?.etag).toBe('"x"');
818
+ });
819
+ });
820
+
821
+ // -- Purges -------------------------------------------------------------
822
+
823
+ describe("purges", () => {
824
+ const personalFeedOfUser1: OnCallCalendarFeedCacheScope = {
825
+ projectId: PROJECT_A,
826
+ userId: USER_1,
827
+ scheduleIds: [SCHEDULE_X],
828
+ };
829
+ const personalFeedOfUser2: OnCallCalendarFeedCacheScope = {
830
+ projectId: PROJECT_A,
831
+ userId: USER_2,
832
+ scheduleIds: [SCHEDULE_X, SCHEDULE_Y],
833
+ };
834
+ const scheduleFeedY: OnCallCalendarFeedCacheScope = {
835
+ projectId: PROJECT_A,
836
+ scheduleIds: [SCHEDULE_Y],
837
+ };
838
+ const projectFeedA: OnCallCalendarFeedCacheScope = {
839
+ projectId: PROJECT_A,
840
+ scheduleIds: [SCHEDULE_X, SCHEDULE_Y],
841
+ };
842
+ const projectFeedB: OnCallCalendarFeedCacheScope = {
843
+ projectId: PROJECT_B,
844
+ scheduleIds: [],
845
+ };
846
+
847
+ const allFeeds: Array<{
848
+ name: string;
849
+ key: string;
850
+ scope: OnCallCalendarFeedCacheScope;
851
+ }> = [
852
+ { name: "user1", key: "user1-token", scope: personalFeedOfUser1 },
853
+ { name: "user2", key: "user2-token", scope: personalFeedOfUser2 },
854
+ { name: "scheduleY", key: "scheduleY-token", scope: scheduleFeedY },
855
+ { name: "projectA", key: "projectA-token", scope: projectFeedA },
856
+ { name: "projectB", key: "projectB-token", scope: projectFeedB },
857
+ ];
858
+
859
+ async function fillAll(): Promise<void> {
860
+ for (const feed of allFeeds) {
861
+ await OnCallCalendarFeedCache.setBody({
862
+ key: feed.key,
863
+ scope: feed.scope,
864
+ value: body(feed.name),
865
+ ttlSeconds: 300,
866
+ });
867
+ }
868
+ }
869
+
870
+ async function survivors(): Promise<Array<string>> {
871
+ const alive: Array<string> = [];
872
+
873
+ for (const feed of allFeeds) {
874
+ const cached: CachedCalendarBody | null =
875
+ await OnCallCalendarFeedCache.getBody({
876
+ key: feed.key,
877
+ scope: feed.scope,
878
+ });
879
+
880
+ if (cached) {
881
+ alive.push(feed.name);
882
+ }
883
+ }
884
+
885
+ return alive;
886
+ }
887
+
888
+ it("purgeForUser drops that user's feed in that project and nothing else", async () => {
889
+ await fillAll();
890
+
891
+ await OnCallCalendarFeedCache.purgeForUser(PROJECT_A, USER_1);
892
+
893
+ expect(await survivors()).toEqual([
894
+ "user2",
895
+ "scheduleY",
896
+ "projectA",
897
+ "projectB",
898
+ ]);
899
+ });
900
+
901
+ it("purgeForUser is scoped to the project", async () => {
902
+ await fillAll();
903
+
904
+ await OnCallCalendarFeedCache.purgeForUser(PROJECT_B, USER_1);
905
+
906
+ expect(await survivors()).toEqual([
907
+ "user1",
908
+ "user2",
909
+ "scheduleY",
910
+ "projectA",
911
+ "projectB",
912
+ ]);
913
+ });
914
+
915
+ it("purgeForSchedule drops every feed rendered from that schedule", async () => {
916
+ await fillAll();
917
+
918
+ await OnCallCalendarFeedCache.purgeForSchedule(SCHEDULE_Y);
919
+
920
+ /* user1 does not depend on Y; projectB is another project. */
921
+ expect(await survivors()).toEqual(["user1", "projectB"]);
922
+ });
923
+
924
+ it("purgeForSchedule on a schedule nobody depends on drops nothing", async () => {
925
+ await fillAll();
926
+
927
+ await OnCallCalendarFeedCache.purgeForSchedule(
928
+ "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee",
929
+ );
930
+
931
+ expect(await survivors()).toHaveLength(allFeeds.length);
932
+ });
933
+
934
+ it("purgeForProject drops every feed in the project and no other project's", async () => {
935
+ await fillAll();
936
+
937
+ await OnCallCalendarFeedCache.purgeForProject(PROJECT_A);
938
+
939
+ expect(await survivors()).toEqual(["projectB"]);
940
+ });
941
+
942
+ it("a purged feed can be re-cached and read again", async () => {
943
+ await fillAll();
944
+ await OnCallCalendarFeedCache.purgeForUser(PROJECT_A, USER_1);
945
+
946
+ await OnCallCalendarFeedCache.setBody({
947
+ key: "user1-token",
948
+ scope: personalFeedOfUser1,
949
+ value: body("user1-v2"),
950
+ ttlSeconds: 300,
951
+ });
952
+
953
+ expect(
954
+ (
955
+ await OnCallCalendarFeedCache.getBody({
956
+ key: "user1-token",
957
+ scope: personalFeedOfUser1,
958
+ })
959
+ )?.etag,
960
+ ).toBe('"user1-v2"');
961
+ });
962
+
963
+ it("two purges in a row do not resurrect the first generation", async () => {
964
+ await fillAll();
965
+
966
+ await OnCallCalendarFeedCache.purgeForUser(PROJECT_A, USER_1);
967
+ await OnCallCalendarFeedCache.purgeForUser(PROJECT_A, USER_1);
968
+
969
+ expect(await survivors()).not.toContain("user1");
970
+ });
971
+
972
+ it("writes the generation with a TTL, so a deleted scope does not leak a key forever", async () => {
973
+ await OnCallCalendarFeedCache.purgeForSchedule(SCHEDULE_X);
974
+
975
+ const keys: Array<string> = redis.keysWithPrefix("gen:schedule:");
976
+
977
+ expect(keys).toHaveLength(1);
978
+ expect(redis.store.get(keys[0]!)?.ttl).toBeGreaterThan(
979
+ LAST_GOOD_TTL_SECONDS,
980
+ );
981
+ });
982
+
983
+ it("purges are visible to another process reading the same Redis", async () => {
984
+ /*
985
+ * The in-process generation memo must never mask a purge another
986
+ * process made: simulate that by writing the generation straight into
987
+ * Redis, bypassing this process's memory.
988
+ */
989
+ await fillAll();
990
+
991
+ const [genKey] = redis.keysWithPrefix("gen:");
992
+ expect(genKey).toBeUndefined();
993
+
994
+ await OnCallCalendarFeedCache.setBody({
995
+ key: "user1-token",
996
+ scope: personalFeedOfUser1,
997
+ value: body("user1"),
998
+ ttlSeconds: 300,
999
+ });
1000
+
1001
+ redis.store.set(
1002
+ `${ON_CALL_CALENDAR_FEED_CACHE_NAMESPACE}-gen:user:${PROJECT_A}:${USER_1}`,
1003
+ { value: "other-process-bump", ttl: 100 },
1004
+ );
1005
+
1006
+ expect(
1007
+ await OnCallCalendarFeedCache.getBody({
1008
+ key: "user1-token",
1009
+ scope: personalFeedOfUser1,
1010
+ }),
1011
+ ).toBeNull();
1012
+ });
1013
+ });
1014
+
1015
+ // -- Last-good ----------------------------------------------------------
1016
+
1017
+ describe("last-good", () => {
1018
+ it("returns null when nothing was ever stored", async () => {
1019
+ expect(await OnCallCalendarFeedCache.getLastGood("k")).toBeNull();
1020
+ });
1021
+
1022
+ it("round-trips a body", async () => {
1023
+ expect(await OnCallCalendarFeedCache.setLastGood("k", body("good"))).toBe(
1024
+ true,
1025
+ );
1026
+
1027
+ const cached: CachedCalendarBody | null =
1028
+ await OnCallCalendarFeedCache.getLastGood("k");
1029
+
1030
+ expect(cached?.etag).toBe('"good"');
1031
+ expect(cached?.lastModified.toISOString()).toBe(
1032
+ "2026-08-30T10:11:12.000Z",
1033
+ );
1034
+ });
1035
+
1036
+ it("is kept for a day", async () => {
1037
+ await OnCallCalendarFeedCache.setLastGood("k", body("good"));
1038
+
1039
+ const keys: Array<string> = redis.keysWithPrefix("lastgood:");
1040
+
1041
+ expect(keys).toHaveLength(1);
1042
+ expect(redis.store.get(keys[0]!)?.ttl).toBe(LAST_GOOD_TTL_SECONDS);
1043
+ expect(LAST_GOOD_TTL_SECONDS).toBe(24 * 60 * 60);
1044
+ });
1045
+
1046
+ it("is stored apart from the fresh body under the same key", async () => {
1047
+ const scope: OnCallCalendarFeedCacheScope = { projectId: PROJECT_A };
1048
+
1049
+ await OnCallCalendarFeedCache.setBody({
1050
+ key: "k",
1051
+ scope,
1052
+ value: body("fresh"),
1053
+ ttlSeconds: 300,
1054
+ });
1055
+ await OnCallCalendarFeedCache.setLastGood("k", body("good"));
1056
+
1057
+ expect(
1058
+ (await OnCallCalendarFeedCache.getBody({ key: "k", scope }))?.etag,
1059
+ ).toBe('"fresh"');
1060
+ expect((await OnCallCalendarFeedCache.getLastGood("k"))?.etag).toBe(
1061
+ '"good"',
1062
+ );
1063
+ });
1064
+
1065
+ /*
1066
+ * The stale-while-error tier exists for the moment a render FAILS, and a
1067
+ * hook purge is one of the things that precedes a render. Its key embeds
1068
+ * the feed's own key (token hash + candidate set), so nothing a purge
1069
+ * protects against can reach it.
1070
+ */
1071
+ it("survives every kind of purge", async () => {
1072
+ await OnCallCalendarFeedCache.setLastGood("k", body("good"));
1073
+
1074
+ await OnCallCalendarFeedCache.purgeForUser(PROJECT_A, USER_1);
1075
+ await OnCallCalendarFeedCache.purgeForSchedule(SCHEDULE_X);
1076
+ await OnCallCalendarFeedCache.purgeForProject(PROJECT_A);
1077
+
1078
+ expect((await OnCallCalendarFeedCache.getLastGood("k"))?.etag).toBe(
1079
+ '"good"',
1080
+ );
1081
+ });
1082
+
1083
+ it("refuses a body over its own, larger size cap", async () => {
1084
+ expect(
1085
+ await OnCallCalendarFeedCache.setLastGood("k", {
1086
+ ...body("huge"),
1087
+ body: "x".repeat(MAX_CACHED_LAST_GOOD_BYTES + 1),
1088
+ }),
1089
+ ).toBe(false);
1090
+ expect(await OnCallCalendarFeedCache.getLastGood("k")).toBeNull();
1091
+ });
1092
+
1093
+ /*
1094
+ * Regression: the last-good tier used to share the body cache's 2 MiB
1095
+ * cap. A VEVENT is roughly 0.9 KB, so that cap is crossed at about 2,200
1096
+ * events -- less than half of MAX_EVENTS -- and the feeds that cross it
1097
+ * are the expensive ones, the ones most likely to trip the render cap and
1098
+ * need a stale body. Refusing to keep one meant those feeds answered 503
1099
+ * instead, which is exactly what this tier exists to prevent.
1100
+ */
1101
+ it("keeps a body that the body cache is too small for", async () => {
1102
+ const big: string = "x".repeat(MAX_CACHED_BODY_BYTES + 1);
1103
+
1104
+ expect(MAX_CACHED_LAST_GOOD_BYTES).toBeGreaterThan(MAX_CACHED_BODY_BYTES);
1105
+
1106
+ expect(
1107
+ await OnCallCalendarFeedCache.setBody({
1108
+ key: "k",
1109
+ scope: { projectId: PROJECT_A },
1110
+ value: { ...body("big"), body: big },
1111
+ ttlSeconds: 300,
1112
+ }),
1113
+ ).toBe(false);
1114
+
1115
+ expect(
1116
+ await OnCallCalendarFeedCache.setLastGood("k", {
1117
+ ...body("big"),
1118
+ body: big,
1119
+ }),
1120
+ ).toBe(true);
1121
+
1122
+ expect((await OnCallCalendarFeedCache.getLastGood("k"))?.body).toBe(big);
1123
+ });
1124
+
1125
+ it("never puts the caller's key into the Redis key", async () => {
1126
+ await OnCallCalendarFeedCache.setLastGood("deadbeefcafe", body("x"));
1127
+
1128
+ for (const key of redis.keysWithPrefix("lastgood:")) {
1129
+ expect(key).not.toContain("deadbeefcafe");
1130
+ }
1131
+ });
1132
+ });
1133
+
1134
+ // -- Redis down ---------------------------------------------------------
1135
+
1136
+ describe("when Redis is unavailable", () => {
1137
+ const scope: OnCallCalendarFeedCacheScope = {
1138
+ projectId: PROJECT_A,
1139
+ userId: USER_1,
1140
+ scheduleIds: [SCHEDULE_X],
1141
+ };
1142
+
1143
+ const scenarios: Array<{ name: string; arrange: () => void }> = [
1144
+ {
1145
+ name: "not connected",
1146
+ arrange: (): void => {
1147
+ isConnectedMock.mockReturnValue(false);
1148
+ },
1149
+ },
1150
+ {
1151
+ name: "no client",
1152
+ arrange: (): void => {
1153
+ getClientMock.mockReturnValue(null);
1154
+ },
1155
+ },
1156
+ {
1157
+ name: "commands throwing",
1158
+ arrange: (): void => {
1159
+ redis.failReads = true;
1160
+ redis.failWrites = true;
1161
+ },
1162
+ },
1163
+ ];
1164
+
1165
+ for (const scenario of scenarios) {
1166
+ describe(scenario.name, () => {
1167
+ beforeEach(() => {
1168
+ scenario.arrange();
1169
+ });
1170
+
1171
+ it("still renders and caches segments in process", async () => {
1172
+ const renderFn: MockedFn = jest.fn(async () => {
1173
+ return ["seg"];
1174
+ });
1175
+
1176
+ const read: () => Promise<unknown> = () => {
1177
+ return OnCallCalendarFeedCache.getOrRenderScheduleSegments({
1178
+ scheduleId: SCHEDULE_X,
1179
+ key: "v1",
1180
+ ttlSeconds: 3600,
1181
+ render: renderFn as unknown as () => Promise<unknown>,
1182
+ });
1183
+ };
1184
+
1185
+ expect(await read()).toEqual(["seg"]);
1186
+ expect(await read()).toEqual(["seg"]);
1187
+ expect(renderFn).toHaveBeenCalledTimes(1);
1188
+ });
1189
+
1190
+ it("still stores and reads bodies in process", async () => {
1191
+ expect(
1192
+ await OnCallCalendarFeedCache.setBody({
1193
+ key: "k",
1194
+ scope,
1195
+ value: body("x"),
1196
+ ttlSeconds: 300,
1197
+ }),
1198
+ ).toBe(true);
1199
+
1200
+ expect(
1201
+ (await OnCallCalendarFeedCache.getBody({ key: "k", scope }))?.etag,
1202
+ ).toBe('"x"');
1203
+ });
1204
+
1205
+ it("still stores and reads last-good in process", async () => {
1206
+ await OnCallCalendarFeedCache.setLastGood("k", body("good"));
1207
+
1208
+ expect((await OnCallCalendarFeedCache.getLastGood("k"))?.etag).toBe(
1209
+ '"good"',
1210
+ );
1211
+ });
1212
+
1213
+ it("purges still take effect in this process", async () => {
1214
+ await OnCallCalendarFeedCache.setBody({
1215
+ key: "k",
1216
+ scope,
1217
+ value: body("x"),
1218
+ ttlSeconds: 300,
1219
+ });
1220
+
1221
+ await OnCallCalendarFeedCache.purgeForUser(PROJECT_A, USER_1);
1222
+
1223
+ expect(
1224
+ await OnCallCalendarFeedCache.getBody({ key: "k", scope }),
1225
+ ).toBeNull();
1226
+ });
1227
+
1228
+ it("segment purges still take effect in this process", async () => {
1229
+ const renderFn: MockedFn = jest.fn(async () => {
1230
+ return ["seg"];
1231
+ });
1232
+
1233
+ const read: () => Promise<unknown> = () => {
1234
+ return OnCallCalendarFeedCache.getOrRenderScheduleSegments({
1235
+ scheduleId: SCHEDULE_X,
1236
+ key: "v1",
1237
+ ttlSeconds: 3600,
1238
+ render: renderFn as unknown as () => Promise<unknown>,
1239
+ });
1240
+ };
1241
+
1242
+ await read();
1243
+ await OnCallCalendarFeedCache.purgeForSchedule(SCHEDULE_X);
1244
+ await read();
1245
+
1246
+ expect(renderFn).toHaveBeenCalledTimes(2);
1247
+ });
1248
+
1249
+ it("never throws from any operation", async () => {
1250
+ await expect(
1251
+ OnCallCalendarFeedCache.getBody({ key: "k", scope }),
1252
+ ).resolves.toBeNull();
1253
+ await expect(
1254
+ OnCallCalendarFeedCache.setBody({
1255
+ key: "k",
1256
+ scope,
1257
+ value: body("x"),
1258
+ ttlSeconds: 300,
1259
+ }),
1260
+ ).resolves.toBe(true);
1261
+ await expect(
1262
+ OnCallCalendarFeedCache.getLastGood("k"),
1263
+ ).resolves.toBeDefined();
1264
+ await expect(
1265
+ OnCallCalendarFeedCache.setLastGood("k", body("x")),
1266
+ ).resolves.toBe(true);
1267
+ await expect(
1268
+ OnCallCalendarFeedCache.purgeForUser(PROJECT_A, USER_1),
1269
+ ).resolves.toBeUndefined();
1270
+ await expect(
1271
+ OnCallCalendarFeedCache.purgeForSchedule(SCHEDULE_X),
1272
+ ).resolves.toBeUndefined();
1273
+ await expect(
1274
+ OnCallCalendarFeedCache.purgeForProject(PROJECT_A),
1275
+ ).resolves.toBeUndefined();
1276
+ });
1277
+
1278
+ it("logs at debug only, never warn or error, so an outage does not flood the log", async () => {
1279
+ for (let i: number = 0; i < 20; i++) {
1280
+ await OnCallCalendarFeedCache.getBody({ key: `k${i}`, scope });
1281
+ }
1282
+
1283
+ expect(loggerWarnMock).not.toHaveBeenCalled();
1284
+ expect(loggerErrorMock).not.toHaveBeenCalled();
1285
+ });
1286
+ });
1287
+ }
1288
+
1289
+ /*
1290
+ * Regression: the in-process tiers were bounded by entry count only (500).
1291
+ * What they hold during an outage is whole calendar bodies and whole
1292
+ * schedule expansions -- megabytes each -- so 500 entries was hundreds of
1293
+ * megabytes of retained strings on the API tier. Nothing reads a fallback
1294
+ * tier once Redis is healthy again, so expired entries were never swept
1295
+ * either: they stayed resident until 500 newer writes pushed them out.
1296
+ */
1297
+ it("bounds the in-process fallback by bytes, not just by entry count", async () => {
1298
+ isConnectedMock.mockReturnValue(false);
1299
+
1300
+ const chunk: string = "x".repeat(4 * 1024 * 1024);
1301
+
1302
+ for (let i: number = 0; i < 12; i++) {
1303
+ await OnCallCalendarFeedCache.setLastGood(`big-${i}`, {
1304
+ ...body("x"),
1305
+ body: chunk,
1306
+ });
1307
+ }
1308
+
1309
+ expect(OnCallCalendarFeedCache.getInProcessBytes()).toBeLessThanOrEqual(
1310
+ MEMORY_MAX_BYTES,
1311
+ );
1312
+
1313
+ /* The newest write survived; the oldest were evicted to make room. */
1314
+ expect(
1315
+ (await OnCallCalendarFeedCache.getLastGood("big-11"))?.body.length,
1316
+ ).toBe(chunk.length);
1317
+ expect(await OnCallCalendarFeedCache.getLastGood("big-0")).toBeNull();
1318
+ });
1319
+
1320
+ it("drops an entry whose TTL has passed instead of holding it for 500 writes", async () => {
1321
+ isConnectedMock.mockReturnValue(false);
1322
+
1323
+ await OnCallCalendarFeedCache.setBody({
1324
+ key: "short",
1325
+ scope,
1326
+ value: body("short"),
1327
+ ttlSeconds: 1,
1328
+ });
1329
+
1330
+ expect(OnCallCalendarFeedCache.getInProcessBytes()).toBeGreaterThan(0);
1331
+
1332
+ const realNow: () => number = Date.now;
1333
+
1334
+ try {
1335
+ Date.now = (): number => {
1336
+ return realNow() + 5000;
1337
+ };
1338
+
1339
+ /* Any write is enough to make the store reclaim what has expired. */
1340
+ await OnCallCalendarFeedCache.setBody({
1341
+ key: "next",
1342
+ scope,
1343
+ value: body("next"),
1344
+ ttlSeconds: 300,
1345
+ });
1346
+
1347
+ expect(
1348
+ await OnCallCalendarFeedCache.getBody({ key: "short", scope }),
1349
+ ).toBeNull();
1350
+ } finally {
1351
+ Date.now = realNow;
1352
+ }
1353
+ });
1354
+
1355
+ it("a render that throws still propagates while Redis is down", async () => {
1356
+ isConnectedMock.mockReturnValue(false);
1357
+
1358
+ await expect(
1359
+ OnCallCalendarFeedCache.getOrRenderScheduleSegments({
1360
+ scheduleId: SCHEDULE_X,
1361
+ key: "v1",
1362
+ ttlSeconds: 3600,
1363
+ render: async () => {
1364
+ throw new Error("boom");
1365
+ },
1366
+ }),
1367
+ ).rejects.toThrow("boom");
1368
+ });
1369
+
1370
+ it("does not serve the in-process copy while Redis is healthy", async () => {
1371
+ /*
1372
+ * Fill the in-process tier during an outage, then bring Redis back
1373
+ * empty: the read must miss, because another process may have purged
1374
+ * in the meantime and this process's copy is not authoritative.
1375
+ */
1376
+ isConnectedMock.mockReturnValue(false);
1377
+
1378
+ await OnCallCalendarFeedCache.setBody({
1379
+ key: "k",
1380
+ scope,
1381
+ value: body("during-outage"),
1382
+ ttlSeconds: 300,
1383
+ });
1384
+
1385
+ isConnectedMock.mockReturnValue(true);
1386
+
1387
+ expect(
1388
+ await OnCallCalendarFeedCache.getBody({ key: "k", scope }),
1389
+ ).toBeNull();
1390
+ });
1391
+
1392
+ it("a write that fails after a successful read falls back without losing the value", async () => {
1393
+ redis.failWrites = true;
1394
+
1395
+ await OnCallCalendarFeedCache.setBody({
1396
+ key: "k",
1397
+ scope,
1398
+ value: body("x"),
1399
+ ttlSeconds: 300,
1400
+ });
1401
+
1402
+ /* Redis has it not; memory does; reads go to Redis first and miss. */
1403
+ expect(redis.keysWithPrefix("body:")).toHaveLength(0);
1404
+
1405
+ redis.failReads = true;
1406
+
1407
+ expect(
1408
+ (await OnCallCalendarFeedCache.getBody({ key: "k", scope }))?.etag,
1409
+ ).toBe('"x"');
1410
+ });
1411
+ });
1412
+
1413
+ // -- Render slots -------------------------------------------------------
1414
+
1415
+ describe("render slots", () => {
1416
+ it("defaults to four concurrent renders per process", () => {
1417
+ expect(DEFAULT_RENDER_CONCURRENCY).toBe(4);
1418
+ expect(OnCallCalendarFeedCache.getRenderConcurrency()).toBe(4);
1419
+ });
1420
+
1421
+ it("grants slots up to the cap and refuses the next", () => {
1422
+ for (let i: number = 0; i < DEFAULT_RENDER_CONCURRENCY; i++) {
1423
+ expect(OnCallCalendarFeedCache.tryAcquireRenderSlot()).toBe(true);
1424
+ }
1425
+
1426
+ expect(OnCallCalendarFeedCache.tryAcquireRenderSlot()).toBe(false);
1427
+ expect(OnCallCalendarFeedCache.getActiveRenderSlots()).toBe(
1428
+ DEFAULT_RENDER_CONCURRENCY,
1429
+ );
1430
+ });
1431
+
1432
+ it("a release frees a slot", () => {
1433
+ for (let i: number = 0; i < DEFAULT_RENDER_CONCURRENCY; i++) {
1434
+ OnCallCalendarFeedCache.tryAcquireRenderSlot();
1435
+ }
1436
+
1437
+ OnCallCalendarFeedCache.releaseRenderSlot();
1438
+
1439
+ expect(OnCallCalendarFeedCache.tryAcquireRenderSlot()).toBe(true);
1440
+ expect(OnCallCalendarFeedCache.tryAcquireRenderSlot()).toBe(false);
1441
+ });
1442
+
1443
+ it("a refused acquire does not consume a slot", () => {
1444
+ for (let i: number = 0; i < DEFAULT_RENDER_CONCURRENCY + 5; i++) {
1445
+ OnCallCalendarFeedCache.tryAcquireRenderSlot();
1446
+ }
1447
+
1448
+ expect(OnCallCalendarFeedCache.getActiveRenderSlots()).toBe(
1449
+ DEFAULT_RENDER_CONCURRENCY,
1450
+ );
1451
+ });
1452
+
1453
+ it("never goes below zero on an unbalanced release", () => {
1454
+ OnCallCalendarFeedCache.releaseRenderSlot();
1455
+ OnCallCalendarFeedCache.releaseRenderSlot();
1456
+
1457
+ expect(OnCallCalendarFeedCache.getActiveRenderSlots()).toBe(0);
1458
+
1459
+ for (let i: number = 0; i < DEFAULT_RENDER_CONCURRENCY; i++) {
1460
+ expect(OnCallCalendarFeedCache.tryAcquireRenderSlot()).toBe(true);
1461
+ }
1462
+
1463
+ expect(OnCallCalendarFeedCache.tryAcquireRenderSlot()).toBe(false);
1464
+ });
1465
+
1466
+ it("honours a configured concurrency", () => {
1467
+ OnCallCalendarFeedCache.configure({ renderConcurrency: 2 });
1468
+
1469
+ expect(OnCallCalendarFeedCache.getRenderConcurrency()).toBe(2);
1470
+ expect(OnCallCalendarFeedCache.tryAcquireRenderSlot()).toBe(true);
1471
+ expect(OnCallCalendarFeedCache.tryAcquireRenderSlot()).toBe(true);
1472
+ expect(OnCallCalendarFeedCache.tryAcquireRenderSlot()).toBe(false);
1473
+ });
1474
+
1475
+ it("ignores a concurrency that is not a positive integer", () => {
1476
+ for (const bad of [0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY]) {
1477
+ OnCallCalendarFeedCache.configure({ renderConcurrency: bad });
1478
+
1479
+ expect(OnCallCalendarFeedCache.getRenderConcurrency()).toBe(
1480
+ DEFAULT_RENDER_CONCURRENCY,
1481
+ );
1482
+ }
1483
+ });
1484
+
1485
+ it("is not Redis-backed, so the cap holds while Redis is down", () => {
1486
+ isConnectedMock.mockReturnValue(false);
1487
+
1488
+ for (let i: number = 0; i < DEFAULT_RENDER_CONCURRENCY; i++) {
1489
+ expect(OnCallCalendarFeedCache.tryAcquireRenderSlot()).toBe(true);
1490
+ }
1491
+
1492
+ expect(OnCallCalendarFeedCache.tryAcquireRenderSlot()).toBe(false);
1493
+ expect(redis.setCalls).toBe(0);
1494
+ });
1495
+
1496
+ /*
1497
+ * Regression: /my-shifts (a session route whose caller can retry and
1498
+ * whose mobile client falls back to its roster list) used to be able to
1499
+ * take every slot, leaving the public feed routes -- whose callers are
1500
+ * calendar clients that answer a 503 by showing a stale or empty calendar
1501
+ * -- with none.
1502
+ */
1503
+ it("leaveFreeSlots keeps a caller out of the last slots", () => {
1504
+ expect(
1505
+ OnCallCalendarFeedCache.tryAcquireRenderSlot({ leaveFreeSlots: 2 }),
1506
+ ).toBe(true);
1507
+ expect(
1508
+ OnCallCalendarFeedCache.tryAcquireRenderSlot({ leaveFreeSlots: 2 }),
1509
+ ).toBe(true);
1510
+ expect(
1511
+ OnCallCalendarFeedCache.tryAcquireRenderSlot({ leaveFreeSlots: 2 }),
1512
+ ).toBe(false);
1513
+
1514
+ /* The reserved slots are still there for a caller that reserves none. */
1515
+ expect(OnCallCalendarFeedCache.tryAcquireRenderSlot()).toBe(true);
1516
+ expect(OnCallCalendarFeedCache.tryAcquireRenderSlot()).toBe(true);
1517
+ expect(OnCallCalendarFeedCache.tryAcquireRenderSlot()).toBe(false);
1518
+ });
1519
+
1520
+ it("leaveFreeSlots can never reserve every slot", () => {
1521
+ OnCallCalendarFeedCache.configure({ renderConcurrency: 1 });
1522
+
1523
+ expect(
1524
+ OnCallCalendarFeedCache.tryAcquireRenderSlot({ leaveFreeSlots: 9 }),
1525
+ ).toBe(true);
1526
+ expect(
1527
+ OnCallCalendarFeedCache.tryAcquireRenderSlot({ leaveFreeSlots: 9 }),
1528
+ ).toBe(false);
1529
+ });
1530
+
1531
+ it("ignores a negative or fractional reserve", () => {
1532
+ expect(
1533
+ OnCallCalendarFeedCache.tryAcquireRenderSlot({ leaveFreeSlots: -3 }),
1534
+ ).toBe(true);
1535
+ expect(
1536
+ OnCallCalendarFeedCache.tryAcquireRenderSlot({ leaveFreeSlots: 1.9 }),
1537
+ ).toBe(true);
1538
+ expect(
1539
+ OnCallCalendarFeedCache.tryAcquireRenderSlot({ leaveFreeSlots: 1.9 }),
1540
+ ).toBe(true);
1541
+ expect(
1542
+ OnCallCalendarFeedCache.tryAcquireRenderSlot({ leaveFreeSlots: 1.9 }),
1543
+ ).toBe(false);
1544
+ });
1545
+
1546
+ it("clearInProcessState releases every slot and restores the default cap", () => {
1547
+ OnCallCalendarFeedCache.configure({ renderConcurrency: 1 });
1548
+ OnCallCalendarFeedCache.tryAcquireRenderSlot();
1549
+
1550
+ OnCallCalendarFeedCache.clearInProcessState();
1551
+
1552
+ expect(OnCallCalendarFeedCache.getActiveRenderSlots()).toBe(0);
1553
+ expect(OnCallCalendarFeedCache.getRenderConcurrency()).toBe(
1554
+ DEFAULT_RENDER_CONCURRENCY,
1555
+ );
1556
+ });
1557
+ });
1558
+
1559
+ // -- Namespace ---------------------------------------------------------
1560
+
1561
+ describe("namespace", () => {
1562
+ it("keeps every key under its own namespace", async () => {
1563
+ await OnCallCalendarFeedCache.setBody({
1564
+ key: "k",
1565
+ scope: { projectId: PROJECT_A },
1566
+ value: body("x"),
1567
+ ttlSeconds: 300,
1568
+ });
1569
+ await OnCallCalendarFeedCache.setLastGood("k", body("x"));
1570
+ await OnCallCalendarFeedCache.purgeForProject(PROJECT_A);
1571
+ await OnCallCalendarFeedCache.getOrRenderScheduleSegments({
1572
+ scheduleId: SCHEDULE_X,
1573
+ key: "v1",
1574
+ ttlSeconds: 60,
1575
+ render: async () => {
1576
+ return [];
1577
+ },
1578
+ });
1579
+
1580
+ expect(redis.keys().length).toBeGreaterThan(0);
1581
+
1582
+ for (const key of redis.keys()) {
1583
+ expect(
1584
+ key.startsWith(`${ON_CALL_CALENDAR_FEED_CACHE_NAMESPACE}-`),
1585
+ ).toBe(true);
1586
+ }
1587
+ });
1588
+ });
1589
+ });