@remit/web-client 0.0.134 → 0.0.136
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 +1 -1
- package/src/components/compose/ComposeForm.tsx +68 -66
- package/src/components/compose/compose-send-stops-autosave.render.test.ts +92 -4
- package/src/components/mail/MessageAttachments.interaction.test.ts +245 -0
- package/src/components/mail/MessageAttachments.tsx +153 -0
- package/src/components/mail/MessageCard.tsx +15 -6
- package/src/hooks/useSaveDraft.ts +60 -15
- package/src/lib/attachment-download.test.ts +207 -0
- package/src/lib/attachment-download.ts +195 -0
- package/src/lib/message-attachments.test.ts +86 -0
- package/src/lib/message-attachments.ts +54 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.136",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
|
|
6
6
|
"exports": {
|
|
@@ -3,7 +3,6 @@ import {
|
|
|
3
3
|
outboxDetailOperationsDeleteOutboxMessageMutation,
|
|
4
4
|
outboxDetailOperationsGetOutboxMessageOptions,
|
|
5
5
|
outboxDetailOperationsSendOutboxMessageMutation,
|
|
6
|
-
outboxOperationsCreateOutboxMessageMutation,
|
|
7
6
|
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
8
7
|
import type {
|
|
9
8
|
RemitImapAccountResponse,
|
|
@@ -442,10 +441,11 @@ export const ComposeForm = ({
|
|
|
442
441
|
[setOutboxMessageId],
|
|
443
442
|
);
|
|
444
443
|
|
|
445
|
-
const { saveStatus, saveError, saveDraft, stopAutoSave } =
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
444
|
+
const { saveStatus, saveError, saveDraft, saveImmediately, stopAutoSave } =
|
|
445
|
+
useSaveDraft({
|
|
446
|
+
outboxMessageId,
|
|
447
|
+
onDraftCreated: adoptCreatedDraft,
|
|
448
|
+
});
|
|
449
449
|
|
|
450
450
|
// Auto-save runs on a debounce, so a failure has no inline call site to
|
|
451
451
|
// surface it. Push the real error detail to a banner instead of leaving only
|
|
@@ -460,10 +460,6 @@ export const ComposeForm = ({
|
|
|
460
460
|
});
|
|
461
461
|
}, [saveError, pushError]);
|
|
462
462
|
|
|
463
|
-
const createMutation = useMutation(
|
|
464
|
-
outboxOperationsCreateOutboxMessageMutation(),
|
|
465
|
-
);
|
|
466
|
-
|
|
467
463
|
const sendMutation = useMutation(
|
|
468
464
|
outboxDetailOperationsSendOutboxMessageMutation(),
|
|
469
465
|
);
|
|
@@ -496,7 +492,11 @@ export const ComposeForm = ({
|
|
|
496
492
|
? accountIsMissingSmtp(selectedAccount)
|
|
497
493
|
: false;
|
|
498
494
|
|
|
499
|
-
|
|
495
|
+
// The action bar refuses a second press while one is in flight, but the
|
|
496
|
+
// editor's own Cmd+Enter goes straight to `handleSend`, and the write that
|
|
497
|
+
// now precedes the request widens the window a second press lands in.
|
|
498
|
+
const sendInFlightRef = useRef(false);
|
|
499
|
+
const [isSending, setIsSending] = useState(false);
|
|
500
500
|
const canSend =
|
|
501
501
|
toAddresses.length > 0 &&
|
|
502
502
|
!!selectedAccountId &&
|
|
@@ -538,76 +538,79 @@ export const ComposeForm = ({
|
|
|
538
538
|
]);
|
|
539
539
|
|
|
540
540
|
const handleSend = useCallback(async () => {
|
|
541
|
+
if (sendInFlightRef.current) return;
|
|
541
542
|
if (!selectedAccountId || toAddresses.length === 0) return;
|
|
542
543
|
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
? getReferences(sourceMessage)
|
|
548
|
-
: {};
|
|
544
|
+
sendInFlightRef.current = true;
|
|
545
|
+
setIsSending(true);
|
|
546
|
+
try {
|
|
547
|
+
stopAutoSave();
|
|
549
548
|
|
|
550
|
-
|
|
551
|
-
|
|
549
|
+
const replyData =
|
|
550
|
+
sourceMessage && (mode === "reply" || mode === "reply_all")
|
|
551
|
+
? getReferences(sourceMessage)
|
|
552
|
+
: {};
|
|
552
553
|
|
|
553
|
-
if (!messageId) {
|
|
554
554
|
const { html: htmlBody, text: textBody } = body;
|
|
555
|
+
const createdThisAttempt = !outboxMessageId;
|
|
556
|
+
|
|
557
|
+
// The debounce dropped above may have been holding the last two seconds
|
|
558
|
+
// of typing, and an existing entry would otherwise go out as the server
|
|
559
|
+
// last saw it (#674). What is on screen is written first, and a write
|
|
560
|
+
// that fails stops the send rather than transmitting the older copy.
|
|
561
|
+
const flushed = await saveImmediately({
|
|
562
|
+
accountId: selectedAccountId,
|
|
563
|
+
toAddresses: toAddresses.map((a) => a.email),
|
|
564
|
+
ccAddresses:
|
|
565
|
+
ccAddresses.length > 0 ? ccAddresses.map((a) => a.email) : undefined,
|
|
566
|
+
bccAddresses:
|
|
567
|
+
bccAddresses.length > 0
|
|
568
|
+
? bccAddresses.map((a) => a.email)
|
|
569
|
+
: undefined,
|
|
570
|
+
subject: subject || undefined,
|
|
571
|
+
textBody: textBody || undefined,
|
|
572
|
+
htmlBody: htmlBody || undefined,
|
|
573
|
+
...replyData,
|
|
574
|
+
});
|
|
555
575
|
|
|
556
|
-
|
|
576
|
+
if (flushed.outcome === "failed") {
|
|
577
|
+
pushError({
|
|
578
|
+
title: "Couldn't send message",
|
|
579
|
+
detail:
|
|
580
|
+
formatErrorDetail(flushed.error) ??
|
|
581
|
+
"Saving the message failed, so nothing was sent. Try again.",
|
|
582
|
+
error: flushed.error,
|
|
583
|
+
});
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const messageId = flushed.outboxMessageId;
|
|
588
|
+
|
|
589
|
+
const sent = await sendMutation
|
|
557
590
|
.mutateAsync({
|
|
558
|
-
|
|
559
|
-
accountId: selectedAccountId,
|
|
560
|
-
toAddresses: toAddresses.map((a) => a.email),
|
|
561
|
-
ccAddresses:
|
|
562
|
-
ccAddresses.length > 0
|
|
563
|
-
? ccAddresses.map((a) => a.email)
|
|
564
|
-
: undefined,
|
|
565
|
-
bccAddresses:
|
|
566
|
-
bccAddresses.length > 0
|
|
567
|
-
? bccAddresses.map((a) => a.email)
|
|
568
|
-
: undefined,
|
|
569
|
-
subject: subject || undefined,
|
|
570
|
-
textBody: textBody || undefined,
|
|
571
|
-
htmlBody: htmlBody || undefined,
|
|
572
|
-
sendImmediately: false,
|
|
573
|
-
...replyData,
|
|
574
|
-
},
|
|
591
|
+
path: { outboxMessageId: messageId },
|
|
575
592
|
})
|
|
576
593
|
.catch((error: unknown) => {
|
|
577
594
|
pushError({
|
|
578
595
|
title: "Couldn't send message",
|
|
579
|
-
detail:
|
|
596
|
+
detail:
|
|
597
|
+
formatErrorDetail(error) ??
|
|
598
|
+
(createdThisAttempt
|
|
599
|
+
? "The draft was saved but the send request failed. Try again from the Outbox."
|
|
600
|
+
: "The send request failed. Try again."),
|
|
580
601
|
error,
|
|
581
602
|
});
|
|
582
603
|
return null;
|
|
583
604
|
});
|
|
584
|
-
if (
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
605
|
+
if (sent === null) return;
|
|
606
|
+
|
|
607
|
+
stopAutoSave(messageId);
|
|
608
|
+
startSendPolling(messageId);
|
|
609
|
+
onClose();
|
|
610
|
+
} finally {
|
|
611
|
+
sendInFlightRef.current = false;
|
|
612
|
+
setIsSending(false);
|
|
588
613
|
}
|
|
589
|
-
|
|
590
|
-
const sent = await sendMutation
|
|
591
|
-
.mutateAsync({
|
|
592
|
-
path: { outboxMessageId: messageId },
|
|
593
|
-
})
|
|
594
|
-
.catch((error: unknown) => {
|
|
595
|
-
pushError({
|
|
596
|
-
title: "Couldn't send message",
|
|
597
|
-
detail:
|
|
598
|
-
formatErrorDetail(error) ??
|
|
599
|
-
(createdThisAttempt
|
|
600
|
-
? "The draft was saved but the send request failed. Try again from the Outbox."
|
|
601
|
-
: "The send request failed. Try again."),
|
|
602
|
-
error,
|
|
603
|
-
});
|
|
604
|
-
return null;
|
|
605
|
-
});
|
|
606
|
-
if (sent === null) return;
|
|
607
|
-
|
|
608
|
-
stopAutoSave(messageId);
|
|
609
|
-
startSendPolling(messageId);
|
|
610
|
-
onClose();
|
|
611
614
|
}, [
|
|
612
615
|
selectedAccountId,
|
|
613
616
|
toAddresses,
|
|
@@ -618,11 +621,10 @@ export const ComposeForm = ({
|
|
|
618
621
|
mode,
|
|
619
622
|
sourceMessage,
|
|
620
623
|
outboxMessageId,
|
|
621
|
-
|
|
624
|
+
saveImmediately,
|
|
622
625
|
sendMutation,
|
|
623
626
|
stopAutoSave,
|
|
624
627
|
startSendPolling,
|
|
625
|
-
setOutboxMessageId,
|
|
626
628
|
pushError,
|
|
627
629
|
onClose,
|
|
628
630
|
]);
|
|
@@ -14,6 +14,12 @@
|
|
|
14
14
|
* then sends it — and creating it as already-queued makes that send a second
|
|
15
15
|
* dispatch, which the server refuses.
|
|
16
16
|
*
|
|
17
|
+
* Issue #674 is the boundary between the two: pressing Send inside the two
|
|
18
|
+
* seconds the debounce is still counting down. The cancelled timer was carrying
|
|
19
|
+
* the last edits, and the entry went out as the server last saw it. So the send
|
|
20
|
+
* writes what is on screen first, and a write that fails takes the send with it
|
|
21
|
+
* rather than transmitting the older copy behind the user's back.
|
|
22
|
+
*
|
|
17
23
|
* The stub holds the outbox rule the API holds: only a draft accepts a send.
|
|
18
24
|
*/
|
|
19
25
|
|
|
@@ -81,8 +87,18 @@ afterEach(() => {
|
|
|
81
87
|
const callsTo = (suffix: string) =>
|
|
82
88
|
(http?.calls ?? []).filter((call) => call.path.endsWith(suffix));
|
|
83
89
|
|
|
84
|
-
const
|
|
85
|
-
(http?.calls ?? []).filter((call) => call.method === "PATCH")
|
|
90
|
+
const patches = () =>
|
|
91
|
+
(http?.calls ?? []).filter((call) => call.method === "PATCH");
|
|
92
|
+
|
|
93
|
+
const patchCount = (): number => patches().length;
|
|
94
|
+
|
|
95
|
+
const patchesAfterTheSend = (): number => {
|
|
96
|
+
const calls = http?.calls ?? [];
|
|
97
|
+
const sendIndex = calls.findIndex((call) => call.path.endsWith("/send"));
|
|
98
|
+
if (sendIndex === -1) throw new Error("the send never went out");
|
|
99
|
+
return calls.slice(sendIndex + 1).filter((call) => call.method === "PATCH")
|
|
100
|
+
.length;
|
|
101
|
+
};
|
|
86
102
|
|
|
87
103
|
const Opened = ({ outboxMessageId }: { outboxMessageId?: string }) => {
|
|
88
104
|
const { state, openCompose } = useCompose();
|
|
@@ -108,6 +124,8 @@ interface MountOptions {
|
|
|
108
124
|
outboxMessageId?: string;
|
|
109
125
|
/** Hold the PATCH response open so a save is still in flight at send time. */
|
|
110
126
|
gatePatch?: boolean;
|
|
127
|
+
/** Refuse every PATCH, so the write compose makes before sending fails. */
|
|
128
|
+
failPatch?: boolean;
|
|
111
129
|
}
|
|
112
130
|
|
|
113
131
|
const mount = async (
|
|
@@ -140,6 +158,7 @@ const mount = async (
|
|
|
140
158
|
|
|
141
159
|
if (call.method === "PATCH") {
|
|
142
160
|
await patchGate;
|
|
161
|
+
if (options.failPatch) return httpError(409, "The draft moved on.");
|
|
143
162
|
return outboxEntry(status);
|
|
144
163
|
}
|
|
145
164
|
|
|
@@ -172,7 +191,7 @@ const sendButton = (): HTMLElement => {
|
|
|
172
191
|
return button;
|
|
173
192
|
};
|
|
174
193
|
|
|
175
|
-
describe("compose and the outbox entry it is sending (#604)", () => {
|
|
194
|
+
describe("compose and the outbox entry it is sending (#604, #674)", () => {
|
|
176
195
|
it("writes no further PATCH to an entry once it has been sent", async () => {
|
|
177
196
|
const { releasePatch } = await mount({
|
|
178
197
|
outboxMessageId: OUTBOX_MESSAGE_ID,
|
|
@@ -193,7 +212,76 @@ describe("compose and the outbox entry it is sending (#604)", () => {
|
|
|
193
212
|
await harness?.wait(AUTOSAVE_DEBOUNCE_MS + 200);
|
|
194
213
|
|
|
195
214
|
assert.equal(callsTo("/send").length, 1, "the send went out");
|
|
196
|
-
assert.equal(
|
|
215
|
+
assert.equal(patchesAfterTheSend(), 0, "no write followed the send");
|
|
216
|
+
// The in-flight autosave and the write before the send are the two, in
|
|
217
|
+
// that order: a second write issued alongside the first could land behind
|
|
218
|
+
// it and put the older body back.
|
|
219
|
+
assert.equal(
|
|
220
|
+
patchCount(),
|
|
221
|
+
2,
|
|
222
|
+
"the two writes went out one after the other",
|
|
223
|
+
);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("writes the edit made inside the debounce window before it sends", async () => {
|
|
227
|
+
await mount({ outboxMessageId: OUTBOX_MESSAGE_ID });
|
|
228
|
+
|
|
229
|
+
harness?.type(subjectField(), "Re: Lunch tomorrow");
|
|
230
|
+
await harness?.wait(AUTOSAVE_DEBOUNCE_MS + 100);
|
|
231
|
+
assert.equal(patchCount(), 1, "the typing burst autosaves once");
|
|
232
|
+
|
|
233
|
+
// Inside the two seconds the next autosave would have waited.
|
|
234
|
+
harness?.type(subjectField(), "Re: Lunch on Thursday");
|
|
235
|
+
harness?.click(sendButton());
|
|
236
|
+
await harness?.flush();
|
|
237
|
+
await harness?.wait(100);
|
|
238
|
+
|
|
239
|
+
assert.equal(callsTo("/send").length, 1, "the send went out");
|
|
240
|
+
const written = patches().at(-1);
|
|
241
|
+
assert.equal(
|
|
242
|
+
written?.body?.subject,
|
|
243
|
+
"Re: Lunch on Thursday",
|
|
244
|
+
"the entry carries the edit the debounce was still holding",
|
|
245
|
+
);
|
|
246
|
+
// Autosave never writes these, so an entry autosaved before Send used to
|
|
247
|
+
// go out unchained from the message it replies to.
|
|
248
|
+
assert.equal(written?.body?.inReplyTo, "<m1@example.com>");
|
|
249
|
+
assert.deepEqual(written?.body?.references, ["<m1@example.com>"]);
|
|
250
|
+
assert.equal(closed, 1, "compose closed on a successful send");
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("sends nothing, and says so, when that write fails", async () => {
|
|
254
|
+
await mount({ outboxMessageId: OUTBOX_MESSAGE_ID, failPatch: true });
|
|
255
|
+
|
|
256
|
+
harness?.type(subjectField(), "Re: Lunch on Thursday");
|
|
257
|
+
harness?.click(sendButton());
|
|
258
|
+
await harness?.flush();
|
|
259
|
+
await harness?.wait(100);
|
|
260
|
+
|
|
261
|
+
assert.equal(patchCount(), 1, "the write was attempted");
|
|
262
|
+
assert.equal(callsTo("/send").length, 0, "the older copy stayed put");
|
|
263
|
+
assert.equal(closed, 0, "compose stayed open");
|
|
264
|
+
assert.match(harness?.text() ?? "", /Couldn't send message/);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("dispatches once when Send is pressed twice over the write", async () => {
|
|
268
|
+
const { releasePatch } = await mount({
|
|
269
|
+
outboxMessageId: OUTBOX_MESSAGE_ID,
|
|
270
|
+
gatePatch: true,
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
harness?.type(subjectField(), "Re: Lunch on Thursday");
|
|
274
|
+
harness?.click(sendButton());
|
|
275
|
+
harness?.click(sendButton());
|
|
276
|
+
await harness?.flush();
|
|
277
|
+
|
|
278
|
+
releasePatch();
|
|
279
|
+
await harness?.flush();
|
|
280
|
+
await harness?.wait(100);
|
|
281
|
+
|
|
282
|
+
assert.equal(patchCount(), 1, "the entry was written once");
|
|
283
|
+
assert.equal(callsTo("/send").length, 1, "the message went out once");
|
|
284
|
+
assert.equal(closed, 1, "compose closed on a successful send");
|
|
197
285
|
});
|
|
198
286
|
|
|
199
287
|
it("sends a reply pressed before the first autosave, in one dispatch", async () => {
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The attachment list wired to the real download (#683): a click fetches the
|
|
3
|
+
* part's `contentUrl` and hands the bytes to the browser under the sanitized
|
|
4
|
+
* name, and a fetch that fails says what failed instead of doing nothing.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { afterEach, describe, it } from "node:test";
|
|
9
|
+
import { messageOperationsDescribeMessageQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
10
|
+
import type { RemitImapBodyPartResponse } from "@remit/api-http-client/types.gen.ts";
|
|
11
|
+
import { createElement } from "react";
|
|
12
|
+
import { createDomHarness, type DomHarness } from "../../test-support/dom";
|
|
13
|
+
import { MessageAttachments } from "./MessageAttachments";
|
|
14
|
+
|
|
15
|
+
let harness: DomHarness | undefined;
|
|
16
|
+
const originalFetch = globalThis.fetch;
|
|
17
|
+
const originalCreateElement = document.createElement.bind(document);
|
|
18
|
+
const objectUrls = globalThis.URL as unknown as {
|
|
19
|
+
createObjectURL?: (blob: Blob) => string;
|
|
20
|
+
revokeObjectURL?: (value: string) => void;
|
|
21
|
+
};
|
|
22
|
+
const originalCreateObjectURL = objectUrls.createObjectURL;
|
|
23
|
+
const originalRevokeObjectURL = objectUrls.revokeObjectURL;
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
harness?.close();
|
|
27
|
+
harness = undefined;
|
|
28
|
+
globalThis.fetch = originalFetch;
|
|
29
|
+
document.createElement = originalCreateElement;
|
|
30
|
+
objectUrls.createObjectURL = originalCreateObjectURL;
|
|
31
|
+
objectUrls.revokeObjectURL = originalRevokeObjectURL;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const bodyParts = (
|
|
35
|
+
...overrides: Partial<RemitImapBodyPartResponse>[]
|
|
36
|
+
): RemitImapBodyPartResponse[] =>
|
|
37
|
+
overrides.map(
|
|
38
|
+
(override, index) =>
|
|
39
|
+
({
|
|
40
|
+
bodyPartId: `part-${index + 1}`,
|
|
41
|
+
mediaType: "APPLICATION",
|
|
42
|
+
mediaSubtype: "PDF",
|
|
43
|
+
sizeOctets: 4096,
|
|
44
|
+
disposition: "attachment",
|
|
45
|
+
dispositionFilename: "report.pdf",
|
|
46
|
+
contentUrl: `https://cdn.test/content/parts/${index + 2}`,
|
|
47
|
+
isMultipart: false,
|
|
48
|
+
...override,
|
|
49
|
+
}) as RemitImapBodyPartResponse,
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
interface SavedFile {
|
|
53
|
+
filename: string;
|
|
54
|
+
url: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const captureSaves = (): SavedFile[] => {
|
|
58
|
+
const saved: SavedFile[] = [];
|
|
59
|
+
objectUrls.createObjectURL = () => "blob:test";
|
|
60
|
+
objectUrls.revokeObjectURL = () => undefined;
|
|
61
|
+
|
|
62
|
+
document.createElement = ((tag: string) => {
|
|
63
|
+
const element = originalCreateElement(tag);
|
|
64
|
+
if (tag === "a") {
|
|
65
|
+
const anchor = element as HTMLAnchorElement;
|
|
66
|
+
anchor.click = () =>
|
|
67
|
+
saved.push({ filename: anchor.download, url: anchor.href });
|
|
68
|
+
}
|
|
69
|
+
return element;
|
|
70
|
+
}) as typeof document.createElement;
|
|
71
|
+
return saved;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const MESSAGE_ID = "msg-1";
|
|
75
|
+
|
|
76
|
+
const mount = (parts?: RemitImapBodyPartResponse[], hasAttachment = true) => {
|
|
77
|
+
harness = createDomHarness();
|
|
78
|
+
harness.renderApp(
|
|
79
|
+
createElement(MessageAttachments, {
|
|
80
|
+
messageId: MESSAGE_ID,
|
|
81
|
+
bodyParts: parts,
|
|
82
|
+
hasAttachment,
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
return harness;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
describe("MessageAttachments", () => {
|
|
89
|
+
it("lists each attachment part with a download control", () => {
|
|
90
|
+
const dom = mount(
|
|
91
|
+
bodyParts(
|
|
92
|
+
{ dispositionFilename: "board-pack.pdf" },
|
|
93
|
+
{
|
|
94
|
+
dispositionFilename: "site-plan.png",
|
|
95
|
+
mediaType: "IMAGE",
|
|
96
|
+
mediaSubtype: "PNG",
|
|
97
|
+
sizeOctets: 1024,
|
|
98
|
+
},
|
|
99
|
+
),
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
assert.match(dom.text(), /2 attachments/);
|
|
103
|
+
assert.ok(dom.byLabel("Download board-pack.pdf"));
|
|
104
|
+
assert.ok(dom.byLabel("Download site-plan.png"));
|
|
105
|
+
assert.match(dom.text(), /PNG · 1 KB/);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("renders nothing for a message with no attachments", () => {
|
|
109
|
+
const dom = mount(
|
|
110
|
+
bodyParts({ disposition: "inline", mediaSubtype: "HTML" }),
|
|
111
|
+
false,
|
|
112
|
+
);
|
|
113
|
+
assert.equal(dom.html(), "");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("downloads the part's content URL and saves it under the sanitized name", async () => {
|
|
117
|
+
const requested: string[] = [];
|
|
118
|
+
globalThis.fetch = (async (url: string) => {
|
|
119
|
+
requested.push(String(url));
|
|
120
|
+
return new Response("payload", { status: 200 });
|
|
121
|
+
}) as unknown as typeof fetch;
|
|
122
|
+
const saved = captureSaves();
|
|
123
|
+
|
|
124
|
+
const dom = mount(
|
|
125
|
+
bodyParts({
|
|
126
|
+
dispositionFilename: "../../../etc/passwd",
|
|
127
|
+
contentUrl: "https://cdn.test/content/parts/2",
|
|
128
|
+
}),
|
|
129
|
+
);
|
|
130
|
+
dom.click(dom.byLabel("Download passwd"));
|
|
131
|
+
await dom.flush();
|
|
132
|
+
|
|
133
|
+
assert.deepEqual(requested, ["https://cdn.test/content/parts/2"]);
|
|
134
|
+
assert.deepEqual(saved, [{ filename: "passwd", url: "blob:test" }]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("states what failed and offers a retry instead of doing nothing", async () => {
|
|
138
|
+
globalThis.fetch = (async () =>
|
|
139
|
+
new Response("gone", {
|
|
140
|
+
status: 404,
|
|
141
|
+
statusText: "Not Found",
|
|
142
|
+
})) as unknown as typeof fetch;
|
|
143
|
+
|
|
144
|
+
const dom = mount(bodyParts({ dispositionFilename: "board-pack.pdf" }));
|
|
145
|
+
dom.click(dom.byLabel("Download board-pack.pdf"));
|
|
146
|
+
await dom.flush();
|
|
147
|
+
|
|
148
|
+
const alert = dom.query('[data-testid="attachment-error"]');
|
|
149
|
+
assert.ok(alert, "a failed download must render an alert");
|
|
150
|
+
assert.match(alert.textContent ?? "", /missing from storage/);
|
|
151
|
+
assert.match(alert.textContent ?? "", /Re-sync the account/);
|
|
152
|
+
assert.match(alert.textContent ?? "", /Try again/);
|
|
153
|
+
assert.match(alert.innerHTML, /issues\/new/);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("retries the same part when the failure's retry is taken", async () => {
|
|
157
|
+
let attempts = 0;
|
|
158
|
+
globalThis.fetch = (async () => {
|
|
159
|
+
attempts += 1;
|
|
160
|
+
return attempts === 1
|
|
161
|
+
? new Response("gone", { status: 404, statusText: "Not Found" })
|
|
162
|
+
: new Response("payload", { status: 200 });
|
|
163
|
+
}) as unknown as typeof fetch;
|
|
164
|
+
const saved = captureSaves();
|
|
165
|
+
|
|
166
|
+
const dom = mount(bodyParts({ dispositionFilename: "board-pack.pdf" }));
|
|
167
|
+
dom.click(dom.byLabel("Download board-pack.pdf"));
|
|
168
|
+
await dom.flush();
|
|
169
|
+
|
|
170
|
+
dom.click(dom.byText("button", "Try again"));
|
|
171
|
+
await dom.flush();
|
|
172
|
+
|
|
173
|
+
assert.equal(attempts, 2);
|
|
174
|
+
assert.deepEqual(saved, [{ filename: "board-pack.pdf", url: "blob:test" }]);
|
|
175
|
+
assert.equal(dom.query('[data-testid="attachment-error"]'), null);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// The signed content URL outlives neither an hour nor a re-signing, and a
|
|
179
|
+
// deferred part is materialized by the describe read itself. Re-hitting the
|
|
180
|
+
// same URL cannot fix either, so the retry goes back through describeMessage
|
|
181
|
+
// and uses the URL that read minted.
|
|
182
|
+
it("renews the content URL through describeMessage when the link expired", async () => {
|
|
183
|
+
const requested: string[] = [];
|
|
184
|
+
globalThis.fetch = (async (url: string) => {
|
|
185
|
+
requested.push(String(url));
|
|
186
|
+
return requested.length === 1
|
|
187
|
+
? new Response("expired", {
|
|
188
|
+
status: 403,
|
|
189
|
+
headers: { "x-remit-403-reason": "expired" },
|
|
190
|
+
})
|
|
191
|
+
: new Response("payload", { status: 200 });
|
|
192
|
+
}) as unknown as typeof fetch;
|
|
193
|
+
const saved = captureSaves();
|
|
194
|
+
|
|
195
|
+
const dom = mount(
|
|
196
|
+
bodyParts({ contentUrl: "https://cdn.test/parts/2?exp=1&sig=old" }),
|
|
197
|
+
);
|
|
198
|
+
dom.queryClient.setQueryData(
|
|
199
|
+
messageOperationsDescribeMessageQueryKey({
|
|
200
|
+
path: { messageId: MESSAGE_ID },
|
|
201
|
+
}),
|
|
202
|
+
{
|
|
203
|
+
bodyParts: bodyParts({
|
|
204
|
+
contentUrl: "https://cdn.test/parts/2?exp=2&sig=new",
|
|
205
|
+
}),
|
|
206
|
+
},
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
dom.click(dom.byLabel("Download report.pdf"));
|
|
210
|
+
await dom.flush();
|
|
211
|
+
await dom.flush();
|
|
212
|
+
|
|
213
|
+
assert.deepEqual(requested, [
|
|
214
|
+
"https://cdn.test/parts/2?exp=1&sig=old",
|
|
215
|
+
"https://cdn.test/parts/2?exp=2&sig=new",
|
|
216
|
+
]);
|
|
217
|
+
assert.equal(saved.length, 1);
|
|
218
|
+
assert.equal(dom.query('[data-testid="attachment-error"]'), null);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("does not re-read the message for a failure a re-read cannot fix", async () => {
|
|
222
|
+
let attempts = 0;
|
|
223
|
+
globalThis.fetch = (async () => {
|
|
224
|
+
attempts += 1;
|
|
225
|
+
return new Response("gone", { status: 404, statusText: "Not Found" });
|
|
226
|
+
}) as unknown as typeof fetch;
|
|
227
|
+
|
|
228
|
+
const dom = mount(bodyParts({}));
|
|
229
|
+
dom.click(dom.byLabel("Download report.pdf"));
|
|
230
|
+
await dom.flush();
|
|
231
|
+
|
|
232
|
+
assert.equal(attempts, 1);
|
|
233
|
+
assert.ok(dom.query('[data-testid="attachment-error"]'));
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("says so when the server flags an attachment no part describes", () => {
|
|
237
|
+
const dom = mount([], true);
|
|
238
|
+
assert.match(dom.text(), /none of its parts describe one/);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("claims nothing while the message is still loading", () => {
|
|
242
|
+
const dom = mount(undefined, true);
|
|
243
|
+
assert.equal(dom.html(), "");
|
|
244
|
+
});
|
|
245
|
+
});
|