@remit/web-client 0.0.70 → 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.
@@ -0,0 +1,267 @@
1
+ import {
2
+ configOperationsGetConfigQueryKey,
3
+ mailboxOperationsListMailboxesQueryKey,
4
+ } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
5
+ import {
6
+ mailboxDetailOperationsDeleteMailbox,
7
+ mailboxOperationsListMailboxes,
8
+ messageBulkOperationsMoveMessages,
9
+ threadOperationsListThreads,
10
+ } from "@remit/api-http-client/sdk.gen.ts";
11
+ import { useQueryClient } from "@tanstack/react-query";
12
+ import { useCallback, useRef, useState } from "react";
13
+ import {
14
+ advanceMove,
15
+ beginMove,
16
+ failMove,
17
+ MOVE_BATCH_SIZE,
18
+ type MoveProgress,
19
+ } from "@/lib/delete-folder";
20
+
21
+ const PAGE_CAP = 50;
22
+
23
+ type Outcome<T> = { ok: true; value: T } | { ok: false; error: string };
24
+
25
+ const errorText = (error: unknown): string =>
26
+ error instanceof Error ? error.message : "Something went wrong.";
27
+
28
+ /**
29
+ * Run a network call to a branchable result instead of a thrown exception, so
30
+ * the caller can surface a failed batch to the UI without a block catch (the
31
+ * fail-fast lint rule reserves those for rethrows).
32
+ */
33
+ const attempt = <T>(work: Promise<T>): Promise<Outcome<T>> =>
34
+ work.then(
35
+ (value) => ({ ok: true as const, value }),
36
+ (error) => ({ ok: false as const, error: errorText(error) }),
37
+ );
38
+
39
+ /**
40
+ * Collect up to `limit` message ids still living in the mailbox, skipping ones
41
+ * already moved this run and anything flagged deleted. Reads fresh from the
42
+ * server every call so a resumed or interrupted move enumerates what is
43
+ * actually left, never a stale snapshot.
44
+ */
45
+ const collectMovableIds = async (
46
+ mailboxId: string,
47
+ alreadyMoved: ReadonlySet<string>,
48
+ limit: number,
49
+ signal: AbortSignal,
50
+ ): Promise<string[]> => {
51
+ const ids: string[] = [];
52
+ let continuationToken: string | undefined;
53
+ for (let page = 0; page < PAGE_CAP; page += 1) {
54
+ if (signal.aborted) return ids;
55
+ const { data } = await threadOperationsListThreads({
56
+ path: { mailboxId },
57
+ query: { order: "desc", continuationToken },
58
+ throwOnError: true,
59
+ });
60
+ for (const item of data.items) {
61
+ if (item.isDeleted) continue;
62
+ if (alreadyMoved.has(item.messageId)) continue;
63
+ if (ids.includes(item.messageId)) continue;
64
+ ids.push(item.messageId);
65
+ if (ids.length >= limit) return ids;
66
+ }
67
+ if (!data.continuationToken) break;
68
+ continuationToken = data.continuationToken;
69
+ }
70
+ return ids;
71
+ };
72
+
73
+ /**
74
+ * Count live messages still listed in the mailbox with no `moved`-set filtering,
75
+ * an independent read the move loop cannot fool. The delete gates on this: if any
76
+ * message still shows here — including one that lags in the source listing after a
77
+ * move — the folder is not emptied yet and must not be deleted.
78
+ */
79
+ const liveMessageCount = async (
80
+ mailboxId: string,
81
+ signal: AbortSignal,
82
+ ): Promise<number> => {
83
+ let count = 0;
84
+ let continuationToken: string | undefined;
85
+ for (let page = 0; page < PAGE_CAP; page += 1) {
86
+ if (signal.aborted) return count;
87
+ const { data } = await threadOperationsListThreads({
88
+ path: { mailboxId },
89
+ query: { order: "desc", continuationToken },
90
+ throwOnError: true,
91
+ });
92
+ for (const item of data.items) {
93
+ if (item.isDeleted) continue;
94
+ count += 1;
95
+ }
96
+ if (!data.continuationToken) break;
97
+ continuationToken = data.continuationToken;
98
+ }
99
+ return count;
100
+ };
101
+
102
+ export type DeleteFolderPhase =
103
+ | "idle"
104
+ | "moving"
105
+ | "deleting"
106
+ | "done"
107
+ | "error";
108
+
109
+ interface UseDeleteFolderOptions {
110
+ accountId: string;
111
+ mailboxId: string;
112
+ onDeleted: () => void;
113
+ }
114
+
115
+ export function useDeleteFolder({
116
+ accountId,
117
+ mailboxId,
118
+ onDeleted,
119
+ }: UseDeleteFolderOptions) {
120
+ const queryClient = useQueryClient();
121
+ const [phase, setPhase] = useState<DeleteFolderPhase>("idle");
122
+ const [progress, setProgress] = useState<MoveProgress | null>(null);
123
+ const [errorMessage, setErrorMessage] = useState<string>();
124
+ const abortRef = useRef<AbortController | null>(null);
125
+
126
+ const invalidate = useCallback(() => {
127
+ queryClient.invalidateQueries({
128
+ queryKey: mailboxOperationsListMailboxesQueryKey({ path: { accountId } }),
129
+ });
130
+ queryClient.invalidateQueries({
131
+ queryKey: configOperationsGetConfigQueryKey(),
132
+ });
133
+ }, [queryClient, accountId]);
134
+
135
+ const deleteMailbox = useCallback(async () => {
136
+ setPhase("deleting");
137
+ const outcome = await attempt(
138
+ mailboxDetailOperationsDeleteMailbox({
139
+ path: { accountId, mailboxId },
140
+ throwOnError: true,
141
+ }),
142
+ );
143
+ if (!outcome.ok) {
144
+ setErrorMessage(outcome.error);
145
+ setPhase("error");
146
+ return;
147
+ }
148
+ invalidate();
149
+ setPhase("done");
150
+ onDeleted();
151
+ }, [accountId, mailboxId, invalidate, onDeleted]);
152
+
153
+ const remainingCount = useCallback(
154
+ (): Promise<number> =>
155
+ mailboxOperationsListMailboxes({
156
+ path: { accountId },
157
+ throwOnError: true,
158
+ }).then(
159
+ ({ data }) =>
160
+ data.items.find((box) => box.mailboxId === mailboxId)?.messageCount ??
161
+ 0,
162
+ ),
163
+ [accountId, mailboxId],
164
+ );
165
+
166
+ const cancel = useCallback(() => {
167
+ abortRef.current?.abort();
168
+ }, []);
169
+
170
+ const moveThenDelete = useCallback(
171
+ async (destinationMailboxId: string) => {
172
+ const controller = new AbortController();
173
+ abortRef.current = controller;
174
+ const { signal } = controller;
175
+ setPhase("moving");
176
+ setErrorMessage(undefined);
177
+
178
+ const counted = await attempt(remainingCount());
179
+ if (signal.aborted) return;
180
+ if (!counted.ok) {
181
+ setErrorMessage(counted.error);
182
+ setPhase("error");
183
+ return;
184
+ }
185
+ let current = beginMove(counted.value);
186
+ setProgress(current);
187
+
188
+ const moved = new Set<string>();
189
+ while (true) {
190
+ if (signal.aborted) return;
191
+ const collected = await attempt(
192
+ collectMovableIds(mailboxId, moved, MOVE_BATCH_SIZE, signal),
193
+ );
194
+ if (signal.aborted) return;
195
+ if (!collected.ok) {
196
+ current = failMove(current, collected.error);
197
+ setProgress(current);
198
+ setErrorMessage(collected.error);
199
+ setPhase("error");
200
+ return;
201
+ }
202
+ const batch = collected.value;
203
+ if (batch.length === 0) break;
204
+
205
+ const movedBatch = await attempt(
206
+ messageBulkOperationsMoveMessages({
207
+ body: { messageIds: batch, destinationMailboxId },
208
+ throwOnError: true,
209
+ }),
210
+ );
211
+ if (signal.aborted) return;
212
+ if (!movedBatch.ok) {
213
+ current = failMove(current, movedBatch.error);
214
+ setProgress(current);
215
+ setErrorMessage(movedBatch.error);
216
+ setPhase("error");
217
+ return;
218
+ }
219
+ for (const id of batch) moved.add(id);
220
+ current = advanceMove(current, batch.length);
221
+ setProgress(current);
222
+ }
223
+
224
+ const remaining = await attempt(liveMessageCount(mailboxId, signal));
225
+ if (signal.aborted) return;
226
+ if (!remaining.ok) {
227
+ current = failMove(current, remaining.error);
228
+ setProgress(current);
229
+ setErrorMessage(remaining.error);
230
+ setPhase("error");
231
+ return;
232
+ }
233
+ if (remaining.value > 0) {
234
+ const message = `${remaining.value} ${
235
+ remaining.value === 1 ? "email is" : "emails are"
236
+ } still syncing. Re-open delete to finish removing this folder.`;
237
+ current = failMove(current, message);
238
+ setProgress(current);
239
+ setErrorMessage(message);
240
+ setPhase("error");
241
+ invalidate();
242
+ return;
243
+ }
244
+
245
+ await deleteMailbox();
246
+ },
247
+ [mailboxId, remainingCount, deleteMailbox, invalidate],
248
+ );
249
+
250
+ const reset = useCallback(() => {
251
+ abortRef.current?.abort();
252
+ abortRef.current = null;
253
+ setPhase("idle");
254
+ setProgress(null);
255
+ setErrorMessage(undefined);
256
+ }, []);
257
+
258
+ return {
259
+ phase,
260
+ progress,
261
+ errorMessage,
262
+ deleteMailbox,
263
+ moveThenDelete,
264
+ cancel,
265
+ reset,
266
+ };
267
+ }
@@ -0,0 +1,186 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ advanceMove,
5
+ appointedRole,
6
+ beginMove,
7
+ excludeFolder,
8
+ type FolderNode,
9
+ failMove,
10
+ guardFolderDeletion,
11
+ hasChildFolders,
12
+ initialStage,
13
+ moveProgressLabel,
14
+ } from "./delete-folder.js";
15
+
16
+ const folder = (
17
+ over: Partial<FolderNode> & { mailboxId: string },
18
+ ): FolderNode => ({
19
+ fullPath: over.mailboxId,
20
+ hierarchyDelimiter: "/",
21
+ messageCount: 0,
22
+ ...over,
23
+ });
24
+
25
+ describe("guardFolderDeletion", () => {
26
+ it("blocks the inbox by its reserved path, case-insensitively", () => {
27
+ const target = folder({ mailboxId: "a", fullPath: "InBoX" });
28
+ const guard = guardFolderDeletion(target, [target], []);
29
+ assert.equal(guard.deletable, false);
30
+ assert.equal(guard.reason, "inbox");
31
+ assert.match(guard.message ?? "", /inbox can't be deleted/i);
32
+ });
33
+
34
+ it("blocks a folder appointed to a canonical role and names the role", () => {
35
+ const target = folder({ mailboxId: "arch", fullPath: "Archive" });
36
+ const guard = guardFolderDeletion(
37
+ target,
38
+ [target],
39
+ [{ role: "Archive", mailboxId: "arch" }],
40
+ );
41
+ assert.equal(guard.deletable, false);
42
+ assert.equal(guard.reason, "role");
43
+ assert.match(guard.message ?? "", /Archive/);
44
+ });
45
+
46
+ it("blocks a folder that has subfolders", () => {
47
+ const target = folder({ mailboxId: "work", fullPath: "Work" });
48
+ const child = folder({ mailboxId: "sub", fullPath: "Work/Shop" });
49
+ const guard = guardFolderDeletion(target, [target, child], []);
50
+ assert.equal(guard.deletable, false);
51
+ assert.equal(guard.reason, "children");
52
+ assert.match(guard.message ?? "", /subfolders/i);
53
+ });
54
+
55
+ it("allows a plain leaf folder with no role and no children", () => {
56
+ const target = folder({
57
+ mailboxId: "r",
58
+ fullPath: "Receipts",
59
+ messageCount: 4,
60
+ });
61
+ const other = folder({ mailboxId: "n", fullPath: "News" });
62
+ const guard = guardFolderDeletion(
63
+ target,
64
+ [target, other],
65
+ [{ role: "Archive", mailboxId: "other-box" }],
66
+ );
67
+ assert.deepEqual(guard, { deletable: true });
68
+ });
69
+
70
+ it("ignores an unfilled role appointment with no mailbox", () => {
71
+ const target = folder({ mailboxId: "r", fullPath: "Receipts" });
72
+ const guard = guardFolderDeletion(target, [target], [{ role: "Junk" }]);
73
+ assert.equal(guard.deletable, true);
74
+ });
75
+ });
76
+
77
+ describe("hasChildFolders", () => {
78
+ it("treats a delimiter-separated nested path as a child", () => {
79
+ const parent = {
80
+ mailboxId: "p",
81
+ fullPath: "Work",
82
+ hierarchyDelimiter: "/",
83
+ };
84
+ const kids = [{ mailboxId: "c", fullPath: "Work/Q3" }];
85
+ assert.equal(hasChildFolders(parent, [...kids, parent]), true);
86
+ });
87
+
88
+ it("does not treat a shared string prefix without a delimiter as a child", () => {
89
+ const parent = {
90
+ mailboxId: "p",
91
+ fullPath: "Work",
92
+ hierarchyDelimiter: "/",
93
+ };
94
+ const lookalike = [{ mailboxId: "w", fullPath: "Workshop" }];
95
+ assert.equal(hasChildFolders(parent, [...lookalike, parent]), false);
96
+ });
97
+
98
+ it("honours a non-slash delimiter", () => {
99
+ const parent = {
100
+ mailboxId: "p",
101
+ fullPath: "INBOX",
102
+ hierarchyDelimiter: ".",
103
+ };
104
+ const kids = [{ mailboxId: "c", fullPath: "INBOX.Sub" }];
105
+ assert.equal(hasChildFolders(parent, [...kids, parent]), true);
106
+ });
107
+ });
108
+
109
+ describe("appointedRole", () => {
110
+ it("returns the role a mailbox fills", () => {
111
+ assert.equal(
112
+ appointedRole("a", [{ role: "Trash", mailboxId: "a" }]),
113
+ "Trash",
114
+ );
115
+ });
116
+
117
+ it("returns undefined when the mailbox fills no role", () => {
118
+ assert.equal(
119
+ appointedRole("a", [{ role: "Trash", mailboxId: "b" }]),
120
+ undefined,
121
+ );
122
+ });
123
+ });
124
+
125
+ describe("excludeFolder", () => {
126
+ it("drops the folder being deleted from the destination list", () => {
127
+ const targets = [
128
+ { mailboxId: "a" },
129
+ { mailboxId: "b" },
130
+ { mailboxId: "c" },
131
+ ];
132
+ assert.deepEqual(excludeFolder(targets, "b"), [
133
+ { mailboxId: "a" },
134
+ { mailboxId: "c" },
135
+ ]);
136
+ });
137
+ });
138
+
139
+ describe("move progress", () => {
140
+ it("begins at zero and moving for a non-empty set", () => {
141
+ assert.deepEqual(beginMove(120), { total: 120, moved: 0, phase: "moving" });
142
+ });
143
+
144
+ it("begins already moved for an empty set", () => {
145
+ assert.deepEqual(beginMove(0), { total: 0, moved: 0, phase: "moved" });
146
+ });
147
+
148
+ it("advances by the batch size and finishes at the total", () => {
149
+ let progress = beginMove(150);
150
+ progress = advanceMove(progress, 100);
151
+ assert.deepEqual(progress, { total: 150, moved: 100, phase: "moving" });
152
+ progress = advanceMove(progress, 50);
153
+ assert.deepEqual(progress, { total: 150, moved: 150, phase: "moved" });
154
+ });
155
+
156
+ it("clamps moved to the total if the folder shrank underneath the count", () => {
157
+ const progress = advanceMove(beginMove(80), 100);
158
+ assert.equal(progress.moved, 80);
159
+ assert.equal(progress.phase, "moved");
160
+ });
161
+
162
+ it("stops progress on failure and does not advance afterwards", () => {
163
+ const failed = failMove(beginMove(150), "boom");
164
+ assert.equal(failed.phase, "failed");
165
+ assert.equal(failed.moved, 0);
166
+ const after = advanceMove(failed, 100);
167
+ assert.deepEqual(after, failed);
168
+ });
169
+
170
+ it("labels progress as moved-of-total", () => {
171
+ assert.equal(
172
+ moveProgressLabel({ total: 150, moved: 100, phase: "moving" }),
173
+ "Moved 100 of 150",
174
+ );
175
+ });
176
+ });
177
+
178
+ describe("initialStage", () => {
179
+ it("opens on the empty confirm for a folder with no mail", () => {
180
+ assert.equal(initialStage(0), "confirm-empty");
181
+ });
182
+
183
+ it("opens on the fate choice for a folder holding mail", () => {
184
+ assert.equal(initialStage(3), "choose-fate");
185
+ });
186
+ });
@@ -0,0 +1,151 @@
1
+ export const MOVE_BATCH_SIZE = 100;
2
+
3
+ export interface FolderNode {
4
+ mailboxId: string;
5
+ fullPath: string;
6
+ hierarchyDelimiter: string;
7
+ messageCount: number;
8
+ }
9
+
10
+ export interface RoleAppointment {
11
+ role: string;
12
+ mailboxId?: string | null;
13
+ }
14
+
15
+ export type DeleteBlockReason = "inbox" | "role" | "children";
16
+
17
+ export interface DeleteGuard {
18
+ deletable: boolean;
19
+ reason?: DeleteBlockReason;
20
+ message?: string;
21
+ }
22
+
23
+ const isInboxPath = (fullPath: string): boolean =>
24
+ fullPath.trim().toLowerCase() === "inbox";
25
+
26
+ /**
27
+ * A folder has children when another mailbox's path is nested under it — the
28
+ * folder's own path followed by the hierarchy delimiter. The delimiter matters:
29
+ * `Work` is not a parent of `Workshop`, only of `Work/Shop`.
30
+ */
31
+ export const hasChildFolders = (
32
+ folder: Pick<FolderNode, "mailboxId" | "fullPath" | "hierarchyDelimiter">,
33
+ all: readonly Pick<FolderNode, "mailboxId" | "fullPath">[],
34
+ ): boolean => {
35
+ const prefix = `${folder.fullPath}${folder.hierarchyDelimiter}`;
36
+ return all.some(
37
+ (other) =>
38
+ other.mailboxId !== folder.mailboxId && other.fullPath.startsWith(prefix),
39
+ );
40
+ };
41
+
42
+ /** The canonical role a mailbox is appointed to, or `undefined` when unfilled. */
43
+ export const appointedRole = (
44
+ mailboxId: string,
45
+ appointments: readonly RoleAppointment[],
46
+ ): string | undefined =>
47
+ appointments.find((a) => a.mailboxId != null && a.mailboxId === mailboxId)
48
+ ?.role;
49
+
50
+ /**
51
+ * Why a folder can't be deleted, or `{ deletable: true }` when it can. The
52
+ * inbox is reserved, a folder that fills a canonical role must be released
53
+ * first, and a folder with subfolders must have them handled before it goes.
54
+ */
55
+ export function guardFolderDeletion(
56
+ folder: FolderNode,
57
+ all: readonly FolderNode[],
58
+ appointments: readonly RoleAppointment[],
59
+ ): DeleteGuard {
60
+ if (isInboxPath(folder.fullPath))
61
+ return {
62
+ deletable: false,
63
+ reason: "inbox",
64
+ message: "The inbox can't be deleted.",
65
+ };
66
+
67
+ const role = appointedRole(folder.mailboxId, appointments);
68
+ if (role)
69
+ return {
70
+ deletable: false,
71
+ reason: "role",
72
+ message: `This folder is your ${role} folder. Reassign that role before deleting it.`,
73
+ };
74
+
75
+ if (hasChildFolders(folder, all))
76
+ return {
77
+ deletable: false,
78
+ reason: "children",
79
+ message: "This folder has subfolders. Delete them first.",
80
+ };
81
+
82
+ return { deletable: true };
83
+ }
84
+
85
+ /** Destinations with the folder being deleted removed. */
86
+ export function excludeFolder<T extends { mailboxId: string }>(
87
+ targets: readonly T[],
88
+ excludeMailboxId: string,
89
+ ): T[] {
90
+ return targets.filter((target) => target.mailboxId !== excludeMailboxId);
91
+ }
92
+
93
+ export type MovePhase = "moving" | "moved" | "failed";
94
+
95
+ export interface MoveProgress {
96
+ total: number;
97
+ moved: number;
98
+ phase: MovePhase;
99
+ error?: string;
100
+ }
101
+
102
+ /** Progress at the start of a move; an empty set is already `moved`. */
103
+ export function beginMove(total: number): MoveProgress {
104
+ return { total, moved: 0, phase: total === 0 ? "moved" : "moving" };
105
+ }
106
+
107
+ /**
108
+ * Count a successful batch. Clamps to `total` so a folder that grew mid-move
109
+ * never shows more moved than started, and only a still-`moving` progress
110
+ * advances — a failed or finished move stays put.
111
+ */
112
+ export function advanceMove(
113
+ progress: MoveProgress,
114
+ batchSize: number,
115
+ ): MoveProgress {
116
+ if (progress.phase !== "moving") return progress;
117
+ const moved = Math.min(
118
+ progress.total,
119
+ progress.moved + Math.max(0, batchSize),
120
+ );
121
+ return {
122
+ ...progress,
123
+ moved,
124
+ phase: moved >= progress.total ? "moved" : "moving",
125
+ };
126
+ }
127
+
128
+ /** A failed batch stops progress and surfaces the error; nothing advances after. */
129
+ export function failMove(progress: MoveProgress, error: string): MoveProgress {
130
+ return { ...progress, phase: "failed", error };
131
+ }
132
+
133
+ export function moveProgressLabel(progress: MoveProgress): string {
134
+ return `Moved ${progress.moved} of ${progress.total}`;
135
+ }
136
+
137
+ export type DeleteStage =
138
+ | "confirm-empty"
139
+ | "choose-fate"
140
+ | "confirm-delete-all"
141
+ | "pick-destination"
142
+ | "moving"
143
+ | "deleting"
144
+ | "error";
145
+
146
+ /** Where the wizard opens: a straight confirm for an empty folder, otherwise the fate step. */
147
+ export function initialStage(
148
+ messageCount: number,
149
+ ): "confirm-empty" | "choose-fate" {
150
+ return messageCount > 0 ? "choose-fate" : "confirm-empty";
151
+ }
@@ -0,0 +1,109 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ composeFolderPath,
5
+ type FolderTarget,
6
+ validateNewFolderName,
7
+ } from "./new-folder.js";
8
+
9
+ const parent: FolderTarget = { fullPath: "INBOX", hierarchyDelimiter: "/" };
10
+
11
+ describe("composeFolderPath", () => {
12
+ it("returns the trimmed name for a root folder", () => {
13
+ assert.equal(composeFolderPath(" Receipts "), "Receipts");
14
+ });
15
+
16
+ it("joins the parent path by the parent's delimiter", () => {
17
+ assert.equal(composeFolderPath("Receipts", parent), "INBOX/Receipts");
18
+ });
19
+
20
+ it("honours a non-slash delimiter", () => {
21
+ assert.equal(
22
+ composeFolderPath("Receipts", {
23
+ fullPath: "INBOX",
24
+ hierarchyDelimiter: ".",
25
+ }),
26
+ "INBOX.Receipts",
27
+ );
28
+ });
29
+ });
30
+
31
+ describe("validateNewFolderName", () => {
32
+ const base = { delimiter: "/", existingPaths: ["INBOX", "INBOX/Archive"] };
33
+
34
+ it("rejects an empty or whitespace name", () => {
35
+ assert.match(
36
+ validateNewFolderName({ ...base, name: " " }) ?? "",
37
+ /Enter a folder name/,
38
+ );
39
+ });
40
+
41
+ it("rejects a name containing the hierarchy delimiter", () => {
42
+ assert.match(
43
+ validateNewFolderName({ ...base, name: "a/b" }) ?? "",
44
+ /can't contain/,
45
+ );
46
+ });
47
+
48
+ it("rejects a name that would contain a non-slash delimiter", () => {
49
+ assert.match(
50
+ validateNewFolderName({
51
+ delimiter: ".",
52
+ existingPaths: [],
53
+ name: "a.b",
54
+ }) ?? "",
55
+ /can't contain/,
56
+ );
57
+ });
58
+
59
+ it("rejects an exact collision with an existing path", () => {
60
+ assert.match(
61
+ validateNewFolderName({
62
+ ...base,
63
+ name: "Archive",
64
+ parent,
65
+ }) ?? "",
66
+ /already exists/,
67
+ );
68
+ });
69
+
70
+ it("rejects a root name that collides with an existing top-level fullPath", () => {
71
+ assert.match(
72
+ validateNewFolderName({
73
+ delimiter: "/",
74
+ existingPaths: ["Work"],
75
+ name: "Work",
76
+ }) ?? "",
77
+ /already exists/,
78
+ );
79
+ });
80
+
81
+ it("treats INBOX case-insensitively for collisions", () => {
82
+ assert.match(
83
+ validateNewFolderName({
84
+ delimiter: "/",
85
+ existingPaths: ["INBOX"],
86
+ name: "inbox",
87
+ }) ?? "",
88
+ /already exists/,
89
+ );
90
+ });
91
+
92
+ it("compares non-INBOX segments case-sensitively", () => {
93
+ assert.equal(
94
+ validateNewFolderName({
95
+ delimiter: "/",
96
+ existingPaths: ["Work"],
97
+ name: "work",
98
+ }),
99
+ undefined,
100
+ );
101
+ });
102
+
103
+ it("accepts a valid new nested folder", () => {
104
+ assert.equal(
105
+ validateNewFolderName({ ...base, name: "Receipts", parent }),
106
+ undefined,
107
+ );
108
+ });
109
+ });