@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
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { messageOperationsDescribeMessageQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
2
|
+
import type {
|
|
3
|
+
RemitImapBodyPartResponse,
|
|
4
|
+
RemitImapDescribeMessageResponse,
|
|
5
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
6
|
+
import { type AttachmentDownloadState, AttachmentList } from "@remit/ui";
|
|
7
|
+
import { useQueryClient } from "@tanstack/react-query";
|
|
8
|
+
import { useMemo, useState } from "react";
|
|
9
|
+
import { useAuthProvider } from "@/auth/provider";
|
|
10
|
+
import {
|
|
11
|
+
attachmentFailureContent,
|
|
12
|
+
attachmentReportUrl,
|
|
13
|
+
extractAttachmentFailureDetail,
|
|
14
|
+
extractAttachmentFailureReason,
|
|
15
|
+
fetchAttachment,
|
|
16
|
+
isRepairableByRefetch,
|
|
17
|
+
saveBlob,
|
|
18
|
+
} from "@/lib/attachment-download";
|
|
19
|
+
import {
|
|
20
|
+
type MessageAttachment,
|
|
21
|
+
selectMessageAttachments,
|
|
22
|
+
} from "@/lib/message-attachments";
|
|
23
|
+
|
|
24
|
+
interface MessageAttachmentsProps {
|
|
25
|
+
messageId: string;
|
|
26
|
+
/** Body parts from `describeMessage`; absent while the message is loading. */
|
|
27
|
+
bodyParts?: readonly RemitImapBodyPartResponse[];
|
|
28
|
+
/** The thread row's server-side attachment flag. */
|
|
29
|
+
hasAttachment?: boolean;
|
|
30
|
+
className?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const IDLE: AttachmentDownloadState = { status: "idle" };
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The attachments carried by an open message, and the download behind each row.
|
|
37
|
+
*
|
|
38
|
+
* Download state is per row and lives here rather than in a query: a download is
|
|
39
|
+
* a one-shot user action with no cached result to hold, and the bytes are handed
|
|
40
|
+
* to the browser rather than rendered.
|
|
41
|
+
*/
|
|
42
|
+
export const MessageAttachments = ({
|
|
43
|
+
messageId,
|
|
44
|
+
bodyParts,
|
|
45
|
+
hasAttachment = false,
|
|
46
|
+
className,
|
|
47
|
+
}: MessageAttachmentsProps) => {
|
|
48
|
+
const { getToken } = useAuthProvider();
|
|
49
|
+
const queryClient = useQueryClient();
|
|
50
|
+
const [downloads, setDownloads] = useState<
|
|
51
|
+
Record<string, AttachmentDownloadState>
|
|
52
|
+
>({});
|
|
53
|
+
|
|
54
|
+
const attachments = useMemo(
|
|
55
|
+
() => selectMessageAttachments(bodyParts ?? []),
|
|
56
|
+
[bodyParts],
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const describeKey = messageOperationsDescribeMessageQueryKey({
|
|
60
|
+
path: { messageId },
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A stale signature and an unmaterialized part are both repaired by re-reading
|
|
65
|
+
* the message: `describeMessage` re-signs every `contentUrl`, and it
|
|
66
|
+
* materializes the deferred per-part objects on its way through. Re-hitting
|
|
67
|
+
* the same URL alone cannot fix either (remit-mail/remit#1240), so the retry
|
|
68
|
+
* goes through the read path and then uses the URL it just minted.
|
|
69
|
+
*/
|
|
70
|
+
const fetchThroughRefreshedDescribe = (
|
|
71
|
+
attachment: MessageAttachment,
|
|
72
|
+
): Promise<Blob> =>
|
|
73
|
+
fetchAttachment(attachment.contentUrl, getToken).catch(
|
|
74
|
+
(error: unknown): Promise<Blob> => {
|
|
75
|
+
if (!isRepairableByRefetch(extractAttachmentFailureReason(error))) {
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
return queryClient
|
|
79
|
+
.refetchQueries({ queryKey: describeKey })
|
|
80
|
+
.then(() => {
|
|
81
|
+
const refreshed =
|
|
82
|
+
queryClient.getQueryData<RemitImapDescribeMessageResponse>(
|
|
83
|
+
describeKey,
|
|
84
|
+
);
|
|
85
|
+
const renewed = refreshed
|
|
86
|
+
? selectMessageAttachments(refreshed.bodyParts).find(
|
|
87
|
+
(candidate) => candidate.bodyPartId === attachment.bodyPartId,
|
|
88
|
+
)
|
|
89
|
+
: undefined;
|
|
90
|
+
return fetchAttachment(
|
|
91
|
+
renewed?.contentUrl ?? attachment.contentUrl,
|
|
92
|
+
getToken,
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
},
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const setState = (id: string, state: AttachmentDownloadState) =>
|
|
99
|
+
setDownloads((current) => ({ ...current, [id]: state }));
|
|
100
|
+
|
|
101
|
+
const download = (bodyPartId: string) => {
|
|
102
|
+
const attachment = attachments.find(
|
|
103
|
+
(candidate) => candidate.bodyPartId === bodyPartId,
|
|
104
|
+
);
|
|
105
|
+
if (!attachment) {
|
|
106
|
+
throw new Error(
|
|
107
|
+
`No attachment part ${bodyPartId} on message ${messageId}`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
setState(bodyPartId, { status: "downloading" });
|
|
112
|
+
fetchThroughRefreshedDescribe(attachment)
|
|
113
|
+
.then((blob) => {
|
|
114
|
+
saveBlob(blob, attachment.filename);
|
|
115
|
+
setState(bodyPartId, IDLE);
|
|
116
|
+
})
|
|
117
|
+
.catch((error: unknown) => {
|
|
118
|
+
const reason = extractAttachmentFailureReason(error);
|
|
119
|
+
const fallback = extractAttachmentFailureDetail(error);
|
|
120
|
+
const { title, detail } = attachmentFailureContent(
|
|
121
|
+
reason,
|
|
122
|
+
attachment.filename,
|
|
123
|
+
fallback,
|
|
124
|
+
);
|
|
125
|
+
setState(bodyPartId, {
|
|
126
|
+
status: "failed",
|
|
127
|
+
title,
|
|
128
|
+
detail,
|
|
129
|
+
reportUrl: attachmentReportUrl(reason, attachment.filename, fallback),
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// Only claim an attachment is unaccounted for once the parts have arrived;
|
|
135
|
+
// while `describeMessage` is in flight there is nothing to contradict.
|
|
136
|
+
const hasUnlistedAttachment =
|
|
137
|
+
hasAttachment && bodyParts !== undefined && attachments.length === 0;
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<AttachmentList
|
|
141
|
+
className={className}
|
|
142
|
+
attachments={attachments.map((attachment) => ({
|
|
143
|
+
attachmentId: attachment.bodyPartId,
|
|
144
|
+
filename: attachment.filename,
|
|
145
|
+
typeLabel: attachment.typeLabel,
|
|
146
|
+
sizeOctets: attachment.sizeOctets,
|
|
147
|
+
download: downloads[attachment.bodyPartId] ?? IDLE,
|
|
148
|
+
}))}
|
|
149
|
+
onDownload={download}
|
|
150
|
+
hasUnlistedAttachment={hasUnlistedAttachment}
|
|
151
|
+
/>
|
|
152
|
+
);
|
|
153
|
+
};
|
|
@@ -16,6 +16,7 @@ import { formatDatePreset } from "@/lib/format";
|
|
|
16
16
|
import { cn } from "@/lib/utils";
|
|
17
17
|
import { AutoMovedIndicator } from "./AutoMovedIndicator";
|
|
18
18
|
import { MessageActionMenu } from "./MessageActionMenu";
|
|
19
|
+
import { MessageAttachments } from "./MessageAttachments";
|
|
19
20
|
import { MessageBody } from "./MessageBody";
|
|
20
21
|
import { MobileMessageBar } from "./MobileMessageBar";
|
|
21
22
|
import { RawMessageView } from "./RawMessageView";
|
|
@@ -134,7 +135,11 @@ const CollapsedCard = ({
|
|
|
134
135
|
trailing={
|
|
135
136
|
<>
|
|
136
137
|
{threadMessage.hasAttachment && (
|
|
137
|
-
<Paperclip
|
|
138
|
+
<Paperclip
|
|
139
|
+
className="size-3 shrink-0 text-fg-subtle"
|
|
140
|
+
role="img"
|
|
141
|
+
aria-label="Has an attachment"
|
|
142
|
+
/>
|
|
138
143
|
)}
|
|
139
144
|
<StarButton
|
|
140
145
|
isStarred={threadMessage.hasStars}
|
|
@@ -238,11 +243,6 @@ const ExpandedCard = ({
|
|
|
238
243
|
onToggleStar={onToggleStar}
|
|
239
244
|
isStarPending={isStarPending}
|
|
240
245
|
/>
|
|
241
|
-
{threadMessage.hasAttachment && (
|
|
242
|
-
<span className="text-fg-subtle p-0.5">
|
|
243
|
-
<Paperclip className="size-3.5" />
|
|
244
|
-
</span>
|
|
245
|
-
)}
|
|
246
246
|
</div>
|
|
247
247
|
{isUnread && (
|
|
248
248
|
// biome-ignore lint/a11y/useAriaPropsSupportedByRole: aria-label on decorative indicator provides useful context for assistive tech
|
|
@@ -324,6 +324,15 @@ const ExpandedCard = ({
|
|
|
324
324
|
isTrusted={isTrusted}
|
|
325
325
|
category={toDisplayCategory(threadMessage.category)}
|
|
326
326
|
/>
|
|
327
|
+
{/* The body slot runs edge to edge on a phone; the attachment
|
|
328
|
+
list is app chrome, not part of the email, so it takes the
|
|
329
|
+
gutter back. */}
|
|
330
|
+
<MessageAttachments
|
|
331
|
+
messageId={threadMessage.messageId}
|
|
332
|
+
bodyParts={messageData?.bodyParts}
|
|
333
|
+
hasAttachment={threadMessage.hasAttachment}
|
|
334
|
+
className="mt-4 px-2 lg:px-0"
|
|
335
|
+
/>
|
|
327
336
|
</div>
|
|
328
337
|
)
|
|
329
338
|
}
|
|
@@ -8,6 +8,10 @@ import { useCallback, useRef, useState } from "react";
|
|
|
8
8
|
|
|
9
9
|
export type SaveStatus = "idle" | "saving" | "saved" | "error";
|
|
10
10
|
|
|
11
|
+
export type ImmediateSave =
|
|
12
|
+
| { outcome: "saved"; outboxMessageId: string }
|
|
13
|
+
| { outcome: "failed"; error: unknown };
|
|
14
|
+
|
|
11
15
|
interface DraftData {
|
|
12
16
|
accountId: string;
|
|
13
17
|
toAddresses: string[];
|
|
@@ -25,6 +29,12 @@ interface UseSaveDraftOptions {
|
|
|
25
29
|
onDraftCreated: (id: string) => void;
|
|
26
30
|
}
|
|
27
31
|
|
|
32
|
+
const settled = (promise: Promise<unknown>): Promise<void> =>
|
|
33
|
+
promise.then(
|
|
34
|
+
() => undefined,
|
|
35
|
+
() => undefined,
|
|
36
|
+
);
|
|
37
|
+
|
|
28
38
|
export const useSaveDraft = ({
|
|
29
39
|
outboxMessageId,
|
|
30
40
|
onDraftCreated,
|
|
@@ -35,6 +45,17 @@ export const useSaveDraft = ({
|
|
|
35
45
|
const closedIdsRef = useRef<Set<string>>(new Set());
|
|
36
46
|
const queryClient = useQueryClient();
|
|
37
47
|
|
|
48
|
+
// The entry a save writes to. That is the prop, except between a save
|
|
49
|
+
// creating the draft and the id arriving back as the prop — reading the prop
|
|
50
|
+
// in that window creates the same draft a second time and strands one of the
|
|
51
|
+
// two in the outbox.
|
|
52
|
+
const propIdRef = useRef(outboxMessageId);
|
|
53
|
+
const targetIdRef = useRef(outboxMessageId);
|
|
54
|
+
if (propIdRef.current !== outboxMessageId) {
|
|
55
|
+
propIdRef.current = outboxMessageId;
|
|
56
|
+
targetIdRef.current = outboxMessageId;
|
|
57
|
+
}
|
|
58
|
+
|
|
38
59
|
const createMutation = useMutation(
|
|
39
60
|
outboxOperationsCreateOutboxMessageMutation(),
|
|
40
61
|
);
|
|
@@ -47,9 +68,10 @@ export const useSaveDraft = ({
|
|
|
47
68
|
setSaveStatus("saving");
|
|
48
69
|
setSaveError(null);
|
|
49
70
|
|
|
50
|
-
|
|
71
|
+
const targetId = targetIdRef.current;
|
|
72
|
+
if (targetId) {
|
|
51
73
|
const result = await updateMutation.mutateAsync({
|
|
52
|
-
path: { outboxMessageId },
|
|
74
|
+
path: { outboxMessageId: targetId },
|
|
53
75
|
body: {
|
|
54
76
|
toAddresses: data.toAddresses,
|
|
55
77
|
ccAddresses: data.ccAddresses,
|
|
@@ -71,6 +93,7 @@ export const useSaveDraft = ({
|
|
|
71
93
|
sendImmediately: false,
|
|
72
94
|
},
|
|
73
95
|
});
|
|
96
|
+
targetIdRef.current = result.outboxMessageId;
|
|
74
97
|
onDraftCreated(result.outboxMessageId);
|
|
75
98
|
setSaveStatus("saved");
|
|
76
99
|
queryClient.invalidateQueries({
|
|
@@ -78,38 +101,60 @@ export const useSaveDraft = ({
|
|
|
78
101
|
});
|
|
79
102
|
return result;
|
|
80
103
|
},
|
|
81
|
-
[
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
104
|
+
[createMutation, updateMutation, onDraftCreated, queryClient],
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
// One entry takes one write at a time. Overlapping writes settle in whatever
|
|
108
|
+
// order the network gives them, so an older body can land last — and two of
|
|
109
|
+
// them racing while the draft has no id yet each create one.
|
|
110
|
+
const writesRef = useRef<Promise<void>>(Promise.resolve());
|
|
111
|
+
const enqueueSave = useCallback(
|
|
112
|
+
(data: DraftData) => {
|
|
113
|
+
const write = writesRef.current.then(() => executeSave(data));
|
|
114
|
+
writesRef.current = settled(write);
|
|
115
|
+
return write;
|
|
116
|
+
},
|
|
117
|
+
[executeSave],
|
|
88
118
|
);
|
|
89
119
|
|
|
90
120
|
const saveDraft = useCallback(
|
|
91
121
|
(data: DraftData) => {
|
|
92
|
-
if (outboxMessageId && closedIdsRef.current.has(outboxMessageId)) return;
|
|
93
122
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
94
123
|
timerRef.current = setTimeout(() => {
|
|
124
|
+
const targetId = targetIdRef.current;
|
|
125
|
+
if (targetId && closedIdsRef.current.has(targetId)) return;
|
|
95
126
|
// Keep the real error, not just a vague "error" status — the caller
|
|
96
127
|
// surfaces its detail in a banner. A fatal 5xx additionally escalates
|
|
97
128
|
// through the global MutationCache.onError sink.
|
|
98
|
-
|
|
129
|
+
enqueueSave(data).catch((error: unknown) => {
|
|
99
130
|
setSaveError(error);
|
|
100
131
|
setSaveStatus("error");
|
|
101
132
|
});
|
|
102
133
|
}, 2000);
|
|
103
134
|
},
|
|
104
|
-
[
|
|
135
|
+
[enqueueSave],
|
|
105
136
|
);
|
|
106
137
|
|
|
138
|
+
// Whoever asks for this is acting on the draft right now and owns the
|
|
139
|
+
// outcome, so the failure is returned rather than thrown and `saveError` is
|
|
140
|
+
// left alone — the caller's own message is the accurate one, and setting
|
|
141
|
+
// `saveError` would raise a second "Couldn't save draft" banner beside it.
|
|
107
142
|
const saveImmediately = useCallback(
|
|
108
|
-
(data: DraftData) => {
|
|
143
|
+
(data: DraftData): Promise<ImmediateSave> => {
|
|
109
144
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
110
|
-
return
|
|
145
|
+
return enqueueSave(data)
|
|
146
|
+
.then(
|
|
147
|
+
(result): ImmediateSave => ({
|
|
148
|
+
outcome: "saved",
|
|
149
|
+
outboxMessageId: result.outboxMessageId,
|
|
150
|
+
}),
|
|
151
|
+
)
|
|
152
|
+
.catch((error: unknown): ImmediateSave => {
|
|
153
|
+
setSaveStatus("error");
|
|
154
|
+
return { outcome: "failed", error };
|
|
155
|
+
});
|
|
111
156
|
},
|
|
112
|
-
[
|
|
157
|
+
[enqueueSave],
|
|
113
158
|
);
|
|
114
159
|
|
|
115
160
|
// Called with an id, the entry is closed to autosave for good. Sending and
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { afterEach, describe, it } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
AttachmentFetchError,
|
|
5
|
+
attachmentFailureContent,
|
|
6
|
+
attachmentReportUrl,
|
|
7
|
+
classifyAttachmentFailure,
|
|
8
|
+
extractAttachmentFailureDetail,
|
|
9
|
+
extractAttachmentFailureReason,
|
|
10
|
+
fetchAttachment,
|
|
11
|
+
isRepairableByRefetch,
|
|
12
|
+
saveBlob,
|
|
13
|
+
} from "./attachment-download";
|
|
14
|
+
|
|
15
|
+
const originalFetch = globalThis.fetch;
|
|
16
|
+
|
|
17
|
+
const respondWith = (
|
|
18
|
+
init: {
|
|
19
|
+
status: number;
|
|
20
|
+
statusText?: string;
|
|
21
|
+
headers?: Record<string, string>;
|
|
22
|
+
},
|
|
23
|
+
body: string = "bytes",
|
|
24
|
+
) => {
|
|
25
|
+
globalThis.fetch = (async () =>
|
|
26
|
+
new Response(init.status === 204 ? null : body, {
|
|
27
|
+
status: init.status,
|
|
28
|
+
statusText: init.statusText ?? "",
|
|
29
|
+
headers: init.headers,
|
|
30
|
+
})) as typeof fetch;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
globalThis.fetch = originalFetch;
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("classifyAttachmentFailure", () => {
|
|
38
|
+
it("reads an edge denial as an expired session", () => {
|
|
39
|
+
assert.equal(classifyAttachmentFailure(401, null), "auth");
|
|
40
|
+
assert.equal(classifyAttachmentFailure(403, "tenant-mismatch"), "auth");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("reads an origin 403/404 as a missing object", () => {
|
|
44
|
+
assert.equal(classifyAttachmentFailure(403, null), "missing");
|
|
45
|
+
assert.equal(classifyAttachmentFailure(404, null), "missing");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// A stale signature is not a stale session — telling the user to sign in
|
|
49
|
+
// again would send them somewhere that cannot help.
|
|
50
|
+
it("separates an aged-out content signature from an expired session", () => {
|
|
51
|
+
assert.equal(classifyAttachmentFailure(403, "expired"), "link-expired");
|
|
52
|
+
assert.equal(isRepairableByRefetch("link-expired"), true);
|
|
53
|
+
assert.equal(isRepairableByRefetch("not-ready"), true);
|
|
54
|
+
assert.equal(isRepairableByRefetch("auth"), false);
|
|
55
|
+
assert.equal(isRepairableByRefetch("missing"), false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("reads anything else as generic", () => {
|
|
59
|
+
assert.equal(classifyAttachmentFailure(500, null), "generic");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("fetchAttachment", () => {
|
|
64
|
+
it("returns the bytes on a 200", async () => {
|
|
65
|
+
respondWith({ status: 200 }, "payload");
|
|
66
|
+
const blob = await fetchAttachment("https://cdn.test/x", async () => null);
|
|
67
|
+
assert.equal(await blob.text(), "payload");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("sends the session token when there is one", async () => {
|
|
71
|
+
let seen: string | null = null;
|
|
72
|
+
globalThis.fetch = (async (_url: string, init?: RequestInit) => {
|
|
73
|
+
seen = new Headers(init?.headers).get("Authorization") ?? null;
|
|
74
|
+
return new Response("payload", { status: 200 });
|
|
75
|
+
}) as unknown as typeof fetch;
|
|
76
|
+
await fetchAttachment("https://cdn.test/x", async () => "tok");
|
|
77
|
+
assert.equal(seen, "Bearer tok");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("treats a 202 as not-ready rather than as an empty file", async () => {
|
|
81
|
+
respondWith({ status: 202 });
|
|
82
|
+
await assert.rejects(
|
|
83
|
+
fetchAttachment("https://cdn.test/x", async () => null),
|
|
84
|
+
(error: unknown) =>
|
|
85
|
+
error instanceof AttachmentFetchError && error.reason === "not-ready",
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("throws with the classified reason on a failure status", async () => {
|
|
90
|
+
respondWith({ status: 404, statusText: "Not Found" });
|
|
91
|
+
await assert.rejects(
|
|
92
|
+
fetchAttachment("https://cdn.test/x", async () => null),
|
|
93
|
+
(error: unknown) =>
|
|
94
|
+
error instanceof AttachmentFetchError &&
|
|
95
|
+
error.reason === "missing" &&
|
|
96
|
+
error.status === 404,
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("attachmentFailureContent", () => {
|
|
102
|
+
it("tells an expired session what to do, and names the file", () => {
|
|
103
|
+
const content = attachmentFailureContent(
|
|
104
|
+
"auth",
|
|
105
|
+
"report.pdf",
|
|
106
|
+
"irrelevant",
|
|
107
|
+
);
|
|
108
|
+
assert.match(content.title, /session expired/i);
|
|
109
|
+
assert.match(content.detail, /report\.pdf/);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("distinguishes a missing object from a slow one", () => {
|
|
113
|
+
assert.notEqual(
|
|
114
|
+
attachmentFailureContent("missing", "x", "f").title,
|
|
115
|
+
attachmentFailureContent("not-ready", "x", "f").title,
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("surfaces the underlying error for an unclassified failure", () => {
|
|
120
|
+
const content = attachmentFailureContent(
|
|
121
|
+
"generic",
|
|
122
|
+
"x",
|
|
123
|
+
"Failed to download attachment (500 )",
|
|
124
|
+
);
|
|
125
|
+
assert.equal(content.detail, "Failed to download attachment (500 )");
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("failure extraction", () => {
|
|
130
|
+
it("reads the reason off an AttachmentFetchError", () => {
|
|
131
|
+
assert.equal(
|
|
132
|
+
extractAttachmentFailureReason(
|
|
133
|
+
new AttachmentFetchError("missing", "gone", 404),
|
|
134
|
+
),
|
|
135
|
+
"missing",
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("treats an unrecognised throw as generic", () => {
|
|
140
|
+
assert.equal(extractAttachmentFailureReason("boom"), "generic");
|
|
141
|
+
assert.equal(extractAttachmentFailureDetail("boom"), "boom");
|
|
142
|
+
assert.equal(
|
|
143
|
+
extractAttachmentFailureDetail(new Error("transport lost")),
|
|
144
|
+
"transport lost",
|
|
145
|
+
);
|
|
146
|
+
assert.match(extractAttachmentFailureDetail({}), /unexpected error/);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("attachmentReportUrl", () => {
|
|
151
|
+
it("offers a prefilled issue for a failure the user cannot fix", () => {
|
|
152
|
+
const url = attachmentReportUrl("missing", "report.pdf", "gone");
|
|
153
|
+
assert.ok(url, "a reportable failure must carry an issue URL");
|
|
154
|
+
assert.ok(
|
|
155
|
+
url.startsWith("https://github.com/remit-mail/reader/issues/new?"),
|
|
156
|
+
);
|
|
157
|
+
assert.match(decodeURIComponent(url), /report\.pdf/);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("offers none for an expired session, which is not a bug", () => {
|
|
161
|
+
assert.equal(
|
|
162
|
+
attachmentReportUrl("auth", "report.pdf", "denied"),
|
|
163
|
+
undefined,
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("saveBlob", () => {
|
|
169
|
+
it("saves under the name it was given and cleans up the anchor", async () => {
|
|
170
|
+
const created: string[] = [];
|
|
171
|
+
const revoked: string[] = [];
|
|
172
|
+
const url = globalThis.URL as unknown as {
|
|
173
|
+
createObjectURL?: (blob: Blob) => string;
|
|
174
|
+
revokeObjectURL?: (value: string) => void;
|
|
175
|
+
};
|
|
176
|
+
url.createObjectURL = () => {
|
|
177
|
+
created.push("blob:test");
|
|
178
|
+
return "blob:test";
|
|
179
|
+
};
|
|
180
|
+
url.revokeObjectURL = (value) => revoked.push(value);
|
|
181
|
+
|
|
182
|
+
let clickedName: string | null = null;
|
|
183
|
+
const anchor = document.createElement("a");
|
|
184
|
+
anchor.click = () => {
|
|
185
|
+
clickedName = anchor.download;
|
|
186
|
+
};
|
|
187
|
+
const originalCreateElement = document.createElement.bind(document);
|
|
188
|
+
document.createElement = ((tag: string) =>
|
|
189
|
+
tag === "a"
|
|
190
|
+
? anchor
|
|
191
|
+
: originalCreateElement(tag)) as typeof document.createElement;
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
saveBlob(new Blob(["x"]), "report.pdf");
|
|
195
|
+
} finally {
|
|
196
|
+
document.createElement = originalCreateElement;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
assert.equal(clickedName, "report.pdf");
|
|
200
|
+
assert.deepEqual(created, ["blob:test"]);
|
|
201
|
+
assert.equal(document.body.contains(anchor), false);
|
|
202
|
+
assert.deepEqual(revoked, [], "revoking in the click's task cancels it");
|
|
203
|
+
|
|
204
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
205
|
+
assert.deepEqual(revoked, ["blob:test"]);
|
|
206
|
+
});
|
|
207
|
+
});
|