@remit/web-client 0.0.239 → 0.0.241
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/auth/auth-interceptor.test.ts +41 -38
- package/src/components/compose/ComposeForm.tsx +51 -2
- package/src/components/compose/attachment-only-source.render.test.ts +233 -0
- package/src/components/mail/RoleAppointmentPromptProvider.tsx +7 -3
- package/src/components/settings/QuarantinePanel.delimiter.test.ts +112 -0
- package/src/components/settings/QuarantinePanel.tsx +38 -15
- package/src/components/ui/folder-role-refusal.test.ts +56 -26
- package/src/components/ui/folder-role-refusal.ts +6 -18
- package/src/components/ui/placement-refusal.test.ts +14 -6
- package/src/components/ui/placement-refusal.ts +5 -6
- package/src/hooks/refusal-escalation.render.test.ts +187 -0
- package/src/hooks/use-system-update.test.ts +104 -0
- package/src/hooks/use-system-update.ts +6 -0
- package/src/hooks/useDeleteMessages.ts +5 -0
- package/src/hooks/useEmptyTrash.ts +4 -0
- package/src/hooks/useMessageBodyContent.ts +7 -0
- package/src/hooks/useResultFolderIndex.ts +39 -9
- package/src/lib/api.ts +42 -5
- package/src/lib/client.test.ts +68 -0
- package/src/lib/client.ts +2 -8
- package/src/lib/quarantine-entries.ts +5 -3
- package/src/routes/mail.tsx +1 -1
- package/src/routes/settings/folders.tsx +4 -0
- package/src/test-support/http.ts +3 -2
- package/src/hooks/useAccountDelimiters.ts +0 -41
|
@@ -6,7 +6,10 @@
|
|
|
6
6
|
* visible: the list, the reassurance that there is nothing in it, and the
|
|
7
7
|
* failure to read it at all. None of them is an empty panel.
|
|
8
8
|
*/
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
configOperationsGetConfigOptions,
|
|
11
|
+
meOperationsListQuarantineOptions,
|
|
12
|
+
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
10
13
|
import type { RemitImapQuarantineResponse } from "@remit/api-http-client/types.gen.ts";
|
|
11
14
|
import {
|
|
12
15
|
Button,
|
|
@@ -20,9 +23,10 @@ import { useQuery } from "@tanstack/react-query";
|
|
|
20
23
|
import { Bug } from "lucide-react";
|
|
21
24
|
import { useMemo, useState } from "react";
|
|
22
25
|
import { formatErrorMessage } from "@/components/ui/ErrorState";
|
|
23
|
-
import {
|
|
26
|
+
import { useResultFolderIndex } from "@/hooks/useResultFolderIndex";
|
|
24
27
|
import { buildBugReportContext, buildGitHubIssueUrl } from "@/lib/bug-report";
|
|
25
28
|
import { toQuarantineEntry } from "@/lib/quarantine-entries";
|
|
29
|
+
import type { ResultFolderIndex } from "@/lib/result-folder";
|
|
26
30
|
|
|
27
31
|
/** Stable identity, so an empty list does not rebuild the memos below. */
|
|
28
32
|
const EMPTY_WIRE_ENTRIES: readonly RemitImapQuarantineResponse[] = [];
|
|
@@ -126,30 +130,49 @@ export function QuarantinePanelView({
|
|
|
126
130
|
);
|
|
127
131
|
}
|
|
128
132
|
|
|
133
|
+
/**
|
|
134
|
+
* A row names the folder it came from by the leaf of its path, which cannot be
|
|
135
|
+
* cut until that account's own delimiter has arrived. A mailbox the account no
|
|
136
|
+
* longer lists never gets one, and keeps its whole path — `folderLeaf` on an
|
|
137
|
+
* empty delimiter is the path itself.
|
|
138
|
+
*/
|
|
139
|
+
function delimiterForMailbox(
|
|
140
|
+
folders: ResultFolderIndex,
|
|
141
|
+
mailboxId: string,
|
|
142
|
+
): string {
|
|
143
|
+
return folders.get(mailboxId)?.hierarchyDelimiter ?? "";
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The folder names come from the account's mailbox lists, which are a second
|
|
148
|
+
* read — started alongside the quarantine list, never behind it. Until both have
|
|
149
|
+
* landed the pane says it is still reading, because a path cut on a delimiter
|
|
150
|
+
* that has not arrived is a wrong folder name rather than a late one, and a
|
|
151
|
+
* failure on either read is reported rather than papered over with a guess.
|
|
152
|
+
*/
|
|
129
153
|
export function QuarantinePanel() {
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
);
|
|
154
|
+
const quarantine = useQuery(meOperationsListQuarantineOptions());
|
|
155
|
+
const config = useQuery(configOperationsGetConfigOptions());
|
|
156
|
+
const accounts = useMemo(() => config.data?.accounts ?? [], [config.data]);
|
|
157
|
+
const folders = useResultFolderIndex(accounts);
|
|
133
158
|
|
|
134
|
-
const wireEntries = data?.entries ?? EMPTY_WIRE_ENTRIES;
|
|
135
|
-
const accountIds = useMemo(
|
|
136
|
-
() => [...new Set(wireEntries.map((entry) => entry.accountId))],
|
|
137
|
-
[wireEntries],
|
|
138
|
-
);
|
|
139
|
-
const delimiters = useAccountDelimiters(accountIds);
|
|
159
|
+
const wireEntries = quarantine.data?.entries ?? EMPTY_WIRE_ENTRIES;
|
|
140
160
|
const entries = useMemo(
|
|
141
161
|
() =>
|
|
142
162
|
wireEntries.map((entry) =>
|
|
143
|
-
toQuarantineEntry(
|
|
163
|
+
toQuarantineEntry(
|
|
164
|
+
entry,
|
|
165
|
+
delimiterForMailbox(folders.index, entry.mailboxId),
|
|
166
|
+
),
|
|
144
167
|
),
|
|
145
|
-
[wireEntries,
|
|
168
|
+
[wireEntries, folders.index],
|
|
146
169
|
);
|
|
147
170
|
|
|
148
171
|
return (
|
|
149
172
|
<QuarantinePanelView
|
|
150
173
|
entries={entries}
|
|
151
|
-
isPending={isPending}
|
|
152
|
-
error={error}
|
|
174
|
+
isPending={quarantine.isPending || config.isPending || folders.isPending}
|
|
175
|
+
error={quarantine.error ?? config.error ?? folders.error}
|
|
153
176
|
/>
|
|
154
177
|
);
|
|
155
178
|
}
|
|
@@ -1,19 +1,26 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
|
+
import { wrapHttpFailure } from "@/lib/api";
|
|
3
4
|
import {
|
|
4
5
|
isFolderRoleRefusal,
|
|
5
6
|
isMailboxNotSettledRefusal,
|
|
6
7
|
} from "./folder-role-refusal.js";
|
|
7
8
|
|
|
8
|
-
const
|
|
9
|
+
const wireBody = {
|
|
9
10
|
code: "folder_role_unresolved",
|
|
10
11
|
message: "No folder is appointed as Trash",
|
|
11
12
|
details: { role: "Trash", reason: "none", accountId: "acct-1" },
|
|
12
13
|
};
|
|
13
14
|
|
|
15
|
+
// Every call site takes the error interceptor, so what reaches these readers is
|
|
16
|
+
// always the `ApiError` `wrapHttpFailure` builds — never the flat wire body it
|
|
17
|
+
// carries at `.body` (#1004).
|
|
18
|
+
const refused = (body: Record<string, unknown> = wireBody) =>
|
|
19
|
+
wrapHttpFailure(body, 409);
|
|
20
|
+
|
|
14
21
|
describe("isFolderRoleRefusal", () => {
|
|
15
22
|
it("carries the account and the reason the prompt needs", () => {
|
|
16
|
-
assert.deepEqual(isFolderRoleRefusal(
|
|
23
|
+
assert.deepEqual(isFolderRoleRefusal(refused()), {
|
|
17
24
|
reason: "none",
|
|
18
25
|
role: "Trash",
|
|
19
26
|
accountId: "acct-1",
|
|
@@ -23,10 +30,9 @@ describe("isFolderRoleRefusal", () => {
|
|
|
23
30
|
it("reads every reason the API declares", () => {
|
|
24
31
|
for (const reason of ["none", "stale", "unconfirmed"]) {
|
|
25
32
|
assert.equal(
|
|
26
|
-
isFolderRoleRefusal(
|
|
27
|
-
...
|
|
28
|
-
|
|
29
|
-
})?.reason,
|
|
33
|
+
isFolderRoleRefusal(
|
|
34
|
+
refused({ ...wireBody, details: { ...wireBody.details, reason } }),
|
|
35
|
+
)?.reason,
|
|
30
36
|
reason,
|
|
31
37
|
);
|
|
32
38
|
}
|
|
@@ -34,14 +40,18 @@ describe("isFolderRoleRefusal", () => {
|
|
|
34
40
|
|
|
35
41
|
it("does not open the prompt for a 409 without the code", () => {
|
|
36
42
|
assert.equal(
|
|
37
|
-
isFolderRoleRefusal(
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
43
|
+
isFolderRoleRefusal(
|
|
44
|
+
refused({
|
|
45
|
+
message: "No folder is appointed as Trash",
|
|
46
|
+
details: wireBody.details,
|
|
47
|
+
}),
|
|
48
|
+
),
|
|
41
49
|
undefined,
|
|
42
50
|
);
|
|
43
51
|
assert.equal(
|
|
44
|
-
isFolderRoleRefusal(
|
|
52
|
+
isFolderRoleRefusal(
|
|
53
|
+
refused({ ...wireBody, code: "mailbox_not_settled" }),
|
|
54
|
+
),
|
|
45
55
|
undefined,
|
|
46
56
|
);
|
|
47
57
|
});
|
|
@@ -51,22 +61,32 @@ describe("isFolderRoleRefusal", () => {
|
|
|
51
61
|
isFolderRoleRefusal(new Error("folder_role_unresolved: Trash")),
|
|
52
62
|
undefined,
|
|
53
63
|
);
|
|
64
|
+
assert.equal(
|
|
65
|
+
isFolderRoleRefusal(
|
|
66
|
+
wrapHttpFailure({ message: "folder_role_unresolved" }, 409),
|
|
67
|
+
),
|
|
68
|
+
undefined,
|
|
69
|
+
);
|
|
54
70
|
});
|
|
55
71
|
|
|
56
72
|
it("refuses a body missing anything the prompt has to have", () => {
|
|
57
|
-
assert.equal(isFolderRoleRefusal({ ...refusal, details: {} }), undefined);
|
|
58
73
|
assert.equal(
|
|
59
|
-
isFolderRoleRefusal({
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
74
|
+
isFolderRoleRefusal(refused({ ...wireBody, details: {} })),
|
|
75
|
+
undefined,
|
|
76
|
+
);
|
|
77
|
+
assert.equal(
|
|
78
|
+
isFolderRoleRefusal(
|
|
79
|
+
refused({
|
|
80
|
+
...wireBody,
|
|
81
|
+
details: { role: "Trash", reason: "sideways", accountId: "acct-1" },
|
|
82
|
+
}),
|
|
83
|
+
),
|
|
63
84
|
undefined,
|
|
64
85
|
);
|
|
65
86
|
assert.equal(
|
|
66
|
-
isFolderRoleRefusal(
|
|
67
|
-
...
|
|
68
|
-
|
|
69
|
-
}),
|
|
87
|
+
isFolderRoleRefusal(
|
|
88
|
+
refused({ ...wireBody, details: { role: "Trash", reason: "none" } }),
|
|
89
|
+
),
|
|
70
90
|
undefined,
|
|
71
91
|
);
|
|
72
92
|
});
|
|
@@ -75,20 +95,30 @@ describe("isFolderRoleRefusal", () => {
|
|
|
75
95
|
for (const value of [undefined, null, "boom", 409, []]) {
|
|
76
96
|
assert.equal(isFolderRoleRefusal(value), undefined);
|
|
77
97
|
}
|
|
98
|
+
assert.equal(
|
|
99
|
+
isFolderRoleRefusal(wrapHttpFailure(undefined, 500)),
|
|
100
|
+
undefined,
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("still reads a bare wire body", () => {
|
|
105
|
+
assert.equal(isFolderRoleRefusal(wireBody)?.accountId, "acct-1");
|
|
78
106
|
});
|
|
79
107
|
});
|
|
80
108
|
|
|
81
109
|
describe("isMailboxNotSettledRefusal", () => {
|
|
82
110
|
it("matches only the appointment write's own refusal", () => {
|
|
83
111
|
assert.equal(
|
|
84
|
-
isMailboxNotSettledRefusal(
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
112
|
+
isMailboxNotSettledRefusal(
|
|
113
|
+
refused({
|
|
114
|
+
code: "mailbox_not_settled",
|
|
115
|
+
message: "Mailbox is still being created",
|
|
116
|
+
details: { mailboxId: "mbx-1", syncStatus: "pending" },
|
|
117
|
+
}),
|
|
118
|
+
),
|
|
89
119
|
true,
|
|
90
120
|
);
|
|
91
|
-
assert.equal(isMailboxNotSettledRefusal(
|
|
121
|
+
assert.equal(isMailboxNotSettledRefusal(refused()), false);
|
|
92
122
|
assert.equal(isMailboxNotSettledRefusal(new Error("pending")), false);
|
|
93
123
|
});
|
|
94
124
|
});
|
|
@@ -5,11 +5,9 @@
|
|
|
5
5
|
* appointment prompt over an unrelated conflict. A 409 without one of these
|
|
6
6
|
* codes is somebody else's error and keeps today's banner.
|
|
7
7
|
*/
|
|
8
|
-
import type {
|
|
9
|
-
ApiError,
|
|
10
|
-
RemitImapCanonicalMailboxRole,
|
|
11
|
-
} from "@remit/api-http-client/types.gen.ts";
|
|
8
|
+
import type { RemitImapCanonicalMailboxRole } from "@remit/api-http-client/types.gen.ts";
|
|
12
9
|
import { CanonicalMailboxRole } from "@remit/domain-enums";
|
|
10
|
+
import { type CodedApiErrorBody, codedApiErrorBody } from "@/lib/api";
|
|
13
11
|
|
|
14
12
|
/** Why the role is unresolved, as the API's `details.reason` spells it. */
|
|
15
13
|
export type FolderRoleRefusalReason = "none" | "stale" | "unconfirmed";
|
|
@@ -29,18 +27,8 @@ const REASONS: ReadonlySet<string> = new Set<FolderRoleRefusalReason>([
|
|
|
29
27
|
|
|
30
28
|
const ROLES: ReadonlySet<string> = new Set(Object.values(CanonicalMailboxRole));
|
|
31
29
|
|
|
32
|
-
/**
|
|
33
|
-
* The wire body as `handleError` emits it — flat, so `code` and `details` sit
|
|
34
|
-
* at the top level. Everything is re-checked at runtime: this is a network
|
|
35
|
-
* boundary, and the type only says what the contract promises.
|
|
36
|
-
*/
|
|
37
|
-
const bodyOf = (error: unknown): Partial<ApiError> | undefined =>
|
|
38
|
-
typeof error === "object" && error !== null
|
|
39
|
-
? (error as Partial<ApiError>)
|
|
40
|
-
: undefined;
|
|
41
|
-
|
|
42
30
|
const stringAt = (
|
|
43
|
-
details:
|
|
31
|
+
details: CodedApiErrorBody["details"],
|
|
44
32
|
key: string,
|
|
45
33
|
): string | undefined => {
|
|
46
34
|
const value = details?.[key];
|
|
@@ -55,10 +43,10 @@ const stringAt = (
|
|
|
55
43
|
export const isFolderRoleRefusal = (
|
|
56
44
|
error: unknown,
|
|
57
45
|
): FolderRoleRefusal | undefined => {
|
|
58
|
-
const body =
|
|
46
|
+
const body = codedApiErrorBody(error);
|
|
59
47
|
if (body?.code !== "folder_role_unresolved") return undefined;
|
|
60
48
|
const { details } = body;
|
|
61
|
-
if (
|
|
49
|
+
if (!details) return undefined;
|
|
62
50
|
const reason = stringAt(details, "reason");
|
|
63
51
|
const role = stringAt(details, "role");
|
|
64
52
|
const accountId = stringAt(details, "accountId");
|
|
@@ -78,4 +66,4 @@ export const isFolderRoleRefusal = (
|
|
|
78
66
|
* one, retrying does not.
|
|
79
67
|
*/
|
|
80
68
|
export const isMailboxNotSettledRefusal = (error: unknown): boolean =>
|
|
81
|
-
|
|
69
|
+
codedApiErrorBody(error)?.code === "mailbox_not_settled";
|
|
@@ -1,15 +1,23 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
|
+
import { wrapHttpFailure } from "@/lib/api";
|
|
3
4
|
import {
|
|
4
5
|
isPlacementRefusal,
|
|
5
6
|
placementRefusalBanner,
|
|
6
7
|
} from "./placement-refusal.js";
|
|
7
8
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
// The reader is handed what the error interceptor produces, not the flat wire
|
|
10
|
+
// body it carries at `.body` (#1004).
|
|
11
|
+
const refusal = (reason: string) =>
|
|
12
|
+
wrapHttpFailure(
|
|
13
|
+
{
|
|
14
|
+
message:
|
|
15
|
+
"Message 4f1 was not acted on: its folder and uid do not name...",
|
|
16
|
+
code: "message_placement_unsettled",
|
|
17
|
+
details: { accountId: "acc-1", messageId: "msg-1", reason },
|
|
18
|
+
},
|
|
19
|
+
409,
|
|
20
|
+
);
|
|
13
21
|
|
|
14
22
|
describe("isPlacementRefusal", () => {
|
|
15
23
|
it("reads the reason and the message off the coded body", () => {
|
|
@@ -23,7 +31,7 @@ describe("isPlacementRefusal", () => {
|
|
|
23
31
|
assert.equal(isPlacementRefusal(undefined), undefined);
|
|
24
32
|
assert.equal(isPlacementRefusal(new Error("network")), undefined);
|
|
25
33
|
assert.equal(
|
|
26
|
-
isPlacementRefusal({
|
|
34
|
+
isPlacementRefusal(wrapHttpFailure({ code: "other" }, 409)),
|
|
27
35
|
undefined,
|
|
28
36
|
);
|
|
29
37
|
assert.equal(isPlacementRefusal(refusal("something_else")), undefined);
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* server's sentence names a uuid and no remedy, and a message-string match
|
|
5
5
|
* would start firing on an unrelated conflict the moment the copy changes.
|
|
6
6
|
*/
|
|
7
|
-
import type { ApiError } from "@remit/api-http-client/types.gen.ts";
|
|
8
7
|
import type { PushErrorInput } from "@/components/ui/error-banners";
|
|
8
|
+
import { type CodedApiErrorBody, codedApiErrorBody } from "@/lib/api";
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* `in_flight` clears on its own once the mail server confirms the move;
|
|
@@ -25,7 +25,7 @@ const REASONS: ReadonlySet<string> = new Set<PlacementRefusalReason>([
|
|
|
25
25
|
]);
|
|
26
26
|
|
|
27
27
|
const stringAt = (
|
|
28
|
-
details:
|
|
28
|
+
details: CodedApiErrorBody["details"],
|
|
29
29
|
key: string,
|
|
30
30
|
): string | undefined => {
|
|
31
31
|
const value = details?.[key];
|
|
@@ -35,11 +35,10 @@ const stringAt = (
|
|
|
35
35
|
export const isPlacementRefusal = (
|
|
36
36
|
error: unknown,
|
|
37
37
|
): PlacementRefusal | undefined => {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if (body.code !== "message_placement_unsettled") return undefined;
|
|
38
|
+
const body = codedApiErrorBody(error);
|
|
39
|
+
if (body?.code !== "message_placement_unsettled") return undefined;
|
|
41
40
|
const { details } = body;
|
|
42
|
-
if (
|
|
41
|
+
if (!details) return undefined;
|
|
43
42
|
const reason = stringAt(details, "reason");
|
|
44
43
|
const messageId = stringAt(details, "messageId");
|
|
45
44
|
if (!reason || !REASONS.has(reason) || !messageId) return undefined;
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A coded 409 must reach the surface that answers it and nothing else (#1004).
|
|
3
|
+
*
|
|
4
|
+
* Both delete paths raise through `useMutation`, and the app wires the global
|
|
5
|
+
* fail-fast sink on the `MutationCache` (`shell/index.tsx`), where a 409 with
|
|
6
|
+
* no `meta` escalates to the full-screen fatal overlay. The prompt then opens
|
|
7
|
+
* underneath a red page nobody can see past — the refusal is handled and still
|
|
8
|
+
* never reaches the user. Every other test mounts on the harness's plain
|
|
9
|
+
* `QueryClient`, which has no such sink, so none of them can see this.
|
|
10
|
+
*
|
|
11
|
+
* Mounted here on the client the app actually builds, with the error
|
|
12
|
+
* interceptor registered.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import assert from "node:assert/strict";
|
|
16
|
+
import { afterEach, describe, it } from "node:test";
|
|
17
|
+
import {
|
|
18
|
+
MutationCache,
|
|
19
|
+
QueryCache,
|
|
20
|
+
QueryClient,
|
|
21
|
+
QueryClientProvider,
|
|
22
|
+
} from "@tanstack/react-query";
|
|
23
|
+
import { act, createElement } from "react";
|
|
24
|
+
import { RoleAppointmentPromptProvider } from "@/components/mail/RoleAppointmentPromptProvider";
|
|
25
|
+
import { ErrorBannerProvider } from "@/components/ui/ErrorBannerProvider";
|
|
26
|
+
import { __resetFatalError, getCurrentFatalError } from "@/lib/fatal-error";
|
|
27
|
+
import {
|
|
28
|
+
handleMutationCacheError,
|
|
29
|
+
handleQueryCacheError,
|
|
30
|
+
} from "@/lib/query-error-handler";
|
|
31
|
+
import { createDomHarness, type DomHarness } from "../test-support/dom";
|
|
32
|
+
import { type HttpMock, mockFetch } from "../test-support/http";
|
|
33
|
+
import { useDeleteMessages } from "./useDeleteMessages";
|
|
34
|
+
import { type EmptyTrashState, useEmptyTrash } from "./useEmptyTrash";
|
|
35
|
+
import "@/lib/client";
|
|
36
|
+
|
|
37
|
+
const ACCOUNT = "acc-1";
|
|
38
|
+
const INBOX = "mbx-inbox";
|
|
39
|
+
const TRASH = "mbx-trash";
|
|
40
|
+
|
|
41
|
+
let harness: DomHarness | undefined;
|
|
42
|
+
let http: HttpMock | undefined;
|
|
43
|
+
let deleteMessages: ((messageIds: string[]) => void) | undefined;
|
|
44
|
+
let trash: EmptyTrashState | undefined;
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
harness?.close();
|
|
48
|
+
harness = undefined;
|
|
49
|
+
http?.restore();
|
|
50
|
+
http = undefined;
|
|
51
|
+
deleteMessages = undefined;
|
|
52
|
+
trash = undefined;
|
|
53
|
+
__resetFatalError();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const CONFIG = {
|
|
57
|
+
accounts: [
|
|
58
|
+
{
|
|
59
|
+
accountId: ACCOUNT,
|
|
60
|
+
email: "you@example.com",
|
|
61
|
+
folderAppointments: [
|
|
62
|
+
{ role: "Trash", mailboxId: TRASH, source: "Proposed" },
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** Fresh every call — a `Response` body can only be read once. */
|
|
69
|
+
const refusal = (): Response =>
|
|
70
|
+
new Response(
|
|
71
|
+
JSON.stringify({
|
|
72
|
+
status: 409,
|
|
73
|
+
message: "Nobody has confirmed which folder is this account's Trash",
|
|
74
|
+
code: "folder_role_unresolved",
|
|
75
|
+
details: { reason: "unconfirmed", role: "Trash", accountId: ACCOUNT },
|
|
76
|
+
}),
|
|
77
|
+
{ status: 409, headers: { "content-type": "application/json" } },
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const broken = (): Response =>
|
|
81
|
+
new Response(JSON.stringify({ message: "boom" }), {
|
|
82
|
+
status: 500,
|
|
83
|
+
headers: { "content-type": "application/json" },
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const Probe = () => {
|
|
87
|
+
deleteMessages = useDeleteMessages({
|
|
88
|
+
mailboxId: INBOX,
|
|
89
|
+
accountId: ACCOUNT,
|
|
90
|
+
}).deleteMessages;
|
|
91
|
+
trash = useEmptyTrash({ accountId: ACCOUNT, mailboxId: TRASH });
|
|
92
|
+
return null;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** The client `shell/index.tsx` builds: the real global escalation sink. */
|
|
96
|
+
const appQueryClient = (): QueryClient =>
|
|
97
|
+
new QueryClient({
|
|
98
|
+
queryCache: new QueryCache({ onError: handleQueryCacheError }),
|
|
99
|
+
mutationCache: new MutationCache({ onError: handleMutationCacheError }),
|
|
100
|
+
defaultOptions: {
|
|
101
|
+
queries: { retry: false },
|
|
102
|
+
mutations: { retry: false },
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const settle = async (): Promise<void> => {
|
|
107
|
+
if (!harness) throw new Error("nothing mounted");
|
|
108
|
+
for (let round = 0; round < 40; round += 1) {
|
|
109
|
+
await harness.flush();
|
|
110
|
+
await harness.wait(0);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const mount = async (respond: (path: string) => unknown): Promise<void> => {
|
|
115
|
+
http = mockFetch((call) => respond(call.path));
|
|
116
|
+
harness = createDomHarness({ queryClient: appQueryClient() });
|
|
117
|
+
await harness.renderAsync(
|
|
118
|
+
createElement(
|
|
119
|
+
QueryClientProvider,
|
|
120
|
+
{ client: harness.queryClient as never },
|
|
121
|
+
createElement(
|
|
122
|
+
ErrorBannerProvider,
|
|
123
|
+
null,
|
|
124
|
+
createElement(
|
|
125
|
+
RoleAppointmentPromptProvider,
|
|
126
|
+
null,
|
|
127
|
+
createElement(Probe),
|
|
128
|
+
),
|
|
129
|
+
),
|
|
130
|
+
),
|
|
131
|
+
);
|
|
132
|
+
await settle();
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const responder =
|
|
136
|
+
(endpoint: string, answer: () => Response) =>
|
|
137
|
+
(path: string): unknown => {
|
|
138
|
+
if (path.endsWith("/config")) return CONFIG;
|
|
139
|
+
if (path.endsWith(endpoint)) return answer();
|
|
140
|
+
return {};
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
describe("a delete refused for its folder role", () => {
|
|
144
|
+
it("opens the appointment prompt without taking the screen", async () => {
|
|
145
|
+
await mount(responder("/messages/delete", refusal));
|
|
146
|
+
|
|
147
|
+
await act(async () => {
|
|
148
|
+
deleteMessages?.(["msg-1"]);
|
|
149
|
+
});
|
|
150
|
+
await settle();
|
|
151
|
+
|
|
152
|
+
assert.match(harness?.text() ?? "", /Confirm this account's Trash folder/);
|
|
153
|
+
assert.equal(
|
|
154
|
+
getCurrentFatalError(),
|
|
155
|
+
null,
|
|
156
|
+
"a refusal the prompt answers is not a fatal error",
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("still escalates a 500 on the same delete", async () => {
|
|
161
|
+
await mount(responder("/messages/delete", broken));
|
|
162
|
+
|
|
163
|
+
await act(async () => {
|
|
164
|
+
deleteMessages?.(["msg-1"]);
|
|
165
|
+
});
|
|
166
|
+
await settle();
|
|
167
|
+
|
|
168
|
+
assert.ok(
|
|
169
|
+
getCurrentFatalError(),
|
|
170
|
+
"the 409 opt-out must not cover anything else",
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe("an empty Trash refused for its folder role", () => {
|
|
176
|
+
it("states the refusal in the pane without taking the screen", async () => {
|
|
177
|
+
await mount(responder("/trash/empty", refusal));
|
|
178
|
+
|
|
179
|
+
await act(async () => {
|
|
180
|
+
trash?.emptyTrash();
|
|
181
|
+
});
|
|
182
|
+
await settle();
|
|
183
|
+
|
|
184
|
+
assert.equal(trash?.refusal?.reason, "unconfirmed");
|
|
185
|
+
assert.equal(getCurrentFatalError(), null);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
@@ -558,6 +558,110 @@ describe("useSystemUpdate — actions", () => {
|
|
|
558
558
|
}
|
|
559
559
|
});
|
|
560
560
|
|
|
561
|
+
test("checking from the success row reaches the pane, not a dead button (#1015)", async () => {
|
|
562
|
+
// An undismissed run outcome wins over any check in flight, so a press
|
|
563
|
+
// made while the success banner holds the pane would leave the screen
|
|
564
|
+
// exactly as it was. The row's control retires the outcome first.
|
|
565
|
+
const succeeded = {
|
|
566
|
+
currentVersion: "0.9.4",
|
|
567
|
+
check: { status: "ok", updateAvailable: false },
|
|
568
|
+
run: run({ outcome: "succeeded" }),
|
|
569
|
+
};
|
|
570
|
+
const { dom } = mountApi(() => succeeded, createElement(SelfUpdatePanel));
|
|
571
|
+
await settle(dom);
|
|
572
|
+
assert.match(dom.html(), /Updated to Remit 0\.9\.4/);
|
|
573
|
+
|
|
574
|
+
dom.click(dom.byText("button", "Check for updates"));
|
|
575
|
+
await waitFor(
|
|
576
|
+
dom,
|
|
577
|
+
() => /Looking for a newer version/.test(dom.html()),
|
|
578
|
+
"the pressed check to reach the pane",
|
|
579
|
+
);
|
|
580
|
+
assert.doesNotMatch(dom.html(), /Updated to Remit/);
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
test("a check pressed from the success row fails in the open, not behind the banner (#1015)", async () => {
|
|
584
|
+
const succeeded = {
|
|
585
|
+
currentVersion: "0.9.4",
|
|
586
|
+
check: { status: "ok", updateAvailable: false },
|
|
587
|
+
run: run({ outcome: "succeeded" }),
|
|
588
|
+
};
|
|
589
|
+
const { dom } = mountApi(() => succeeded, createElement(SelfUpdatePanel));
|
|
590
|
+
await settle(dom);
|
|
591
|
+
|
|
592
|
+
const control = dom.byText("button", "Check for updates");
|
|
593
|
+
const realNow = Date.now;
|
|
594
|
+
try {
|
|
595
|
+
await act(async () => {
|
|
596
|
+
// Dispatched inside this `act` rather than through `dom.click`, so
|
|
597
|
+
// the press is stamped off the real clock and the wait it arms is
|
|
598
|
+
// measured against one already past the budget.
|
|
599
|
+
control.dispatchEvent(
|
|
600
|
+
new MouseEvent("click", { bubbles: true, cancelable: true }),
|
|
601
|
+
);
|
|
602
|
+
Date.now = () => realNow() + CHECK_ANSWER_BUDGET_MS + 1_000;
|
|
603
|
+
await dom.flush();
|
|
604
|
+
});
|
|
605
|
+
await dom.wait(5);
|
|
606
|
+
await dom.flush();
|
|
607
|
+
|
|
608
|
+
assert.match(dom.html(), /The updater did not answer/);
|
|
609
|
+
assert.doesNotMatch(dom.html(), /Updated to Remit/);
|
|
610
|
+
} finally {
|
|
611
|
+
Date.now = realNow;
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
test("dismissing an outcome does not uncover the check verdict it hid", async () => {
|
|
616
|
+
// The failure belongs to a press made before the run, on a pane the user
|
|
617
|
+
// has not been able to see since. Serving it as the current verdict claims
|
|
618
|
+
// a check failed that may well have succeeded.
|
|
619
|
+
let response: unknown = available;
|
|
620
|
+
const { dom, api } = mountApi(
|
|
621
|
+
() => response,
|
|
622
|
+
createElement(SelfUpdatePanel),
|
|
623
|
+
);
|
|
624
|
+
await settle(dom);
|
|
625
|
+
|
|
626
|
+
const realNow = Date.now;
|
|
627
|
+
try {
|
|
628
|
+
await act(async () => {
|
|
629
|
+
api().onCheck();
|
|
630
|
+
Date.now = () => realNow() + CHECK_ANSWER_BUDGET_MS + 1_000;
|
|
631
|
+
await dom.flush();
|
|
632
|
+
});
|
|
633
|
+
await dom.wait(5);
|
|
634
|
+
await dom.flush();
|
|
635
|
+
assert.match(dom.html(), /The updater did not answer/);
|
|
636
|
+
} finally {
|
|
637
|
+
Date.now = realNow;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
response = {
|
|
641
|
+
currentVersion: "0.9.4",
|
|
642
|
+
check: { status: "ok", updateAvailable: false },
|
|
643
|
+
run: run({ outcome: "succeeded" }),
|
|
644
|
+
};
|
|
645
|
+
await retryConnection(dom, api);
|
|
646
|
+
await waitFor(
|
|
647
|
+
dom,
|
|
648
|
+
() => /Updated to Remit 0\.9\.4/.test(dom.html()),
|
|
649
|
+
"the finished run to reach the pane",
|
|
650
|
+
);
|
|
651
|
+
|
|
652
|
+
await act(async () => {
|
|
653
|
+
api().onDismissResult();
|
|
654
|
+
await dom.flush();
|
|
655
|
+
});
|
|
656
|
+
await waitFor(
|
|
657
|
+
dom,
|
|
658
|
+
() => !/Updated to Remit/.test(dom.html()),
|
|
659
|
+
"the outcome to be retired",
|
|
660
|
+
);
|
|
661
|
+
assert.doesNotMatch(dom.html(), /The updater did not answer/);
|
|
662
|
+
assert.match(dom.html(), /0\.9\.4 is the latest version/);
|
|
663
|
+
});
|
|
664
|
+
|
|
561
665
|
test("a refused refresh is shown, never swallowed back into the old verdict (#599)", async () => {
|
|
562
666
|
let refused = false;
|
|
563
667
|
const { dom, api } = mountApi(
|
|
@@ -249,6 +249,12 @@ export function useSystemUpdate(): SelfUpdateApi {
|
|
|
249
249
|
if (shownRunIdRef.current !== null)
|
|
250
250
|
setDismissedRunId(shownRunIdRef.current);
|
|
251
251
|
setHeld(null);
|
|
252
|
+
// Retiring the outcome uncovers the check verdict beneath it. A failure
|
|
253
|
+
// recorded while the outcome held the pane describes a press the user can
|
|
254
|
+
// no longer see, and an update run of its own supersedes anything an
|
|
255
|
+
// earlier check concluded — either way it must not resurface here.
|
|
256
|
+
setCheckPress(null);
|
|
257
|
+
setCheckFailure(null);
|
|
252
258
|
}, []);
|
|
253
259
|
|
|
254
260
|
return {
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
} from "@/components/ui/placement-refusal";
|
|
17
17
|
import { resolveMailboxesForMessages } from "@/hooks/useMarkAsRead";
|
|
18
18
|
import { runChunkedMutation } from "@/lib/bulk-actions";
|
|
19
|
+
import { softErrorStatuses } from "@/lib/error-classifier";
|
|
19
20
|
import {
|
|
20
21
|
cancelThreadListQueries,
|
|
21
22
|
invalidateThreadListQueries,
|
|
@@ -92,6 +93,10 @@ export const useDeleteMessages = ({
|
|
|
92
93
|
|
|
93
94
|
const { mutateAsync, isPending } = useMutation({
|
|
94
95
|
...messageBulkOperationsDeleteMessagesMutation(),
|
|
96
|
+
// A coded 409 is answered here — the prompt, or a banner — so it must not
|
|
97
|
+
// also take the whole screen. Only 409: a 401, 403 or 5xx on a delete is
|
|
98
|
+
// still the fatal page's (#1059).
|
|
99
|
+
meta: softErrorStatuses(409),
|
|
95
100
|
onMutate: async (variables): Promise<ThreadMutationContext> => {
|
|
96
101
|
const messageIds = new Set(variables.body.messageIds ?? []);
|
|
97
102
|
|