@remit/web-client 0.0.240 → 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/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/lib/api.ts +42 -5
- package/src/lib/client.test.ts +68 -0
- package/src/lib/client.ts +2 -8
- package/src/routes/settings/folders.tsx +4 -0
- package/src/test-support/http.ts +3 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.241",
|
|
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": {
|
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import assert from "node:assert";
|
|
2
|
-
import { describe, test } from "node:test";
|
|
2
|
+
import { afterEach, describe, test } from "node:test";
|
|
3
|
+
import { client } from "@remit/api-http-client/client.gen.ts";
|
|
4
|
+
import { trashOperationsEmptyTrash } from "@remit/api-http-client/sdk.gen.ts";
|
|
5
|
+
import { type HttpMock, mockFetch } from "../test-support/http";
|
|
3
6
|
import { AuthTokenError } from "./better-auth-config";
|
|
4
7
|
import { type AuthProvider, noneAuthProvider } from "./provider";
|
|
5
8
|
|
|
6
|
-
type RequestFn = (req: Request) => Promise<Request>;
|
|
7
|
-
|
|
8
|
-
declare global {
|
|
9
|
-
var __REMIT_CLIENT_MOCKS__: { requestFns: RequestFn[] } | undefined;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
9
|
let cacheBust = 1000;
|
|
10
|
+
let http: HttpMock | undefined;
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
http?.restore();
|
|
14
|
+
http = undefined;
|
|
15
|
+
client.interceptors.request.clear();
|
|
16
|
+
});
|
|
13
17
|
|
|
14
18
|
const providerWithToken = (
|
|
15
19
|
getToken: AuthProvider["getToken"],
|
|
@@ -17,15 +21,30 @@ const providerWithToken = (
|
|
|
17
21
|
|
|
18
22
|
const loadInterceptor = async () => {
|
|
19
23
|
cacheBust += 1;
|
|
20
|
-
|
|
24
|
+
client.interceptors.request.clear();
|
|
21
25
|
return import(`./auth-interceptor.ts?v=${cacheBust}`);
|
|
22
26
|
};
|
|
23
27
|
|
|
28
|
+
const send = (headers?: Record<string, string>): Promise<unknown> => {
|
|
29
|
+
http = mockFetch();
|
|
30
|
+
return trashOperationsEmptyTrash({
|
|
31
|
+
path: { accountId: "acc-1" },
|
|
32
|
+
headers,
|
|
33
|
+
throwOnError: true,
|
|
34
|
+
});
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const sentHeaders = (): Record<string, string> => {
|
|
38
|
+
assert.ok(http, "expected a request to have been sent");
|
|
39
|
+
assert.equal(http.calls.length, 1);
|
|
40
|
+
return http.calls[0].headers;
|
|
41
|
+
};
|
|
42
|
+
|
|
24
43
|
describe("installAuthInterceptor", () => {
|
|
25
44
|
test("registers exactly one request interceptor", async () => {
|
|
26
45
|
const mod = await loadInterceptor();
|
|
27
46
|
mod.installAuthInterceptor(providerWithToken(async () => null));
|
|
28
|
-
assert.equal(
|
|
47
|
+
assert.equal(client.interceptors.request.fns.length, 1);
|
|
29
48
|
});
|
|
30
49
|
|
|
31
50
|
test("is idempotent — subsequent calls do not register additional interceptors", async () => {
|
|
@@ -34,17 +53,14 @@ describe("installAuthInterceptor", () => {
|
|
|
34
53
|
mod.installAuthInterceptor(provider);
|
|
35
54
|
mod.installAuthInterceptor(provider);
|
|
36
55
|
mod.installAuthInterceptor(provider);
|
|
37
|
-
assert.equal(
|
|
56
|
+
assert.equal(client.interceptors.request.fns.length, 1);
|
|
38
57
|
});
|
|
39
58
|
|
|
40
59
|
test("attaches the provider's Bearer token when one exists", async () => {
|
|
41
60
|
const mod = await loadInterceptor();
|
|
42
61
|
mod.installAuthInterceptor(providerWithToken(async () => "ID-TOKEN-123"));
|
|
43
|
-
|
|
44
|
-
assert.
|
|
45
|
-
const req = new Request("https://api.example.com/thing");
|
|
46
|
-
const out = await fn(req);
|
|
47
|
-
assert.equal(out.headers.get("Authorization"), "Bearer ID-TOKEN-123");
|
|
62
|
+
await send();
|
|
63
|
+
assert.equal(sentHeaders().authorization, "Bearer ID-TOKEN-123");
|
|
48
64
|
});
|
|
49
65
|
|
|
50
66
|
test("abandons the request when the token cannot be minted — never sends it unauthenticated", async () => {
|
|
@@ -54,42 +70,32 @@ describe("installAuthInterceptor", () => {
|
|
|
54
70
|
throw new AuthTokenError("Could not mint a session token: 429", 429);
|
|
55
71
|
}),
|
|
56
72
|
);
|
|
57
|
-
const fn = globalThis.__REMIT_CLIENT_MOCKS__?.requestFns[0];
|
|
58
|
-
assert.ok(fn);
|
|
59
|
-
const req = new Request("https://api.example.com/thing");
|
|
60
73
|
await assert.rejects(
|
|
61
|
-
() =>
|
|
74
|
+
() => send(),
|
|
62
75
|
(error: unknown) => {
|
|
63
76
|
assert.ok(error instanceof AuthTokenError);
|
|
64
77
|
assert.equal(error.status, 429);
|
|
65
78
|
return true;
|
|
66
79
|
},
|
|
67
80
|
);
|
|
68
|
-
assert.equal(
|
|
81
|
+
assert.equal(http?.calls.length, 0);
|
|
69
82
|
});
|
|
70
83
|
|
|
71
84
|
test("omits Authorization header when the deployment presents no identity", async () => {
|
|
72
85
|
const mod = await loadInterceptor();
|
|
73
86
|
mod.installAuthInterceptor(providerWithToken(async () => null));
|
|
74
|
-
|
|
75
|
-
assert.
|
|
76
|
-
const req = new Request("https://api.example.com/thing");
|
|
77
|
-
const out = await fn(req);
|
|
78
|
-
assert.equal(out.headers.get("Authorization"), null);
|
|
87
|
+
await send();
|
|
88
|
+
assert.equal(sentHeaders().authorization, undefined);
|
|
79
89
|
});
|
|
80
90
|
|
|
81
91
|
test("preserves caller-supplied headers when adding Authorization", async () => {
|
|
82
92
|
const mod = await loadInterceptor();
|
|
83
93
|
mod.installAuthInterceptor(providerWithToken(async () => "TOK"));
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const out = await fn(req);
|
|
90
|
-
assert.equal(out.headers.get("X-Trace-Id"), "abc-123");
|
|
91
|
-
assert.equal(out.headers.get("Content-Type"), "application/json");
|
|
92
|
-
assert.equal(out.headers.get("Authorization"), "Bearer TOK");
|
|
94
|
+
await send({ "X-Trace-Id": "abc-123", "X-Client": "reader" });
|
|
95
|
+
const headers = sentHeaders();
|
|
96
|
+
assert.equal(headers["x-trace-id"], "abc-123");
|
|
97
|
+
assert.equal(headers["x-client"], "reader");
|
|
98
|
+
assert.equal(headers.authorization, "Bearer TOK");
|
|
93
99
|
});
|
|
94
100
|
|
|
95
101
|
test("propagates errors when the provider's getToken rejects (let it crash)", async () => {
|
|
@@ -99,9 +105,6 @@ describe("installAuthInterceptor", () => {
|
|
|
99
105
|
throw new Error("session fetch failed");
|
|
100
106
|
}),
|
|
101
107
|
);
|
|
102
|
-
|
|
103
|
-
assert.ok(fn);
|
|
104
|
-
const req = new Request("https://api.example.com/thing");
|
|
105
|
-
await assert.rejects(() => fn(req), /session fetch failed/);
|
|
108
|
+
await assert.rejects(() => send(), /session fetch failed/);
|
|
106
109
|
});
|
|
107
110
|
});
|
|
@@ -14,12 +14,15 @@ import {
|
|
|
14
14
|
ComposeBodySkeleton,
|
|
15
15
|
ComposeFormShell,
|
|
16
16
|
ComposeHeader,
|
|
17
|
+
ComposeQuoteMissingBanner,
|
|
18
|
+
type ComposeQuoteMode,
|
|
17
19
|
type ComposeSendState,
|
|
18
20
|
type ComposeShellLayout,
|
|
19
21
|
ComposeSubjectField,
|
|
20
22
|
composeHeaderSummary,
|
|
21
23
|
defaultComposeLanguages,
|
|
22
24
|
modeOfDraft,
|
|
25
|
+
NO_QUOTABLE_BODY_FORWARD_MESSAGE,
|
|
23
26
|
QuotedText,
|
|
24
27
|
type RichTextValue,
|
|
25
28
|
SMTP_MISSING_MESSAGE,
|
|
@@ -678,6 +681,7 @@ export const ComposeForm = ({
|
|
|
678
681
|
data: sourceBody,
|
|
679
682
|
isLoading: quoteIsLoading,
|
|
680
683
|
isError: quoteFailed,
|
|
684
|
+
hasNoRenderablePart: sourceHasNoQuotableBody,
|
|
681
685
|
refetch: refetchQuote,
|
|
682
686
|
} = useMessageBodyContent({
|
|
683
687
|
messageId: sourceMessage?.message.messageId,
|
|
@@ -685,6 +689,31 @@ export const ComposeForm = ({
|
|
|
685
689
|
enabled: isQuoting && !!sourceMessage,
|
|
686
690
|
});
|
|
687
691
|
|
|
692
|
+
const quoteMode: ComposeQuoteMode = mode === "forward" ? "forward" : "reply";
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* The message being answered has not arrived yet, so the quote's own fetch
|
|
696
|
+
* cannot have started — it picks its part out of what has not landed. A send
|
|
697
|
+
* pressed in this window went out carrying none of the original and was
|
|
698
|
+
* refused by nothing, because "no part picked" reads the same here as "this
|
|
699
|
+
* message has no part to pick" (#1030).
|
|
700
|
+
*/
|
|
701
|
+
const quoteSourceIsLoading = isQuoting && !sourceMessage;
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* The message being answered arrived carrying attachments and no text. A
|
|
705
|
+
* forward of it is refused: the composer sends a text body and an html body
|
|
706
|
+
* and no attachments, so the forward would arrive holding nothing of the
|
|
707
|
+
* original at all. A reply goes out — it carries the answer and the thread's
|
|
708
|
+
* references either way — and says the original is not quoted in it.
|
|
709
|
+
*
|
|
710
|
+
* Not stated for a resumed draft, whose body already holds whatever quote it
|
|
711
|
+
* was saved with; this composer is not the one that assembles it.
|
|
712
|
+
*/
|
|
713
|
+
const quoteHasNoBody =
|
|
714
|
+
isQuoting && !documentHoldsQuote && sourceHasNoQuotableBody;
|
|
715
|
+
const nothingToForward = quoteHasNoBody && quoteMode === "forward";
|
|
716
|
+
|
|
688
717
|
/**
|
|
689
718
|
* The original as it will be sent, and as it is shown while the answer is
|
|
690
719
|
* written — one value for both, because the two disagreeing is the whole of
|
|
@@ -818,7 +847,7 @@ export const ComposeForm = ({
|
|
|
818
847
|
if (selectedAccountMissingSmtp) {
|
|
819
848
|
return { status: "blocked", reason: SMTP_MISSING_MESSAGE };
|
|
820
849
|
}
|
|
821
|
-
if (quoteIsLoading) {
|
|
850
|
+
if (quoteIsLoading || quoteSourceIsLoading) {
|
|
822
851
|
return { status: "blocked", reason: QUOTE_LOADING_MESSAGE };
|
|
823
852
|
}
|
|
824
853
|
if (unparsed) {
|
|
@@ -827,9 +856,26 @@ export const ComposeForm = ({
|
|
|
827
856
|
if (toCount === 0) {
|
|
828
857
|
return { status: "blocked", reason: NO_TO_ADDRESS_MESSAGE };
|
|
829
858
|
}
|
|
859
|
+
// Last of the refusals: the others name something the writer can go and
|
|
860
|
+
// fix, and this one names something about the message being forwarded
|
|
861
|
+
// that they cannot. The banner above states it the whole time either
|
|
862
|
+
// way, so the press is better spent on the fixable thing.
|
|
863
|
+
if (nothingToForward) {
|
|
864
|
+
return {
|
|
865
|
+
status: "blocked",
|
|
866
|
+
reason: NO_QUOTABLE_BODY_FORWARD_MESSAGE,
|
|
867
|
+
};
|
|
868
|
+
}
|
|
830
869
|
return { status: "ready", accountId: selectedAccountId };
|
|
831
870
|
},
|
|
832
|
-
[
|
|
871
|
+
[
|
|
872
|
+
isSending,
|
|
873
|
+
selectedAccountId,
|
|
874
|
+
selectedAccountMissingSmtp,
|
|
875
|
+
quoteIsLoading,
|
|
876
|
+
quoteSourceIsLoading,
|
|
877
|
+
nothingToForward,
|
|
878
|
+
],
|
|
833
879
|
);
|
|
834
880
|
const sendReadiness = useMemo<SendReadiness>(
|
|
835
881
|
() =>
|
|
@@ -1092,6 +1138,9 @@ export const ComposeForm = ({
|
|
|
1092
1138
|
configureRef={smtpConfigureRef}
|
|
1093
1139
|
/>
|
|
1094
1140
|
) : null}
|
|
1141
|
+
{quoteHasNoBody ? (
|
|
1142
|
+
<ComposeQuoteMissingBanner mode={quoteMode} />
|
|
1143
|
+
) : null}
|
|
1095
1144
|
{quoteFailed ? (
|
|
1096
1145
|
<Banner tone="warning" data-testid="compose-quote-failed">
|
|
1097
1146
|
<span>{QUOTE_FAILED_MESSAGE}</span>{" "}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue #1030: a source with no text or html part — a message that is
|
|
3
|
+
* attachments and nothing else — left the quote hook disabled, so nothing was
|
|
4
|
+
* loading, nothing had failed, and the composer sent a forward carrying none of
|
|
5
|
+
* the original at all.
|
|
6
|
+
*
|
|
7
|
+
* The composer sends a text body and an html body and no attachments, so that
|
|
8
|
+
* forward has nothing to arrive with: it is refused, in words. A reply still
|
|
9
|
+
* carries the answer and the thread's references, so it goes out and says the
|
|
10
|
+
* original is not quoted in it. And a source that has not arrived yet is a
|
|
11
|
+
* third thing again — still loading, not a message with nothing in it.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import { afterEach, describe, it } from "node:test";
|
|
16
|
+
import type {
|
|
17
|
+
RemitImapAccountResponse,
|
|
18
|
+
RemitImapDescribeMessageResponse,
|
|
19
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
20
|
+
import { NO_QUOTABLE_BODY_FORWARD_MESSAGE } from "@remit/ui";
|
|
21
|
+
import {
|
|
22
|
+
type AnyRouter,
|
|
23
|
+
createMemoryHistory,
|
|
24
|
+
createRootRoute,
|
|
25
|
+
createRoute,
|
|
26
|
+
createRouter,
|
|
27
|
+
RouterContextProvider,
|
|
28
|
+
} from "@tanstack/react-router";
|
|
29
|
+
import { createElement, useState } from "react";
|
|
30
|
+
import { createDomHarness, type DomHarness } from "../../test-support/dom";
|
|
31
|
+
import { type HttpMock, mockFetch } from "../../test-support/http";
|
|
32
|
+
import { ComposeForm } from "./ComposeForm";
|
|
33
|
+
import type { ComposeMode } from "./ComposeProvider";
|
|
34
|
+
import { ComposeProvider } from "./ComposeProvider";
|
|
35
|
+
|
|
36
|
+
const ACCOUNT_ID = "acc-1";
|
|
37
|
+
|
|
38
|
+
const account = {
|
|
39
|
+
accountId: ACCOUNT_ID,
|
|
40
|
+
email: "me@example.com",
|
|
41
|
+
smtpEnabled: true,
|
|
42
|
+
} as unknown as RemitImapAccountResponse;
|
|
43
|
+
|
|
44
|
+
/** Two scans and a cover sheet — no text part anywhere in it. */
|
|
45
|
+
const attachmentOnlySource = {
|
|
46
|
+
message: { messageId: "msg-1" },
|
|
47
|
+
envelope: {
|
|
48
|
+
subject: "Scans",
|
|
49
|
+
date: Date.UTC(2026, 5, 24, 9, 14),
|
|
50
|
+
messageIdValue: "<m1@example.com>",
|
|
51
|
+
from: [{ normalizedEmail: "dana@example.com", displayName: "Dana" }],
|
|
52
|
+
replyTo: [],
|
|
53
|
+
to: [{ normalizedEmail: "me@example.com" }],
|
|
54
|
+
cc: [],
|
|
55
|
+
},
|
|
56
|
+
references: [],
|
|
57
|
+
bodyParts: [
|
|
58
|
+
{
|
|
59
|
+
mediaType: "MULTIPART",
|
|
60
|
+
mediaSubtype: "MIXED",
|
|
61
|
+
contentUrl: "",
|
|
62
|
+
isMultipart: true,
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
mediaType: "IMAGE",
|
|
66
|
+
mediaSubtype: "JPEG",
|
|
67
|
+
disposition: "attachment",
|
|
68
|
+
contentUrl: "/content/msg-1/2.jpg",
|
|
69
|
+
isMultipart: false,
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
} as unknown as RemitImapDescribeMessageResponse;
|
|
73
|
+
|
|
74
|
+
const outboxEntry = () => ({
|
|
75
|
+
outboxMessageId: "ob-1030",
|
|
76
|
+
accountId: ACCOUNT_ID,
|
|
77
|
+
fromAddress: account.email,
|
|
78
|
+
toAddresses: ["dana@example.com"],
|
|
79
|
+
ccAddresses: [],
|
|
80
|
+
bccAddresses: [],
|
|
81
|
+
references: [],
|
|
82
|
+
subject: "Fwd: Scans",
|
|
83
|
+
status: "draft",
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
let harness: DomHarness | undefined;
|
|
87
|
+
let http: HttpMock | undefined;
|
|
88
|
+
|
|
89
|
+
afterEach(() => {
|
|
90
|
+
harness?.close();
|
|
91
|
+
harness = undefined;
|
|
92
|
+
http?.restore();
|
|
93
|
+
http = undefined;
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const Opened = ({
|
|
97
|
+
mode,
|
|
98
|
+
sourceMessage,
|
|
99
|
+
}: {
|
|
100
|
+
mode: ComposeMode;
|
|
101
|
+
sourceMessage?: RemitImapDescribeMessageResponse;
|
|
102
|
+
}) => {
|
|
103
|
+
const [draftId, setDraftId] = useState<string | undefined>(undefined);
|
|
104
|
+
|
|
105
|
+
return createElement(ComposeForm, {
|
|
106
|
+
mode,
|
|
107
|
+
account,
|
|
108
|
+
sourceMessage,
|
|
109
|
+
outboxMessageId: draftId,
|
|
110
|
+
onDraftCreated: setDraftId,
|
|
111
|
+
onClose: () => {},
|
|
112
|
+
});
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
(globalThis as { self?: typeof globalThis }).self ??= globalThis;
|
|
116
|
+
|
|
117
|
+
const rootRoute = createRootRoute();
|
|
118
|
+
const mailboxRoute = createRoute({
|
|
119
|
+
getParentRoute: () => rootRoute,
|
|
120
|
+
path: "/mail/$mailboxId",
|
|
121
|
+
validateSearch: (search: Record<string, unknown>) => search,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const testRouter = (): AnyRouter =>
|
|
125
|
+
createRouter({
|
|
126
|
+
routeTree: rootRoute.addChildren([mailboxRoute]),
|
|
127
|
+
history: createMemoryHistory({ initialEntries: ["/mail/mbx-1"] }),
|
|
128
|
+
}) as unknown as AnyRouter;
|
|
129
|
+
|
|
130
|
+
const mount = async (options: {
|
|
131
|
+
mode: ComposeMode;
|
|
132
|
+
/** Left out for the deep link that opened before the source landed. */
|
|
133
|
+
sourceMessage?: RemitImapDescribeMessageResponse;
|
|
134
|
+
}): Promise<void> => {
|
|
135
|
+
http = mockFetch(async (call) => {
|
|
136
|
+
if (call.path.endsWith("/config")) return { accounts: [account] };
|
|
137
|
+
return outboxEntry();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
harness = createDomHarness();
|
|
141
|
+
harness.renderApp(
|
|
142
|
+
createElement(RouterContextProvider, {
|
|
143
|
+
router: testRouter(),
|
|
144
|
+
// biome-ignore lint/correctness/noChildrenProp: RouterContextProvider types `children` as a required prop, which createElement's rest-argument form does not satisfy
|
|
145
|
+
children: createElement(
|
|
146
|
+
ComposeProvider,
|
|
147
|
+
null,
|
|
148
|
+
createElement(Opened, {
|
|
149
|
+
mode: options.mode,
|
|
150
|
+
sourceMessage: options.sourceMessage,
|
|
151
|
+
}),
|
|
152
|
+
),
|
|
153
|
+
}),
|
|
154
|
+
);
|
|
155
|
+
await harness.flush();
|
|
156
|
+
await harness.wait(100);
|
|
157
|
+
await harness.flush();
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const sendButton = (): HTMLElement => {
|
|
161
|
+
const button = harness?.byText("button", "Send");
|
|
162
|
+
if (!button) throw new Error("the compose send button is not mounted");
|
|
163
|
+
return button;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const addRecipient = (email: string): void => {
|
|
167
|
+
const field = harness?.query<HTMLInputElement>("#address-field-To");
|
|
168
|
+
if (!field) throw new Error("the To field is not mounted");
|
|
169
|
+
harness?.type(field, email);
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const dispatches = (): number =>
|
|
173
|
+
(http?.calls ?? []).filter((call) => call.path.endsWith("/send")).length;
|
|
174
|
+
|
|
175
|
+
const press = async (): Promise<void> => {
|
|
176
|
+
harness?.click(sendButton());
|
|
177
|
+
await harness?.flush();
|
|
178
|
+
await harness?.wait(100);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
describe("a source with nothing to quote (#1030)", () => {
|
|
182
|
+
it("refuses a forward of a message that is attachments only", async () => {
|
|
183
|
+
await mount({ mode: "forward", sourceMessage: attachmentOnlySource });
|
|
184
|
+
|
|
185
|
+
addRecipient("bob@example.com");
|
|
186
|
+
await press();
|
|
187
|
+
|
|
188
|
+
assert.equal(
|
|
189
|
+
dispatches(),
|
|
190
|
+
0,
|
|
191
|
+
"a forward left with nothing of the message it forwards",
|
|
192
|
+
);
|
|
193
|
+
assert.ok(
|
|
194
|
+
(harness?.text() ?? "").includes(NO_QUOTABLE_BODY_FORWARD_MESSAGE),
|
|
195
|
+
"the refusal was not stated",
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("says so on screen before the forward is even written", async () => {
|
|
200
|
+
await mount({ mode: "forward", sourceMessage: attachmentOnlySource });
|
|
201
|
+
|
|
202
|
+
const banner = harness?.query("[data-testid=compose-quote-missing]");
|
|
203
|
+
assert.ok(banner, "nothing said the message had no body to quote");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("sends a reply to it, saying the original is not quoted", async () => {
|
|
207
|
+
await mount({ mode: "reply", sourceMessage: attachmentOnlySource });
|
|
208
|
+
|
|
209
|
+
assert.ok(
|
|
210
|
+
harness?.query("[data-testid=compose-quote-missing]"),
|
|
211
|
+
"nothing said the original would not be quoted",
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
await press();
|
|
215
|
+
|
|
216
|
+
assert.equal(dispatches(), 1, "the reply was not sent");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("refuses while the source is still on its way, without claiming it is empty", async () => {
|
|
220
|
+
await mount({ mode: "forward" });
|
|
221
|
+
|
|
222
|
+
addRecipient("bob@example.com");
|
|
223
|
+
await press();
|
|
224
|
+
|
|
225
|
+
assert.equal(dispatches(), 0, "a forward left before its source arrived");
|
|
226
|
+
assert.match(harness?.text() ?? "", /Loading the message you're quoting/);
|
|
227
|
+
assert.equal(
|
|
228
|
+
harness?.query("[data-testid=compose-quote-missing]"),
|
|
229
|
+
null,
|
|
230
|
+
"a source still arriving was reported as a message with no body",
|
|
231
|
+
);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
import { isMailboxNotSettledRefusal } from "@/components/ui/folder-role-refusal";
|
|
25
25
|
import { useCreateMailbox } from "@/hooks/useCreateMailbox";
|
|
26
26
|
import { useFolderLabelTranslator } from "@/hooks/useFolderLabelTranslator";
|
|
27
|
+
import { softErrorStatuses } from "@/lib/error-classifier";
|
|
27
28
|
import { buildMailboxRoleMap, labelForMailbox } from "@/lib/folder-roles";
|
|
28
29
|
import { buildMoveOptions, folderDelimiter } from "@/lib/move-options";
|
|
29
30
|
|
|
@@ -90,9 +91,12 @@ export const RoleAppointmentPromptProvider = ({
|
|
|
90
91
|
enabled: !!accountId,
|
|
91
92
|
});
|
|
92
93
|
const { createFolderIn } = useCreateMailbox(accountId);
|
|
93
|
-
const appointMutation = useMutation(
|
|
94
|
-
folderRoleOperationsAppointFolderRoleMutation(),
|
|
95
|
-
|
|
94
|
+
const appointMutation = useMutation({
|
|
95
|
+
...folderRoleOperationsAppointFolderRoleMutation(),
|
|
96
|
+
// A mailbox still settling answers 409, and the prompt states that itself
|
|
97
|
+
// in `appoint-failed`. Escalating would take the screen the prompt is on.
|
|
98
|
+
meta: softErrorStatuses(409),
|
|
99
|
+
});
|
|
96
100
|
|
|
97
101
|
const account = config?.accounts.find((one) => one.accountId === accountId);
|
|
98
102
|
const mailboxes = useMemo(() => mailboxData?.items ?? [], [mailboxData]);
|
|
@@ -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
|
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
type FolderRoleRefusal,
|
|
23
23
|
isFolderRoleRefusal,
|
|
24
24
|
} from "@/components/ui/folder-role-refusal";
|
|
25
|
+
import { softErrorStatuses } from "@/lib/error-classifier";
|
|
25
26
|
import {
|
|
26
27
|
invalidateThreadListQueries,
|
|
27
28
|
threadListCacheKeys,
|
|
@@ -109,6 +110,9 @@ export const useEmptyTrash = ({
|
|
|
109
110
|
|
|
110
111
|
const { mutateAsync, isPending } = useMutation({
|
|
111
112
|
...trashOperationsEmptyTrashMutation(),
|
|
113
|
+
// The refusal is rendered in the pane, so it must not also take the whole
|
|
114
|
+
// screen. Only 409 — every other status is still the fatal page's (#1059).
|
|
115
|
+
meta: softErrorStatuses(409),
|
|
112
116
|
onMutate: (): EmptyTrashContext => {
|
|
113
117
|
inFlight.current = scope;
|
|
114
118
|
return { scope, listMailboxId: listMailboxIdNow() };
|
|
@@ -351,6 +351,12 @@ export const useMessageBodyContent = ({
|
|
|
351
351
|
|
|
352
352
|
const isLoading = query.isLoading && !!picked;
|
|
353
353
|
|
|
354
|
+
// `picked` is null for two different reasons, and a caller that treats them
|
|
355
|
+
// alike is wrong about one of them: the message has not arrived yet, or it
|
|
356
|
+
// arrived carrying nothing renderable. Only the second is a fact about the
|
|
357
|
+
// message, and only it may be stated to the reader (#1030).
|
|
358
|
+
const hasNoRenderablePart = bodyParts !== undefined && picked === null;
|
|
359
|
+
|
|
354
360
|
useEffect(() => {
|
|
355
361
|
if (isLoading) {
|
|
356
362
|
loadStartRef.current = performance.now();
|
|
@@ -374,6 +380,7 @@ export const useMessageBodyContent = ({
|
|
|
374
380
|
picked,
|
|
375
381
|
data,
|
|
376
382
|
isLoading,
|
|
383
|
+
hasNoRenderablePart,
|
|
377
384
|
isError: query.isError,
|
|
378
385
|
error: query.error,
|
|
379
386
|
refetch: query.refetch,
|
package/src/lib/api.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ApiError as ApiErrorWireBody } from "@remit/api-http-client/types.gen.ts";
|
|
1
2
|
import { taggedFetch } from "./network-error";
|
|
2
3
|
|
|
3
4
|
export class ApiError extends Error {
|
|
@@ -11,6 +12,46 @@ export class ApiError extends Error {
|
|
|
11
12
|
}
|
|
12
13
|
}
|
|
13
14
|
|
|
15
|
+
/**
|
|
16
|
+
* The wire body's coded half, re-checked at runtime. `ApiErrorWireBody` is the
|
|
17
|
+
* generated flat shape the API sends; `ApiError` is the class every HTTP
|
|
18
|
+
* failure arrives in, carrying that body at `.body`. Reading `code` off the
|
|
19
|
+
* class is the bug #1004 is about, so nothing outside this module reaches for
|
|
20
|
+
* either shape directly.
|
|
21
|
+
*/
|
|
22
|
+
export interface CodedApiErrorBody {
|
|
23
|
+
code: ApiErrorWireBody["code"];
|
|
24
|
+
details: Record<string, unknown> | undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
28
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
29
|
+
|
|
30
|
+
export const codedApiErrorBody = (
|
|
31
|
+
error: unknown,
|
|
32
|
+
): CodedApiErrorBody | undefined => {
|
|
33
|
+
const body = error instanceof ApiError ? error.body : error;
|
|
34
|
+
if (!isRecord(body)) return undefined;
|
|
35
|
+
const { code, details } = body;
|
|
36
|
+
if (typeof code !== "string") return undefined;
|
|
37
|
+
return { code, details: isRecord(details) ? details : undefined };
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const failureMessage = (body: unknown, status: number): string => {
|
|
41
|
+
if (isRecord(body) && body.message !== undefined && body.message !== null) {
|
|
42
|
+
const message = String(body.message);
|
|
43
|
+
if (message) return message;
|
|
44
|
+
}
|
|
45
|
+
return `Request failed with status ${status}`;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The one place an HTTP failure becomes an `ApiError`, so a test can hand a
|
|
50
|
+
* consumer the exact value the error interceptor produces.
|
|
51
|
+
*/
|
|
52
|
+
export const wrapHttpFailure = (body: unknown, status: number): ApiError =>
|
|
53
|
+
new ApiError(failureMessage(body, status), status, body);
|
|
54
|
+
|
|
14
55
|
interface RequestOptions extends Omit<RequestInit, "body"> {
|
|
15
56
|
params?: Record<string, string | number | boolean | undefined>;
|
|
16
57
|
body?: unknown;
|
|
@@ -52,11 +93,7 @@ const request = async <T>(
|
|
|
52
93
|
|
|
53
94
|
if (!response.ok) {
|
|
54
95
|
const errorBody = await response.json().catch(() => undefined);
|
|
55
|
-
throw
|
|
56
|
-
errorBody?.message || `Request failed with status ${response.status}`,
|
|
57
|
-
response.status,
|
|
58
|
-
errorBody,
|
|
59
|
-
);
|
|
96
|
+
throw wrapHttpFailure(errorBody, response.status);
|
|
60
97
|
}
|
|
61
98
|
|
|
62
99
|
if (response.status === 204) {
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { afterEach, describe, it } from "node:test";
|
|
3
|
+
import { trashOperationsEmptyTrash } from "@remit/api-http-client/sdk.gen.ts";
|
|
4
|
+
import { type HttpMock, mockFetch } from "../test-support/http";
|
|
5
|
+
import { ApiError } from "./api";
|
|
6
|
+
import "./client";
|
|
7
|
+
|
|
8
|
+
let http: HttpMock | undefined;
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
http?.restore();
|
|
12
|
+
http = undefined;
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const answer = (status: number, body: unknown): void => {
|
|
16
|
+
http = mockFetch(
|
|
17
|
+
() =>
|
|
18
|
+
new Response(JSON.stringify(body), {
|
|
19
|
+
status,
|
|
20
|
+
headers: { "content-type": "application/json" },
|
|
21
|
+
}),
|
|
22
|
+
);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const empty = (): Promise<unknown> =>
|
|
26
|
+
trashOperationsEmptyTrash({
|
|
27
|
+
path: { accountId: "acc-1" },
|
|
28
|
+
throwOnError: true,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("the error interceptor", () => {
|
|
32
|
+
it("hands the app an ApiError carrying the status and the wire body", async () => {
|
|
33
|
+
answer(409, {
|
|
34
|
+
code: "folder_role_unresolved",
|
|
35
|
+
message: "Nobody has confirmed which folder is this account's Trash",
|
|
36
|
+
details: { reason: "unconfirmed", role: "Trash", accountId: "acc-1" },
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const error = await empty().then(
|
|
40
|
+
() => undefined,
|
|
41
|
+
(reason: unknown) => reason,
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
assert.ok(error instanceof ApiError);
|
|
45
|
+
assert.equal(error.status, 409);
|
|
46
|
+
assert.equal(
|
|
47
|
+
error.message,
|
|
48
|
+
"Nobody has confirmed which folder is this account's Trash",
|
|
49
|
+
);
|
|
50
|
+
assert.deepEqual(error.body, {
|
|
51
|
+
code: "folder_role_unresolved",
|
|
52
|
+
message: "Nobody has confirmed which folder is this account's Trash",
|
|
53
|
+
details: { reason: "unconfirmed", role: "Trash", accountId: "acc-1" },
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("falls back to the status when the body states no message", async () => {
|
|
58
|
+
answer(503, {});
|
|
59
|
+
|
|
60
|
+
const error = await empty().then(
|
|
61
|
+
() => undefined,
|
|
62
|
+
(reason: unknown) => reason,
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
assert.ok(error instanceof ApiError);
|
|
66
|
+
assert.equal(error.message, "Request failed with status 503");
|
|
67
|
+
});
|
|
68
|
+
});
|
package/src/lib/client.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { client } from "@remit/api-http-client/client.gen.ts";
|
|
2
2
|
import { getRuntimeConfig } from "../runtime-config";
|
|
3
|
-
import { ApiError } from "./api";
|
|
3
|
+
import { ApiError, wrapHttpFailure } from "./api";
|
|
4
4
|
import { taggedFetch } from "./network-error";
|
|
5
5
|
|
|
6
6
|
// In production, the config.js apiUrl points at the deployed API Gateway.
|
|
@@ -27,13 +27,7 @@ client.interceptors.error.use((error, response) => {
|
|
|
27
27
|
if (error instanceof ApiError) return error;
|
|
28
28
|
if (!response) return error;
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
const message =
|
|
32
|
-
body && typeof body === "object" && "message" in body
|
|
33
|
-
? String((body as { message: unknown }).message)
|
|
34
|
-
: `Request failed with status ${response.status}`;
|
|
35
|
-
|
|
36
|
-
return new ApiError(message, response.status, body);
|
|
30
|
+
return wrapHttpFailure(error, response.status);
|
|
37
31
|
});
|
|
38
32
|
|
|
39
33
|
export { client };
|
|
@@ -31,6 +31,7 @@ import { isMailboxNotSettledRefusal } from "@/components/ui/folder-role-refusal"
|
|
|
31
31
|
import { useCreateMailbox } from "@/hooks/useCreateMailbox";
|
|
32
32
|
import { useFolderLabelTranslator } from "@/hooks/useFolderLabelTranslator";
|
|
33
33
|
import { guardFolderDeletion } from "@/lib/delete-folder";
|
|
34
|
+
import { softErrorStatuses } from "@/lib/error-classifier";
|
|
34
35
|
import {
|
|
35
36
|
buildMailboxRoleMap,
|
|
36
37
|
CANONICAL_TO_NAV_ROLE,
|
|
@@ -57,6 +58,9 @@ function AccountFolders({ account }: { account: RemitImapAccountResponse }) {
|
|
|
57
58
|
|
|
58
59
|
const appointMutation = useMutation({
|
|
59
60
|
...folderRoleOperationsAppointFolderRoleMutation(),
|
|
61
|
+
// The pane states a still-settling mailbox inline; see the same meta on
|
|
62
|
+
// the prompt's own write.
|
|
63
|
+
meta: softErrorStatuses(409),
|
|
60
64
|
onSuccess: () => {
|
|
61
65
|
queryClient.invalidateQueries({
|
|
62
66
|
queryKey: configOperationsGetConfigQueryKey(),
|
package/src/test-support/http.ts
CHANGED
|
@@ -71,8 +71,9 @@ export const mockFetch = (responder: Responder = () => ({})): HttpMock => {
|
|
|
71
71
|
|
|
72
72
|
/**
|
|
73
73
|
* A failed request. The status travels in the body as well as on the response:
|
|
74
|
-
* the generated client throws the parsed body,
|
|
75
|
-
*
|
|
74
|
+
* the generated client throws the parsed body, which the error interceptor
|
|
75
|
+
* re-wraps as an `ApiError` carrying the response status. Anything read off the
|
|
76
|
+
* body — a `code`, its `details` — sits at `.body`, never at the top level.
|
|
76
77
|
*/
|
|
77
78
|
export const httpError = (status: number, message = "boom"): Response =>
|
|
78
79
|
new Response(JSON.stringify({ status, message }), {
|