@remit/web-client 0.0.131 → 0.0.133

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.131",
3
+ "version": "0.0.133",
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": {
@@ -84,6 +84,7 @@ import {
84
84
  toThreadRowData,
85
85
  } from "@/lib/brief";
86
86
  import { isServerError } from "@/lib/error-classifier";
87
+ import { junkDestination } from "@/lib/junk-destination";
87
88
  import type { ListHeaderChrome } from "@/lib/list-header-chrome";
88
89
  import { useMailContext } from "@/lib/mail-context";
89
90
  import { useMailFreshness } from "@/lib/mail-freshness";
@@ -280,7 +281,7 @@ function BriefSelectionChrome({
280
281
  [rows, selectedIds],
281
282
  );
282
283
 
283
- const canJunk = !!junkMailboxId && junkMailboxId !== scope.mailboxId;
284
+ const junkDestinationId = junkDestination(junkMailboxId, scope.mailboxId);
284
285
 
285
286
  // One select-all for both surfaces: the desktop toolbar and the touch sheet
286
287
  // offer the same control over the same rendered rows, so the verb a phone
@@ -316,7 +317,7 @@ function BriefSelectionChrome({
316
317
  onDelete={() => wizard.start("delete")}
317
318
  onMove={() => wizard.start("move")}
318
319
  onOrganize={() => wizard.start("organize")}
319
- onJunk={canJunk ? () => wizard.start("junk") : undefined}
320
+ onJunk={junkDestinationId ? () => wizard.start("junk") : undefined}
320
321
  onMarkRead={() => wizard.start("markRead")}
321
322
  overflowSlot={
322
323
  scope.accountId &&
@@ -0,0 +1,134 @@
1
+ /**
2
+ * The spam quick action stayed clickable throughout its own request (issue
3
+ * #648 review): the server dedupes message ids only within a single call, so
4
+ * two clicks fired two separate HTTP requests. For the undo direction this
5
+ * produced a wrong message — concurrent `notSpam` #2 reads
6
+ * `originalMailboxId` before #1 clears it, waits on #1's restore move, and
7
+ * can throw `MoveNotSettledError` on an undo that already succeeded. Wires
8
+ * the real `useReportSpam` hook to the real `IntelligencePanel` (the shape
9
+ * `IntelligencePane.tsx`'s `WiredPanel` uses) and clicks the real button
10
+ * twice to prove the fix: disabled while pending blocks the second request
11
+ * at the DOM level, not just in the hook.
12
+ */
13
+
14
+ import assert from "node:assert/strict";
15
+ import { afterEach, describe, it } from "node:test";
16
+ import type { IntelligenceData } from "@remit/ui";
17
+ import { IntelligencePanel } from "@remit/ui";
18
+ import { createElement } from "react";
19
+ import { useReportSpam } from "@/hooks/useReportSpam";
20
+ import { createDomHarness, type DomHarness } from "@/test-support/dom";
21
+ import { type HttpMock, mockFetch } from "@/test-support/http";
22
+
23
+ const baseData: IntelligenceData = {
24
+ sender: {
25
+ name: "Alex Rivera",
26
+ email: "alex@example.com",
27
+ trust: "wellknown",
28
+ firstSeenLabel: "Jan 2025",
29
+ },
30
+ authenticity: {
31
+ verdict: "aligned",
32
+ fromDomain: "example.com",
33
+ dkimDomain: "example.com",
34
+ summary: "This message was signed by example.com.",
35
+ },
36
+ category: { value: "Personal" },
37
+ similar: [],
38
+ };
39
+
40
+ const ReportHarness = () => {
41
+ const { reportSpam, isReporting } = useReportSpam({ mailboxId: "mbx-inbox" });
42
+ return createElement(IntelligencePanel, {
43
+ data: baseData,
44
+ actions: { onReportSpam: () => reportSpam(["msg-1"]) },
45
+ reportSpamPending: isReporting,
46
+ });
47
+ };
48
+
49
+ const UndoHarness = () => {
50
+ const { notSpam, isRestoring } = useReportSpam({ mailboxId: "mbx-junk" });
51
+ return createElement(IntelligencePanel, {
52
+ data: baseData,
53
+ actions: { onNotSpam: () => notSpam(["msg-1"]) },
54
+ notSpamPending: isRestoring,
55
+ });
56
+ };
57
+
58
+ let harness: DomHarness | undefined;
59
+ let http: HttpMock;
60
+
61
+ const waitFor = async (
62
+ predicate: () => boolean,
63
+ timeoutMs = 2000,
64
+ ): Promise<void> => {
65
+ if (!harness) throw new Error("nothing mounted");
66
+ const deadline = Date.now() + timeoutMs;
67
+ while (!predicate()) {
68
+ if (Date.now() > deadline) {
69
+ throw new Error(
70
+ `waitFor: condition never became true within ${timeoutMs}ms`,
71
+ );
72
+ }
73
+ await harness.flush();
74
+ await harness.wait(5);
75
+ }
76
+ };
77
+
78
+ afterEach(() => {
79
+ harness?.close();
80
+ harness = undefined;
81
+ http.restore();
82
+ });
83
+
84
+ describe("the spam quick action disables itself for the duration of its own request (#648 review)", () => {
85
+ it("report direction: a second click while the report is in flight sends only one request", async () => {
86
+ let resolveRequest: (() => void) | undefined;
87
+ http = mockFetch(
88
+ () =>
89
+ new Promise((resolve) => {
90
+ resolveRequest = () => resolve({ successCount: 1, failureCount: 0 });
91
+ }),
92
+ );
93
+ harness = createDomHarness();
94
+ harness.renderApp(createElement(ReportHarness));
95
+
96
+ const button = harness.byText("button", "Report spam") as HTMLButtonElement;
97
+ harness.click(button);
98
+ await waitFor(() => button.textContent === "Reporting…");
99
+
100
+ assert.equal(button.disabled, true, "a control mid-request must disable");
101
+ harness.click(button);
102
+
103
+ assert.ok(resolveRequest, "the request never reached the mock");
104
+ resolveRequest?.();
105
+ await waitFor(() => button.textContent === "Report spam");
106
+
107
+ assert.equal(http.to("/messages/report-spam").length, 1);
108
+ });
109
+
110
+ it("undo direction: a second click while the undo is in flight sends only one request", async () => {
111
+ let resolveRequest: (() => void) | undefined;
112
+ http = mockFetch(
113
+ () =>
114
+ new Promise((resolve) => {
115
+ resolveRequest = () => resolve({ successCount: 1, failureCount: 0 });
116
+ }),
117
+ );
118
+ harness = createDomHarness();
119
+ harness.renderApp(createElement(UndoHarness));
120
+
121
+ const button = harness.byText("button", "Not spam") as HTMLButtonElement;
122
+ harness.click(button);
123
+ await waitFor(() => button.textContent === "Undoing…");
124
+
125
+ assert.equal(button.disabled, true, "a control mid-request must disable");
126
+ harness.click(button);
127
+
128
+ assert.ok(resolveRequest, "the request never reached the mock");
129
+ resolveRequest?.();
130
+ await waitFor(() => button.textContent === "Not spam");
131
+
132
+ assert.equal(http.to("/messages/not-spam").length, 1);
133
+ });
134
+ });
@@ -111,6 +111,7 @@ import {
111
111
  sameInboxFilter,
112
112
  } from "@/lib/inbox-filters";
113
113
  import { readIntelligencePref } from "@/lib/intelligence-pref";
114
+ import { junkDestination } from "@/lib/junk-destination";
114
115
  import { useMailContext } from "@/lib/mail-context";
115
116
  import { useMailFreshness } from "@/lib/mail-freshness";
116
117
  import { isRescueCandidate } from "@/lib/rescue-candidates";
@@ -658,6 +659,7 @@ function MailboxPaneProvider({
658
659
 
659
660
  const { junkMailboxId } = useJunkMailbox(mailboxAccountId);
660
661
  const isSpamFolder = junkMailboxId != null && junkMailboxId === mailboxId;
662
+ const junkDestinationId = junkDestination(junkMailboxId, mailboxId);
661
663
  const { candidates: rescueCandidates } = useRescueCandidates(
662
664
  isSpamFolder ? junkMailboxId : undefined,
663
665
  );
@@ -724,7 +726,7 @@ function MailboxPaneProvider({
724
726
 
725
727
  const triageMarkJunk = useCallback(() => {
726
728
  if (listCommandsRef.current?.requestVerb("junk")) return;
727
- if (!junkMailboxId) return;
729
+ if (!junkDestinationId) return;
728
730
  const ids = triageTargetMessageIds();
729
731
  if (ids.length === 0) return;
730
732
  recordRescueSentToJunk(telemetry, {
@@ -732,10 +734,10 @@ function MailboxPaneProvider({
732
734
  senderTrust: focusedThread?.senderTrust ?? "unknown",
733
735
  wasRescuable: focusedThread ? isRescueCandidate(focusedThread) : false,
734
736
  });
735
- triageMove(ids, junkMailboxId);
737
+ triageMove(ids, junkDestinationId);
736
738
  }, [
737
739
  listCommandsRef,
738
- junkMailboxId,
740
+ junkDestinationId,
739
741
  triageTargetMessageIds,
740
742
  triageMove,
741
743
  telemetry,
@@ -46,8 +46,10 @@ import {
46
46
  escalationActionLabel,
47
47
  } from "@/lib/escalation-label";
48
48
  import { formatDeleteToTrashTitle, formatEmailDate } from "@/lib/format";
49
+ import { junkDestination } from "@/lib/junk-destination";
49
50
  import { tabStopId } from "@/lib/list-focus";
50
51
  import { useListHeaderChrome } from "@/lib/list-header-chrome";
52
+ import { listVerbRequest } from "@/lib/list-verb-request";
51
53
  import { shouldExitSelectionOnNavigate } from "@/lib/selection-mode";
52
54
  import { cn } from "@/lib/utils";
53
55
  import { useSelectionWizard, useWizardStepValue } from "@/lib/wizard-history";
@@ -290,6 +292,7 @@ export const MessageList = ({
290
292
  // The Junk quick action moves the selection to the account's appointed Junk
291
293
  // mailbox — the message-flags API has no `$Junk` field, so "junk" is a move.
292
294
  const { junkMailboxId } = useJunkMailbox(accountId);
295
+ const junkDestinationId = junkDestination(junkMailboxId, mailboxId);
293
296
 
294
297
  // Selection state
295
298
  const {
@@ -571,39 +574,50 @@ export const MessageList = ({
571
574
  }
572
575
  }, [orderedIds, selectAll]);
573
576
 
574
- // A keyboard verb, routed the same way the bar routes its own. Over a
575
- // selection every verb opens the wizard, so the keyboard cannot reach a bulk
576
- // action the bar would have reviewed. Over a bare cursor only Delete is the
577
- // list's, and it keeps the confirmation and the cursor hand-back.
577
+ // A keyboard verb, routed by `listVerbRequest` — the same routing the bar
578
+ // renders from, so a verb the bar withholds is not still reachable by
579
+ // shortcut. Over a selection every verb opens the wizard (#477 1.4), whatever
580
+ // the selection is: the ticked rows, a selection spanning accounts — the
581
+ // wizard is where that restriction is stated, on the step that needs one
582
+ // account (#477 5.5) — or the predicate the list escalated to, which the
583
+ // wizard names on its match step and counts on its review screen before
584
+ // anything is sent (#508).
578
585
  const requestVerb = useCallback(
579
586
  (verb: Verb): boolean => {
580
- // The confirmation is already asking about a delete: the keypress belongs
581
- // to it, and answering it is the Confirm button's job. Claiming the press
582
- // here is what stops a second Delete from reaching an unconfirmed delete.
583
- if (pendingDelete !== null) return true;
584
- if (hasSelection) {
585
- // Every verb on the bar opens the wizard (#477 1.4), whatever the
586
- // selection is: the ticked rows, a selection spanning accounts — the
587
- // wizard is where that restriction is stated, on the step that needs
588
- // one account (#477 5.5) or the predicate the list escalated to,
589
- // which the wizard names on its match step and counts on its review
590
- // screen before anything is sent (#508).
587
+ const request = listVerbRequest({
588
+ verb,
589
+ confirmingDelete: pendingDelete !== null,
590
+ hasSelection,
591
+ junkMailboxId,
592
+ currentMailboxId: mailboxId,
593
+ deletableMessageId: onDeleteMessages ? focusedMessageId : undefined,
594
+ });
595
+ if (request.kind === "openWizard") {
591
596
  startWizard(verb);
592
597
  return true;
593
598
  }
594
- if (verb !== "delete" || !onDeleteMessages || !focusedMessageId) {
595
- return false;
599
+ if (request.kind === "confirmDelete") {
600
+ requestDelete([request.messageId]);
601
+ return true;
596
602
  }
597
- requestDelete([focusedMessageId]);
598
- return true;
603
+ // A shortcut that does nothing at all is indistinguishable from one that
604
+ // is broken, so a verb this mailbox cannot take says why on the press.
605
+ if (request.kind === "unavailable") {
606
+ pushError({ severity: "warning", title: request.reason });
607
+ return true;
608
+ }
609
+ return request.kind === "withheld";
599
610
  },
600
611
  [
601
612
  pendingDelete,
602
613
  onDeleteMessages,
603
614
  hasSelection,
615
+ junkMailboxId,
616
+ mailboxId,
604
617
  startWizard,
605
618
  focusedMessageId,
606
619
  requestDelete,
620
+ pushError,
607
621
  ],
608
622
  );
609
623
 
@@ -1178,11 +1192,7 @@ export const MessageList = ({
1178
1192
  onDelete={() => startWizard("delete")}
1179
1193
  onMove={() => startWizard("move")}
1180
1194
  onOrganize={organizeSelection}
1181
- onJunk={
1182
- junkMailboxId && junkMailboxId !== mailboxId
1183
- ? () => startWizard("junk")
1184
- : undefined
1185
- }
1195
+ onJunk={junkDestinationId ? () => startWizard("junk") : undefined}
1186
1196
  onMarkRead={() => startWizard("markRead")}
1187
1197
  overflowSlot={
1188
1198
  accountId && mailboxId && selectedCount > 0 && labels.length > 0 ? (
@@ -45,7 +45,7 @@ describe("ending the run", () => {
45
45
  assert.match(source, /onCancelRun: runInFlight \? stopRun : undefined/);
46
46
  assert.match(
47
47
  source,
48
- /const runInFlight =\s*bulkRun !== undefined && bulkRun\.outcome === undefined;/,
48
+ /const runInFlight =\s*bulkRun !== undefined &&\s*bulkRun\.outcome === undefined &&\s*bulkRun\.failureReason === undefined;/,
49
49
  );
50
50
  });
51
51
 
@@ -48,6 +48,7 @@ import { useOrganizeWiden } from "@/hooks/useOrganizeWiden";
48
48
  import { useRulePreview } from "@/hooks/useRulePreview";
49
49
  import { useSelectedSubjects } from "@/hooks/useSelectedSubjects";
50
50
  import type { BulkActionProgress, BulkRunOutcome } from "@/lib/bulk-actions";
51
+ import { NO_JUNK_FOLDER_REASON } from "@/lib/junk-destination";
51
52
  import { useListHeaderChrome } from "@/lib/list-header-chrome";
52
53
  import { useMailContext } from "@/lib/mail-context";
53
54
  import { buildMoveOptions, folderDelimiter } from "@/lib/move-options";
@@ -174,6 +175,12 @@ interface RunSnapshot {
174
175
  applied: number;
175
176
  failed: number;
176
177
  failures: readonly WizardMessage[];
178
+ /**
179
+ * Why a commit never started, when the reason is one the same commit cannot
180
+ * get past. Carried to the run screen, which states it and offers no retry
181
+ * (#522).
182
+ */
183
+ failureReason?: string;
177
184
  }
178
185
 
179
186
  const NOT_STARTED: RunSnapshot = {
@@ -222,21 +229,12 @@ const widenedRunsAsJob = (verb: Verb): boolean =>
222
229
  * The commit pressed with nowhere for the mail to go. A verb that files mail
223
230
  * needs a destination, and reaching the run screen without one is a failure the
224
231
  * screen states along with the way out of it — never a control that does
225
- * nothing.
232
+ * nothing, and never a retry that re-sends the same commit to the same absence.
226
233
  */
227
- const noDestinationOutcome = (
228
- verb: Verb,
229
- ids: readonly string[],
230
- ): BulkRunOutcome => ({
231
- done: 0,
232
- failedIds: [...ids],
233
- cancelled: false,
234
- error: new Error(
235
- verb === "junk"
236
- ? "This account has no Junk folder appointed, so there is nowhere to file these. Appoint one under Settings › Folders."
237
- : "No destination was chosen, so there is nowhere to file these. Go back and pick a folder.",
238
- ),
239
- });
234
+ const noDestinationReason = (verb: Verb): string =>
235
+ verb === "junk"
236
+ ? NO_JUNK_FOLDER_REASON
237
+ : "No destination was chosen, so there is nowhere to file these. Go back and pick a folder.";
240
238
 
241
239
  /**
242
240
  * Where the wizard meets the app (#483). The steps and their bodies belong to
@@ -305,7 +303,8 @@ function SelectionWizardSession({
305
303
  OrganizeScope | undefined
306
304
  >(undefined);
307
305
  const [bulkRun, setBulkRun] = useState<
308
- { matched: number; outcome?: BulkRunOutcome } | undefined
306
+ | { matched: number; outcome?: BulkRunOutcome; failureReason?: string }
307
+ | undefined
309
308
  >(undefined);
310
309
  // The predicate the create chains its pass to. Undefined when the rule cannot
311
310
  // be back-applied — a `HasWords` clause the vector-free pass cannot evaluate —
@@ -589,7 +588,7 @@ function SelectionWizardSession({
589
588
  if (!action) {
590
589
  setBulkRun({
591
590
  matched: ids.length,
592
- outcome: noDestinationOutcome(verb, ids),
591
+ failureReason: noDestinationReason(verb),
593
592
  });
594
593
  return;
595
594
  }
@@ -610,7 +609,7 @@ function SelectionWizardSession({
610
609
  if (!action) {
611
610
  setBulkRun({
612
611
  matched: escalated.total,
613
- outcome: noDestinationOutcome(verb, []),
612
+ failureReason: noDestinationReason(verb),
614
613
  });
615
614
  return;
616
615
  }
@@ -747,7 +746,12 @@ function SelectionWizardSession({
747
746
 
748
747
  const bulkSnapshot = useCallback((): RunSnapshot => {
749
748
  if (!bulkRun) return NOT_STARTED;
750
- const { matched, outcome } = bulkRun;
749
+ const { matched, outcome, failureReason } = bulkRun;
750
+ // Nothing was sent, and nothing about sending it again resolves what was
751
+ // missing — so the screen carries why rather than the generic ending (#522).
752
+ if (failureReason !== undefined) {
753
+ return { ...NOT_STARTED, state: "commitFailed", failureReason };
754
+ }
751
755
  if (!outcome) {
752
756
  const applied = runProgress?.done ?? 0;
753
757
  // A predicate matches more by the time the run re-pages it than the count
@@ -867,7 +871,11 @@ function SelectionWizardSession({
867
871
  // list's runner and a bounded selection by this wizard's own, so the stop
868
872
  // follows whichever one is paging.
869
873
  const { stop: stopBulk } = bulk;
870
- const runInFlight = bulkRun !== undefined && bulkRun.outcome === undefined;
874
+ // A commit that never started has nothing paging and nothing to end.
875
+ const runInFlight =
876
+ bulkRun !== undefined &&
877
+ bulkRun.outcome === undefined &&
878
+ bulkRun.failureReason === undefined;
871
879
  const stopRun = useCallback(() => {
872
880
  if (escalated) {
873
881
  escalated.stop();
@@ -1001,6 +1009,7 @@ function SelectionWizardSession({
1001
1009
  applied: run.applied,
1002
1010
  failedCount: run.failed,
1003
1011
  failures: run.failures,
1012
+ failureReason: run.failureReason,
1004
1013
  onRetry: retry,
1005
1014
  onDismiss: dismiss,
1006
1015
  onCancelRun: runInFlight ? stopRun : undefined,
@@ -191,13 +191,20 @@ describe("every keyboard verb over a selection goes through the list", () => {
191
191
  markJunk: "junk",
192
192
  };
193
193
 
194
+ /**
195
+ * How a list may answer a verb aimed at the bare cursor: with the shared
196
+ * decision, whose rules are in `../../lib/list-verb-request.test.ts`, or
197
+ * inline. Either way only Delete is the list's.
198
+ */
199
+ const CURSOR_DELETE_ONLY = /listVerbRequest\(\{|if \(verb !== "delete"/;
200
+
194
201
  for (const file of LISTS) {
195
202
  it(`${file} publishes the one seam and claims a selection`, () => {
196
203
  const source = read(file);
197
204
  assert.match(source, /requestVerb,/);
198
205
  assert.match(
199
206
  source,
200
- /if \(verb !== "delete"/,
207
+ CURSOR_DELETE_ONLY,
201
208
  "only delete is the list's over a bare cursor",
202
209
  );
203
210
  assert.doesNotMatch(
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Closes the seam the existing #648-review tests each cover half of.
3
+ * `useReportSpam.integration.test.ts` builds a synthetic mutation via
4
+ * `mutationCache.build()` and never mounts the hook; `useReportSpam.render.
5
+ * test.ts` mounts the hook but under `createDomHarness`'s default
6
+ * `QueryClient`, which carries no global cache handlers. Neither exercises the
7
+ * JOIN: the real `useMutation` calls in `useReportSpam.ts`, under a
8
+ * `QueryClient` wired with the real `QueryCache`/`MutationCache` handlers from
9
+ * `lib/query-error-handler.ts` the way `shell/index.tsx` builds it, wrapped in
10
+ * the real `ErrorBannerProvider`. Deleting `meta: { softError: true }` from
11
+ * the real hook passes both existing tests — that is the bug that shipped
12
+ * twice and passed CI both times.
13
+ */
14
+
15
+ import assert from "node:assert/strict";
16
+ import { afterEach, describe, it } from "node:test";
17
+ import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
18
+ import { createElement, Fragment } from "react";
19
+ import { FatalErrorOverlay } from "../components/ui/FatalErrorOverlay";
20
+ import { __resetFatalError, subscribeFatalError } from "../lib/fatal-error";
21
+ import {
22
+ handleMutationCacheError,
23
+ handleQueryCacheError,
24
+ } from "../lib/query-error-handler";
25
+ import { createDomHarness, type DomHarness } from "../test-support/dom";
26
+ import { type HttpMock, httpError, mockFetch } from "../test-support/http";
27
+ import { useReportSpam } from "./useReportSpam";
28
+
29
+ let harness: DomHarness | undefined;
30
+ let http: HttpMock;
31
+
32
+ const mountHook = <T>(useHook: () => T): (() => T) => {
33
+ let value: T | undefined;
34
+ const Probe = () => {
35
+ value = useHook();
36
+ return null;
37
+ };
38
+ const queryClient = new QueryClient({
39
+ queryCache: new QueryCache({ onError: handleQueryCacheError }),
40
+ mutationCache: new MutationCache({ onError: handleMutationCacheError }),
41
+ defaultOptions: { mutations: { retry: false } },
42
+ });
43
+ harness = createDomHarness({ queryClient });
44
+ harness.renderApp(
45
+ createElement(
46
+ Fragment,
47
+ null,
48
+ createElement(FatalErrorOverlay),
49
+ createElement(Probe),
50
+ ),
51
+ );
52
+ return () => {
53
+ if (value === undefined) throw new Error("hook did not render");
54
+ return value;
55
+ };
56
+ };
57
+
58
+ /** See `useReportSpam.render.test.ts` — the chain from a resolved fetch to a re-render crosses enough async boundaries that a fixed microtask count reads as flaky under load. */
59
+ const waitFor = async (
60
+ predicate: () => boolean,
61
+ timeoutMs = 2000,
62
+ ): Promise<void> => {
63
+ if (!harness) throw new Error("nothing mounted");
64
+ const deadline = Date.now() + timeoutMs;
65
+ while (!predicate()) {
66
+ if (Date.now() > deadline) {
67
+ throw new Error(
68
+ `waitFor: condition never became true within ${timeoutMs}ms`,
69
+ );
70
+ }
71
+ await harness.flush();
72
+ await harness.wait(5);
73
+ }
74
+ };
75
+
76
+ const bannerAlerts = () =>
77
+ harness?.queryAll('[aria-label="Notifications"] [role="alert"]') ?? [];
78
+
79
+ const fatalOverlay = () =>
80
+ harness?.query('[data-testid="fatal-error-overlay"]') ?? null;
81
+
82
+ afterEach(() => {
83
+ harness?.close();
84
+ harness = undefined;
85
+ http.restore();
86
+ __resetFatalError();
87
+ });
88
+
89
+ describe("useReportSpam under the real global cache handlers (#648 review, the join both existing tests missed)", () => {
90
+ it("a per-message report failure banners once and never mounts the fatal overlay — report direction", async () => {
91
+ const fatalsSeen: string[] = [];
92
+ subscribeFatalError((fatal) => fatalsSeen.push(fatal.message));
93
+ http = mockFetch(() => ({ failureCount: 1 }));
94
+ const hook = mountHook(() => useReportSpam({ mailboxId: "mbx-inbox" }));
95
+
96
+ hook().reportSpam(["msg-1"]);
97
+ await waitFor(() => bannerAlerts().length > 0);
98
+
99
+ assert.equal(
100
+ fatalOverlay() !== null,
101
+ false,
102
+ "the fatal overlay must not mount",
103
+ );
104
+ assert.deepEqual(fatalsSeen, [], "no fatal error must be raised");
105
+ assert.equal(bannerAlerts().length, 1, "exactly one banner");
106
+ });
107
+
108
+ it("a per-message undo failure banners once and never mounts the fatal overlay — undo direction", async () => {
109
+ const fatalsSeen: string[] = [];
110
+ subscribeFatalError((fatal) => fatalsSeen.push(fatal.message));
111
+ http = mockFetch(() => ({ failureCount: 1 }));
112
+ const hook = mountHook(() => useReportSpam({ mailboxId: "mbx-junk" }));
113
+
114
+ hook().notSpam(["msg-1"]);
115
+ await waitFor(() => bannerAlerts().length > 0);
116
+
117
+ assert.equal(
118
+ fatalOverlay() !== null,
119
+ false,
120
+ "the fatal overlay must not mount",
121
+ );
122
+ assert.deepEqual(fatalsSeen, [], "no fatal error must be raised");
123
+ assert.equal(bannerAlerts().length, 1, "exactly one banner");
124
+ });
125
+
126
+ it("a genuine 500 from the same endpoint still escalates — meta.softError must not swallow the 5xx class", async () => {
127
+ http = mockFetch(() => httpError(500, "spam service is down"));
128
+ const hook = mountHook(() => useReportSpam({ mailboxId: "mbx-inbox" }));
129
+
130
+ hook().reportSpam(["msg-1"]);
131
+ await waitFor(() => fatalOverlay() !== null);
132
+
133
+ assert.ok(fatalOverlay(), "a 5xx must still reach the fatal overlay");
134
+ });
135
+ });
@@ -0,0 +1,40 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ ALREADY_IN_JUNK_REASON,
5
+ junkDestination,
6
+ junkWithheldReason,
7
+ NO_JUNK_FOLDER_REASON,
8
+ } from "./junk-destination";
9
+
10
+ const JUNK = "mbx-junk";
11
+ const INBOX = "mbx-inbox";
12
+
13
+ describe("junkDestination", () => {
14
+ it("is the appointed folder when the mail is somewhere else", () => {
15
+ assert.equal(junkDestination(JUNK, INBOX), JUNK);
16
+ });
17
+
18
+ it("is nothing when the account appointed no Junk folder", () => {
19
+ assert.equal(junkDestination(undefined, INBOX), undefined);
20
+ });
21
+
22
+ it("is nothing when the mail is already in the Junk folder", () => {
23
+ assert.equal(junkDestination(JUNK, JUNK), undefined);
24
+ });
25
+ });
26
+
27
+ describe("junkWithheldReason", () => {
28
+ it("is nothing to say when the verb can act", () => {
29
+ assert.equal(junkWithheldReason(JUNK, INBOX), undefined);
30
+ });
31
+
32
+ it("names the setting that appoints a folder", () => {
33
+ assert.equal(junkWithheldReason(undefined, INBOX), NO_JUNK_FOLDER_REASON);
34
+ assert.match(NO_JUNK_FOLDER_REASON, /Settings › Folders/);
35
+ });
36
+
37
+ it("says the mail is already where the verb would put it", () => {
38
+ assert.equal(junkWithheldReason(JUNK, JUNK), ALREADY_IN_JUNK_REASON);
39
+ });
40
+ });
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Where the Junk verb can file mail, and why it cannot when it cannot (#522).
3
+ * One reading, for every surface that offers the verb: the selection bar, the
4
+ * keyboard, the brief's own bar and the wizard's commit. A second derivation is
5
+ * what let the keyboard open a wizard on a verb the bar was withholding.
6
+ */
7
+
8
+ export const NO_JUNK_FOLDER_REASON =
9
+ "This account has no Junk folder appointed, so there is nowhere to file these. Appoint one under Settings › Folders.";
10
+
11
+ export const ALREADY_IN_JUNK_REASON =
12
+ "These are already in Junk, so there is nowhere to file them.";
13
+
14
+ /**
15
+ * The account's appointed Junk folder, and nothing at all when the account has
16
+ * appointed none or when that folder is the one the mail is already in.
17
+ */
18
+ export const junkDestination = (
19
+ junkMailboxId: string | undefined,
20
+ currentMailboxId: string | undefined,
21
+ ): string | undefined =>
22
+ junkMailboxId !== undefined && junkMailboxId !== currentMailboxId
23
+ ? junkMailboxId
24
+ : undefined;
25
+
26
+ /** Why the verb cannot be offered here, and nothing when it can. */
27
+ export const junkWithheldReason = (
28
+ junkMailboxId: string | undefined,
29
+ currentMailboxId: string | undefined,
30
+ ): string | undefined => {
31
+ if (junkDestination(junkMailboxId, currentMailboxId) !== undefined) {
32
+ return undefined;
33
+ }
34
+ return junkMailboxId === undefined
35
+ ? NO_JUNK_FOLDER_REASON
36
+ : ALREADY_IN_JUNK_REASON;
37
+ };
@@ -0,0 +1,124 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type { Verb } from "@remit/ui";
4
+ import {
5
+ ALREADY_IN_JUNK_REASON,
6
+ NO_JUNK_FOLDER_REASON,
7
+ } from "./junk-destination";
8
+ import { type ListVerbReading, listVerbRequest } from "./list-verb-request";
9
+
10
+ const JUNK = "mbx-junk";
11
+ const INBOX = "mbx-inbox";
12
+
13
+ const reading = (over: Partial<ListVerbReading> = {}): ListVerbReading => ({
14
+ verb: "junk",
15
+ confirmingDelete: false,
16
+ hasSelection: true,
17
+ junkMailboxId: JUNK,
18
+ currentMailboxId: INBOX,
19
+ deletableMessageId: undefined,
20
+ ...over,
21
+ });
22
+
23
+ const VERBS: Verb[] = ["delete", "move", "junk", "markRead", "organize"];
24
+
25
+ describe("listVerbRequest", () => {
26
+ it("opens the wizard for every verb over a selection", () => {
27
+ for (const verb of VERBS) {
28
+ assert.deepEqual(
29
+ listVerbRequest(reading({ verb })),
30
+ { kind: "openWizard" },
31
+ verb,
32
+ );
33
+ }
34
+ });
35
+
36
+ it("leaves a verb aimed at the bare cursor to the pane, except delete", () => {
37
+ for (const verb of VERBS.filter((each) => each !== "delete")) {
38
+ assert.deepEqual(
39
+ listVerbRequest(
40
+ reading({ verb, hasSelection: false, deletableMessageId: "m1" }),
41
+ ),
42
+ { kind: "declined" },
43
+ verb,
44
+ );
45
+ }
46
+ assert.deepEqual(
47
+ listVerbRequest(
48
+ reading({
49
+ verb: "delete",
50
+ hasSelection: false,
51
+ deletableMessageId: "m1",
52
+ }),
53
+ ),
54
+ { kind: "confirmDelete", messageId: "m1" },
55
+ );
56
+ });
57
+
58
+ it("declines a delete the surface cannot make", () => {
59
+ assert.deepEqual(
60
+ listVerbRequest(
61
+ reading({
62
+ verb: "delete",
63
+ hasSelection: false,
64
+ deletableMessageId: undefined,
65
+ }),
66
+ ),
67
+ { kind: "declined" },
68
+ );
69
+ });
70
+
71
+ it("claims a second delete rather than letting it past the confirmation", () => {
72
+ assert.deepEqual(
73
+ listVerbRequest(reading({ verb: "delete", confirmingDelete: true })),
74
+ { kind: "withheld" },
75
+ );
76
+ });
77
+
78
+ /**
79
+ * #522. The bar withholds Junk on exactly these two readings; the keyboard
80
+ * used to claim the press regardless and open the wizard on a verb whose
81
+ * commit then resolved no destination — ending on a Try again that re-sent the
82
+ * identical commit forever.
83
+ */
84
+ describe("Junk with nowhere to file into", () => {
85
+ it("is withheld over a selection when no Junk folder is appointed", () => {
86
+ assert.deepEqual(listVerbRequest(reading({ junkMailboxId: undefined })), {
87
+ kind: "unavailable",
88
+ reason: NO_JUNK_FOLDER_REASON,
89
+ });
90
+ });
91
+
92
+ it("is withheld over a selection inside the Junk folder itself", () => {
93
+ assert.deepEqual(listVerbRequest(reading({ currentMailboxId: JUNK })), {
94
+ kind: "unavailable",
95
+ reason: ALREADY_IN_JUNK_REASON,
96
+ });
97
+ });
98
+
99
+ it("is withheld over a bare cursor too, rather than falling to the pane", () => {
100
+ // Declining here hands the press to the pane, which moves the focused row
101
+ // into the folder it is already in and reports it as done.
102
+ assert.deepEqual(
103
+ listVerbRequest(
104
+ reading({
105
+ hasSelection: false,
106
+ currentMailboxId: JUNK,
107
+ deletableMessageId: "m1",
108
+ }),
109
+ ),
110
+ { kind: "unavailable", reason: ALREADY_IN_JUNK_REASON },
111
+ );
112
+ });
113
+
114
+ it("withholds nothing else on the same reading", () => {
115
+ for (const verb of VERBS.filter((each) => each !== "junk")) {
116
+ assert.deepEqual(
117
+ listVerbRequest(reading({ verb, junkMailboxId: undefined })),
118
+ { kind: "openWizard" },
119
+ verb,
120
+ );
121
+ }
122
+ });
123
+ });
124
+ });
@@ -0,0 +1,62 @@
1
+ /**
2
+ * What a message list does with a verb the keyboard aimed at it (#477 1.4,
3
+ * #508, #522). Pure, so the routing is testable without the DOM, the router and
4
+ * the data hooks a list wires together.
5
+ */
6
+
7
+ import type { Verb } from "@remit/ui";
8
+ import { junkWithheldReason } from "./junk-destination";
9
+
10
+ /**
11
+ * - `openWizard` — the selection walks the wizard, which is where a bulk action
12
+ * is reviewed before it reaches the mail server.
13
+ * - `confirmDelete` — the one verb a bare cursor keeps, with its confirmation.
14
+ * - `withheld` — the press belongs to what is already on screen: the delete
15
+ * confirmation is asking, and answering it is the Confirm button's job.
16
+ * - `unavailable` — the verb cannot act on this mail from here, and this is why.
17
+ * The list takes the press and says so, rather than leaving a shortcut that
18
+ * silently does nothing.
19
+ * - `declined` — not the list's press, so the pane acts on the focused row.
20
+ */
21
+ export type ListVerbRequest =
22
+ | { kind: "openWizard" }
23
+ | { kind: "confirmDelete"; messageId: string }
24
+ | { kind: "withheld" }
25
+ | { kind: "unavailable"; reason: string }
26
+ | { kind: "declined" };
27
+
28
+ export interface ListVerbReading {
29
+ verb: Verb;
30
+ /** The delete confirmation is on screen. */
31
+ confirmingDelete: boolean;
32
+ hasSelection: boolean;
33
+ /** The account's appointed Junk folder, when it has appointed one. */
34
+ junkMailboxId: string | undefined;
35
+ /** The mailbox the rows are in, which the Junk destination cannot also be. */
36
+ currentMailboxId: string | undefined;
37
+ /** The row under the cursor, when this surface can delete it. */
38
+ deletableMessageId: string | undefined;
39
+ }
40
+
41
+ export const listVerbRequest = ({
42
+ verb,
43
+ confirmingDelete,
44
+ hasSelection,
45
+ junkMailboxId,
46
+ currentMailboxId,
47
+ deletableMessageId,
48
+ }: ListVerbReading): ListVerbRequest => {
49
+ if (confirmingDelete) return { kind: "withheld" };
50
+ // Junk with nowhere to file into is kept off the keyboard exactly as the bar
51
+ // keeps it off the screen. Opening the wizard on it instead ends on a commit
52
+ // that resolves no destination, whatever is ticked (#522).
53
+ if (verb === "junk") {
54
+ const reason = junkWithheldReason(junkMailboxId, currentMailboxId);
55
+ if (reason !== undefined) return { kind: "unavailable", reason };
56
+ }
57
+ if (hasSelection) return { kind: "openWizard" };
58
+ if (verb !== "delete" || deletableMessageId === undefined) {
59
+ return { kind: "declined" };
60
+ }
61
+ return { kind: "confirmDelete", messageId: deletableMessageId };
62
+ };
@@ -46,6 +46,14 @@ export interface DomOptions {
46
46
  /** Screen posture `matchMedia` answers against — jsdom has no device. */
47
47
  orientation?: "portrait" | "landscape";
48
48
  pointer?: "coarse" | "fine";
49
+ /**
50
+ * Use this `QueryClient` instead of the harness's default retry-disabled
51
+ * one — e.g. one wired with the real `QueryCache`/`MutationCache` error
52
+ * handlers from `lib/query-error-handler.ts`, the way `shell/index.tsx`
53
+ * builds it, so a test can exercise the real global escalation path
54
+ * instead of just the per-mutation `onError`.
55
+ */
56
+ queryClient?: QueryClient;
49
57
  }
50
58
 
51
59
  export const createDomHarness = (options: DomOptions = {}): DomHarness => {
@@ -64,12 +72,14 @@ export const createDomHarness = (options: DomOptions = {}): DomHarness => {
64
72
  let root: Root | undefined = createRoot(container);
65
73
  // No retries: a test asserting a failure should not have to wait out a
66
74
  // backoff before it can see one.
67
- const queryClient = new QueryClient({
68
- defaultOptions: {
69
- queries: { retry: false },
70
- mutations: { retry: false },
71
- },
72
- });
75
+ const queryClient =
76
+ options.queryClient ??
77
+ new QueryClient({
78
+ defaultOptions: {
79
+ queries: { retry: false },
80
+ mutations: { retry: false },
81
+ },
82
+ });
73
83
 
74
84
  const requireRoot = (): Root => {
75
85
  if (!root) throw new Error("harness already unmounted");
@@ -1,93 +0,0 @@
1
- /**
2
- * Integration: prove a per-message report-spam/not-spam failure resolves to a
3
- * banner, never the full-screen fatal overlay, through the REAL global
4
- * `MutationCache` wiring (`lib/query-error-handler.ts`, wired on the
5
- * `QueryClient` in `shell/index.tsx`) — not just `ErrorBannerProvider`'s own
6
- * `isAlwaysFatal` check.
7
- *
8
- * That distinction is the whole point of this file. `ErrorBannerProvider.
9
- * pushError` refuses to banner an always-fatal error, but every mutation
10
- * ALSO reports to the MutationCache's global `onError`, independent of
11
- * whatever the per-mutation `onError` did — and `shouldEscalate` (what the
12
- * global handler calls) escalates by DEFAULT for any non-5xx that didn't opt
13
- * out via `meta.softError`. A test that only checked `isAlwaysFatal` passed
14
- * while the real app crashed to the fatal overlay on the same error: this is
15
- * exactly `query-escalation.integration.test.ts`'s pattern, applied to
16
- * `useReportSpam`'s own mutation shape (mutationFn + meta), because that is
17
- * the seam the earlier fix went through unexercised.
18
- */
19
-
20
- import assert from "node:assert/strict";
21
- import { afterEach, describe, it } from "node:test";
22
- import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
23
- import { __resetFatalError, subscribeFatalError } from "../lib/fatal-error";
24
- import {
25
- handleMutationCacheError,
26
- handleQueryCacheError,
27
- } from "../lib/query-error-handler";
28
- import { throwOnBulkFailure } from "./useReportSpam.js";
29
-
30
- afterEach(() => {
31
- __resetFatalError();
32
- });
33
-
34
- const makeClient = () =>
35
- new QueryClient({
36
- queryCache: new QueryCache({ onError: handleQueryCacheError }),
37
- mutationCache: new MutationCache({ onError: handleMutationCacheError }),
38
- defaultOptions: { mutations: { retry: false } },
39
- });
40
-
41
- /** The exact shape a failed report-spam/not-spam call resolves to on the wire (200, not a rejection) — see `throwOnBulkFailure`. */
42
- const failedBulkResult = () => ({
43
- successCount: 0,
44
- failureCount: 1,
45
- failures: [
46
- {
47
- messageId: "9m2k7x4vqz1jd0tn3wf8b6y5c",
48
- reason:
49
- "Message 9m2k7x4vqz1jd0tn3wf8b6y5c's move to Junk has not settled yet; try again in a moment.",
50
- },
51
- ],
52
- });
53
-
54
- describe("useReportSpam's mutations under the real MutationCache (#648 review)", () => {
55
- it("with meta.softError, a per-message failure does NOT escalate to the fatal overlay (regression)", async () => {
56
- const seen: string[] = [];
57
- subscribeFatalError((fatal) => seen.push(fatal.message));
58
- const client = makeClient();
59
-
60
- const mutation = client.getMutationCache().build(client, {
61
- mutationFn: async () => {
62
- throwOnBulkFailure(failedBulkResult());
63
- },
64
- meta: { softError: true },
65
- });
66
- await mutation.execute(undefined).catch(() => {});
67
-
68
- assert.deepEqual(
69
- seen,
70
- [],
71
- "a designed, retryable per-message failure must not reach the fatal overlay",
72
- );
73
- });
74
-
75
- it("without meta.softError, the same failure DOES escalate — proving the opt-out is load-bearing, not a no-op", async () => {
76
- const seen: string[] = [];
77
- subscribeFatalError((fatal) => seen.push(fatal.message));
78
- const client = makeClient();
79
-
80
- const mutation = client.getMutationCache().build(client, {
81
- mutationFn: async () => {
82
- throwOnBulkFailure(failedBulkResult());
83
- },
84
- });
85
- await mutation.execute(undefined).catch(() => {});
86
-
87
- assert.equal(
88
- seen.length,
89
- 1,
90
- "this asserts the failure mode the fix removes — a non-5xx with no softError opt-out escalates by default",
91
- );
92
- });
93
- });