@remit/web-client 0.0.52 → 0.0.54

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.
@@ -0,0 +1,82 @@
1
+ /**
2
+ * ErrorState is the shared "this didn't load" surface. Two things matter about
3
+ * it: the message it shows comes from whatever the caller caught (which is
4
+ * rarely an `Error`), and Retry only exists when there is something to retry.
5
+ */
6
+
7
+ import assert from "node:assert/strict";
8
+ import { describe, it } from "node:test";
9
+ import React, { createElement } from "react";
10
+ import { renderToString } from "react-dom/server";
11
+ import { ErrorState, formatErrorMessage } from "./ErrorState";
12
+
13
+ (globalThis as { React?: typeof React }).React = React;
14
+
15
+ const render = (props: Parameters<typeof ErrorState>[0]): string =>
16
+ renderToString(createElement(ErrorState, props) as never);
17
+
18
+ describe("formatErrorMessage", () => {
19
+ it("uses an Error's message", () => {
20
+ assert.equal(
21
+ formatErrorMessage(new Error("imap timed out")),
22
+ "imap timed out",
23
+ );
24
+ });
25
+
26
+ it("passes a bare string through", () => {
27
+ assert.equal(formatErrorMessage("nope"), "nope");
28
+ });
29
+
30
+ it("reads `message` off a plain object — what a rejected fetch body looks like", () => {
31
+ assert.equal(formatErrorMessage({ message: "Bad Gateway" }), "Bad Gateway");
32
+ });
33
+
34
+ it("falls back for a shape it cannot read", () => {
35
+ assert.equal(
36
+ formatErrorMessage({ status: 500 }),
37
+ "An unexpected error occurred",
38
+ );
39
+ assert.equal(formatErrorMessage(null), "An unexpected error occurred");
40
+ assert.equal(
41
+ formatErrorMessage({ message: 42 }),
42
+ "An unexpected error occurred",
43
+ );
44
+ });
45
+ });
46
+
47
+ describe("ErrorState", () => {
48
+ it("announces itself as an alert and shows the caught message", () => {
49
+ const html = render({ error: new Error("mailbox unreachable") });
50
+ assert.match(html, /role="alert"/);
51
+ assert.match(html, /mailbox unreachable/);
52
+ assert.match(html, /Couldn&#x27;t load content/);
53
+ });
54
+
55
+ it("takes a caller-supplied title over the default", () => {
56
+ const html = render({ error: "x", title: "Couldn't load folders" });
57
+ assert.match(html, /Couldn&#x27;t load folders/);
58
+ assert.doesNotMatch(html, /load content/);
59
+ });
60
+
61
+ it("offers no Retry when the caller has no retry to give", () => {
62
+ assert.doesNotMatch(render({ error: "x" }), /Retry/);
63
+ assert.doesNotMatch(render({ error: "x", variant: "inline" }), /Retry/);
64
+ });
65
+
66
+ it("offers Retry in both variants when it can retry", () => {
67
+ const retry = () => undefined;
68
+ assert.match(render({ error: "x", onRetry: retry }), /Retry/);
69
+ assert.match(
70
+ render({ error: "x", onRetry: retry, variant: "inline" }),
71
+ /Retry/,
72
+ );
73
+ });
74
+
75
+ it("renders the inline variant as a single row, not the centred block", () => {
76
+ const inline = render({ error: "x", variant: "inline" });
77
+ const block = render({ error: "x" });
78
+ assert.match(inline, /items-start/);
79
+ assert.match(block, /justify-center/);
80
+ assert.doesNotMatch(inline, /justify-center/);
81
+ });
82
+ });
@@ -0,0 +1,278 @@
1
+ /**
2
+ * The mutation hooks behind delete, move, star and mark-read all make the same
3
+ * promise: the list changes the moment the user acts, every listing that shows
4
+ * the row changes with it — including the unified one the daily brief reads
5
+ * (#140, #149) — and a server refusal puts the row back and says so.
6
+ *
7
+ * These mount the real hooks against a real QueryClient, so the optimistic
8
+ * patch, the rollback and the banner are exercised together rather than as
9
+ * three separate pure helpers.
10
+ */
11
+
12
+ import assert from "node:assert/strict";
13
+ import { afterEach, beforeEach, describe, it } from "node:test";
14
+ import {
15
+ threadOperationsListThreadsQueryKey,
16
+ unifiedThreadOperationsListAllThreadsQueryKey,
17
+ } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
18
+ import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
19
+ import { createElement } from "react";
20
+ import { createDomHarness, type DomHarness } from "../test-support/dom";
21
+ import { makeThreadMessage } from "../test-support/fixtures";
22
+ import { type HttpMock, httpError, mockFetch } from "../test-support/http";
23
+ import { useDeleteMessages } from "./useDeleteMessages";
24
+ import { useToggleReadFor } from "./useMarkAsRead";
25
+ import { useMoveMessages } from "./useMoveMessages";
26
+ import { useToggleStar } from "./useToggleStar";
27
+
28
+ const INBOX = "mbx-inbox";
29
+ const SENT = "mbx-sent";
30
+ const ARCHIVE = "mbx-archive";
31
+
32
+ const inboxRow = makeThreadMessage({ messageId: "msg-1", mailboxId: INBOX });
33
+ const sentRow = makeThreadMessage({
34
+ messageId: "msg-2",
35
+ mailboxId: SENT,
36
+ threadMessageId: "tm-2",
37
+ });
38
+
39
+ let harness: DomHarness | undefined;
40
+ let http: HttpMock;
41
+
42
+ const inboxKey = threadOperationsListThreadsQueryKey({
43
+ path: { mailboxId: INBOX },
44
+ });
45
+ const sentKey = threadOperationsListThreadsQueryKey({
46
+ path: { mailboxId: SENT },
47
+ });
48
+ const briefKey = unifiedThreadOperationsListAllThreadsQueryKey();
49
+
50
+ const seed = (dom: DomHarness): void => {
51
+ dom.queryClient.setQueryData(inboxKey, { items: [inboxRow] });
52
+ dom.queryClient.setQueryData(sentKey, { items: [sentRow] });
53
+ dom.queryClient.setQueryData(briefKey, { items: [inboxRow, sentRow] });
54
+ };
55
+
56
+ const rows = (
57
+ dom: DomHarness,
58
+ key: readonly unknown[],
59
+ ): RemitImapThreadMessageResponse[] =>
60
+ (
61
+ dom.queryClient.getQueryData(key) as
62
+ | { items: RemitImapThreadMessageResponse[] }
63
+ | undefined
64
+ )?.items ?? [];
65
+
66
+ const ids = (dom: DomHarness, key: readonly unknown[]): string[] =>
67
+ rows(dom, key).map((row) => row.messageId);
68
+
69
+ /** Mount a hook and hand its return value back to the test. */
70
+ const mountHook = <T>(useHook: () => T): { current: () => T } => {
71
+ let value: T | undefined;
72
+ const Probe = () => {
73
+ value = useHook();
74
+ return null;
75
+ };
76
+ harness = createDomHarness();
77
+ seed(harness);
78
+ harness.renderApp(createElement(Probe));
79
+ return {
80
+ current: () => {
81
+ if (value === undefined) throw new Error("hook did not render");
82
+ return value;
83
+ },
84
+ };
85
+ };
86
+
87
+ const dom = (): DomHarness => {
88
+ if (!harness) throw new Error("nothing mounted");
89
+ return harness;
90
+ };
91
+
92
+ beforeEach(() => {
93
+ http = mockFetch(() => ({}));
94
+ });
95
+
96
+ afterEach(() => {
97
+ harness?.close();
98
+ harness = undefined;
99
+ http.restore();
100
+ });
101
+
102
+ describe("deleting from a listing", () => {
103
+ it("removes the row from its mailbox and from the brief at once", async () => {
104
+ const hook = mountHook(() => useDeleteMessages({ mailboxId: INBOX }));
105
+
106
+ hook.current().deleteMessages(["msg-1"]);
107
+ await dom().flush();
108
+
109
+ assert.deepEqual(ids(dom(), inboxKey), []);
110
+ assert.deepEqual(ids(dom(), briefKey), ["msg-2"]);
111
+ });
112
+
113
+ it("tells the caller the row is gone so it can leave the open thread", async () => {
114
+ const removed: string[][] = [];
115
+ const hook = mountHook(() =>
116
+ useDeleteMessages({
117
+ mailboxId: INBOX,
118
+ onAfterOptimisticRemove: (messageIds) => removed.push(messageIds),
119
+ }),
120
+ );
121
+
122
+ hook.current().deleteMessages(["msg-1"]);
123
+ await dom().flush();
124
+
125
+ assert.deepEqual(removed, [["msg-1"]]);
126
+ });
127
+
128
+ it("patches the mailbox the message is really in, not the one being browsed", async () => {
129
+ const hook = mountHook(() =>
130
+ useDeleteMessages({ mailboxId: INBOX, messages: [inboxRow, sentRow] }),
131
+ );
132
+
133
+ hook.current().deleteMessages(["msg-2"]);
134
+ await dom().flush();
135
+
136
+ assert.deepEqual(ids(dom(), sentKey), []);
137
+ assert.deepEqual(ids(dom(), inboxKey), ["msg-1"]);
138
+ });
139
+
140
+ it("does nothing at all for an empty selection", async () => {
141
+ const hook = mountHook(() => useDeleteMessages({ mailboxId: INBOX }));
142
+
143
+ hook.current().deleteMessages([]);
144
+ await dom().flush();
145
+
146
+ assert.deepEqual(http.calls, []);
147
+ assert.deepEqual(ids(dom(), inboxKey), ["msg-1"]);
148
+ });
149
+
150
+ it("puts the row back and says so when the server refuses", async () => {
151
+ http.restore();
152
+ http = mockFetch(() => httpError(409, "mailbox is locked"));
153
+ const hook = mountHook(() => useDeleteMessages({ mailboxId: INBOX }));
154
+
155
+ hook.current().deleteMessages(["msg-1"]);
156
+ await dom().flush();
157
+
158
+ assert.deepEqual(ids(dom(), inboxKey), ["msg-1"]);
159
+ assert.deepEqual(ids(dom(), briefKey), ["msg-1", "msg-2"]);
160
+ assert.match(dom().text(), /Couldn't delete this message/);
161
+ });
162
+
163
+ it("counts the messages in the failure it reports", async () => {
164
+ http.restore();
165
+ http = mockFetch(() => httpError(409));
166
+ const hook = mountHook(() => useDeleteMessages({ mailboxId: INBOX }));
167
+
168
+ hook.current().deleteMessages(["msg-1", "msg-2"]);
169
+ await dom().flush();
170
+
171
+ assert.match(dom().text(), /Couldn't delete 2 messages/);
172
+ });
173
+ });
174
+
175
+ describe("moving between folders", () => {
176
+ it("takes the row out of the source listing straight away", async () => {
177
+ const hook = mountHook(() => useMoveMessages({ mailboxId: INBOX }));
178
+
179
+ hook.current().moveMessages(["msg-1"], ARCHIVE);
180
+ await dom().flush();
181
+
182
+ assert.deepEqual(ids(dom(), inboxKey), []);
183
+ const [moved] = http.calls;
184
+ assert.equal(moved?.body?.destinationMailboxId, ARCHIVE);
185
+ });
186
+
187
+ it("restores the source listing when the move fails", async () => {
188
+ http.restore();
189
+ http = mockFetch(() => httpError(409));
190
+ const hook = mountHook(() => useMoveMessages({ mailboxId: INBOX }));
191
+
192
+ hook.current().moveMessages(["msg-1"], ARCHIVE);
193
+ await dom().flush();
194
+
195
+ assert.deepEqual(ids(dom(), inboxKey), ["msg-1"]);
196
+ assert.match(dom().text(), /Couldn't move/);
197
+ });
198
+ });
199
+
200
+ describe("starring a message", () => {
201
+ it("stars the row in the mailbox it lives in, not the browsed one (#46)", async () => {
202
+ const hook = mountHook(() =>
203
+ useToggleStar({
204
+ threadId: "thread-1",
205
+ mailboxId: INBOX,
206
+ messages: [inboxRow, sentRow],
207
+ }),
208
+ );
209
+
210
+ hook.current().toggleStar("msg-2", false);
211
+ await dom().flush();
212
+
213
+ assert.equal(rows(dom(), sentKey)[0].hasStars, true);
214
+ assert.equal(rows(dom(), inboxKey)[0].hasStars, false);
215
+ });
216
+
217
+ it("unstars a starred message", async () => {
218
+ const hook = mountHook(() =>
219
+ useToggleStar({ threadId: "thread-1", mailboxId: INBOX }),
220
+ );
221
+
222
+ hook.current().toggleStar("msg-1", false);
223
+ await dom().flush();
224
+ assert.equal(rows(dom(), inboxKey)[0].hasStars, true);
225
+
226
+ hook.current().toggleStar("msg-1", true);
227
+ await dom().flush();
228
+ assert.equal(rows(dom(), inboxKey)[0].hasStars, false);
229
+ });
230
+
231
+ it("rolls the star back and names the direction that failed", async () => {
232
+ http.restore();
233
+ http = mockFetch(() => httpError(409));
234
+ const hook = mountHook(() =>
235
+ useToggleStar({ threadId: "thread-1", mailboxId: INBOX }),
236
+ );
237
+
238
+ hook.current().toggleStar("msg-1", false);
239
+ await dom().flush();
240
+
241
+ assert.equal(rows(dom(), inboxKey)[0].hasStars, false);
242
+ assert.match(dom().text(), /Couldn't star message/);
243
+ });
244
+ });
245
+
246
+ describe("marking a selection read", () => {
247
+ it("sends the ids and the direction the caller asked for", async () => {
248
+ const hook = mountHook(() => useToggleReadFor({ mailboxId: INBOX }));
249
+
250
+ hook.current().toggleReadFor(["msg-1"], true);
251
+ await dom().flush();
252
+
253
+ const [call] = http.calls;
254
+ assert.equal(call?.path, "/messages/flags");
255
+ assert.deepEqual(call?.body?.messageIds, ["msg-1"]);
256
+ assert.equal(call?.body?.isRead, true);
257
+ });
258
+
259
+ it("stays quiet for an empty selection", async () => {
260
+ const hook = mountHook(() => useToggleReadFor({ mailboxId: INBOX }));
261
+
262
+ hook.current().toggleReadFor([], true);
263
+ await dom().flush();
264
+
265
+ assert.deepEqual(http.calls, []);
266
+ });
267
+
268
+ it("says which way it failed — read, not unread", async () => {
269
+ http.restore();
270
+ http = mockFetch(() => httpError(409));
271
+ const hook = mountHook(() => useToggleReadFor({ mailboxId: INBOX }));
272
+
273
+ hook.current().toggleReadFor(["msg-1"], false);
274
+ await dom().flush();
275
+
276
+ assert.match(dom().text(), /Couldn't mark as unread/);
277
+ });
278
+ });
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Mount-and-poke harness over the jsdom globals `dom-env.mjs` installs.
3
+ *
4
+ * Excluded from coverage by `test:run`; it lives under `src/` only because the
5
+ * build's `rootDir` is `src/`. Node's test runner gives every test file its own
6
+ * process, so nothing here leaks between files.
7
+ */
8
+
9
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
10
+ import React, { act, createElement } from "react";
11
+ import { createRoot, type Root } from "react-dom/client";
12
+ import {
13
+ DEFAULT_VIEWPORT_WIDTH,
14
+ setViewportWidth,
15
+ } from "../../test-support/dom-env.mjs";
16
+ import { ErrorBannerProvider } from "../components/ui/ErrorBannerProvider";
17
+
18
+ export interface DomHarness {
19
+ window: Window & typeof globalThis;
20
+ document: Document;
21
+ container: HTMLElement;
22
+ /** The client behind `renderApp`; seed it with `setQueryData`. */
23
+ queryClient: QueryClient;
24
+ render: (element: React.ReactNode) => void;
25
+ /** Render under the providers every mail surface expects. */
26
+ renderApp: (element: React.ReactNode) => void;
27
+ renderAsync: (element: React.ReactNode) => Promise<void>;
28
+ unmount: () => void;
29
+ close: () => void;
30
+ html: () => string;
31
+ text: () => string;
32
+ query: <T extends Element = HTMLElement>(selector: string) => T | null;
33
+ queryAll: <T extends Element = HTMLElement>(selector: string) => T[];
34
+ byLabel: (label: string) => HTMLElement;
35
+ byText: (selector: string, text: string) => HTMLElement;
36
+ click: (element: Element) => void;
37
+ /** Pick an option by value and let React see the change. */
38
+ select: (element: Element, value: string) => void;
39
+ dispatch: (target: EventTarget, event: Event) => void;
40
+ type: (element: Element, value: string) => void;
41
+ flush: () => Promise<void>;
42
+ /** Let real timers fire — some flows stage their steps on a delay. */
43
+ wait: (ms: number) => Promise<void>;
44
+ }
45
+
46
+ export interface DomOptions {
47
+ /** Width `matchMedia` answers against — jsdom has no layout of its own. */
48
+ viewportWidth?: number;
49
+ }
50
+
51
+ export const createDomHarness = (options: DomOptions = {}): DomHarness => {
52
+ setViewportWidth(options.viewportWidth ?? DEFAULT_VIEWPORT_WIDTH);
53
+ // The test loader transpiles remit-ui's `.tsx` with the classic JSX runtime,
54
+ // which reads a global `React`. Vite uses the automatic runtime, so this
55
+ // shim exists only for the test harness.
56
+ (globalThis as { React?: typeof React }).React = React;
57
+
58
+ const container = document.createElement("div");
59
+ document.body.appendChild(container);
60
+ let root: Root | undefined = createRoot(container);
61
+ // No retries: a test asserting a failure should not have to wait out a
62
+ // backoff before it can see one.
63
+ const queryClient = new QueryClient({
64
+ defaultOptions: {
65
+ queries: { retry: false },
66
+ mutations: { retry: false },
67
+ },
68
+ });
69
+
70
+ const requireRoot = (): Root => {
71
+ if (!root) throw new Error("harness already unmounted");
72
+ return root;
73
+ };
74
+
75
+ const harness: DomHarness = {
76
+ window: globalThis.window,
77
+ document: globalThis.document,
78
+ container,
79
+ queryClient,
80
+ render: (element) => {
81
+ act(() => requireRoot().render(element as never));
82
+ },
83
+ renderApp: (element) => {
84
+ harness.render(
85
+ createElement(
86
+ QueryClientProvider,
87
+ { client: queryClient },
88
+ createElement(ErrorBannerProvider, null, element),
89
+ ),
90
+ );
91
+ },
92
+ renderAsync: async (element) => {
93
+ await act(async () => {
94
+ requireRoot().render(element as never);
95
+ });
96
+ },
97
+ unmount: () => {
98
+ if (!root) return;
99
+ const current = root;
100
+ root = undefined;
101
+ act(() => current.unmount());
102
+ },
103
+ close: () => {
104
+ harness.unmount();
105
+ // Drops every cached query along with its garbage-collection timer,
106
+ // which otherwise holds the test process open for its full gcTime.
107
+ queryClient.clear();
108
+ container.remove();
109
+ setViewportWidth(DEFAULT_VIEWPORT_WIDTH);
110
+ },
111
+ html: () => container.innerHTML,
112
+ text: () => container.textContent ?? "",
113
+ query: <T extends Element = HTMLElement>(selector: string) =>
114
+ container.querySelector(selector) as T | null,
115
+ queryAll: <T extends Element = HTMLElement>(selector: string) =>
116
+ [...container.querySelectorAll(selector)] as T[],
117
+ byLabel: (label) => {
118
+ const found = container.querySelector(`[aria-label="${label}"]`);
119
+ if (!found) throw new Error(`no element labelled "${label}"`);
120
+ return found as HTMLElement;
121
+ },
122
+ byText: (selector, text) => {
123
+ const found = [...container.querySelectorAll(selector)].find((node) =>
124
+ (node.textContent ?? "").includes(text),
125
+ );
126
+ if (!found) throw new Error(`no ${selector} containing "${text}"`);
127
+ return found as HTMLElement;
128
+ },
129
+ click: (element) => {
130
+ act(() => {
131
+ element.dispatchEvent(
132
+ new MouseEvent("click", { bubbles: true, cancelable: true }),
133
+ );
134
+ });
135
+ },
136
+ select: (element, value) => {
137
+ act(() => {
138
+ const node = element as HTMLSelectElement;
139
+ Object.getOwnPropertyDescriptor(
140
+ HTMLSelectElement.prototype,
141
+ "value",
142
+ )?.set?.call(node, value);
143
+ node.dispatchEvent(new Event("change", { bubbles: true }));
144
+ });
145
+ },
146
+ dispatch: (target, event) => {
147
+ act(() => {
148
+ target.dispatchEvent(event);
149
+ });
150
+ },
151
+ type: (element, value) => {
152
+ act(() => {
153
+ const input = element as HTMLInputElement | HTMLTextAreaElement;
154
+ const prototype =
155
+ input instanceof HTMLTextAreaElement
156
+ ? HTMLTextAreaElement.prototype
157
+ : HTMLInputElement.prototype;
158
+ Object.getOwnPropertyDescriptor(prototype, "value")?.set?.call(
159
+ input,
160
+ value,
161
+ );
162
+ input.dispatchEvent(new Event("input", { bubbles: true }));
163
+ });
164
+ },
165
+ wait: async (ms) => {
166
+ await act(async () => {
167
+ await new Promise((resolve) => setTimeout(resolve, ms));
168
+ });
169
+ },
170
+ flush: async () => {
171
+ // Enough turns for a chain of awaits — form validation, then the
172
+ // mutation, then its `onSuccess` — to settle.
173
+ await act(async () => {
174
+ for (let turn = 0; turn < 8; turn += 1) await Promise.resolve();
175
+ });
176
+ },
177
+ };
178
+
179
+ return harness;
180
+ };
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Fully-shaped API fixtures. Lives outside `src/` so it is not measured for
3
+ * coverage. Every builder returns a complete generated type — no casts at the
4
+ * call sites, so a contract change surfaces here once instead of in every test.
5
+ */
6
+
7
+ import type {
8
+ RemitImapAccountResponse,
9
+ RemitImapMailboxResponse,
10
+ RemitImapThreadMessageResponse,
11
+ } from "@remit/api-http-client/types.gen.ts";
12
+
13
+ export const makeMailbox = (
14
+ overrides: Partial<RemitImapMailboxResponse> & {
15
+ mailboxId: string;
16
+ fullPath: string;
17
+ },
18
+ ): RemitImapMailboxResponse => ({
19
+ accountId: "acc-1",
20
+ namespaceType: "personal",
21
+ namespacePrefix: "",
22
+ hierarchyDelimiter: "/",
23
+ messageCount: 0,
24
+ unseenCount: 0,
25
+ deletedCount: 0,
26
+ lastSyncUid: 0,
27
+ highWaterMarkUid: 0,
28
+ lastMessageSyncAt: 0,
29
+ createdAt: 0,
30
+ updatedAt: 0,
31
+ ...overrides,
32
+ });
33
+
34
+ export const makeAccount = (
35
+ overrides: Partial<RemitImapAccountResponse> & { accountId: string },
36
+ ): RemitImapAccountResponse => ({
37
+ accountConfigId: "cfg-1",
38
+ username: "alice@example.com",
39
+ email: "alice@example.com",
40
+ authType: "password",
41
+ imapHost: "imap.example.com",
42
+ imapPort: 993,
43
+ imapTls: true,
44
+ imapStartTls: false,
45
+ smtpEnabled: true,
46
+ smtpHost: "smtp.example.com",
47
+ smtpPort: 587,
48
+ smtpTls: false,
49
+ smtpStartTls: true,
50
+ smtpUsername: "alice@example.com",
51
+ isActive: true,
52
+ connectionState: "authenticated",
53
+ createdAt: 0,
54
+ updatedAt: 0,
55
+ folderAppointments: [],
56
+ ...overrides,
57
+ });
58
+
59
+ export const makeThreadMessage = (
60
+ overrides: Partial<RemitImapThreadMessageResponse> & {
61
+ messageId: string;
62
+ },
63
+ ): RemitImapThreadMessageResponse => ({
64
+ threadId: "thread-1",
65
+ threadMessageId: `tm-${overrides.messageId}`,
66
+ accountConfigId: "cfg-1",
67
+ mailboxId: "mbx-inbox",
68
+ accountId: "acc-1",
69
+ subject: "Quarterly report",
70
+ fromName: "Alice",
71
+ fromEmail: "alice@example.com",
72
+ sentDate: 1_767_225_600_000,
73
+ snippet: "",
74
+ isRead: false,
75
+ hasAttachment: false,
76
+ star: "none",
77
+ hasStars: false,
78
+ isDeleted: false,
79
+ senderTrust: "unknown",
80
+ createdAt: 0,
81
+ updatedAt: 0,
82
+ ...overrides,
83
+ });
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Fetch seam for the tests. The generated SDK talks to the network through
3
+ * `globalThis.fetch`, so intercepting it — rather than the client module —
4
+ * exercises the real request the app builds: method, path, and JSON body.
5
+ */
6
+
7
+ export interface HttpCall {
8
+ method: string;
9
+ url: string;
10
+ path: string;
11
+ body: Record<string, unknown> | undefined;
12
+ }
13
+
14
+ export interface HttpMock {
15
+ calls: HttpCall[];
16
+ /** Calls whose path ends with `suffix`, in order. */
17
+ to: (suffix: string) => HttpCall[];
18
+ restore: () => void;
19
+ }
20
+
21
+ type Responder = (call: HttpCall) => unknown;
22
+
23
+ /**
24
+ * Answer every request with `responder`'s return value as JSON. Throwing from
25
+ * the responder, or returning a `Response`, is how a test drives a failure.
26
+ */
27
+ export const mockFetch = (responder: Responder = () => ({})): HttpMock => {
28
+ const original = globalThis.fetch;
29
+ const calls: HttpCall[] = [];
30
+
31
+ globalThis.fetch = (async (
32
+ input: RequestInfo | URL,
33
+ init?: RequestInit,
34
+ ): Promise<Response> => {
35
+ const request = input instanceof Request ? input : undefined;
36
+ const url = request ? request.url : String(input);
37
+ const rawBody = request ? await request.clone().text() : undefined;
38
+ const call: HttpCall = {
39
+ method: (request?.method ?? init?.method ?? "GET").toUpperCase(),
40
+ url,
41
+ path: new URL(url, "http://localhost").pathname,
42
+ body: rawBody ? JSON.parse(rawBody) : undefined,
43
+ };
44
+ calls.push(call);
45
+
46
+ const result = responder(call);
47
+ if (result instanceof Response) return result;
48
+ return new Response(JSON.stringify(result ?? {}), {
49
+ status: 200,
50
+ headers: { "content-type": "application/json" },
51
+ });
52
+ }) as typeof globalThis.fetch;
53
+
54
+ return {
55
+ calls,
56
+ to: (suffix) => calls.filter((call) => call.path.endsWith(suffix)),
57
+ restore: () => {
58
+ globalThis.fetch = original;
59
+ },
60
+ };
61
+ };
62
+
63
+ /**
64
+ * A failed request. The status travels in the body as well as on the response:
65
+ * the generated client throws the parsed body, and the app's error classifier
66
+ * reads the status off it.
67
+ */
68
+ export const httpError = (status: number, message = "boom"): Response =>
69
+ new Response(JSON.stringify({ status, message }), {
70
+ status,
71
+ headers: { "content-type": "application/json" },
72
+ });