@remit/web-client 0.0.71 → 0.0.72

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.71",
3
+ "version": "0.0.72",
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,467 @@
1
+ import assert from "node:assert/strict";
2
+ import { afterEach, beforeEach, describe, it } from "node:test";
3
+ import type {
4
+ RemitImapFolderAppointment,
5
+ RemitImapMailboxResponse,
6
+ } from "@remit/api-http-client/types.gen.ts";
7
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
8
+ import React, { act, createElement } from "react";
9
+ import { createRoot, type Root } from "react-dom/client";
10
+ import { DeleteFolderDialog } from "./DeleteFolderDialog";
11
+
12
+ // remit-ui's `.tsx` is transpiled with the classic JSX runtime, which
13
+ // references a global `React`; the app uses the automatic runtime, so this
14
+ // shim only exists for the test harness.
15
+ (globalThis as { React?: typeof React }).React = React;
16
+
17
+ const mailbox = (
18
+ over: Partial<RemitImapMailboxResponse> & {
19
+ mailboxId: string;
20
+ fullPath: string;
21
+ },
22
+ ): RemitImapMailboxResponse =>
23
+ ({
24
+ accountId: "acc-1",
25
+ namespaceType: "personal",
26
+ namespacePrefix: "",
27
+ hierarchyDelimiter: "/",
28
+ messageCount: 0,
29
+ unseenCount: 0,
30
+ deletedCount: 0,
31
+ lastSyncUid: 0,
32
+ highWaterMarkUid: 0,
33
+ lastMessageSyncAt: 0,
34
+ createdAt: 0,
35
+ updatedAt: 0,
36
+ ...over,
37
+ }) as RemitImapMailboxResponse;
38
+
39
+ const mailboxes: RemitImapMailboxResponse[] = [
40
+ mailbox({ mailboxId: "inbox", fullPath: "INBOX" }),
41
+ mailbox({ mailboxId: "receipts", fullPath: "Receipts", messageCount: 3 }),
42
+ mailbox({ mailboxId: "empty", fullPath: "Empty" }),
43
+ mailbox({ mailboxId: "archive", fullPath: "Archive" }),
44
+ ];
45
+
46
+ const appointments: RemitImapFolderAppointment[] = [
47
+ { role: "Inbox", mailboxId: "inbox" },
48
+ { role: "Archive", mailboxId: "archive" },
49
+ ];
50
+
51
+ let container: HTMLElement;
52
+ let root: Root;
53
+ const originalFetch = globalThis.fetch;
54
+
55
+ interface FetchCall {
56
+ url: string;
57
+ method: string;
58
+ body: string;
59
+ }
60
+ type FetchRoute = (call: FetchCall) => Response;
61
+ let route: FetchRoute = () => new Response("{}", { status: 200 });
62
+
63
+ const json = (body: unknown): Response =>
64
+ new Response(JSON.stringify(body), {
65
+ status: 200,
66
+ headers: { "Content-Type": "application/json" },
67
+ });
68
+
69
+ const threadItems = (ids: readonly string[]) => ({
70
+ items: ids.map((id) => ({
71
+ threadMessageId: `t-${id}`,
72
+ threadId: "th",
73
+ messageId: id,
74
+ accountConfigId: "acc-1",
75
+ mailboxId: "receipts",
76
+ isDeleted: false,
77
+ })),
78
+ });
79
+
80
+ beforeEach(() => {
81
+ container = document.createElement("div");
82
+ document.body.appendChild(container);
83
+ root = createRoot(container);
84
+ globalThis.fetch = (async (input: RequestInfo | URL) => {
85
+ const request = input as Request;
86
+ const body = request.method === "GET" ? "" : await request.clone().text();
87
+ return route({ url: request.url, method: request.method, body });
88
+ }) as typeof fetch;
89
+ });
90
+
91
+ afterEach(() => {
92
+ act(() => root.unmount());
93
+ container.remove();
94
+ globalThis.fetch = originalFetch;
95
+ });
96
+
97
+ const render = (props: {
98
+ open: boolean;
99
+ folder: RemitImapMailboxResponse;
100
+ onClose?: () => void;
101
+ }) => {
102
+ act(() => {
103
+ root.render(
104
+ createElement(
105
+ QueryClientProvider,
106
+ { client: new QueryClient() },
107
+ createElement(DeleteFolderDialog, {
108
+ open: props.open,
109
+ accountId: "acc-1",
110
+ folder: props.folder,
111
+ mailboxes,
112
+ appointments,
113
+ onClose: props.onClose ?? (() => undefined),
114
+ }),
115
+ ) as never,
116
+ );
117
+ });
118
+ };
119
+
120
+ const buttonByText = (re: RegExp): HTMLButtonElement | undefined =>
121
+ Array.from(container.querySelectorAll("button")).find((b) =>
122
+ re.test(b.textContent ?? ""),
123
+ );
124
+
125
+ const flush = async () => {
126
+ for (let i = 0; i < 12; i += 1) {
127
+ await act(async () => {
128
+ await new Promise((resolve) => setTimeout(resolve, 0));
129
+ });
130
+ }
131
+ };
132
+
133
+ describe("DeleteFolderDialog", () => {
134
+ it("renders nothing when closed", () => {
135
+ render({ open: false, folder: mailboxes[1] as RemitImapMailboxResponse });
136
+ assert.equal(container.textContent, "");
137
+ });
138
+
139
+ it("confirms a single destructive step for an empty folder", () => {
140
+ render({ open: true, folder: mailboxes[2] as RemitImapMailboxResponse });
141
+ assert.match(container.textContent ?? "", /empty and will be removed/);
142
+ assert.ok(buttonByText(/^Delete folder$/), "delete folder button present");
143
+ });
144
+
145
+ it("asks what happens to the mail for a non-empty folder", () => {
146
+ render({ open: true, folder: mailboxes[1] as RemitImapMailboxResponse });
147
+ assert.match(container.textContent ?? "", /holds 3 emails/);
148
+ assert.ok(buttonByText(/Delete them with the folder/));
149
+ assert.ok(buttonByText(/Move them to another folder/));
150
+ });
151
+
152
+ it("routes the delete-with-folder choice to a counted confirm and back", () => {
153
+ render({ open: true, folder: mailboxes[1] as RemitImapMailboxResponse });
154
+ act(() => buttonByText(/Delete them with the folder/)?.click());
155
+ assert.match(container.textContent ?? "", /and its 3 emails/);
156
+ assert.ok(buttonByText(/Delete folder and emails/));
157
+ act(() => buttonByText(/^Back$/)?.click());
158
+ assert.match(container.textContent ?? "", /What should happen to them/);
159
+ });
160
+
161
+ it("offers a destination picker that excludes the folder being deleted", () => {
162
+ render({ open: true, folder: mailboxes[1] as RemitImapMailboxResponse });
163
+ act(() => buttonByText(/Move them to another folder/)?.click());
164
+ const search = container.querySelector(
165
+ 'input[aria-label="Filter folders"]',
166
+ );
167
+ assert.ok(search, "the move picker is shown");
168
+ const options = Array.from(container.querySelectorAll('[role="option"]'))
169
+ .map((o) => o.textContent ?? "")
170
+ .join("|");
171
+ assert.doesNotMatch(options, /Receipts/);
172
+ assert.match(options, /Archive/);
173
+ });
174
+
175
+ it("deletes an empty folder and closes on success", async () => {
176
+ let closed = false;
177
+ route = ({ method }) => {
178
+ if (method === "DELETE") return new Response(null, { status: 204 });
179
+ return new Response("{}", { status: 200 });
180
+ };
181
+ render({
182
+ open: true,
183
+ folder: mailboxes[2] as RemitImapMailboxResponse,
184
+ onClose: () => {
185
+ closed = true;
186
+ },
187
+ });
188
+ await act(async () => {
189
+ buttonByText(/^Delete folder$/)?.click();
190
+ });
191
+ await flush();
192
+ assert.equal(closed, true);
193
+ });
194
+
195
+ it("moves the mail in batches then deletes the emptied folder", async () => {
196
+ let closed = false;
197
+ let moved: string[] = [];
198
+ let threadCalls = 0;
199
+ route = ({ url, method, body: reqBody }) => {
200
+ if (method === "DELETE") return new Response(null, { status: 204 });
201
+ if (url.includes("/messages/move")) {
202
+ const body = JSON.parse(reqBody) as { messageIds: string[] };
203
+ moved = moved.concat(body.messageIds);
204
+ return new Response(JSON.stringify({ moved: body.messageIds.length }), {
205
+ status: 200,
206
+ headers: { "Content-Type": "application/json" },
207
+ });
208
+ }
209
+ if (url.includes("/threads")) {
210
+ threadCalls += 1;
211
+ const items =
212
+ threadCalls === 1
213
+ ? ["m1", "m2", "m3"].map((id) => ({
214
+ threadMessageId: `t-${id}`,
215
+ threadId: "th",
216
+ messageId: id,
217
+ accountConfigId: "acc-1",
218
+ mailboxId: "receipts",
219
+ isDeleted: false,
220
+ }))
221
+ : [];
222
+ return new Response(JSON.stringify({ items }), {
223
+ status: 200,
224
+ headers: { "Content-Type": "application/json" },
225
+ });
226
+ }
227
+ return new Response(JSON.stringify({ items: mailboxes }), {
228
+ status: 200,
229
+ headers: { "Content-Type": "application/json" },
230
+ });
231
+ };
232
+ render({
233
+ open: true,
234
+ folder: mailboxes[1] as RemitImapMailboxResponse,
235
+ onClose: () => {
236
+ closed = true;
237
+ },
238
+ });
239
+ act(() => buttonByText(/Move them to another folder/)?.click());
240
+ await act(async () => {
241
+ (
242
+ container.querySelector('button[aria-label="Move to Archive"]') as
243
+ | HTMLButtonElement
244
+ | undefined
245
+ )?.click();
246
+ });
247
+ await flush();
248
+ assert.deepEqual(moved.sort(), ["m1", "m2", "m3"]);
249
+ assert.equal(closed, true);
250
+ });
251
+
252
+ it("surfaces a failed batch and keeps the dialog open", async () => {
253
+ let closed = false;
254
+ route = ({ url }) => {
255
+ if (url.includes("/messages/move"))
256
+ return new Response(JSON.stringify({ detail: "server exploded" }), {
257
+ status: 500,
258
+ headers: { "Content-Type": "application/json" },
259
+ });
260
+ if (url.includes("/threads"))
261
+ return new Response(
262
+ JSON.stringify({
263
+ items: [
264
+ {
265
+ threadMessageId: "t1",
266
+ threadId: "th",
267
+ messageId: "m1",
268
+ accountConfigId: "acc-1",
269
+ mailboxId: "receipts",
270
+ isDeleted: false,
271
+ },
272
+ ],
273
+ }),
274
+ { status: 200, headers: { "Content-Type": "application/json" } },
275
+ );
276
+ return new Response(JSON.stringify({ items: mailboxes }), {
277
+ status: 200,
278
+ headers: { "Content-Type": "application/json" },
279
+ });
280
+ };
281
+ render({
282
+ open: true,
283
+ folder: mailboxes[1] as RemitImapMailboxResponse,
284
+ onClose: () => {
285
+ closed = true;
286
+ },
287
+ });
288
+ act(() => buttonByText(/Move them to another folder/)?.click());
289
+ await act(async () => {
290
+ (
291
+ container.querySelector('button[aria-label="Move to Archive"]') as
292
+ | HTMLButtonElement
293
+ | undefined
294
+ )?.click();
295
+ });
296
+ await flush();
297
+ assert.match(container.textContent ?? "", /stay moved/);
298
+ assert.equal(closed, false);
299
+ });
300
+
301
+ const clickMoveToArchive = async () => {
302
+ act(() => buttonByText(/Move them to another folder/)?.click());
303
+ await act(async () => {
304
+ (
305
+ container.querySelector('button[aria-label="Move to Archive"]') as
306
+ | HTMLButtonElement
307
+ | undefined
308
+ )?.click();
309
+ });
310
+ };
311
+
312
+ it("iterates multiple batches and deletes only after the folder drains", async () => {
313
+ let deleted = false;
314
+ const moveCalls: string[][] = [];
315
+ let threadRound = 0;
316
+ route = ({ url, method, body: reqBody }) => {
317
+ if (method === "DELETE") {
318
+ deleted = true;
319
+ return new Response(null, { status: 204 });
320
+ }
321
+ if (url.includes("/messages/move")) {
322
+ const body = JSON.parse(reqBody) as { messageIds: string[] };
323
+ moveCalls.push(body.messageIds);
324
+ return json({ moved: body.messageIds.length });
325
+ }
326
+ if (url.includes("/threads")) {
327
+ threadRound += 1;
328
+ if (threadRound === 1) return json(threadItems(["m1", "m2"]));
329
+ if (threadRound === 2) return json(threadItems(["m3", "m4"]));
330
+ return json(threadItems([]));
331
+ }
332
+ return json({ items: mailboxes });
333
+ };
334
+ render({ open: true, folder: mailboxes[1] as RemitImapMailboxResponse });
335
+ await clickMoveToArchive();
336
+ await flush();
337
+ assert.equal(moveCalls.length, 2, "two move calls, one per batch");
338
+ assert.deepEqual(moveCalls[0], ["m1", "m2"]);
339
+ assert.deepEqual(moveCalls[1], ["m3", "m4"]);
340
+ assert.equal(deleted, true, "delete fires only after drain and recheck");
341
+ });
342
+
343
+ it("does not delete or error when the dialog is closed mid-move", async () => {
344
+ let deleted = false;
345
+ let closed = false;
346
+ const moveCalls: string[][] = [];
347
+ route = ({ url, method, body: reqBody }) => {
348
+ if (method === "DELETE") {
349
+ deleted = true;
350
+ return new Response(null, { status: 204 });
351
+ }
352
+ if (url.includes("/messages/move")) {
353
+ const body = JSON.parse(reqBody) as { messageIds: string[] };
354
+ moveCalls.push(body.messageIds);
355
+ container
356
+ .querySelector<HTMLButtonElement>('button[aria-label="Cancel"]')
357
+ ?.click();
358
+ return json({ moved: body.messageIds.length });
359
+ }
360
+ if (url.includes("/threads")) return json(threadItems(["m1", "m2"]));
361
+ return json({ items: mailboxes });
362
+ };
363
+ render({
364
+ open: true,
365
+ folder: mailboxes[1] as RemitImapMailboxResponse,
366
+ onClose: () => {
367
+ closed = true;
368
+ },
369
+ });
370
+ await clickMoveToArchive();
371
+ await flush();
372
+ assert.equal(closed, true, "closing the dialog aborts the run");
373
+ assert.equal(moveCalls.length, 1, "the in-flight batch completes");
374
+ assert.equal(deleted, false, "abort never reaches the delete");
375
+ assert.doesNotMatch(
376
+ container.textContent ?? "",
377
+ /went wrong|still syncing|stay moved/,
378
+ "aborting is a cancel, not an error",
379
+ );
380
+ });
381
+
382
+ it("refuses the delete when an independent recheck still sees mail", async () => {
383
+ let deleted = false;
384
+ const moveCalls: string[][] = [];
385
+ route = ({ url, method, body: reqBody }) => {
386
+ if (method === "DELETE") {
387
+ deleted = true;
388
+ return new Response(null, { status: 204 });
389
+ }
390
+ if (url.includes("/messages/move")) {
391
+ const body = JSON.parse(reqBody) as { messageIds: string[] };
392
+ moveCalls.push(body.messageIds);
393
+ return json({ moved: body.messageIds.length });
394
+ }
395
+ if (url.includes("/threads")) return json(threadItems(["m1"]));
396
+ return json({ items: mailboxes });
397
+ };
398
+ render({ open: true, folder: mailboxes[1] as RemitImapMailboxResponse });
399
+ await clickMoveToArchive();
400
+ await flush();
401
+ assert.equal(
402
+ moveCalls.length,
403
+ 1,
404
+ "m1 moves once, then the drain filters it",
405
+ );
406
+ assert.equal(
407
+ deleted,
408
+ false,
409
+ "a live message still listed blocks the delete",
410
+ );
411
+ assert.match(container.textContent ?? "", /still syncing/);
412
+ });
413
+
414
+ it("resumes after re-open and skips already-moved mail", async () => {
415
+ let deleted = false;
416
+ const moveCalls: string[][] = [];
417
+ const movedOnServer = new Set<string>();
418
+ const folderIds = ["m1", "m2"];
419
+ let interruptNextMove = true;
420
+ route = ({ url, method, body: reqBody }) => {
421
+ if (method === "DELETE") {
422
+ deleted = true;
423
+ return new Response(null, { status: 204 });
424
+ }
425
+ if (url.includes("/messages/move")) {
426
+ const body = JSON.parse(reqBody) as { messageIds: string[] };
427
+ moveCalls.push(body.messageIds);
428
+ for (const id of body.messageIds) movedOnServer.add(id);
429
+ if (interruptNextMove) {
430
+ interruptNextMove = false;
431
+ container
432
+ .querySelector<HTMLButtonElement>('button[aria-label="Cancel"]')
433
+ ?.click();
434
+ }
435
+ return json({ moved: body.messageIds.length });
436
+ }
437
+ if (url.includes("/threads"))
438
+ return json(
439
+ threadItems(folderIds.filter((id) => !movedOnServer.has(id))),
440
+ );
441
+ return json({ items: mailboxes });
442
+ };
443
+ render({ open: true, folder: mailboxes[1] as RemitImapMailboxResponse });
444
+ await clickMoveToArchive();
445
+ await flush();
446
+ assert.deepEqual(
447
+ moveCalls,
448
+ [["m1", "m2"]],
449
+ "run one moves the first batch",
450
+ );
451
+ assert.equal(deleted, false, "the interrupted run does not delete");
452
+
453
+ folderIds.push("m3");
454
+ render({ open: false, folder: mailboxes[1] as RemitImapMailboxResponse });
455
+ render({ open: true, folder: mailboxes[1] as RemitImapMailboxResponse });
456
+ await clickMoveToArchive();
457
+ await flush();
458
+
459
+ const secondRunIds = moveCalls.slice(1).flat();
460
+ assert.deepEqual(
461
+ secondRunIds,
462
+ ["m3"],
463
+ "the resumed run moves only what is left, skipping already-moved ids",
464
+ );
465
+ assert.equal(deleted, true, "the resumed run drains and deletes");
466
+ });
467
+ });