@remit/web-client 0.0.240 → 0.0.242

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.240",
3
+ "version": "0.0.242",
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
- globalThis.__REMIT_CLIENT_MOCKS__ = { requestFns: [] };
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(globalThis.__REMIT_CLIENT_MOCKS__?.requestFns.length, 1);
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(globalThis.__REMIT_CLIENT_MOCKS__?.requestFns.length, 1);
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
- const fn = globalThis.__REMIT_CLIENT_MOCKS__?.requestFns[0];
44
- assert.ok(fn, "expected interceptor fn to be registered");
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
- () => fn(req),
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(req.headers.get("Authorization"), null);
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
- const fn = globalThis.__REMIT_CLIENT_MOCKS__?.requestFns[0];
75
- assert.ok(fn);
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
- const fn = globalThis.__REMIT_CLIENT_MOCKS__?.requestFns[0];
85
- assert.ok(fn);
86
- const req = new Request("https://api.example.com/thing", {
87
- headers: { "X-Trace-Id": "abc-123", "Content-Type": "application/json" },
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
- const fn = globalThis.__REMIT_CLIENT_MOCKS__?.requestFns[0];
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
- [isSending, selectedAccountId, selectedAccountMissingSmtp, quoteIsLoading],
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 refusal = {
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(refusal), {
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
- ...refusal,
28
- details: { ...refusal.details, reason },
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
- message: "No folder is appointed as Trash",
39
- details: refusal.details,
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({ ...refusal, code: "mailbox_not_settled" }),
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
- ...refusal,
61
- details: { role: "Trash", reason: "sideways", accountId: "acct-1" },
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
- ...refusal,
68
- details: { role: "Trash", reason: "none" },
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
- code: "mailbox_not_settled",
86
- message: "Mailbox is still being created",
87
- details: { mailboxId: "mbx-1", syncStatus: "pending" },
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(refusal), false);
121
+ assert.equal(isMailboxNotSettledRefusal(refused()), false);
92
122
  assert.equal(isMailboxNotSettledRefusal(new Error("pending")), false);
93
123
  });
94
124
  });