@remit/web-client 0.0.132 → 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.
|
|
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": {
|
|
@@ -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
|
+
});
|
|
@@ -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
|
+
});
|
package/src/test-support/dom.ts
CHANGED
|
@@ -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 =
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
});
|