@remit/backend 0.0.75 → 0.0.77
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type { AccountSettingItem, MailboxItem } from "@remit/data-ports";
|
|
4
|
+
import { isPublicApiError } from "@remit/data-ports/errors";
|
|
5
|
+
import { composeFolderRoleAppointmentLabelName } from "@remit/data-ports/folder-role";
|
|
6
|
+
import { CanonicalMailboxRole, MailboxSyncStatus } from "@remit/domain-enums";
|
|
7
|
+
import { assertMailboxSettled } from "./folder-role.js";
|
|
8
|
+
import { applyMailboxPatch, type MailboxPatchClient } from "./mailbox.js";
|
|
9
|
+
|
|
10
|
+
const mailbox = (over: Partial<MailboxItem>): MailboxItem =>
|
|
11
|
+
({
|
|
12
|
+
mailboxId: "mb-1",
|
|
13
|
+
fullPath: "INBOX/Prullenbak",
|
|
14
|
+
hierarchyDelimiter: "/",
|
|
15
|
+
...over,
|
|
16
|
+
}) as unknown as MailboxItem;
|
|
17
|
+
|
|
18
|
+
const caught = (run: () => void): unknown => {
|
|
19
|
+
let thrown: unknown;
|
|
20
|
+
assert.throws(run, (error: unknown) => {
|
|
21
|
+
thrown = error;
|
|
22
|
+
return true;
|
|
23
|
+
});
|
|
24
|
+
return thrown;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const publicErrorOf = (error: unknown) => {
|
|
28
|
+
if (typeof error !== "object" || error === null) return undefined;
|
|
29
|
+
const { publicApiError } = error as { publicApiError?: unknown };
|
|
30
|
+
return isPublicApiError(publicApiError) ? publicApiError : undefined;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
describe("assertMailboxSettled", () => {
|
|
34
|
+
it("refuses a folder the mail server has not created yet", () => {
|
|
35
|
+
const error = caught(() =>
|
|
36
|
+
assertMailboxSettled(mailbox({ syncStatus: MailboxSyncStatus.pending })),
|
|
37
|
+
);
|
|
38
|
+
assert.equal(publicErrorOf(error)?.code, "mailbox_not_settled");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("refuses a folder on its way out", () => {
|
|
42
|
+
const error = caught(() =>
|
|
43
|
+
assertMailboxSettled(mailbox({ syncStatus: MailboxSyncStatus.deleting })),
|
|
44
|
+
);
|
|
45
|
+
assert.equal(publicErrorOf(error)?.code, "mailbox_not_settled");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("carries the mailbox and its state, so the client words a wait", () => {
|
|
49
|
+
const error = caught(() =>
|
|
50
|
+
assertMailboxSettled(
|
|
51
|
+
mailbox({ mailboxId: "mb-9", syncStatus: MailboxSyncStatus.pending }),
|
|
52
|
+
),
|
|
53
|
+
);
|
|
54
|
+
assert.deepEqual(publicErrorOf(error)?.details, {
|
|
55
|
+
mailboxId: "mb-9",
|
|
56
|
+
syncStatus: "pending",
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("allows a settled folder, and one whose delete failed", () => {
|
|
61
|
+
assert.doesNotThrow(() =>
|
|
62
|
+
assertMailboxSettled(mailbox({ syncStatus: MailboxSyncStatus.synced })),
|
|
63
|
+
);
|
|
64
|
+
assert.doesNotThrow(() =>
|
|
65
|
+
assertMailboxSettled(mailbox({ syncStatus: MailboxSyncStatus.failed })),
|
|
66
|
+
);
|
|
67
|
+
assert.doesNotThrow(() => assertMailboxSettled(mailbox({})));
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("applyMailboxPatch — the appointment label follows a rename", () => {
|
|
72
|
+
const labelName = composeFolderRoleAppointmentLabelName(
|
|
73
|
+
"acc-1",
|
|
74
|
+
CanonicalMailboxRole.Trash,
|
|
75
|
+
);
|
|
76
|
+
const sentLabelName = composeFolderRoleAppointmentLabelName(
|
|
77
|
+
"acc-1",
|
|
78
|
+
CanonicalMailboxRole.Sent,
|
|
79
|
+
);
|
|
80
|
+
const appointmentName = (role: string) =>
|
|
81
|
+
`FolderRoleAppointment#acc-1#${role}`;
|
|
82
|
+
|
|
83
|
+
const settingsFor = (
|
|
84
|
+
rows: Record<string, string>,
|
|
85
|
+
renamed = "INBOX",
|
|
86
|
+
): { store: Record<string, string>; client: MailboxPatchClient } => {
|
|
87
|
+
const store: Record<string, string> = { ...rows };
|
|
88
|
+
const client = {
|
|
89
|
+
mailbox: {
|
|
90
|
+
get: async () => mailbox({ fullPath: renamed }),
|
|
91
|
+
},
|
|
92
|
+
mailboxQueue: {
|
|
93
|
+
renameMailbox: async (mailboxId: string, newPath: string) =>
|
|
94
|
+
mailbox({ mailboxId, fullPath: newPath }),
|
|
95
|
+
},
|
|
96
|
+
accountSetting: {
|
|
97
|
+
get: async (_configId: string, name: string) =>
|
|
98
|
+
store[name] === undefined
|
|
99
|
+
? undefined
|
|
100
|
+
: ({
|
|
101
|
+
name,
|
|
102
|
+
value: { kind: "String", value: store[name] },
|
|
103
|
+
} as AccountSettingItem),
|
|
104
|
+
upsert: async (item: AccountSettingItem) => {
|
|
105
|
+
if (item.value.kind === "String") store[item.name] = item.value.value;
|
|
106
|
+
return item;
|
|
107
|
+
},
|
|
108
|
+
delete: async (_configId: string, name: string) => {
|
|
109
|
+
delete store[name];
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
} as unknown as MailboxPatchClient;
|
|
113
|
+
return { store, client };
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
it("rewrites the recorded path for the folder that was renamed", async () => {
|
|
117
|
+
const { store, client } = settingsFor(
|
|
118
|
+
{
|
|
119
|
+
[appointmentName(CanonicalMailboxRole.Trash)]: "mb-1",
|
|
120
|
+
[labelName]: "INBOX/Prullenbak",
|
|
121
|
+
},
|
|
122
|
+
"INBOX/Prullenbak",
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
await applyMailboxPatch(client, "cfg-1", "mb-1", "acc-1", {
|
|
126
|
+
fullPath: "INBOX/Verwijderd",
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
assert.equal(store[labelName], "INBOX/Verwijderd");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// IMAP RENAME moves the subtree in one command and `renameChildPaths`
|
|
133
|
+
// rewrites every descendant row, so every label under the branch moves too.
|
|
134
|
+
it("carries every appointed folder under the renamed branch with it", async () => {
|
|
135
|
+
const { store, client } = settingsFor(
|
|
136
|
+
{
|
|
137
|
+
[appointmentName(CanonicalMailboxRole.Trash)]: "mb-trash",
|
|
138
|
+
[labelName]: "INBOX/Prullenbak",
|
|
139
|
+
[appointmentName(CanonicalMailboxRole.Sent)]: "mb-sent",
|
|
140
|
+
[sentLabelName]: "INBOX/Verzonden",
|
|
141
|
+
},
|
|
142
|
+
"INBOX",
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
await applyMailboxPatch(client, "cfg-1", "mb-1", "acc-1", {
|
|
146
|
+
fullPath: "Mail",
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
assert.equal(store[labelName], "Mail/Prullenbak");
|
|
150
|
+
assert.equal(store[sentLabelName], "Mail/Verzonden");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("leaves a folder outside the renamed branch alone", async () => {
|
|
154
|
+
const { store, client } = settingsFor(
|
|
155
|
+
{
|
|
156
|
+
[appointmentName(CanonicalMailboxRole.Trash)]: "mb-trash",
|
|
157
|
+
[labelName]: "Archief/Prullenbak",
|
|
158
|
+
},
|
|
159
|
+
"INBOX",
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
await applyMailboxPatch(client, "cfg-1", "mb-1", "acc-1", {
|
|
163
|
+
fullPath: "Mail",
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
assert.equal(store[labelName], "Archief/Prullenbak");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("never rebases a sibling that merely shares the prefix", async () => {
|
|
170
|
+
const { store, client } = settingsFor(
|
|
171
|
+
{
|
|
172
|
+
[appointmentName(CanonicalMailboxRole.Trash)]: "mb-trash",
|
|
173
|
+
[labelName]: "INBOXES/Prullenbak",
|
|
174
|
+
},
|
|
175
|
+
"INBOX",
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
await applyMailboxPatch(client, "cfg-1", "mb-1", "acc-1", {
|
|
179
|
+
fullPath: "Mail",
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
assert.equal(store[labelName], "INBOXES/Prullenbak");
|
|
183
|
+
});
|
|
184
|
+
});
|
|
@@ -2,6 +2,9 @@ import type {
|
|
|
2
2
|
AppointFolderRoleInput,
|
|
3
3
|
CanonicalMailboxRole,
|
|
4
4
|
} from "@remit/api-openapi-types";
|
|
5
|
+
import type { MailboxItem } from "@remit/data-ports";
|
|
6
|
+
import { MailboxNotSettledError } from "@remit/data-ports/errors";
|
|
7
|
+
import { MailboxSyncStatus } from "@remit/domain-enums";
|
|
5
8
|
import type { APIGatewayProxyEvent } from "aws-lambda";
|
|
6
9
|
import { getAccountConfigIdFromEvent } from "../auth.js";
|
|
7
10
|
import { getClient } from "../service/data-client.js";
|
|
@@ -16,6 +19,30 @@ import {
|
|
|
16
19
|
} from "./folder-role-appointments.js";
|
|
17
20
|
import { assertMailboxInAccount } from "./mailbox.js";
|
|
18
21
|
|
|
22
|
+
/** Mailbox states in which the mail server has not settled the folder yet. */
|
|
23
|
+
const UNSETTLED: ReadonlySet<string> = new Set([
|
|
24
|
+
MailboxSyncStatus.pending,
|
|
25
|
+
MailboxSyncStatus.deleting,
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Appointing a role to a folder the mail server is still creating or deleting
|
|
30
|
+
* would bind the account's Trash to a folder that may never exist (D16 item 3,
|
|
31
|
+
* imap-mutations R2: wait). A `\Noselect` container is refused by construction
|
|
32
|
+
* — mailbox-sync keeps no row for one — so there is nothing to appoint.
|
|
33
|
+
*/
|
|
34
|
+
export const assertMailboxSettled = (
|
|
35
|
+
target: Pick<MailboxItem, "mailboxId" | "fullPath" | "syncStatus">,
|
|
36
|
+
): void => {
|
|
37
|
+
const syncStatus = target.syncStatus;
|
|
38
|
+
if (!syncStatus || !UNSETTLED.has(syncStatus)) return;
|
|
39
|
+
throw new MailboxNotSettledError(
|
|
40
|
+
`Mailbox ${target.fullPath} is not settled on the mail server yet`,
|
|
41
|
+
target.mailboxId,
|
|
42
|
+
syncStatus,
|
|
43
|
+
);
|
|
44
|
+
};
|
|
45
|
+
|
|
19
46
|
/**
|
|
20
47
|
* RFC 032 exclusive-folder-appointment (#976): the single write operation for
|
|
21
48
|
* the per-account role map. `appoint(role, mailboxId)` sets `map[role]` —
|
|
@@ -47,6 +74,7 @@ export const FolderRoleOperations: Record<
|
|
|
47
74
|
if (body.mailboxId) {
|
|
48
75
|
const target = await mailbox.get(accountId, body.mailboxId);
|
|
49
76
|
assertMailboxInAccount(target, accountId, "act");
|
|
77
|
+
assertMailboxSettled(target);
|
|
50
78
|
lastKnownPath = target.fullPath;
|
|
51
79
|
}
|
|
52
80
|
|
package/src/handlers/mailbox.ts
CHANGED
|
@@ -4,6 +4,10 @@ import type {
|
|
|
4
4
|
} from "@remit/api-openapi-types";
|
|
5
5
|
import type { IAccountSettingRepository, MailboxItem } from "@remit/data-ports";
|
|
6
6
|
import { ForbiddenError, NotFoundError } from "@remit/data-ports/errors";
|
|
7
|
+
import {
|
|
8
|
+
type CanonicalMailboxRoleValue,
|
|
9
|
+
composeFolderRoleAppointmentLabelName,
|
|
10
|
+
} from "@remit/data-ports/folder-role";
|
|
7
11
|
import { MailboxSyncStatus, MessageSystemFlag } from "@remit/domain-enums";
|
|
8
12
|
import type { APIGatewayProxyEvent } from "aws-lambda";
|
|
9
13
|
import { getAccountConfigIdFromEvent } from "../auth.js";
|
|
@@ -25,6 +29,7 @@ import {
|
|
|
25
29
|
type MailboxOverrides,
|
|
26
30
|
} from "./account-overrides.js";
|
|
27
31
|
import { assertAccountOwnership } from "./account-ownership.js";
|
|
32
|
+
import { loadFolderAppointmentsForAccount } from "./folder-role-appointments.js";
|
|
28
33
|
|
|
29
34
|
/**
|
|
30
35
|
* The mute flag and the display-name override are user preferences that live
|
|
@@ -68,9 +73,73 @@ export interface MailboxPatchClient {
|
|
|
68
73
|
accountId: string,
|
|
69
74
|
): Promise<MailboxItem>;
|
|
70
75
|
};
|
|
71
|
-
accountSetting: Pick<IAccountSettingRepository, "upsert" | "delete">;
|
|
76
|
+
accountSetting: Pick<IAccountSettingRepository, "get" | "upsert" | "delete">;
|
|
72
77
|
}
|
|
73
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Where a recorded path lands after the rename, or `undefined` when the rename
|
|
81
|
+
* did not move it. IMAP RENAME moves the whole subtree in one command and
|
|
82
|
+
* `renameChildPaths` rewrites every descendant row with it, so a label under
|
|
83
|
+
* the renamed branch moves exactly as far as its prefix does.
|
|
84
|
+
*/
|
|
85
|
+
const rebasePath = (
|
|
86
|
+
recorded: string,
|
|
87
|
+
oldPath: string,
|
|
88
|
+
newPath: string,
|
|
89
|
+
delimiter: string,
|
|
90
|
+
): string | undefined => {
|
|
91
|
+
if (recorded === oldPath) return newPath;
|
|
92
|
+
const branch = `${oldPath}${delimiter}`;
|
|
93
|
+
if (!recorded.startsWith(branch)) return undefined;
|
|
94
|
+
return `${newPath}${recorded.slice(oldPath.length)}`;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* A reader-side rename keeps every mailboxId, so the appointments survive it —
|
|
99
|
+
* but the paths recorded beside them (#887) would still name where the folders
|
|
100
|
+
* were before. Move the labels with the branch, or a later third-party delete
|
|
101
|
+
* names a path the user has not seen since the rename.
|
|
102
|
+
*
|
|
103
|
+
* The renamed folder is matched by id; its descendants are matched by the path
|
|
104
|
+
* each label already holds, which is the path their rows carried until this
|
|
105
|
+
* rename rewrote them.
|
|
106
|
+
*/
|
|
107
|
+
const refreshAppointmentLabels = async (
|
|
108
|
+
accountSetting: Pick<IAccountSettingRepository, "get" | "upsert">,
|
|
109
|
+
accountConfigId: string,
|
|
110
|
+
accountId: string,
|
|
111
|
+
renamed: { mailboxId: string; oldPath: string; newPath: string },
|
|
112
|
+
delimiter: string,
|
|
113
|
+
): Promise<void> => {
|
|
114
|
+
const persisted = await loadFolderAppointmentsForAccount(
|
|
115
|
+
accountSetting,
|
|
116
|
+
accountConfigId,
|
|
117
|
+
accountId,
|
|
118
|
+
);
|
|
119
|
+
for (const [role, appointment] of persisted) {
|
|
120
|
+
const moved =
|
|
121
|
+
appointment.mailboxId === renamed.mailboxId
|
|
122
|
+
? renamed.newPath
|
|
123
|
+
: appointment.lastKnownPath === undefined
|
|
124
|
+
? undefined
|
|
125
|
+
: rebasePath(
|
|
126
|
+
appointment.lastKnownPath,
|
|
127
|
+
renamed.oldPath,
|
|
128
|
+
renamed.newPath,
|
|
129
|
+
delimiter,
|
|
130
|
+
);
|
|
131
|
+
if (moved === undefined || moved === appointment.lastKnownPath) continue;
|
|
132
|
+
await accountSetting.upsert({
|
|
133
|
+
accountConfigId,
|
|
134
|
+
name: composeFolderRoleAppointmentLabelName(
|
|
135
|
+
accountId,
|
|
136
|
+
role as CanonicalMailboxRoleValue,
|
|
137
|
+
),
|
|
138
|
+
value: { kind: "String", value: moved },
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
74
143
|
/**
|
|
75
144
|
* Apply a mailbox PATCH body: override changes first (mute flag + display-name/
|
|
76
145
|
* role overrides — written to per-mailbox AccountSetting rows, no IMAP
|
|
@@ -109,7 +178,26 @@ export const applyMailboxPatch = async (
|
|
|
109
178
|
return client.mailbox.get(accountId, mailboxId);
|
|
110
179
|
}
|
|
111
180
|
|
|
112
|
-
|
|
181
|
+
// Read before the rename: the labels of the folders under this one are
|
|
182
|
+
// rebased off the path it is leaving, which the row no longer carries after.
|
|
183
|
+
const before = await client.mailbox.get(accountId, mailboxId);
|
|
184
|
+
const renamed = await client.mailboxQueue.renameMailbox(
|
|
185
|
+
mailboxId,
|
|
186
|
+
fullPath,
|
|
187
|
+
accountId,
|
|
188
|
+
);
|
|
189
|
+
await refreshAppointmentLabels(
|
|
190
|
+
client.accountSetting,
|
|
191
|
+
accountConfigId,
|
|
192
|
+
accountId,
|
|
193
|
+
{
|
|
194
|
+
mailboxId,
|
|
195
|
+
oldPath: before.fullPath,
|
|
196
|
+
newPath: renamed.fullPath,
|
|
197
|
+
},
|
|
198
|
+
before.hierarchyDelimiter,
|
|
199
|
+
);
|
|
200
|
+
return renamed;
|
|
113
201
|
};
|
|
114
202
|
|
|
115
203
|
/**
|
|
@@ -143,6 +143,22 @@ const createInMemoryOutboxRepository = (): IOutboxMessageRepository => {
|
|
|
143
143
|
const acceptingSqsClient = (): SQSClient =>
|
|
144
144
|
({ send: async () => ({}) }) as unknown as SQSClient;
|
|
145
145
|
|
|
146
|
+
/** A queue that refuses every send until `accept()` is called. */
|
|
147
|
+
const refusingSqsClient = (): { client: SQSClient; accept: () => void } => {
|
|
148
|
+
let accepting = false;
|
|
149
|
+
return {
|
|
150
|
+
client: {
|
|
151
|
+
send: async () => {
|
|
152
|
+
if (!accepting) throw new Error("SQS unavailable");
|
|
153
|
+
return {};
|
|
154
|
+
},
|
|
155
|
+
} as unknown as SQSClient,
|
|
156
|
+
accept: () => {
|
|
157
|
+
accepting = true;
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
};
|
|
161
|
+
|
|
146
162
|
const accountRepository = {
|
|
147
163
|
get: async () => ({
|
|
148
164
|
accountId: ACCOUNT_ID,
|
|
@@ -210,7 +226,7 @@ const createAttachmentRepository = (
|
|
|
210
226
|
},
|
|
211
227
|
}) as unknown as IOutboxAttachmentRepository;
|
|
212
228
|
|
|
213
|
-
const installClient = (): void => {
|
|
229
|
+
const installClient = (sqsClient: SQSClient = acceptingSqsClient()): void => {
|
|
214
230
|
const outboxMessage = createInMemoryOutboxRepository();
|
|
215
231
|
const storage = createMockStorageService();
|
|
216
232
|
attachmentRows = new Map<string, OutboxAttachmentItem>();
|
|
@@ -230,7 +246,7 @@ const installClient = (): void => {
|
|
|
230
246
|
outboxAttachmentService,
|
|
231
247
|
accountService: accountRepository,
|
|
232
248
|
sqsSmtpQueueUrl: "http://localhost:9324/queue/outbox-test",
|
|
233
|
-
sqsClient
|
|
249
|
+
sqsClient,
|
|
234
250
|
}),
|
|
235
251
|
} as unknown as RemitClient);
|
|
236
252
|
};
|
|
@@ -259,6 +275,8 @@ const updateDraft =
|
|
|
259
275
|
OutboxDetailOperations.OutboxDetailOperations_updateOutboxMessage as Handler;
|
|
260
276
|
const deleteDraft =
|
|
261
277
|
OutboxDetailOperations.OutboxDetailOperations_deleteOutboxMessage as Handler;
|
|
278
|
+
const getMessage =
|
|
279
|
+
OutboxDetailOperations.OutboxDetailOperations_getOutboxMessage as Handler;
|
|
262
280
|
|
|
263
281
|
type Outcome =
|
|
264
282
|
| { readonly ok: true; readonly body: Record<string, unknown> }
|
|
@@ -394,6 +412,90 @@ describe("an outbox entry that has left draft (#604)", () => {
|
|
|
394
412
|
});
|
|
395
413
|
});
|
|
396
414
|
|
|
415
|
+
describe("a send whose enqueue fails (#845.8)", () => {
|
|
416
|
+
const draftAgainstQueue = async (
|
|
417
|
+
queue: SQSClient,
|
|
418
|
+
): Promise<{ outboxMessageId: string; response: APIGatewayProxyResult }> => {
|
|
419
|
+
installClient(queue);
|
|
420
|
+
const draft = await createDraft(
|
|
421
|
+
requestContext({}),
|
|
422
|
+
authorizedEvent({
|
|
423
|
+
accountId: ACCOUNT_ID,
|
|
424
|
+
toAddresses: ["recipient@example.com"],
|
|
425
|
+
subject: "the one that got stuck",
|
|
426
|
+
textBody: "sending",
|
|
427
|
+
}),
|
|
428
|
+
);
|
|
429
|
+
const outboxMessageId = String(draft.outboxMessageId);
|
|
430
|
+
const response = await respond(() =>
|
|
431
|
+
sendMessage(
|
|
432
|
+
requestContext({ params: { outboxMessageId } }),
|
|
433
|
+
authorizedEvent(),
|
|
434
|
+
),
|
|
435
|
+
);
|
|
436
|
+
return { outboxMessageId, response };
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
const statusOf = async (outboxMessageId: string): Promise<unknown> => {
|
|
440
|
+
const response = await respond(() =>
|
|
441
|
+
getMessage(
|
|
442
|
+
requestContext({ params: { outboxMessageId } }),
|
|
443
|
+
authorizedEvent(),
|
|
444
|
+
),
|
|
445
|
+
);
|
|
446
|
+
assert.equal(response.statusCode, 200);
|
|
447
|
+
return (JSON.parse(response.body) as { status?: string }).status;
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
it("answers 500, never a success the send never had", async () => {
|
|
451
|
+
const { response } = await draftAgainstQueue(refusingSqsClient().client);
|
|
452
|
+
|
|
453
|
+
assert.equal(response.statusCode, 500);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it("leaves the row a draft, not stranded at queued", async () => {
|
|
457
|
+
const { outboxMessageId } = await draftAgainstQueue(
|
|
458
|
+
refusingSqsClient().client,
|
|
459
|
+
);
|
|
460
|
+
|
|
461
|
+
assert.equal(await statusOf(outboxMessageId), OutboxMessageStatus.draft);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it("still lets the user discard what could not be queued", async () => {
|
|
465
|
+
const { outboxMessageId } = await draftAgainstQueue(
|
|
466
|
+
refusingSqsClient().client,
|
|
467
|
+
);
|
|
468
|
+
|
|
469
|
+
const response = await respond(() =>
|
|
470
|
+
deleteDraft(
|
|
471
|
+
requestContext({ params: { outboxMessageId } }),
|
|
472
|
+
authorizedEvent(),
|
|
473
|
+
),
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
assert.equal(response.statusCode, 204);
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it("sends once the queue comes back", async () => {
|
|
480
|
+
const queue = refusingSqsClient();
|
|
481
|
+
const { outboxMessageId } = await draftAgainstQueue(queue.client);
|
|
482
|
+
queue.accept();
|
|
483
|
+
|
|
484
|
+
const response = await respond(() =>
|
|
485
|
+
sendMessage(
|
|
486
|
+
requestContext({ params: { outboxMessageId } }),
|
|
487
|
+
authorizedEvent(),
|
|
488
|
+
),
|
|
489
|
+
);
|
|
490
|
+
|
|
491
|
+
assert.equal(response.statusCode, 200);
|
|
492
|
+
assert.equal(
|
|
493
|
+
(JSON.parse(response.body) as { status?: string }).status,
|
|
494
|
+
OutboxMessageStatus.queued,
|
|
495
|
+
);
|
|
496
|
+
});
|
|
497
|
+
});
|
|
498
|
+
|
|
397
499
|
describe("discarding a draft that carries files (#679)", () => {
|
|
398
500
|
it("takes the stored bytes with it, leaving nothing behind", async () => {
|
|
399
501
|
installClient();
|