@remit/backend 0.0.143 → 0.0.144

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 (269) hide show
  1. package/dist/dev-server/content-handler.d.ts +38 -0
  2. package/dist/dev-server/content-handler.js +46 -0
  3. package/dist/src/api.d.ts +7 -0
  4. package/dist/src/api.js +144 -0
  5. package/dist/src/auth.d.ts +30 -0
  6. package/dist/src/auth.js +73 -0
  7. package/dist/src/config/msoauth.d.ts +30 -0
  8. package/dist/src/config/msoauth.js +30 -0
  9. package/{src/data-backend.ts → dist/src/data-backend.d.ts} +2 -4
  10. package/dist/src/data-backend.js +12 -0
  11. package/{src/derive/autoMoved.ts → dist/src/derive/autoMoved.d.ts} +1 -29
  12. package/dist/src/derive/autoMoved.js +49 -0
  13. package/dist/src/derive/contentSignature.d.ts +41 -0
  14. package/{src/derive/contentSignature.ts → dist/src/derive/contentSignature.js} +27 -72
  15. package/dist/src/derive/contentUrl.d.ts +46 -0
  16. package/dist/src/derive/contentUrl.js +46 -0
  17. package/{src/derive/deriveMuted.ts → dist/src/derive/deriveMuted.d.ts} +1 -3
  18. package/dist/src/derive/deriveMuted.js +7 -0
  19. package/dist/src/derive/enrichThreadRows.d.ts +118 -0
  20. package/dist/src/derive/enrichThreadRows.js +151 -0
  21. package/dist/src/derive/filterThreadCriteria.d.ts +23 -0
  22. package/dist/src/derive/filterThreadCriteria.js +24 -0
  23. package/{src/derive/pendingMoveCounts.ts → dist/src/derive/pendingMoveCounts.d.ts} +5 -53
  24. package/dist/src/derive/pendingMoveCounts.js +53 -0
  25. package/{src/derive/senderTrust.ts → dist/src/derive/senderTrust.d.ts} +1 -9
  26. package/dist/src/derive/senderTrust.js +19 -0
  27. package/dist/src/error.d.ts +21 -0
  28. package/dist/src/error.js +85 -0
  29. package/dist/src/handlers/account-guards.d.ts +36 -0
  30. package/dist/src/handlers/account-guards.js +107 -0
  31. package/dist/src/handlers/account-oauth.d.ts +14 -0
  32. package/dist/src/handlers/account-oauth.js +387 -0
  33. package/dist/src/handlers/account-overrides.d.ts +53 -0
  34. package/dist/src/handlers/account-overrides.js +233 -0
  35. package/dist/src/handlers/account-signature.d.ts +28 -0
  36. package/dist/src/handlers/account-signature.js +85 -0
  37. package/dist/src/handlers/account.d.ts +6 -0
  38. package/dist/src/handlers/account.js +350 -0
  39. package/dist/src/handlers/address.d.ts +35 -0
  40. package/dist/src/handlers/address.js +141 -0
  41. package/dist/src/handlers/calendar-event.d.ts +70 -0
  42. package/dist/src/handlers/calendar-event.js +425 -0
  43. package/dist/src/handlers/calendar-feed.d.ts +47 -0
  44. package/dist/src/handlers/calendar-feed.js +192 -0
  45. package/dist/src/handlers/calendar-suggestion.d.ts +47 -0
  46. package/dist/src/handlers/calendar-suggestion.js +212 -0
  47. package/dist/src/handlers/calendar.d.ts +116 -0
  48. package/dist/src/handlers/calendar.js +330 -0
  49. package/dist/src/handlers/config.d.ts +43 -0
  50. package/dist/src/handlers/config.js +176 -0
  51. package/dist/src/handlers/ensure-account-config.d.ts +9 -0
  52. package/dist/src/handlers/ensure-account-config.js +27 -0
  53. package/dist/src/handlers/filter.d.ts +3 -0
  54. package/dist/src/handlers/filter.js +103 -0
  55. package/dist/src/handlers/folder-role-appointments.d.ts +68 -0
  56. package/dist/src/handlers/folder-role-appointments.js +168 -0
  57. package/dist/src/handlers/folder-role-labels.d.ts +26 -0
  58. package/dist/src/handlers/folder-role-labels.js +40 -0
  59. package/dist/src/handlers/folder-role.d.ts +9 -0
  60. package/dist/src/handlers/folder-role.js +40 -0
  61. package/dist/src/handlers/index.d.ts +2 -0
  62. package/dist/src/handlers/index.js +60 -0
  63. package/dist/src/handlers/label.d.ts +43 -0
  64. package/dist/src/handlers/label.js +114 -0
  65. package/dist/src/handlers/mailbox-bindings.d.ts +41 -0
  66. package/dist/src/handlers/mailbox-bindings.js +61 -0
  67. package/dist/src/handlers/mailbox.d.ts +23 -0
  68. package/dist/src/handlers/mailbox.js +219 -0
  69. package/dist/src/handlers/me.d.ts +2 -0
  70. package/dist/src/handlers/me.js +138 -0
  71. package/dist/src/handlers/message.d.ts +84 -0
  72. package/dist/src/handlers/message.js +607 -0
  73. package/dist/src/handlers/organize.d.ts +10 -0
  74. package/dist/src/handlers/organize.js +112 -0
  75. package/dist/src/handlers/outbox-attachment.d.ts +23 -0
  76. package/dist/src/handlers/outbox-attachment.js +81 -0
  77. package/dist/src/handlers/outbox.d.ts +4 -0
  78. package/dist/src/handlers/outbox.js +144 -0
  79. package/dist/src/handlers/sync-progress.d.ts +46 -0
  80. package/dist/src/handlers/sync-progress.js +63 -0
  81. package/dist/src/handlers/sync.d.ts +28 -0
  82. package/dist/src/handlers/sync.js +82 -0
  83. package/dist/src/handlers/system-update.d.ts +2 -0
  84. package/dist/src/handlers/system-update.js +172 -0
  85. package/dist/src/handlers/thread.d.ts +148 -0
  86. package/dist/src/handlers/thread.js +233 -0
  87. package/dist/src/handlers/unified-threads.d.ts +247 -0
  88. package/dist/src/handlers/unified-threads.js +409 -0
  89. package/{src/handlers/vip-suggestions.ts → dist/src/handlers/vip-suggestions.d.ts} +1 -13
  90. package/dist/src/handlers/vip-suggestions.js +16 -0
  91. package/dist/src/index.d.ts +2 -0
  92. package/dist/src/index.js +37 -0
  93. package/{src/json.ts → dist/src/json.d.ts} +1 -4
  94. package/dist/src/json.js +10 -0
  95. package/dist/src/jwt-auth.d.ts +18 -0
  96. package/dist/src/jwt-auth.js +97 -0
  97. package/dist/src/request-context.d.ts +9 -0
  98. package/dist/src/request-context.js +22 -0
  99. package/dist/src/request.d.ts +3 -0
  100. package/dist/src/request.js +7 -0
  101. package/dist/src/response.d.ts +27 -0
  102. package/dist/src/response.js +87 -0
  103. package/dist/src/service/account-deletion.d.ts +22 -0
  104. package/dist/src/service/account-deletion.js +39 -0
  105. package/dist/src/service/compose-sqlite.d.ts +2 -0
  106. package/dist/src/service/compose-sqlite.js +11 -0
  107. package/dist/src/service/create-remit-client.d.ts +95 -0
  108. package/dist/src/service/create-remit-client.js +207 -0
  109. package/dist/src/service/data-client.d.ts +12 -0
  110. package/dist/src/service/data-client.js +44 -0
  111. package/dist/src/service/export-identity.d.ts +3 -0
  112. package/dist/src/service/export-identity.js +35 -0
  113. package/dist/src/service/fire-and-forget.d.ts +54 -0
  114. package/dist/src/service/fire-and-forget.js +38 -0
  115. package/dist/src/service/organize.d.ts +184 -0
  116. package/dist/src/service/organize.js +384 -0
  117. package/dist/src/service/refusal.d.ts +4 -0
  118. package/dist/src/service/refusal.js +20 -0
  119. package/dist/src/service/sender-category.d.ts +51 -0
  120. package/{src/service/sender-category.ts → dist/src/service/sender-category.js} +40 -81
  121. package/dist/src/service/sqs.d.ts +1 -0
  122. package/{src/service/sqs.ts → dist/src/service/sqs.js} +0 -1
  123. package/dist/src/service/trigger-sync.d.ts +51 -0
  124. package/dist/src/service/trigger-sync.js +39 -0
  125. package/dist/src/types.d.ts +49 -0
  126. package/dist/src/types.js +1 -0
  127. package/package.json +113 -98
  128. package/.env.test +0 -4
  129. package/dev-server/content-auth.test.ts +0 -113
  130. package/dev-server/content-auth.ts +0 -54
  131. package/dev-server/content-handler.test.ts +0 -105
  132. package/dev-server/content-handler.ts +0 -79
  133. package/dev-server/content-path.test.ts +0 -37
  134. package/dev-server/content-path.ts +0 -27
  135. package/dev-server/cors.test.ts +0 -48
  136. package/dev-server/cors.ts +0 -25
  137. package/dev-server/keep-alive.test.ts +0 -24
  138. package/dev-server/lambda-helpers.ts +0 -69
  139. package/dev-server/log-output.test.ts +0 -173
  140. package/dev-server/oauth-callback-route.test.ts +0 -66
  141. package/dev-server/relational-health.test.ts +0 -83
  142. package/dev-server/relational-health.ts +0 -39
  143. package/dev-server/server.ts +0 -384
  144. package/dev-server/sync-age.test.ts +0 -91
  145. package/dev-server/sync-age.ts +0 -55
  146. package/dev-server/upload-handler.ts +0 -132
  147. package/dev-server/upload-route.test.ts +0 -196
  148. package/scripts/backfill-classification.ts +0 -91
  149. package/scripts/backfill-list-id.ts +0 -83
  150. package/scripts/config-save.ts +0 -128
  151. package/src/auth.test.ts +0 -12
  152. package/src/auth.ts +0 -92
  153. package/src/config/msoauth.test.ts +0 -48
  154. package/src/config/msoauth.ts +0 -48
  155. package/src/data-backend.test.ts +0 -23
  156. package/src/derive/autoMoved.test.ts +0 -104
  157. package/src/derive/contentSignature.test.ts +0 -161
  158. package/src/derive/contentUrl.test.ts +0 -172
  159. package/src/derive/contentUrl.ts +0 -86
  160. package/src/derive/deriveMuted.test.ts +0 -34
  161. package/src/derive/enrichThreadRows.test.ts +0 -360
  162. package/src/derive/enrichThreadRows.ts +0 -234
  163. package/src/derive/filterThreadCriteria.test.ts +0 -126
  164. package/src/derive/filterThreadCriteria.ts +0 -51
  165. package/src/derive/senderTrust.test.ts +0 -67
  166. package/src/error.test.ts +0 -201
  167. package/src/error.ts +0 -123
  168. package/src/handlers/account-guards.test.ts +0 -67
  169. package/src/handlers/account-guards.ts +0 -169
  170. package/src/handlers/account-oauth-callback.test.ts +0 -155
  171. package/src/handlers/account-oauth-consent.test.ts +0 -336
  172. package/src/handlers/account-oauth-start.test.ts +0 -191
  173. package/src/handlers/account-oauth.test.ts +0 -439
  174. package/src/handlers/account-oauth.ts +0 -635
  175. package/src/handlers/account-overrides.test.ts +0 -88
  176. package/src/handlers/account-overrides.ts +0 -323
  177. package/src/handlers/account-ownership.ts +0 -25
  178. package/src/handlers/account-signature.ts +0 -117
  179. package/src/handlers/account-synced-services.test.ts +0 -245
  180. package/src/handlers/account.ts +0 -552
  181. package/src/handlers/address.test.ts +0 -381
  182. package/src/handlers/address.ts +0 -200
  183. package/src/handlers/calendar-event.test.ts +0 -685
  184. package/src/handlers/calendar-event.ts +0 -738
  185. package/src/handlers/calendar-feed-validators.test.ts +0 -228
  186. package/src/handlers/calendar-feed.test.ts +0 -439
  187. package/src/handlers/calendar-feed.ts +0 -291
  188. package/src/handlers/calendar-sqlite-fixture.ts +0 -36
  189. package/src/handlers/calendar-suggestion.test.ts +0 -1269
  190. package/src/handlers/calendar-suggestion.ts +0 -425
  191. package/src/handlers/calendar.test.ts +0 -1080
  192. package/src/handlers/calendar.ts +0 -517
  193. package/src/handlers/config.test.ts +0 -245
  194. package/src/handlers/config.ts +0 -332
  195. package/src/handlers/ensure-account-config.ts +0 -30
  196. package/src/handlers/filter.test.ts +0 -644
  197. package/src/handlers/filter.ts +0 -518
  198. package/src/handlers/folder-role-appointments.test.ts +0 -438
  199. package/src/handlers/folder-role-appointments.ts +0 -249
  200. package/src/handlers/folder-role-labels.ts +0 -63
  201. package/src/handlers/folder-role.test.ts +0 -209
  202. package/src/handlers/folder-role.ts +0 -80
  203. package/src/handlers/index.ts +0 -83
  204. package/src/handlers/label.test.ts +0 -112
  205. package/src/handlers/label.ts +0 -210
  206. package/src/handlers/mailbox-bindings.ts +0 -111
  207. package/src/handlers/mailbox-rename-refusal.test.ts +0 -247
  208. package/src/handlers/mailbox-settled-target.test.ts +0 -251
  209. package/src/handlers/mailbox.test.ts +0 -280
  210. package/src/handlers/mailbox.ts +0 -581
  211. package/src/handlers/me.test.ts +0 -81
  212. package/src/handlers/me.ts +0 -203
  213. package/src/handlers/message-read-backfill.test.ts +0 -103
  214. package/src/handlers/message.test.ts +0 -131
  215. package/src/handlers/message.ts +0 -1131
  216. package/src/handlers/organize.test.ts +0 -277
  217. package/src/handlers/organize.ts +0 -197
  218. package/src/handlers/outbox-attachment.test.ts +0 -765
  219. package/src/handlers/outbox-attachment.ts +0 -126
  220. package/src/handlers/outbox-list.test.ts +0 -179
  221. package/src/handlers/outbox.test.ts +0 -772
  222. package/src/handlers/outbox.ts +0 -258
  223. package/src/handlers/search.test.ts +0 -161
  224. package/src/handlers/search.ts +0 -105
  225. package/src/handlers/sync-progress.test.ts +0 -205
  226. package/src/handlers/sync-progress.ts +0 -99
  227. package/src/handlers/sync.test.ts +0 -168
  228. package/src/handlers/sync.ts +0 -118
  229. package/src/handlers/system-update.test.ts +0 -497
  230. package/src/handlers/system-update.ts +0 -230
  231. package/src/handlers/thread.test.ts +0 -396
  232. package/src/handlers/thread.ts +0 -427
  233. package/src/handlers/unified-threads-mail-sources.sqlite.test.ts +0 -197
  234. package/src/handlers/unified-threads.test.ts +0 -785
  235. package/src/handlers/unified-threads.ts +0 -669
  236. package/src/index.test.ts +0 -83
  237. package/src/index.ts +0 -228
  238. package/src/jwt-auth.test.ts +0 -152
  239. package/src/jwt-auth.ts +0 -132
  240. package/src/log-redaction.test.ts +0 -144
  241. package/src/openapi-spec.test.ts +0 -118
  242. package/src/request-context.test.ts +0 -61
  243. package/src/request-context.ts +0 -33
  244. package/src/request-pipeline.test.ts +0 -88
  245. package/src/request.ts +0 -10
  246. package/src/response.test.ts +0 -130
  247. package/src/response.ts +0 -144
  248. package/src/service/compose-sqlite.ts +0 -98
  249. package/src/service/config-import.ts +0 -39
  250. package/src/service/create-remit-client.ts +0 -522
  251. package/src/service/data-client.test.ts +0 -97
  252. package/src/service/data-client.ts +0 -58
  253. package/src/service/export-identity.ts +0 -39
  254. package/src/service/filter.ts +0 -40
  255. package/src/service/fire-and-forget.test.ts +0 -126
  256. package/src/service/fire-and-forget.ts +0 -79
  257. package/src/service/localhost-env-config.test.ts +0 -36
  258. package/src/service/organize-queue.ts +0 -98
  259. package/src/service/organize.test.ts +0 -1451
  260. package/src/service/organize.ts +0 -1025
  261. package/src/service/read-path-filter-config.test.ts +0 -243
  262. package/src/service/semantic-capability.test.ts +0 -78
  263. package/src/service/semantic-capability.ts +0 -95
  264. package/src/service/sender-category.test.ts +0 -235
  265. package/src/service/trigger-sync.test.ts +0 -205
  266. package/src/service/trigger-sync.ts +0 -100
  267. package/src/stdout-capture-fixture.ts +0 -40
  268. package/src/types.ts +0 -271
  269. package/tsconfig.json +0 -7
@@ -1,1269 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { after, before, describe, test } from "node:test";
3
- import {
4
- acceptCalendarSuggestion,
5
- reopenCalendarSuggestion,
6
- } from "@remit/calendar-service";
7
- import type {
8
- CalendarSuggestionItem,
9
- CreateFilterInput,
10
- FilterItem,
11
- ICalendarSuggestionRepository,
12
- ICalendarUnitOfWork,
13
- MessageData,
14
- PutCalendarSuggestionInput,
15
- SettleCalendarSuggestionInput,
16
- } from "@remit/data-ports";
17
- import {
18
- CalendarAnswerOvertaken,
19
- CalendarInviteAnswer,
20
- CalendarInviteMethod,
21
- CalendarSuggestionSource,
22
- CalendarSuggestionState,
23
- FilterState,
24
- RecurrenceScope,
25
- ZoneCertainty,
26
- } from "@remit/domain-enums";
27
- import type { APIGatewayProxyEvent } from "aws-lambda";
28
- import type { Context } from "openapi-backend";
29
- import { deriveAccountConfigId } from "../auth.js";
30
- import {
31
- _resetForTest,
32
- type RemitClient,
33
- setClient,
34
- } from "../service/data-client.js";
35
- import { calendarDepsOf } from "./calendar.js";
36
- import {
37
- calendarEventDepsOf,
38
- updateCalendarEventFor,
39
- } from "./calendar-event.js";
40
- import { createCalendarSqliteClient } from "./calendar-sqlite-fixture.js";
41
- import {
42
- assertSettleable,
43
- CalendarSuggestionActionOperations,
44
- CalendarSuggestionOperations,
45
- MessageCalendarSuggestionOperations,
46
- type MuteSenderDeps,
47
- muteSender,
48
- settleSuggestion,
49
- toCalendarSuggestionResponse,
50
- } from "./calendar-suggestion.js";
51
-
52
- const ACCOUNT_CONFIG_ID = "cfg-1";
53
-
54
- const suggestion = (
55
- overrides: Partial<CalendarSuggestionItem> = {},
56
- ): CalendarSuggestionItem => ({
57
- suggestionId: "sug-1",
58
- accountConfigId: ACCOUNT_CONFIG_ID,
59
- messageId: "msg-1",
60
- bodyPartId: "part-1",
61
- icalUid: "invite@example.test",
62
- sequence: 0,
63
- method: CalendarInviteMethod.Request,
64
- source: CalendarSuggestionSource.IcalendarPart,
65
- state: CalendarSuggestionState.Pending,
66
- summary: "Quarterly review",
67
- dtStart: "2026-09-01T10:00:00+02:00",
68
- dtEnd: "2026-09-01T11:00:00+02:00",
69
- endsAtUtc: "2026-09-01T09:00:00Z",
70
- allDay: false,
71
- location: "Room 4",
72
- organizer: "organizer@example.test",
73
- zoneCertainty: "Explicit",
74
- icalData: "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n",
75
- acceptedCalendarObjectId: "",
76
- supersededByMessageId: "",
77
- createdAt: 1,
78
- updatedAt: 1,
79
- ...overrides,
80
- });
81
-
82
- const repoOf = (
83
- initial: CalendarSuggestionItem,
84
- ): {
85
- repo: ICalendarSuggestionRepository;
86
- settles: SettleCalendarSuggestionInput[];
87
- } => {
88
- let row = initial;
89
- const settles: SettleCalendarSuggestionInput[] = [];
90
- const repo = {
91
- get: async () => row,
92
- settle: async (
93
- _accountConfigId: string,
94
- _suggestionId: string,
95
- input: SettleCalendarSuggestionInput,
96
- ) => {
97
- settles.push(input);
98
- row = { ...row, ...input };
99
- return row;
100
- },
101
- } as unknown as ICalendarSuggestionRepository;
102
- return { repo, settles };
103
- };
104
-
105
- const muteDepsOf = (
106
- from: string | null,
107
- existing: FilterItem[] = [],
108
- ): { deps: MuteSenderDeps; created: CreateFilterInput[] } => {
109
- const created: CreateFilterInput[] = [];
110
- const rules = [...existing];
111
- const deps: MuteSenderDeps = {
112
- envelope: {
113
- getMessageData: async () =>
114
- ({
115
- envelopeAddress: from
116
- ? [{ addressRole: "from", normalizedEmail: from }]
117
- : [{ addressRole: "to", normalizedEmail: "user@example.test" }],
118
- }) as unknown as MessageData,
119
- },
120
- filter: {
121
- listByAccountAndState: async () => rules,
122
- create: async (input: CreateFilterInput) => {
123
- created.push(input);
124
- const row = {
125
- ...input,
126
- actionLabelId: input.actionLabelId ?? "None",
127
- actionMailboxId: input.actionMailboxId ?? "None",
128
- } as unknown as FilterItem;
129
- rules.push(row);
130
- return row;
131
- },
132
- },
133
- };
134
- return { deps, created };
135
- };
136
-
137
- const muteRule = (sender: string): FilterItem =>
138
- ({
139
- filterId: `mute-${sender}`,
140
- accountConfigId: ACCOUNT_CONFIG_ID,
141
- name: `Muted invitations from ${sender}`,
142
- scope: "Standing",
143
- state: "Active",
144
- matchOperator: "And",
145
- literalClauses: [{ field: "From", value: sender }],
146
- actionLabelId: "None",
147
- actionMailboxId: "None",
148
- }) as unknown as FilterItem;
149
-
150
- describe("toCalendarSuggestionResponse", () => {
151
- test("keeps the raw invitation bytes on the server", async () => {
152
- const response = toCalendarSuggestionResponse(
153
- suggestion(),
154
- CalendarAnswerOvertaken.None,
155
- );
156
-
157
- assert.equal("icalData" in response, false);
158
- assert.equal(response.summary, "Quarterly review");
159
- assert.equal(response.organizer, "organizer@example.test");
160
- });
161
- });
162
-
163
- describe("assertSettleable", () => {
164
- test("lets a pending card be answered", () => {
165
- assertSettleable(suggestion(), CalendarSuggestionState.Accepted);
166
- });
167
-
168
- test("refuses to accept an event a revision already retired", () => {
169
- assert.throws(
170
- () =>
171
- assertSettleable(
172
- suggestion({ state: CalendarSuggestionState.Superseded }),
173
- CalendarSuggestionState.Accepted,
174
- ),
175
- /already superseded/,
176
- );
177
- });
178
-
179
- test("refuses to decline an event that is already in the calendar", () => {
180
- // A resource exists. Declining would say no to a meeting the user's
181
- // calendar still shows, which is a lie the API must not tell.
182
- assert.throws(
183
- () =>
184
- assertSettleable(
185
- suggestion({ state: CalendarSuggestionState.Accepted }),
186
- CalendarSuggestionState.Declined,
187
- ),
188
- /already accepted/,
189
- );
190
- });
191
-
192
- test("lets a repeat of the same answer through", () => {
193
- assertSettleable(
194
- suggestion({ state: CalendarSuggestionState.Declined }),
195
- CalendarSuggestionState.Declined,
196
- );
197
- });
198
- });
199
-
200
- describe("settleSuggestion", () => {
201
- test("records a decline", async () => {
202
- const { repo, settles } = repoOf(suggestion());
203
-
204
- const settled = await settleSuggestion(
205
- repo,
206
- ACCOUNT_CONFIG_ID,
207
- "sug-1",
208
- CalendarSuggestionState.Declined,
209
- );
210
-
211
- assert.equal(settled.state, CalendarSuggestionState.Declined);
212
- assert.deepEqual(settles, [
213
- {
214
- state: CalendarSuggestionState.Declined,
215
- acceptedCalendarObjectId: "",
216
- },
217
- ]);
218
- });
219
-
220
- test("writes nothing on a repeated decline", async () => {
221
- const { repo, settles } = repoOf(
222
- suggestion({ state: CalendarSuggestionState.Declined }),
223
- );
224
-
225
- const settled = await settleSuggestion(
226
- repo,
227
- ACCOUNT_CONFIG_ID,
228
- "sug-1",
229
- CalendarSuggestionState.Declined,
230
- );
231
-
232
- assert.equal(settled.state, CalendarSuggestionState.Declined);
233
- assert.deepEqual(settles, []);
234
- });
235
-
236
- test("never names a calendar object on a decision that wrote none", async () => {
237
- // Dismiss and decline write no resource, so the field that points at one
238
- // stays the empty sentinel rather than carrying a stale id.
239
- const { repo, settles } = repoOf(suggestion());
240
-
241
- await settleSuggestion(
242
- repo,
243
- ACCOUNT_CONFIG_ID,
244
- "sug-1",
245
- CalendarSuggestionState.Dismissed,
246
- );
247
-
248
- assert.deepEqual(
249
- settles.map((settle) => settle.acceptedCalendarObjectId),
250
- [""],
251
- );
252
- });
253
- });
254
-
255
- describe("muteSender", () => {
256
- test("writes a standing rule on the message's sender", async () => {
257
- const { deps, created } = muteDepsOf("organizer@example.test");
258
-
259
- await muteSender(deps, ACCOUNT_CONFIG_ID, "msg-1");
260
-
261
- assert.equal(created.length, 1);
262
- assert.equal(created[0]?.accountConfigId, ACCOUNT_CONFIG_ID);
263
- assert.equal(created[0]?.scope, "Standing");
264
- assert.deepEqual(created[0]?.literalClauses, [
265
- { field: "From", value: "organizer@example.test" },
266
- ]);
267
- assert.match(created[0]?.name ?? "", /organizer@example\.test/);
268
- });
269
-
270
- test("writes one rule however often the dismiss is retried", async () => {
271
- // A retried dismiss is the same instruction repeated. A second identical
272
- // rule would only be a second row for the user to find and delete twice.
273
- const { deps, created } = muteDepsOf("organizer@example.test");
274
-
275
- await muteSender(deps, ACCOUNT_CONFIG_ID, "msg-1");
276
- await muteSender(deps, ACCOUNT_CONFIG_ID, "msg-1");
277
- await muteSender(deps, ACCOUNT_CONFIG_ID, "msg-2");
278
-
279
- assert.equal(created.length, 1);
280
- });
281
-
282
- test("adds nothing when the sender is already muted from another card", async () => {
283
- const { deps, created } = muteDepsOf("organizer@example.test", [
284
- muteRule("Organizer@Example.test"),
285
- ]);
286
-
287
- await muteSender(deps, ACCOUNT_CONFIG_ID, "msg-1");
288
-
289
- assert.deepEqual(created, []);
290
- });
291
-
292
- test("still writes a rule when the existing one names a different sender", async () => {
293
- const { deps, created } = muteDepsOf("organizer@example.test", [
294
- muteRule("someone-else@example.test"),
295
- ]);
296
-
297
- await muteSender(deps, ACCOUNT_CONFIG_ID, "msg-1");
298
-
299
- assert.equal(created.length, 1);
300
- });
301
-
302
- test("refuses to mute a message that names no sender", async () => {
303
- const { deps, created } = muteDepsOf(null);
304
-
305
- await assert.rejects(
306
- () => muteSender(deps, ACCOUNT_CONFIG_ID, "msg-1"),
307
- /nobody to mute/,
308
- );
309
- assert.deepEqual(created, []);
310
- });
311
- });
312
-
313
- /**
314
- * The suggestion wrappers driven the way an HTTP request drives them — through
315
- * the registered client — against the SQLite store the self-host build ships.
316
- * The unit tests above pin each half; these pin what one request does.
317
- */
318
-
319
- type Handler = (
320
- context: Context,
321
- event: APIGatewayProxyEvent,
322
- ) => Promise<Record<string, unknown>>;
323
-
324
- const listSuggestions =
325
- CalendarSuggestionOperations.CalendarSuggestionOperations_listCalendarSuggestions as Handler;
326
- const acceptSuggestion =
327
- CalendarSuggestionActionOperations.CalendarSuggestionActionOperations_acceptCalendarSuggestion as Handler;
328
- const dismissSuggestion =
329
- CalendarSuggestionActionOperations.CalendarSuggestionActionOperations_dismissCalendarSuggestion as Handler;
330
- const listMessageSuggestions =
331
- MessageCalendarSuggestionOperations.MessageCalendarSuggestionOperations_listMessageCalendarSuggestions as Handler;
332
- const reopenSuggestion =
333
- CalendarSuggestionActionOperations.CalendarSuggestionActionOperations_reopenCalendarSuggestion as Handler;
334
-
335
- interface Card {
336
- suggestionId: string;
337
- state: string;
338
- supersededByMessageId: string;
339
- supersededByThreadId: string;
340
- summary: string;
341
- }
342
-
343
- const fileInThread = async (
344
- accountConfigId: string,
345
- messageId: string,
346
- threadId: string,
347
- ): Promise<void> => {
348
- await client.threadMessage.create({
349
- accountConfigId,
350
- threadId,
351
- messageId,
352
- mailboxId: "mbx-inbox",
353
- uid: 1,
354
- referenceOrder: 0,
355
- internalDate: 1,
356
- sentDate: 1,
357
- subject: "Invitation: Quarterly review",
358
- isRead: false,
359
- isDeleted: false,
360
- hasAttachment: false,
361
- hasStars: false,
362
- });
363
- };
364
-
365
- let client: RemitClient;
366
- let cleanup: () => void;
367
- let mintedSubs = 0;
368
-
369
- const contextOf = (request: {
370
- params?: Record<string, string>;
371
- query?: Record<string, unknown>;
372
- requestBody?: unknown;
373
- }): Context => ({ request }) as unknown as Context;
374
-
375
- const anAccount = (): {
376
- accountConfigId: string;
377
- event: APIGatewayProxyEvent;
378
- } => {
379
- mintedSubs += 1;
380
- const sub = `calendar-suggestion-sub-${mintedSubs}`;
381
- return {
382
- accountConfigId: deriveAccountConfigId(sub),
383
- event: {
384
- requestContext: { authorizer: { claims: { sub } } },
385
- } as unknown as APIGatewayProxyEvent,
386
- };
387
- };
388
-
389
- const INVITATION = [
390
- "BEGIN:VCALENDAR",
391
- "VERSION:2.0",
392
- "METHOD:REQUEST",
393
- "BEGIN:VEVENT",
394
- "UID:invite@example.test",
395
- "DTSTART:20260901T080000Z",
396
- "DTEND:20260901T090000Z",
397
- "SUMMARY:Quarterly review",
398
- "END:VEVENT",
399
- "END:VCALENDAR",
400
- "",
401
- ].join("\r\n");
402
-
403
- const futureStart = new Date(Date.now() + 86400000).toISOString();
404
- const futureEnd = new Date(Date.now() + 172800000).toISOString();
405
-
406
- const putSuggestion = (
407
- accountConfigId: string,
408
- messageId: string,
409
- method: CalendarSuggestionItem["method"] = CalendarInviteMethod.Request,
410
- times: Pick<
411
- PutCalendarSuggestionInput,
412
- "dtStart" | "dtEnd" | "endsAtUtc" | "icalData"
413
- > = {
414
- dtStart: futureStart,
415
- dtEnd: futureEnd,
416
- endsAtUtc: futureEnd,
417
- icalData: INVITATION,
418
- },
419
- ): Promise<CalendarSuggestionItem> =>
420
- client.calendarSuggestion.put({
421
- accountConfigId,
422
- messageId,
423
- bodyPartId: "part-1",
424
- icalUid: "invite@example.test",
425
- sequence: 0,
426
- method,
427
- source: CalendarSuggestionSource.IcalendarPart,
428
- summary: "Quarterly review",
429
- allDay: false,
430
- location: "Room 4",
431
- organizer: "organizer@example.test",
432
- zoneCertainty: ZoneCertainty.Explicit,
433
- ...times,
434
- });
435
-
436
- /** A message with a From address, which is all muting a sender reads. */
437
- const seedMessageFrom = async (
438
- messageId: string,
439
- sender: string,
440
- ): Promise<void> => {
441
- await client.envelope.createEnvelope({
442
- envelopeId: "",
443
- messageId,
444
- dateValue: Date.parse("2026-08-30T08:00:00Z"),
445
- dateRaw: "Sun, 30 Aug 2026 08:00:00 +0000",
446
- subject: "Invitation: Quarterly review",
447
- messageIdValue: `<${messageId}@example.test>`,
448
- });
449
- await client.address.createEnvelopeAddress({
450
- messageId,
451
- addressId: `address-${messageId}`,
452
- displayName: "The organiser",
453
- normalizedEmail: sender,
454
- addressRole: "from",
455
- addressOrder: 0,
456
- });
457
- };
458
-
459
- before(async () => {
460
- _resetForTest();
461
- ({ client, cleanup } = await createCalendarSqliteClient());
462
- setClient(client);
463
- });
464
-
465
- after(() => {
466
- _resetForTest();
467
- cleanup();
468
- });
469
-
470
- describe("GET /calendar-suggestions", () => {
471
- test("hands the pending set back one page at a time", async () => {
472
- const { accountConfigId, event } = anAccount();
473
- const seeded = await Promise.all(
474
- Array.from({ length: 101 }, (_unused, index) =>
475
- putSuggestion(accountConfigId, `msg-page-${index}`),
476
- ),
477
- );
478
-
479
- const first = (await listSuggestions(
480
- contextOf({ query: { state: CalendarSuggestionState.Pending } }),
481
- event,
482
- )) as unknown as { items: Card[]; continuationToken?: string };
483
- assert.equal(first.items.length, 100);
484
- assert.ok(first.continuationToken, "a full page names where to continue");
485
-
486
- const second = (await listSuggestions(
487
- contextOf({
488
- query: {
489
- state: CalendarSuggestionState.Pending,
490
- continuationToken: first.continuationToken,
491
- },
492
- }),
493
- event,
494
- )) as unknown as { items: Card[]; continuationToken?: string };
495
-
496
- assert.equal(second.items.length, 1);
497
- assert.equal(second.continuationToken, undefined);
498
- const paged = new Set(
499
- [...first.items, ...second.items].map((card) => card.suggestionId),
500
- );
501
- assert.equal(
502
- paged.size,
503
- seeded.length,
504
- "the two pages cover the set once each, with no card in both",
505
- );
506
- });
507
-
508
- test("answers only the state that was asked for, and keeps the raw bytes back", async () => {
509
- const { accountConfigId, event } = anAccount();
510
- await putSuggestion(accountConfigId, "msg-pending");
511
- const dismissed = await putSuggestion(accountConfigId, "msg-dismissed");
512
- await client.calendarSuggestion.settle(
513
- accountConfigId,
514
- dismissed.suggestionId,
515
- {
516
- state: CalendarSuggestionState.Dismissed,
517
- acceptedCalendarObjectId: "",
518
- },
519
- );
520
-
521
- const pending = (await listSuggestions(
522
- contextOf({ query: { state: CalendarSuggestionState.Pending } }),
523
- event,
524
- )) as unknown as { items: Card[] };
525
-
526
- assert.deepEqual(
527
- pending.items.map((card) => card.state),
528
- [CalendarSuggestionState.Pending],
529
- );
530
- assert.equal("icalData" in (pending.items[0] ?? {}), false);
531
- });
532
- });
533
-
534
- describe("GET /messages/{messageId}/calendar-suggestions", () => {
535
- test("names the conversation of the revision that retired a card", async () => {
536
- const { accountConfigId, event } = anAccount();
537
- const older = await putSuggestion(accountConfigId, "msg-revision-0");
538
- await client.calendarSuggestion.supersedeIfPending(
539
- accountConfigId,
540
- older.suggestionId,
541
- "msg-revision-1",
542
- );
543
- await fileInThread(accountConfigId, "msg-revision-1", "thread-revision-1");
544
-
545
- const listed = (await listMessageSuggestions(
546
- contextOf({ params: { messageId: "msg-revision-0" } }),
547
- event,
548
- )) as unknown as { items: Card[] };
549
-
550
- assert.deepEqual(
551
- listed.items.map((card) => [
552
- card.state,
553
- card.supersededByMessageId,
554
- card.supersededByThreadId,
555
- ]),
556
- [
557
- [
558
- CalendarSuggestionState.Superseded,
559
- "msg-revision-1",
560
- "thread-revision-1",
561
- ],
562
- ],
563
- );
564
- });
565
-
566
- test("names no conversation when the newer message sits in none", async () => {
567
- const { accountConfigId, event } = anAccount();
568
- const older = await putSuggestion(accountConfigId, "msg-orphan-0");
569
- await client.calendarSuggestion.supersedeIfPending(
570
- accountConfigId,
571
- older.suggestionId,
572
- "msg-orphan-1",
573
- );
574
-
575
- const listed = (await listMessageSuggestions(
576
- contextOf({ params: { messageId: "msg-orphan-0" } }),
577
- event,
578
- )) as unknown as { items: Card[] };
579
-
580
- assert.equal(listed.items[0]?.supersededByThreadId, "");
581
- });
582
-
583
- test("names no conversation for a card nothing retired", async () => {
584
- const { accountConfigId, event } = anAccount();
585
- await putSuggestion(accountConfigId, "msg-current");
586
-
587
- const listed = (await listMessageSuggestions(
588
- contextOf({ params: { messageId: "msg-current" } }),
589
- event,
590
- )) as unknown as { items: Card[] };
591
-
592
- assert.deepEqual(
593
- listed.items.map((card) => [
594
- card.supersededByMessageId,
595
- card.supersededByThreadId,
596
- ]),
597
- [["", ""]],
598
- );
599
- });
600
- });
601
-
602
- describe("POST /calendar-suggestions/{suggestionId}/accept", () => {
603
- test("answers not-found for a calendar on another account, before writing anything", async () => {
604
- const stranger = anAccount();
605
- const strangersCalendar = await client.calendarCollection.create({
606
- accountConfigId: stranger.accountConfigId,
607
- urlSegment: "default",
608
- displayName: "Calendar",
609
- });
610
- const { accountConfigId, event } = anAccount();
611
- const card = await putSuggestion(accountConfigId, "msg-cross-account");
612
-
613
- await assert.rejects(
614
- () =>
615
- acceptSuggestion(
616
- contextOf({
617
- params: { suggestionId: card.suggestionId },
618
- requestBody: { calendarId: strangersCalendar.calendarId },
619
- }),
620
- event,
621
- ),
622
- (error: unknown) => (error as { statusCode?: number }).statusCode === 404,
623
- );
624
-
625
- assert.deepEqual(
626
- await client.calendarObject.listByCalendar(strangersCalendar.calendarId),
627
- [],
628
- "nothing was written into the calendar the caller does not hold",
629
- );
630
- const untouched = await client.calendarSuggestion.get(
631
- accountConfigId,
632
- card.suggestionId,
633
- );
634
- assert.equal(untouched.state, CalendarSuggestionState.Pending);
635
- assert.equal(untouched.acceptedCalendarObjectId, "");
636
- });
637
-
638
- test("refuses a maybe to a cancellation, before writing anything", async () => {
639
- const { accountConfigId, event } = anAccount();
640
- const calendar = await client.calendarCollection.create({
641
- accountConfigId,
642
- urlSegment: "default",
643
- displayName: "Calendar",
644
- });
645
- const card = await putSuggestion(
646
- accountConfigId,
647
- "msg-cancel-maybe",
648
- CalendarInviteMethod.Cancel,
649
- );
650
-
651
- await assert.rejects(
652
- () =>
653
- acceptSuggestion(
654
- contextOf({
655
- params: { suggestionId: card.suggestionId },
656
- requestBody: {
657
- calendarId: calendar.calendarId,
658
- answer: CalendarInviteAnswer.Tentative,
659
- },
660
- }),
661
- event,
662
- ),
663
- (error: unknown) => (error as { statusCode?: number }).statusCode === 400,
664
- );
665
-
666
- assert.deepEqual(
667
- await client.calendarObject.listByCalendar(calendar.calendarId),
668
- [],
669
- );
670
- const untouched = await client.calendarSuggestion.get(
671
- accountConfigId,
672
- card.suggestionId,
673
- );
674
- assert.equal(untouched.state, CalendarSuggestionState.Pending);
675
- });
676
- });
677
-
678
- describe("POST /calendar-suggestions/{suggestionId}/dismiss", () => {
679
- test("settles the card and writes the sender's mute rule in one request", async () => {
680
- const { accountConfigId, event } = anAccount();
681
- const card = await putSuggestion(accountConfigId, "msg-mute");
682
- await seedMessageFrom("msg-mute", "organizer@example.test");
683
-
684
- const dismissed = (await dismissSuggestion(
685
- contextOf({
686
- params: { suggestionId: card.suggestionId },
687
- requestBody: { muteSender: true },
688
- }),
689
- event,
690
- )) as unknown as Card;
691
-
692
- assert.equal(dismissed.state, CalendarSuggestionState.Dismissed);
693
- const rules = await client.filter.listByAccountAndState(
694
- accountConfigId,
695
- FilterState.Active,
696
- );
697
- assert.equal(rules.length, 1);
698
- assert.deepEqual(rules[0]?.literalClauses, [
699
- { field: "From", value: "organizer@example.test" },
700
- ]);
701
- });
702
-
703
- test("writes no rule when the request does not ask to mute", async () => {
704
- const { accountConfigId, event } = anAccount();
705
- const card = await putSuggestion(accountConfigId, "msg-quiet");
706
- await seedMessageFrom("msg-quiet", "organizer@example.test");
707
-
708
- const dismissed = (await dismissSuggestion(
709
- contextOf({
710
- params: { suggestionId: card.suggestionId },
711
- requestBody: {},
712
- }),
713
- event,
714
- )) as unknown as Card;
715
-
716
- assert.equal(dismissed.state, CalendarSuggestionState.Dismissed);
717
- assert.deepEqual(
718
- await client.filter.listByAccountAndState(
719
- accountConfigId,
720
- FilterState.Active,
721
- ),
722
- [],
723
- );
724
- });
725
- });
726
-
727
- describe("POST /calendar-suggestions/{suggestionId}/accept with corrections", () => {
728
- test("answers not-found for a calendar on another account, before writing anything", async () => {
729
- const stranger = anAccount();
730
- const strangersCalendar = await client.calendarCollection.create({
731
- accountConfigId: stranger.accountConfigId,
732
- urlSegment: "default",
733
- displayName: "Calendar",
734
- });
735
- const { accountConfigId, event } = anAccount();
736
- const card = await putSuggestion(accountConfigId, "msg-edited-cross");
737
-
738
- await assert.rejects(
739
- () =>
740
- acceptSuggestion(
741
- contextOf({
742
- params: { suggestionId: card.suggestionId },
743
- requestBody: {
744
- calendarId: strangersCalendar.calendarId,
745
- event: { summary: "Quarterly review, moved" },
746
- },
747
- }),
748
- event,
749
- ),
750
- (error: unknown) => (error as { statusCode?: number }).statusCode === 404,
751
- );
752
-
753
- assert.deepEqual(
754
- await client.calendarObject.listByCalendar(strangersCalendar.calendarId),
755
- [],
756
- );
757
- const untouched = await client.calendarSuggestion.get(
758
- accountConfigId,
759
- card.suggestionId,
760
- );
761
- assert.equal(untouched.state, CalendarSuggestionState.Pending);
762
- });
763
-
764
- test("refuses a card the reader already declined", async () => {
765
- const { accountConfigId, event } = anAccount();
766
- const calendar = await client.calendarCollection.create({
767
- accountConfigId,
768
- urlSegment: "default",
769
- displayName: "Calendar",
770
- });
771
- const card = await putSuggestion(accountConfigId, "msg-edited-declined");
772
- await client.calendarSuggestion.settle(accountConfigId, card.suggestionId, {
773
- state: CalendarSuggestionState.Declined,
774
- acceptedCalendarObjectId: "",
775
- });
776
-
777
- await assert.rejects(
778
- () =>
779
- acceptSuggestion(
780
- contextOf({
781
- params: { suggestionId: card.suggestionId },
782
- requestBody: {
783
- calendarId: calendar.calendarId,
784
- event: { summary: "Quarterly review, moved" },
785
- },
786
- }),
787
- event,
788
- ),
789
- (error: unknown) => (error as { statusCode?: number }).statusCode === 400,
790
- );
791
- assert.deepEqual(
792
- await client.calendarObject.listByCalendar(calendar.calendarId),
793
- [],
794
- );
795
- });
796
- });
797
-
798
- describe("POST /calendar-suggestions/{suggestionId}/reopen", () => {
799
- const answeredInto = async (
800
- accountConfigId: string,
801
- messageId: string,
802
- answer: (typeof CalendarInviteAnswer)[keyof typeof CalendarInviteAnswer],
803
- ): Promise<{ calendarId: string; card: CalendarSuggestionItem }> => {
804
- const calendar = await client.calendarCollection.create({
805
- accountConfigId,
806
- urlSegment: "default",
807
- displayName: "Calendar",
808
- });
809
- const accepted = await acceptCalendarSuggestion(client.calendarUnitOfWork, {
810
- accountConfigId,
811
- calendarId: calendar.calendarId,
812
- suggestion: await putSuggestion(accountConfigId, messageId),
813
- attendee: "user@example.test",
814
- answer,
815
- });
816
- assert.ok(accepted.ok);
817
- return { calendarId: calendar.calendarId, card: accepted.value.suggestion };
818
- };
819
-
820
- for (const answer of [
821
- CalendarInviteAnswer.Accepted,
822
- CalendarInviteAnswer.Tentative,
823
- ]) {
824
- test(`takes a ${answer} answer back and removes the event it wrote`, async () => {
825
- const { accountConfigId, event } = anAccount();
826
- const { calendarId, card } = await answeredInto(
827
- accountConfigId,
828
- `msg-reopen-${answer}`,
829
- answer,
830
- );
831
- assert.equal(
832
- (await client.calendarObject.listByCalendar(calendarId)).length,
833
- 1,
834
- );
835
-
836
- const reopened = (await reopenSuggestion(
837
- contextOf({ params: { suggestionId: card.suggestionId } }),
838
- event,
839
- )) as unknown as CalendarSuggestionItem;
840
-
841
- assert.equal(reopened.state, CalendarSuggestionState.Pending);
842
- assert.equal(reopened.acceptedCalendarObjectId, "");
843
- assert.deepEqual(
844
- await client.calendarObject.listByCalendar(calendarId),
845
- [],
846
- );
847
- const stored = await client.calendarSuggestion.get(
848
- accountConfigId,
849
- card.suggestionId,
850
- );
851
- assert.equal(stored.state, CalendarSuggestionState.Pending);
852
- });
853
- }
854
-
855
- for (const state of [
856
- CalendarSuggestionState.Declined,
857
- CalendarSuggestionState.Dismissed,
858
- ]) {
859
- test(`returns a ${state} card to Pending`, async () => {
860
- const { accountConfigId, event } = anAccount();
861
- const card = await putSuggestion(accountConfigId, `msg-reopen-${state}`);
862
- await client.calendarSuggestion.settle(
863
- accountConfigId,
864
- card.suggestionId,
865
- { state, acceptedCalendarObjectId: "" },
866
- );
867
-
868
- const reopened = (await reopenSuggestion(
869
- contextOf({ params: { suggestionId: card.suggestionId } }),
870
- event,
871
- )) as unknown as Card;
872
-
873
- assert.equal(reopened.state, CalendarSuggestionState.Pending);
874
- });
875
- }
876
-
877
- test("refuses a superseded card and leaves it as it is", async () => {
878
- const { accountConfigId, event } = anAccount();
879
- const card = await putSuggestion(accountConfigId, "msg-reopen-superseded");
880
- await client.calendarSuggestion.supersedeIfPending(
881
- accountConfigId,
882
- card.suggestionId,
883
- "msg-reopen-newer",
884
- );
885
-
886
- await assert.rejects(
887
- () =>
888
- reopenSuggestion(
889
- contextOf({ params: { suggestionId: card.suggestionId } }),
890
- event,
891
- ),
892
- (error: unknown) => (error as { statusCode?: number }).statusCode === 400,
893
- );
894
-
895
- const untouched = await client.calendarSuggestion.get(
896
- accountConfigId,
897
- card.suggestionId,
898
- );
899
- assert.equal(untouched.state, CalendarSuggestionState.Superseded);
900
- });
901
-
902
- test("refuses a cancellation, whose answer it cannot undo", async () => {
903
- const { accountConfigId, event } = anAccount();
904
- const card = await putSuggestion(
905
- accountConfigId,
906
- "msg-reopen-cancel",
907
- CalendarInviteMethod.Cancel,
908
- );
909
- await client.calendarSuggestion.settle(accountConfigId, card.suggestionId, {
910
- state: CalendarSuggestionState.Dismissed,
911
- acceptedCalendarObjectId: "",
912
- });
913
-
914
- await assert.rejects(
915
- () =>
916
- reopenSuggestion(
917
- contextOf({ params: { suggestionId: card.suggestionId } }),
918
- event,
919
- ),
920
- (error: unknown) => (error as { statusCode?: number }).statusCode === 400,
921
- );
922
- });
923
-
924
- test("answers not-found for a card on another account", async () => {
925
- const stranger = anAccount();
926
- const { card } = await answeredInto(
927
- stranger.accountConfigId,
928
- "msg-reopen-stranger",
929
- CalendarInviteAnswer.Accepted,
930
- );
931
- const { event } = anAccount();
932
-
933
- await assert.rejects(
934
- () =>
935
- reopenSuggestion(
936
- contextOf({ params: { suggestionId: card.suggestionId } }),
937
- event,
938
- ),
939
- (error: unknown) => (error as { statusCode?: number }).statusCode === 404,
940
- );
941
- const untouched = await client.calendarSuggestion.get(
942
- stranger.accountConfigId,
943
- card.suggestionId,
944
- );
945
- assert.equal(untouched.state, CalendarSuggestionState.Accepted);
946
- });
947
- });
948
-
949
- describe("POST /calendar-suggestions/{suggestionId}/reopen, against what else holds the event", () => {
950
- const recurring = (sequence: number): string =>
951
- INVITATION.replace(
952
- "SUMMARY:Quarterly review",
953
- `SEQUENCE:${sequence}\r\nRRULE:FREQ=WEEKLY;COUNT=4\r\nSUMMARY:Quarterly review`,
954
- );
955
-
956
- const putInvite = (
957
- accountConfigId: string,
958
- messageId: string,
959
- sequence: number,
960
- ): Promise<CalendarSuggestionItem> =>
961
- client.calendarSuggestion.put({
962
- accountConfigId,
963
- messageId,
964
- bodyPartId: "part-1",
965
- icalUid: "invite@example.test",
966
- sequence,
967
- method: CalendarInviteMethod.Request,
968
- source: CalendarSuggestionSource.IcalendarPart,
969
- summary: "Quarterly review",
970
- dtStart: futureStart,
971
- dtEnd: futureEnd,
972
- endsAtUtc: futureEnd,
973
- allDay: false,
974
- location: "Room 4",
975
- organizer: "organizer@example.test",
976
- zoneCertainty: "Explicit",
977
- icalData: recurring(sequence),
978
- } as PutCalendarSuggestionInput);
979
-
980
- const acceptInto = async (
981
- accountConfigId: string,
982
- calendarId: string,
983
- suggestion: CalendarSuggestionItem,
984
- ): Promise<CalendarSuggestionItem> => {
985
- const accepted = await acceptCalendarSuggestion(client.calendarUnitOfWork, {
986
- accountConfigId,
987
- calendarId,
988
- suggestion,
989
- attendee: "user@example.test",
990
- answer: CalendarInviteAnswer.Accepted,
991
- });
992
- assert.ok(accepted.ok);
993
- return accepted.value.suggestion;
994
- };
995
-
996
- const calendarOf = (accountConfigId: string) =>
997
- client.calendarCollection.create({
998
- accountConfigId,
999
- urlSegment: "default",
1000
- displayName: "Calendar",
1001
- });
1002
-
1003
- test("refuses a request's answer once its cancellation was answered into the event, and says so on the card", async () => {
1004
- const { accountConfigId, event } = anAccount();
1005
- const calendar = await calendarOf(accountConfigId);
1006
- const first = await acceptInto(
1007
- accountConfigId,
1008
- calendar.calendarId,
1009
- await putInvite(accountConfigId, "msg-overtaken-0", 0),
1010
- );
1011
- await acceptInto(
1012
- accountConfigId,
1013
- calendar.calendarId,
1014
- await putSuggestion(
1015
- accountConfigId,
1016
- "msg-overtaken-cancel",
1017
- CalendarInviteMethod.Cancel,
1018
- ),
1019
- );
1020
-
1021
- await assert.rejects(
1022
- () =>
1023
- reopenSuggestion(
1024
- contextOf({ params: { suggestionId: first.suggestionId } }),
1025
- event,
1026
- ),
1027
- (error: unknown) => (error as { statusCode?: number }).statusCode === 400,
1028
- );
1029
-
1030
- assert.equal(
1031
- (await client.calendarObject.listByCalendar(calendar.calendarId)).length,
1032
- 1,
1033
- );
1034
- const listed = (await listMessageSuggestions(
1035
- contextOf({ params: { messageId: "msg-overtaken-0" } }),
1036
- event,
1037
- )) as unknown as { items: { answerOvertakenBy: string }[] };
1038
- assert.deepEqual(
1039
- listed.items.map((item) => item.answerOvertakenBy),
1040
- [CalendarAnswerOvertaken.Cancellation],
1041
- );
1042
- });
1043
-
1044
- test("takes back both halves of a this-and-following split, and a re-add writes one event", async () => {
1045
- const { accountConfigId, event } = anAccount();
1046
- const calendar = await calendarOf(accountConfigId);
1047
- const card = await acceptInto(
1048
- accountConfigId,
1049
- calendar.calendarId,
1050
- await putInvite(accountConfigId, "msg-split", 0),
1051
- );
1052
- const split = await updateCalendarEventFor(
1053
- calendarEventDepsOf(calendarDepsOf(client)),
1054
- accountConfigId,
1055
- {
1056
- calendarId: calendar.calendarId,
1057
- calendarObjectId: card.acceptedCalendarObjectId,
1058
- scope: RecurrenceScope.Following,
1059
- recurrenceId: "2026-09-15T08:00:00Z",
1060
- ifMatch: undefined,
1061
- },
1062
- { summary: "Quarterly review (moved)" },
1063
- );
1064
- assert.ok(split.ok, JSON.stringify(split));
1065
- assert.equal(
1066
- (await client.calendarObject.listByCalendar(calendar.calendarId)).length,
1067
- 2,
1068
- );
1069
-
1070
- const reopened = (await reopenSuggestion(
1071
- contextOf({ params: { suggestionId: card.suggestionId } }),
1072
- event,
1073
- )) as unknown as CalendarSuggestionItem;
1074
-
1075
- assert.equal(reopened.state, CalendarSuggestionState.Pending);
1076
- assert.deepEqual(
1077
- await client.calendarObject.listByCalendar(calendar.calendarId),
1078
- [],
1079
- );
1080
- await acceptInto(accountConfigId, calendar.calendarId, {
1081
- ...card,
1082
- ...reopened,
1083
- });
1084
- assert.equal(
1085
- (await client.calendarObject.listByCalendar(calendar.calendarId)).length,
1086
- 1,
1087
- );
1088
- });
1089
-
1090
- test("keeps the sender rule a mute wrote", async () => {
1091
- const { accountConfigId, event } = anAccount();
1092
- const card = await putSuggestion(accountConfigId, "msg-mute-reopen");
1093
- await seedMessageFrom("msg-mute-reopen", "organizer@example.test");
1094
- await dismissSuggestion(
1095
- contextOf({
1096
- params: { suggestionId: card.suggestionId },
1097
- requestBody: { muteSender: true },
1098
- }),
1099
- event,
1100
- );
1101
-
1102
- const reopened = (await reopenSuggestion(
1103
- contextOf({ params: { suggestionId: card.suggestionId } }),
1104
- event,
1105
- )) as unknown as Card;
1106
-
1107
- assert.equal(reopened.state, CalendarSuggestionState.Pending);
1108
- const rules = await client.filter.listByAccountAndState(
1109
- accountConfigId,
1110
- FilterState.Active,
1111
- );
1112
- assert.deepEqual(
1113
- rules.map((rule) => rule.literalClauses),
1114
- [[{ field: "From", value: "organizer@example.test" }]],
1115
- );
1116
- });
1117
-
1118
- test("a delete that fails part-way leaves the event and the answer as they were", async () => {
1119
- const { accountConfigId } = anAccount();
1120
- const calendar = await calendarOf(accountConfigId);
1121
- const card = await acceptInto(
1122
- accountConfigId,
1123
- calendar.calendarId,
1124
- await putInvite(accountConfigId, "msg-rollback", 0),
1125
- );
1126
- const failing: ICalendarUnitOfWork = {
1127
- transaction: (fn) =>
1128
- client.calendarUnitOfWork.transaction((repos) =>
1129
- fn({
1130
- ...repos,
1131
- calendarCollection: Object.assign(
1132
- Object.create(repos.calendarCollection),
1133
- {
1134
- bumpSyncSequence: async () => {
1135
- throw new Error("disk full");
1136
- },
1137
- },
1138
- ),
1139
- }),
1140
- ),
1141
- };
1142
-
1143
- await assert.rejects(
1144
- () =>
1145
- reopenCalendarSuggestion(failing, {
1146
- accountConfigId,
1147
- suggestion: card,
1148
- }),
1149
- /disk full/,
1150
- );
1151
-
1152
- const object = await client.calendarObject.find(
1153
- calendar.calendarId,
1154
- card.acceptedCalendarObjectId,
1155
- );
1156
- assert.ok(object, "the event is still on the calendar");
1157
- assert.ok(
1158
- (
1159
- await client.calendarEventIndex.listForObject(
1160
- calendar.calendarId,
1161
- card.acceptedCalendarObjectId,
1162
- )
1163
- ).length > 0,
1164
- "and so are its occurrences",
1165
- );
1166
- const untouched = await client.calendarSuggestion.get(
1167
- accountConfigId,
1168
- card.suggestionId,
1169
- );
1170
- assert.equal(untouched.state, CalendarSuggestionState.Accepted);
1171
- assert.equal(
1172
- untouched.acceptedCalendarObjectId,
1173
- card.acceptedCalendarObjectId,
1174
- );
1175
- });
1176
- });
1177
-
1178
- describe("GET /calendar-suggestions leaves out events that are over", () => {
1179
- const hoursFromNow = (hours: number): string =>
1180
- new Date(Date.now() + hours * 3600000).toISOString();
1181
-
1182
- const past = {
1183
- dtStart: hoursFromNow(-48),
1184
- dtEnd: hoursFromNow(-47),
1185
- endsAtUtc: hoursFromNow(-47),
1186
- icalData: INVITATION,
1187
- };
1188
-
1189
- const listState = async (
1190
- event: APIGatewayProxyEvent,
1191
- state: CalendarSuggestionItem["state"],
1192
- ): Promise<Card[]> =>
1193
- (
1194
- (await listSuggestions(
1195
- contextOf({ query: { state } }),
1196
- event,
1197
- )) as unknown as { items: Card[] }
1198
- ).items;
1199
-
1200
- test("omits a Pending suggestion that ended and keeps one still ahead", async () => {
1201
- const { accountConfigId, event } = anAccount();
1202
- await putSuggestion(
1203
- accountConfigId,
1204
- "msg-past",
1205
- CalendarInviteMethod.Request,
1206
- past,
1207
- );
1208
- const ahead = await putSuggestion(accountConfigId, "msg-ahead");
1209
-
1210
- const pending = await listState(event, CalendarSuggestionState.Pending);
1211
-
1212
- assert.deepEqual(
1213
- pending.map((card) => card.suggestionId),
1214
- [ahead.suggestionId],
1215
- );
1216
- });
1217
-
1218
- test("keeps a weekly series that began two years ago and has no end", async () => {
1219
- const { accountConfigId, event } = anAccount();
1220
- const twoYearsAgo = hoursFromNow(-2 * 365 * 24);
1221
- const stamp = twoYearsAgo.replace(/[-:]/g, "").slice(0, 15).concat("Z");
1222
- const series = await putSuggestion(
1223
- accountConfigId,
1224
- "msg-series",
1225
- CalendarInviteMethod.Request,
1226
- {
1227
- dtStart: twoYearsAgo,
1228
- dtEnd: twoYearsAgo,
1229
- endsAtUtc: "9999-12-31T23:59:59Z",
1230
- icalData: INVITATION.replace(
1231
- /DTSTART[^\r\n]*/,
1232
- `DTSTART:${stamp}`,
1233
- ).replace(/DTEND[^\r\n]*/, `DTEND:${stamp}\r\nRRULE:FREQ=WEEKLY`),
1234
- },
1235
- );
1236
-
1237
- const pending = await listState(event, CalendarSuggestionState.Pending);
1238
-
1239
- assert.deepEqual(
1240
- pending.map((card) => card.suggestionId),
1241
- [series.suggestionId],
1242
- );
1243
- });
1244
-
1245
- test("still returns an Accepted suggestion whose event is over", async () => {
1246
- const { accountConfigId, event } = anAccount();
1247
- const stored = await putSuggestion(
1248
- accountConfigId,
1249
- "msg-accepted",
1250
- CalendarInviteMethod.Request,
1251
- past,
1252
- );
1253
- await client.calendarSuggestion.settle(
1254
- accountConfigId,
1255
- stored.suggestionId,
1256
- {
1257
- state: CalendarSuggestionState.Accepted,
1258
- acceptedCalendarObjectId: "",
1259
- },
1260
- );
1261
-
1262
- const accepted = await listState(event, CalendarSuggestionState.Accepted);
1263
-
1264
- assert.deepEqual(
1265
- accepted.map((card) => card.suggestionId),
1266
- [stored.suggestionId],
1267
- );
1268
- });
1269
- });