@remit/web-client 0.0.128 → 0.0.130
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/components/mail/AutoMovedIndicator.tsx +14 -8
- package/src/components/mail/BriefPane.tsx +14 -1
- package/src/components/mail/FlaggedPane.tsx +14 -1
- package/src/components/mail/IntelligencePane.test.ts +13 -84
- package/src/components/mail/IntelligencePane.tsx +45 -96
- package/src/components/mail/MailboxPane.tsx +22 -2
- package/src/components/mail/MessageCard.tsx +11 -9
- package/src/hooks/useAutoMovedBadge.ts +57 -20
- package/src/hooks/useIntelligenceData.test.ts +13 -3
- package/src/hooks/useIntelligenceData.ts +24 -9
- package/src/hooks/useReportSpam.integration.test.ts +93 -0
- package/src/hooks/useReportSpam.render.test.ts +113 -0
- package/src/hooks/useReportSpam.test.ts +115 -0
- package/src/hooks/useReportSpam.ts +244 -0
- package/src/lib/auto-moved.test.ts +8 -0
- package/src/lib/auto-moved.ts +9 -0
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
RemitImapAutoMovedInfo,
|
|
4
|
+
RemitImapMessageSpamReport,
|
|
5
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
3
6
|
import { useQuery } from "@tanstack/react-query";
|
|
4
7
|
import { useCallback } from "react";
|
|
5
8
|
import {
|
|
6
9
|
autoMovedLabel,
|
|
7
10
|
isAutoMoveInEffect,
|
|
8
11
|
resolveUndoTargetMailboxId,
|
|
12
|
+
spamReportLabel,
|
|
9
13
|
} from "@/lib/auto-moved";
|
|
10
14
|
import { getMailboxDisplayName } from "@/lib/folder-roles";
|
|
11
15
|
import { useInboxMailbox, useJunkMailbox } from "./useArchiveMailbox";
|
|
12
16
|
import { useMoveMessages } from "./useMoveMessages";
|
|
17
|
+
import { useReportSpam } from "./useReportSpam";
|
|
13
18
|
|
|
14
19
|
interface UseAutoMovedBadgeOptions {
|
|
15
20
|
accountId: string | undefined;
|
|
@@ -18,6 +23,14 @@ interface UseAutoMovedBadgeOptions {
|
|
|
18
23
|
/** The message's current mailbox — the row/card it's rendered in. */
|
|
19
24
|
mailboxId: string;
|
|
20
25
|
autoMoved: RemitImapAutoMovedInfo | undefined;
|
|
26
|
+
/**
|
|
27
|
+
* Present when the user reported this message as spam and the report has
|
|
28
|
+
* not been undone (issue #648). Takes precedence over `autoMoved`: a
|
|
29
|
+
* report can be a no-op move (the provider's own filter already placed the
|
|
30
|
+
* message in Junk), so it carries its own badge independent of the
|
|
31
|
+
* message's current folder, unlike a classifier/filter move.
|
|
32
|
+
*/
|
|
33
|
+
spamReport: RemitImapMessageSpamReport | undefined;
|
|
21
34
|
}
|
|
22
35
|
|
|
23
36
|
export interface AutoMovedBadgeState {
|
|
@@ -36,22 +49,27 @@ export interface AutoMovedBadgeState {
|
|
|
36
49
|
}
|
|
37
50
|
|
|
38
51
|
/**
|
|
39
|
-
* Composes the account's mailboxes with the message's `autoMoved`
|
|
40
|
-
* into everything the `AutoMovedBadge` kit component
|
|
41
|
-
* in effect" gate, the plain-language label, and a
|
|
42
|
-
*
|
|
43
|
-
* same bulk move operation, the other direction).
|
|
52
|
+
* Composes the account's mailboxes with the message's `autoMoved` and
|
|
53
|
+
* `spamReport` projections into everything the `AutoMovedBadge` kit component
|
|
54
|
+
* needs: the derived "still in effect" gate, the plain-language label, and a
|
|
55
|
+
* one-click undo.
|
|
44
56
|
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
57
|
+
* Three provenances are handled, `spamReport` taking priority when present:
|
|
58
|
+
* - A spam report (issue #648) undoes via `POST /messages/not-spam`, which
|
|
59
|
+
* resolves its own restore target server-side — the client never needs an
|
|
60
|
+
* Inbox/Junk lookup for it, and the badge shows regardless of the message's
|
|
61
|
+
* current folder (a report can be a no-op move).
|
|
62
|
+
* - A classifier move resolves its Inbox/Junk role mailboxes and undoes via a
|
|
63
|
+
* plain move back to the source role.
|
|
64
|
+
* - A standing-filter move names an arbitrary source folder, whose display
|
|
65
|
+
* name is resolved from the account's mailbox list, and carries a
|
|
66
|
+
* Settings › Filters link so the filter that keeps moving mail is one tap
|
|
67
|
+
* away — undo does not disable it.
|
|
50
68
|
*
|
|
51
|
-
* `show` re-derives on every render from `mailboxId`
|
|
52
|
-
* flag. Once
|
|
53
|
-
* thread row with its updated
|
|
54
|
-
* showing (doc/rules/data-flow.md).
|
|
69
|
+
* `show` re-derives on every render from `mailboxId` (or from `spamReport`'s
|
|
70
|
+
* presence) — no local dismissed flag. Once the undo mutation settles, its
|
|
71
|
+
* query invalidation refetches the thread row with its updated state, and the
|
|
72
|
+
* badge naturally stops showing (doc/rules/data-flow.md).
|
|
55
73
|
*/
|
|
56
74
|
export const useAutoMovedBadge = ({
|
|
57
75
|
accountId,
|
|
@@ -59,10 +77,16 @@ export const useAutoMovedBadge = ({
|
|
|
59
77
|
threadId,
|
|
60
78
|
mailboxId,
|
|
61
79
|
autoMoved,
|
|
80
|
+
spamReport,
|
|
62
81
|
}: UseAutoMovedBadgeOptions): AutoMovedBadgeState => {
|
|
63
82
|
const { inboxMailboxId } = useInboxMailbox(accountId);
|
|
64
83
|
const { junkMailboxId } = useJunkMailbox(accountId);
|
|
65
|
-
const { moveMessages, isPending } = useMoveMessages({
|
|
84
|
+
const { moveMessages, isPending: isMoveUndoing } = useMoveMessages({
|
|
85
|
+
mailboxId,
|
|
86
|
+
threadId,
|
|
87
|
+
accountId,
|
|
88
|
+
});
|
|
89
|
+
const { notSpam, isRestoring: isSpamUndoing } = useReportSpam({
|
|
66
90
|
mailboxId,
|
|
67
91
|
threadId,
|
|
68
92
|
accountId,
|
|
@@ -77,17 +101,30 @@ export const useAutoMovedBadge = ({
|
|
|
77
101
|
});
|
|
78
102
|
|
|
79
103
|
const roleMailboxes = { inboxMailboxId, junkMailboxId };
|
|
80
|
-
const show = isAutoMoveInEffect(autoMoved, mailboxId, roleMailboxes);
|
|
81
104
|
const undoTargetMailboxId = resolveUndoTargetMailboxId(
|
|
82
105
|
autoMoved,
|
|
83
106
|
roleMailboxes,
|
|
84
107
|
);
|
|
85
108
|
|
|
86
|
-
const
|
|
109
|
+
const handleUndoMove = useCallback(() => {
|
|
87
110
|
if (!undoTargetMailboxId) return;
|
|
88
111
|
moveMessages([messageId], undoTargetMailboxId);
|
|
89
112
|
}, [moveMessages, messageId, undoTargetMailboxId]);
|
|
90
113
|
|
|
114
|
+
const handleUndoReport = useCallback(() => {
|
|
115
|
+
notSpam([messageId]);
|
|
116
|
+
}, [notSpam, messageId]);
|
|
117
|
+
|
|
118
|
+
if (spamReport) {
|
|
119
|
+
return {
|
|
120
|
+
show: true,
|
|
121
|
+
label: spamReportLabel,
|
|
122
|
+
onUndo: handleUndoReport,
|
|
123
|
+
isUndoing: isSpamUndoing,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const show = isAutoMoveInEffect(autoMoved, mailboxId, roleMailboxes);
|
|
91
128
|
if (!show || !autoMoved) {
|
|
92
129
|
return { show: false, label: "", isUndoing: false };
|
|
93
130
|
}
|
|
@@ -101,8 +138,8 @@ export const useAutoMovedBadge = ({
|
|
|
101
138
|
return {
|
|
102
139
|
show: true,
|
|
103
140
|
label: autoMovedLabel(autoMoved, sourceFolderName),
|
|
104
|
-
onUndo: undoTargetMailboxId ?
|
|
105
|
-
isUndoing:
|
|
141
|
+
onUndo: undoTargetMailboxId ? handleUndoMove : undefined,
|
|
142
|
+
isUndoing: isMoveUndoing,
|
|
106
143
|
...(autoMoved.filterId ? { filtersHref: "/settings/filters" } : {}),
|
|
107
144
|
};
|
|
108
145
|
};
|
|
@@ -246,7 +246,11 @@ describe("buildAuthenticityIntel", () => {
|
|
|
246
246
|
describe("a passing signature over a claim that does not hold", () => {
|
|
247
247
|
// The InfoMedics invoice phish: an attacker's own free Atlassian tenant,
|
|
248
248
|
// so SPF/DKIM/DMARC genuinely pass for a domain nobody recognises, and the
|
|
249
|
-
// provider's own filter already called it spam.
|
|
249
|
+
// provider's own filter already called it spam. dkimDomain deliberately
|
|
250
|
+
// differs from fromDomain here (the delivery host's re-signature,
|
|
251
|
+
// custmx.one.com, is a different party than the sender's own
|
|
252
|
+
// serviceupdatebank.atlassian.net) — the display-name check was run
|
|
253
|
+
// against fromDomain, never dkimDomain, so the copy must name fromDomain.
|
|
250
254
|
const infoMedics = makeThread({
|
|
251
255
|
fromEmail: "jira@serviceupdatebank.atlassian.net",
|
|
252
256
|
fromName: "InfoMedics",
|
|
@@ -266,11 +270,17 @@ describe("buildAuthenticityIntel", () => {
|
|
|
266
270
|
assert.doesNotMatch(result.summary, /We verified/i);
|
|
267
271
|
});
|
|
268
272
|
|
|
269
|
-
|
|
273
|
+
// The copy must name the domain the comparison was actually run
|
|
274
|
+
// against (fromDomain), never the unrelated dkimDomain — a message
|
|
275
|
+
// signed by a relay or ESP infrastructure domain must not read as
|
|
276
|
+
// "the name looks nothing like <that other party>".
|
|
277
|
+
test("leads with the concern, naming the domain the name was actually compared to, and the link destination", () => {
|
|
270
278
|
const result = buildAuthenticityIntel(infoMedics, 0);
|
|
271
279
|
assert.equal(result.verdict, "caution");
|
|
272
|
-
assert.match(result.summary,
|
|
280
|
+
assert.match(result.summary, /^The name it shows/);
|
|
273
281
|
assert.match(result.summary, /"InfoMedics"/);
|
|
282
|
+
assert.match(result.summary, /serviceupdatebank\.atlassian\.net/);
|
|
283
|
+
assert.doesNotMatch(result.summary, /custmx\.one\.com/);
|
|
274
284
|
assert.match(result.summary, /betaal-vordering\.example/);
|
|
275
285
|
assert.doesNotMatch(result.summary, /DKIM|SPF|DMARC/i);
|
|
276
286
|
});
|
|
@@ -117,6 +117,16 @@ function joinDomains(domains: readonly string[]): string {
|
|
|
117
117
|
* out. Empty when everything the backend compared agreed — including when it
|
|
118
118
|
* compared nothing, which is every message the provider's filter did not
|
|
119
119
|
* already call spam.
|
|
120
|
+
*
|
|
121
|
+
* Each clause leads with the concern and names `auth.fromDomain` — the
|
|
122
|
+
* domain `classifyDisplayNameCorrespondence` actually compared the display
|
|
123
|
+
* name against (`senderMismatch.ts` calls it with the From address's own
|
|
124
|
+
* domain, never the DKIM signing domain). Naming `auth.dkimDomain` instead
|
|
125
|
+
* would assert a comparison that was never made: on a message signed by a
|
|
126
|
+
* relay or ESP infrastructure domain, the display name was checked against
|
|
127
|
+
* the sender's own address, not that domain. These clauses are the caution
|
|
128
|
+
* tier's entire summary: there is no separate "verified" sentence in front
|
|
129
|
+
* of them for the signing fact to hide behind.
|
|
120
130
|
*/
|
|
121
131
|
function describeSenderMismatch(
|
|
122
132
|
auth: NonNullable<RemitImapThreadMessageResponse["authenticity"]>,
|
|
@@ -128,11 +138,11 @@ function describeSenderMismatch(
|
|
|
128
138
|
if (claimedBrand) {
|
|
129
139
|
if (correspondence === DisplayNameCorrespondence.Unrelated) {
|
|
130
140
|
clauses.push(
|
|
131
|
-
`The name it shows, "${claimedBrand}", has nothing to do with
|
|
141
|
+
`The name it shows, "${claimedBrand}", has nothing to do with ${auth.fromDomain}.`,
|
|
132
142
|
);
|
|
133
143
|
} else if (correspondence === DisplayNameCorrespondence.Lookalike) {
|
|
134
144
|
clauses.push(
|
|
135
|
-
`The name it shows, "${claimedBrand}", only looks like
|
|
145
|
+
`The name it shows, "${claimedBrand}", only looks like ${auth.fromDomain}.`,
|
|
136
146
|
);
|
|
137
147
|
}
|
|
138
148
|
}
|
|
@@ -198,10 +208,7 @@ export function buildAuthenticityIntel(
|
|
|
198
208
|
fromDomain: auth.fromDomain,
|
|
199
209
|
dkimDomain: auth.dkimDomain,
|
|
200
210
|
claimedBrand: claimed,
|
|
201
|
-
summary:
|
|
202
|
-
`This message really was sent by ${auth.fromDomain}.`,
|
|
203
|
-
...unlike,
|
|
204
|
-
].join(" "),
|
|
211
|
+
summary: unlike.join(" "),
|
|
205
212
|
};
|
|
206
213
|
}
|
|
207
214
|
return {
|
|
@@ -209,7 +216,7 @@ export function buildAuthenticityIntel(
|
|
|
209
216
|
fromDomain: auth.fromDomain,
|
|
210
217
|
dkimDomain: auth.dkimDomain,
|
|
211
218
|
summary: auth.dkimDomain
|
|
212
|
-
? `
|
|
219
|
+
? `This message was signed by ${auth.dkimDomain}.`
|
|
213
220
|
: `Nothing looks unusual about this sender.`,
|
|
214
221
|
};
|
|
215
222
|
}
|
|
@@ -217,9 +224,17 @@ export function buildAuthenticityIntel(
|
|
|
217
224
|
const fromDomain = auth.fromDomain;
|
|
218
225
|
const dkimDomain = auth.dkimDomain;
|
|
219
226
|
const claimedBrand = claimedBrandOf(thread);
|
|
227
|
+
// dkimDomain is known whenever a mismatch was named against a real
|
|
228
|
+
// domain; the fallback covers the (defensive, not currently reachable)
|
|
229
|
+
// case where a mismatch fires with none — say what actually happened
|
|
230
|
+
// (a signature failed to verify) rather than inventing a sender identity
|
|
231
|
+
// we do not have.
|
|
232
|
+
const whatHappened = dkimDomain
|
|
233
|
+
? `it was actually sent from ${dkimDomain}`
|
|
234
|
+
: "its signature failed to verify";
|
|
220
235
|
const summary = claimedBrand
|
|
221
|
-
? `The display name claims "${claimedBrand}", but
|
|
222
|
-
: `This message claims to be from ${fromDomain}, but
|
|
236
|
+
? `The display name claims "${claimedBrand}", but ${whatHappened}${dkimDomain ? ` — not ${fromDomain}` : ""}. Real senders use their own address.`
|
|
237
|
+
: `This message claims to be from ${fromDomain}, but ${whatHappened}.`;
|
|
223
238
|
return {
|
|
224
239
|
verdict: "mismatch",
|
|
225
240
|
fromDomain,
|
|
@@ -0,0 +1,93 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mounts the real hook against the real fetch seam to prove the pending state
|
|
3
|
+
* a press needs actually toggles (issue #648 review): with no optimistic
|
|
4
|
+
* cache patch, a press that never flips `isReporting`/`isRestoring` would be
|
|
5
|
+
* a genuinely dead control — nothing visible changes until the request lands.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { afterEach, describe, it } from "node:test";
|
|
10
|
+
import { createElement } from "react";
|
|
11
|
+
import { createDomHarness, type DomHarness } from "../test-support/dom";
|
|
12
|
+
import { type HttpMock, mockFetch } from "../test-support/http";
|
|
13
|
+
import { useReportSpam } from "./useReportSpam";
|
|
14
|
+
|
|
15
|
+
let harness: DomHarness | undefined;
|
|
16
|
+
let http: HttpMock;
|
|
17
|
+
|
|
18
|
+
const mountHook = <T>(useHook: () => T): (() => T) => {
|
|
19
|
+
let value: T | undefined;
|
|
20
|
+
const Probe = () => {
|
|
21
|
+
value = useHook();
|
|
22
|
+
return null;
|
|
23
|
+
};
|
|
24
|
+
harness = createDomHarness();
|
|
25
|
+
harness.renderApp(createElement(Probe));
|
|
26
|
+
return () => {
|
|
27
|
+
if (value === undefined) throw new Error("hook did not render");
|
|
28
|
+
return value;
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
harness?.close();
|
|
34
|
+
harness = undefined;
|
|
35
|
+
http.restore();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Polls `predicate` until it's true, yielding real event-loop turns between
|
|
40
|
+
* attempts rather than spinning a fixed count of microtask flushes — the
|
|
41
|
+
* chain from a resolved fetch to a re-render crosses enough async boundaries
|
|
42
|
+
* (response parsing, `throwOnBulkFailure`, `onSuccess`'s invalidation, React's
|
|
43
|
+
* commit) that a fixed count reads as flaky under load instead of wrong.
|
|
44
|
+
*/
|
|
45
|
+
const waitFor = async (
|
|
46
|
+
predicate: () => boolean,
|
|
47
|
+
timeoutMs = 2000,
|
|
48
|
+
): Promise<void> => {
|
|
49
|
+
if (!harness) throw new Error("nothing mounted");
|
|
50
|
+
const deadline = Date.now() + timeoutMs;
|
|
51
|
+
while (!predicate()) {
|
|
52
|
+
if (Date.now() > deadline) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`waitFor: condition never became true within ${timeoutMs}ms`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
await harness.flush();
|
|
58
|
+
await harness.wait(5);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
describe("useReportSpam pending state (#648 review)", () => {
|
|
63
|
+
it("isReporting goes true for the duration of an in-flight report, then false", async () => {
|
|
64
|
+
let resolveRequest: (() => void) | undefined;
|
|
65
|
+
http = mockFetch(
|
|
66
|
+
() =>
|
|
67
|
+
new Promise((resolve) => {
|
|
68
|
+
resolveRequest = () => resolve({ successCount: 1, failureCount: 0 });
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
const hook = mountHook(() => useReportSpam({ mailboxId: "mbx-inbox" }));
|
|
72
|
+
|
|
73
|
+
assert.equal(hook().isReporting, false);
|
|
74
|
+
|
|
75
|
+
hook().reportSpam(["msg-1"]);
|
|
76
|
+
await waitFor(() => hook().isReporting === true);
|
|
77
|
+
|
|
78
|
+
assert.ok(resolveRequest, "the request never reached the mock");
|
|
79
|
+
resolveRequest?.();
|
|
80
|
+
await waitFor(() => hook().isReporting === false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("isRestoring goes true for the duration of an in-flight undo, then false", async () => {
|
|
84
|
+
let resolveRequest: (() => void) | undefined;
|
|
85
|
+
http = mockFetch(
|
|
86
|
+
() =>
|
|
87
|
+
new Promise((resolve) => {
|
|
88
|
+
resolveRequest = () => resolve({ successCount: 1, failureCount: 0 });
|
|
89
|
+
}),
|
|
90
|
+
);
|
|
91
|
+
const hook = mountHook(() => useReportSpam({ mailboxId: "mbx-junk" }));
|
|
92
|
+
|
|
93
|
+
assert.equal(hook().isRestoring, false);
|
|
94
|
+
|
|
95
|
+
hook().notSpam(["msg-1"]);
|
|
96
|
+
await waitFor(() => hook().isRestoring === true);
|
|
97
|
+
|
|
98
|
+
resolveRequest?.();
|
|
99
|
+
await waitFor(() => hook().isRestoring === false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("sends the request to the report-spam endpoint with the pressed message id", async () => {
|
|
103
|
+
http = mockFetch(() => ({ successCount: 1, failureCount: 0 }));
|
|
104
|
+
const hook = mountHook(() => useReportSpam({ mailboxId: "mbx-inbox" }));
|
|
105
|
+
|
|
106
|
+
hook().reportSpam(["msg-1"]);
|
|
107
|
+
await waitFor(() => http.to("/messages/report-spam").length > 0);
|
|
108
|
+
|
|
109
|
+
const calls = http.to("/messages/report-spam");
|
|
110
|
+
assert.equal(calls.length, 1);
|
|
111
|
+
assert.deepEqual(calls[0].body, { messageIds: ["msg-1"] });
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
GENERIC_SPAM_ACTION_FAILURE,
|
|
5
|
+
humanizeSpamFailureReason,
|
|
6
|
+
throwOnBulkFailure,
|
|
7
|
+
} from "./useReportSpam.js";
|
|
8
|
+
|
|
9
|
+
describe("throwOnBulkFailure (#648)", () => {
|
|
10
|
+
test("does not throw when the whole batch succeeded", () => {
|
|
11
|
+
assert.doesNotThrow(() =>
|
|
12
|
+
throwOnBulkFailure({ successCount: 1, failureCount: 0 }),
|
|
13
|
+
);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("throws the server's own reason for a designed failure", () => {
|
|
17
|
+
// Both bulk endpoints answer 200 even when every message failed —
|
|
18
|
+
// settleSpamReportBulk never rejects the HTTP call — so a caller that
|
|
19
|
+
// only checks for a thrown/rejected request would read this as success.
|
|
20
|
+
assert.throws(
|
|
21
|
+
() =>
|
|
22
|
+
throwOnBulkFailure({
|
|
23
|
+
successCount: 0,
|
|
24
|
+
failureCount: 1,
|
|
25
|
+
failures: [
|
|
26
|
+
{
|
|
27
|
+
messageId: "m1",
|
|
28
|
+
reason:
|
|
29
|
+
"Message m1's move to Junk has not settled yet; try again in a moment.",
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
}),
|
|
33
|
+
/has not settled yet/,
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("falls back to a generic message when a failure carries no reason", () => {
|
|
38
|
+
assert.throws(
|
|
39
|
+
() =>
|
|
40
|
+
throwOnBulkFailure({
|
|
41
|
+
successCount: 0,
|
|
42
|
+
failureCount: 1,
|
|
43
|
+
failures: [],
|
|
44
|
+
}),
|
|
45
|
+
new RegExp(GENERIC_SPAM_ACTION_FAILURE.replace(/[.]/g, "\\.")),
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("throws on a partial batch, not just a total failure", () => {
|
|
50
|
+
// This hook is always called with exactly one message today, but the
|
|
51
|
+
// check itself must not treat "some succeeded" as "nothing to report".
|
|
52
|
+
assert.throws(
|
|
53
|
+
() =>
|
|
54
|
+
throwOnBulkFailure({
|
|
55
|
+
successCount: 2,
|
|
56
|
+
failureCount: 1,
|
|
57
|
+
failures: [{ messageId: "m3", reason: "boom" }],
|
|
58
|
+
}),
|
|
59
|
+
/boom/,
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("strips the real messageId shape out of the designed reason", () => {
|
|
64
|
+
// Message ids are 25-char base36 (`translator.generate()` in
|
|
65
|
+
// packages/data-ports/src/id.ts), never a dashed UUID — a fixture
|
|
66
|
+
// shaped like one proves nothing about what the user actually sees.
|
|
67
|
+
const realId = "9m2k7x4vqz1jd0tn3wf8b6y5c";
|
|
68
|
+
assert.throws(
|
|
69
|
+
() =>
|
|
70
|
+
throwOnBulkFailure({
|
|
71
|
+
successCount: 0,
|
|
72
|
+
failureCount: 1,
|
|
73
|
+
failures: [
|
|
74
|
+
{
|
|
75
|
+
messageId: realId,
|
|
76
|
+
reason: `Message ${realId}'s move to Junk has not settled yet; try again in a moment.`,
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
}),
|
|
80
|
+
(error: unknown) => {
|
|
81
|
+
assert.ok(error instanceof Error);
|
|
82
|
+
assert.equal(
|
|
83
|
+
error.message,
|
|
84
|
+
"This message's move to Junk has not settled yet; try again in a moment.",
|
|
85
|
+
);
|
|
86
|
+
assert.ok(!error.message.includes(realId), "the raw id must not leak");
|
|
87
|
+
return true;
|
|
88
|
+
},
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("humanizeSpamFailureReason (#648)", () => {
|
|
94
|
+
test("replaces the real-shaped messageId possessive with plain language", () => {
|
|
95
|
+
// Base36, not a dashed UUID — see the note above.
|
|
96
|
+
const realId = "9m2k7x4vqz1jd0tn3wf8b6y5c";
|
|
97
|
+
assert.equal(
|
|
98
|
+
humanizeSpamFailureReason(
|
|
99
|
+
`Message ${realId}'s move to Junk has not settled yet; try again in a moment.`,
|
|
100
|
+
realId,
|
|
101
|
+
),
|
|
102
|
+
"This message's move to Junk has not settled yet; try again in a moment.",
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("leaves a reason with no embedded messageId unchanged", () => {
|
|
107
|
+
assert.equal(
|
|
108
|
+
humanizeSpamFailureReason(
|
|
109
|
+
GENERIC_SPAM_ACTION_FAILURE,
|
|
110
|
+
"9m2k7x4vqz1jd0tn3wf8b6y5c",
|
|
111
|
+
),
|
|
112
|
+
GENERIC_SPAM_ACTION_FAILURE,
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
});
|