@remit/backend 0.0.143 → 0.0.145

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 (279) 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 +5 -0
  4. package/dist/src/api.js +73 -0
  5. package/dist/src/auth.d.ts +24 -0
  6. package/dist/src/auth.js +81 -0
  7. package/dist/src/config/api-config.d.ts +20 -0
  8. package/dist/src/config/api-config.js +1 -0
  9. package/dist/src/config/from-env.d.ts +2 -0
  10. package/dist/src/config/from-env.js +65 -0
  11. package/dist/src/config/msoauth-secrets-manager.d.ts +2 -0
  12. package/dist/src/config/msoauth-secrets-manager.js +28 -0
  13. package/dist/src/config/msoauth.d.ts +19 -0
  14. package/dist/src/config/msoauth.js +6 -0
  15. package/{src/derive/autoMoved.ts → dist/src/derive/autoMoved.d.ts} +1 -29
  16. package/dist/src/derive/autoMoved.js +49 -0
  17. package/dist/src/derive/contentSignature.d.ts +40 -0
  18. package/{src/derive/contentSignature.ts → dist/src/derive/contentSignature.js} +23 -77
  19. package/dist/src/derive/contentUrl.d.ts +33 -0
  20. package/dist/src/derive/contentUrl.js +27 -0
  21. package/{src/derive/deriveMuted.ts → dist/src/derive/deriveMuted.d.ts} +1 -3
  22. package/dist/src/derive/deriveMuted.js +7 -0
  23. package/dist/src/derive/enrichThreadRows.d.ts +118 -0
  24. package/dist/src/derive/enrichThreadRows.js +151 -0
  25. package/dist/src/derive/filterThreadCriteria.d.ts +23 -0
  26. package/dist/src/derive/filterThreadCriteria.js +24 -0
  27. package/{src/derive/pendingMoveCounts.ts → dist/src/derive/pendingMoveCounts.d.ts} +5 -53
  28. package/dist/src/derive/pendingMoveCounts.js +53 -0
  29. package/{src/derive/senderTrust.ts → dist/src/derive/senderTrust.d.ts} +1 -9
  30. package/dist/src/derive/senderTrust.js +19 -0
  31. package/dist/src/error.d.ts +21 -0
  32. package/dist/src/error.js +85 -0
  33. package/dist/src/handlers/account-guards.d.ts +36 -0
  34. package/dist/src/handlers/account-guards.js +107 -0
  35. package/dist/src/handlers/account-oauth.d.ts +15 -0
  36. package/dist/src/handlers/account-oauth.js +334 -0
  37. package/dist/src/handlers/account-overrides.d.ts +53 -0
  38. package/dist/src/handlers/account-overrides.js +233 -0
  39. package/dist/src/handlers/account-signature.d.ts +28 -0
  40. package/dist/src/handlers/account-signature.js +85 -0
  41. package/dist/src/handlers/account.d.ts +7 -0
  42. package/dist/src/handlers/account.js +337 -0
  43. package/dist/src/handlers/address.d.ts +35 -0
  44. package/dist/src/handlers/address.js +133 -0
  45. package/dist/src/handlers/calendar-event.d.ts +70 -0
  46. package/dist/src/handlers/calendar-event.js +417 -0
  47. package/dist/src/handlers/calendar-feed.d.ts +47 -0
  48. package/dist/src/handlers/calendar-feed.js +184 -0
  49. package/dist/src/handlers/calendar-suggestion.d.ts +47 -0
  50. package/dist/src/handlers/calendar-suggestion.js +204 -0
  51. package/dist/src/handlers/calendar.d.ts +116 -0
  52. package/dist/src/handlers/calendar.js +323 -0
  53. package/dist/src/handlers/config.d.ts +43 -0
  54. package/dist/src/handlers/config.js +164 -0
  55. package/dist/src/handlers/ensure-account-config.d.ts +9 -0
  56. package/dist/src/handlers/ensure-account-config.js +27 -0
  57. package/dist/src/handlers/filter.d.ts +3 -0
  58. package/dist/src/handlers/filter.js +88 -0
  59. package/dist/src/handlers/folder-role-appointments.d.ts +68 -0
  60. package/dist/src/handlers/folder-role-appointments.js +168 -0
  61. package/dist/src/handlers/folder-role-labels.d.ts +26 -0
  62. package/dist/src/handlers/folder-role-labels.js +40 -0
  63. package/dist/src/handlers/folder-role.d.ts +9 -0
  64. package/dist/src/handlers/folder-role.js +37 -0
  65. package/dist/src/handlers/index.d.ts +88 -0
  66. package/dist/src/handlers/index.js +62 -0
  67. package/dist/src/handlers/label.d.ts +43 -0
  68. package/dist/src/handlers/label.js +107 -0
  69. package/dist/src/handlers/mailbox-bindings.d.ts +41 -0
  70. package/dist/src/handlers/mailbox-bindings.js +61 -0
  71. package/dist/src/handlers/mailbox.d.ts +23 -0
  72. package/dist/src/handlers/mailbox.js +211 -0
  73. package/dist/src/handlers/me.d.ts +2 -0
  74. package/dist/src/handlers/me.js +117 -0
  75. package/dist/src/handlers/message.d.ts +82 -0
  76. package/dist/src/handlers/message.js +595 -0
  77. package/dist/src/handlers/organize.d.ts +10 -0
  78. package/dist/src/handlers/organize.js +103 -0
  79. package/dist/src/handlers/outbox-attachment.d.ts +25 -0
  80. package/dist/src/handlers/outbox-attachment.js +77 -0
  81. package/dist/src/handlers/outbox.d.ts +4 -0
  82. package/dist/src/handlers/outbox.js +136 -0
  83. package/dist/src/handlers/sync-progress.d.ts +46 -0
  84. package/dist/src/handlers/sync-progress.js +63 -0
  85. package/dist/src/handlers/sync.d.ts +28 -0
  86. package/dist/src/handlers/sync.js +73 -0
  87. package/dist/src/handlers/system-update.d.ts +2 -0
  88. package/dist/src/handlers/system-update.js +159 -0
  89. package/dist/src/handlers/thread.d.ts +148 -0
  90. package/dist/src/handlers/thread.js +228 -0
  91. package/dist/src/handlers/unified-threads.d.ts +247 -0
  92. package/dist/src/handlers/unified-threads.js +406 -0
  93. package/{src/handlers/vip-suggestions.ts → dist/src/handlers/vip-suggestions.d.ts} +1 -13
  94. package/dist/src/handlers/vip-suggestions.js +16 -0
  95. package/dist/src/index.d.ts +5 -0
  96. package/dist/src/index.js +53 -0
  97. package/{src/json.ts → dist/src/json.d.ts} +1 -4
  98. package/dist/src/json.js +10 -0
  99. package/dist/src/jwt-auth.d.ts +15 -0
  100. package/dist/src/jwt-auth.js +45 -0
  101. package/dist/src/lambda.d.ts +13 -0
  102. package/dist/src/lambda.js +33 -0
  103. package/dist/src/request.d.ts +9 -0
  104. package/dist/src/request.js +17 -0
  105. package/dist/src/response.d.ts +44 -0
  106. package/dist/src/response.js +80 -0
  107. package/dist/src/serve.d.ts +31 -0
  108. package/dist/src/serve.js +117 -0
  109. package/dist/src/service/account-deletion.d.ts +20 -0
  110. package/dist/src/service/account-deletion.js +33 -0
  111. package/dist/src/service/compose-sqlite.d.ts +3 -0
  112. package/dist/src/service/compose-sqlite.js +13 -0
  113. package/dist/src/service/create-remit-client.d.ts +109 -0
  114. package/dist/src/service/create-remit-client.js +188 -0
  115. package/dist/src/service/data-client.d.ts +12 -0
  116. package/dist/src/service/data-client.js +42 -0
  117. package/dist/src/service/export-identity.d.ts +4 -0
  118. package/dist/src/service/export-identity.js +33 -0
  119. package/dist/src/service/fire-and-forget.d.ts +54 -0
  120. package/dist/src/service/fire-and-forget.js +37 -0
  121. package/dist/src/service/organize.d.ts +181 -0
  122. package/dist/src/service/organize.js +367 -0
  123. package/dist/src/service/queues.d.ts +12 -0
  124. package/dist/src/service/queues.js +1 -0
  125. package/dist/src/service/refusal.d.ts +4 -0
  126. package/dist/src/service/refusal.js +20 -0
  127. package/dist/src/service/sender-category.d.ts +51 -0
  128. package/{src/service/sender-category.ts → dist/src/service/sender-category.js} +40 -81
  129. package/dist/src/service/shared-deps.d.ts +6 -0
  130. package/dist/src/service/shared-deps.js +24 -0
  131. package/dist/src/service/trigger-sync.d.ts +57 -0
  132. package/dist/src/service/trigger-sync.js +31 -0
  133. package/dist/src/types.d.ts +72 -0
  134. package/dist/src/types.js +1 -0
  135. package/package.json +125 -98
  136. package/.env.test +0 -4
  137. package/dev-server/content-auth.test.ts +0 -113
  138. package/dev-server/content-auth.ts +0 -54
  139. package/dev-server/content-handler.test.ts +0 -105
  140. package/dev-server/content-handler.ts +0 -79
  141. package/dev-server/content-path.test.ts +0 -37
  142. package/dev-server/content-path.ts +0 -27
  143. package/dev-server/cors.test.ts +0 -48
  144. package/dev-server/cors.ts +0 -25
  145. package/dev-server/keep-alive.test.ts +0 -24
  146. package/dev-server/lambda-helpers.ts +0 -69
  147. package/dev-server/log-output.test.ts +0 -173
  148. package/dev-server/oauth-callback-route.test.ts +0 -66
  149. package/dev-server/relational-health.test.ts +0 -83
  150. package/dev-server/relational-health.ts +0 -39
  151. package/dev-server/server.ts +0 -384
  152. package/dev-server/sync-age.test.ts +0 -91
  153. package/dev-server/sync-age.ts +0 -55
  154. package/dev-server/upload-handler.ts +0 -132
  155. package/dev-server/upload-route.test.ts +0 -196
  156. package/scripts/backfill-classification.ts +0 -91
  157. package/scripts/backfill-list-id.ts +0 -83
  158. package/scripts/config-save.ts +0 -128
  159. package/src/auth.test.ts +0 -12
  160. package/src/auth.ts +0 -92
  161. package/src/config/msoauth.test.ts +0 -48
  162. package/src/config/msoauth.ts +0 -48
  163. package/src/data-backend.test.ts +0 -23
  164. package/src/data-backend.ts +0 -14
  165. package/src/derive/autoMoved.test.ts +0 -104
  166. package/src/derive/contentSignature.test.ts +0 -161
  167. package/src/derive/contentUrl.test.ts +0 -172
  168. package/src/derive/contentUrl.ts +0 -86
  169. package/src/derive/deriveMuted.test.ts +0 -34
  170. package/src/derive/enrichThreadRows.test.ts +0 -360
  171. package/src/derive/enrichThreadRows.ts +0 -234
  172. package/src/derive/filterThreadCriteria.test.ts +0 -126
  173. package/src/derive/filterThreadCriteria.ts +0 -51
  174. package/src/derive/senderTrust.test.ts +0 -67
  175. package/src/error.test.ts +0 -201
  176. package/src/error.ts +0 -123
  177. package/src/handlers/account-guards.test.ts +0 -67
  178. package/src/handlers/account-guards.ts +0 -169
  179. package/src/handlers/account-oauth-callback.test.ts +0 -155
  180. package/src/handlers/account-oauth-consent.test.ts +0 -336
  181. package/src/handlers/account-oauth-start.test.ts +0 -191
  182. package/src/handlers/account-oauth.test.ts +0 -439
  183. package/src/handlers/account-oauth.ts +0 -635
  184. package/src/handlers/account-overrides.test.ts +0 -88
  185. package/src/handlers/account-overrides.ts +0 -323
  186. package/src/handlers/account-ownership.ts +0 -25
  187. package/src/handlers/account-signature.ts +0 -117
  188. package/src/handlers/account-synced-services.test.ts +0 -245
  189. package/src/handlers/account.ts +0 -552
  190. package/src/handlers/address.test.ts +0 -381
  191. package/src/handlers/address.ts +0 -200
  192. package/src/handlers/calendar-event.test.ts +0 -685
  193. package/src/handlers/calendar-event.ts +0 -738
  194. package/src/handlers/calendar-feed-validators.test.ts +0 -228
  195. package/src/handlers/calendar-feed.test.ts +0 -439
  196. package/src/handlers/calendar-feed.ts +0 -291
  197. package/src/handlers/calendar-sqlite-fixture.ts +0 -36
  198. package/src/handlers/calendar-suggestion.test.ts +0 -1269
  199. package/src/handlers/calendar-suggestion.ts +0 -425
  200. package/src/handlers/calendar.test.ts +0 -1080
  201. package/src/handlers/calendar.ts +0 -517
  202. package/src/handlers/config.test.ts +0 -245
  203. package/src/handlers/config.ts +0 -332
  204. package/src/handlers/ensure-account-config.ts +0 -30
  205. package/src/handlers/filter.test.ts +0 -644
  206. package/src/handlers/filter.ts +0 -518
  207. package/src/handlers/folder-role-appointments.test.ts +0 -438
  208. package/src/handlers/folder-role-appointments.ts +0 -249
  209. package/src/handlers/folder-role-labels.ts +0 -63
  210. package/src/handlers/folder-role.test.ts +0 -209
  211. package/src/handlers/folder-role.ts +0 -80
  212. package/src/handlers/index.ts +0 -83
  213. package/src/handlers/label.test.ts +0 -112
  214. package/src/handlers/label.ts +0 -210
  215. package/src/handlers/mailbox-bindings.ts +0 -111
  216. package/src/handlers/mailbox-rename-refusal.test.ts +0 -247
  217. package/src/handlers/mailbox-settled-target.test.ts +0 -251
  218. package/src/handlers/mailbox.test.ts +0 -280
  219. package/src/handlers/mailbox.ts +0 -581
  220. package/src/handlers/me.test.ts +0 -81
  221. package/src/handlers/me.ts +0 -203
  222. package/src/handlers/message-read-backfill.test.ts +0 -103
  223. package/src/handlers/message.test.ts +0 -131
  224. package/src/handlers/message.ts +0 -1131
  225. package/src/handlers/organize.test.ts +0 -277
  226. package/src/handlers/organize.ts +0 -197
  227. package/src/handlers/outbox-attachment.test.ts +0 -765
  228. package/src/handlers/outbox-attachment.ts +0 -126
  229. package/src/handlers/outbox-list.test.ts +0 -179
  230. package/src/handlers/outbox.test.ts +0 -772
  231. package/src/handlers/outbox.ts +0 -258
  232. package/src/handlers/search.test.ts +0 -161
  233. package/src/handlers/search.ts +0 -105
  234. package/src/handlers/sync-progress.test.ts +0 -205
  235. package/src/handlers/sync-progress.ts +0 -99
  236. package/src/handlers/sync.test.ts +0 -168
  237. package/src/handlers/sync.ts +0 -118
  238. package/src/handlers/system-update.test.ts +0 -497
  239. package/src/handlers/system-update.ts +0 -230
  240. package/src/handlers/thread.test.ts +0 -396
  241. package/src/handlers/thread.ts +0 -427
  242. package/src/handlers/unified-threads-mail-sources.sqlite.test.ts +0 -197
  243. package/src/handlers/unified-threads.test.ts +0 -785
  244. package/src/handlers/unified-threads.ts +0 -669
  245. package/src/index.test.ts +0 -83
  246. package/src/index.ts +0 -228
  247. package/src/jwt-auth.test.ts +0 -152
  248. package/src/jwt-auth.ts +0 -132
  249. package/src/log-redaction.test.ts +0 -144
  250. package/src/openapi-spec.test.ts +0 -118
  251. package/src/request-context.test.ts +0 -61
  252. package/src/request-context.ts +0 -33
  253. package/src/request-pipeline.test.ts +0 -88
  254. package/src/request.ts +0 -10
  255. package/src/response.test.ts +0 -130
  256. package/src/response.ts +0 -144
  257. package/src/service/compose-sqlite.ts +0 -98
  258. package/src/service/config-import.ts +0 -39
  259. package/src/service/create-remit-client.ts +0 -522
  260. package/src/service/data-client.test.ts +0 -97
  261. package/src/service/data-client.ts +0 -58
  262. package/src/service/export-identity.ts +0 -39
  263. package/src/service/filter.ts +0 -40
  264. package/src/service/fire-and-forget.test.ts +0 -126
  265. package/src/service/fire-and-forget.ts +0 -79
  266. package/src/service/localhost-env-config.test.ts +0 -36
  267. package/src/service/organize-queue.ts +0 -98
  268. package/src/service/organize.test.ts +0 -1451
  269. package/src/service/organize.ts +0 -1025
  270. package/src/service/read-path-filter-config.test.ts +0 -243
  271. package/src/service/semantic-capability.test.ts +0 -78
  272. package/src/service/semantic-capability.ts +0 -95
  273. package/src/service/sender-category.test.ts +0 -235
  274. package/src/service/sqs.ts +0 -4
  275. package/src/service/trigger-sync.test.ts +0 -205
  276. package/src/service/trigger-sync.ts +0 -100
  277. package/src/stdout-capture-fixture.ts +0 -40
  278. package/src/types.ts +0 -271
  279. 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
- });