@remit/mailbox-service 0.0.96 → 0.0.97

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.
@@ -9,6 +9,11 @@ import { NotFoundError } from "@remit/data-ports/errors";
9
9
  import { rebaseMailboxPath } from "@remit/data-ports/mailbox-name";
10
10
  import { MailboxSyncStatus } from "@remit/domain-enums";
11
11
  import { createQueueProducer } from "@remit/sqs-client/producer";
12
+ import {
13
+ INTENT_RECORDABLE_FROM,
14
+ recordedByRename,
15
+ refuseContestedIntent,
16
+ } from "./mailbox-intent.js";
12
17
  import { EVERY_MAILBOX_STATE } from "./mailbox-presence.js";
13
18
 
14
19
  /**
@@ -149,13 +154,19 @@ export class MailboxQueueService {
149
154
  };
150
155
 
151
156
  /**
152
- * Rename a mailbox.
153
- * Updates local state (including children) and enqueues IMAP RENAME.
157
+ * Record a rename intent over the folder and every descendant, then enqueue
158
+ * IMAP RENAME.
159
+ *
160
+ * The target is recorded, not the path (D2): every row keeps the `fullPath`
161
+ * the server holds and takes its own rewritten target in `pendingPath`, so a
162
+ * rename that never lands needs no unwinding — dropping `pendingPath` is the
163
+ * whole revert — and nothing that resolves a folder path from a row can
164
+ * resolve one the server does not have.
154
165
  *
155
166
  * @param mailboxId - The mailbox to rename
156
- * @param newPath - The new path for the mailbox
167
+ * @param newPath - The path the rename is aiming at
157
168
  * @param accountId - The account ID for the IMAP sync event
158
- * @returns The updated mailbox
169
+ * @returns The folder that was named, carrying its recorded target
159
170
  */
160
171
  renameMailbox = async (
161
172
  mailboxId: string,
@@ -168,32 +179,58 @@ export class MailboxQueueService {
168
179
 
169
180
  // One intent over the folder and every descendant, in one transaction:
170
181
  // IMAP RENAME moves the subtree in one command, so a partially recorded
171
- // rename is the state that produced today's phantom rows (D6). Each
172
- // descendant's new path is equally absent from the server until
173
- // MAILBOX_RENAME lands, so each is `pending` too — otherwise a reconcile
174
- // in that window reaps it as server-deleted (#290).
182
+ // rename is the state that produced today's phantom rows (D6). A
183
+ // descendant already mid-mutation refuses the whole rename.
175
184
  const written = await this.mailboxService.transitionSubtree(
176
185
  accountId,
177
186
  mailboxId,
178
187
  {
179
- from: EVERY_MAILBOX_STATE,
188
+ from: INTENT_RECORDABLE_FROM,
180
189
  to: MailboxSyncStatus.pending,
181
190
  rowSet: (row) => {
182
- const moved = rebaseMailboxPath(
191
+ const target = rebaseMailboxPath(
183
192
  row.fullPath,
184
193
  oldPath,
185
194
  newPath,
186
195
  mailbox.hierarchyDelimiter,
187
196
  );
188
- return moved === undefined ? {} : { fullPath: moved };
197
+ // The subtree is the folder and the rows under its own prefix, so
198
+ // every one of them rebases. A row that does not is a resolution
199
+ // bug, and recording an intent with no target for it would strand
200
+ // it `pending` with nothing able to settle it.
201
+ if (target === undefined) {
202
+ throw new Error(
203
+ `Mailbox ${row.mailboxId} at "${row.fullPath}" is not under "${oldPath}"`,
204
+ );
205
+ }
206
+ return { pendingPath: target };
189
207
  },
190
208
  },
191
209
  );
192
- if (!written) throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
210
+ if (!written) {
211
+ return refuseContestedIntent(
212
+ this.mailboxService,
213
+ accountId,
214
+ mailboxId,
215
+ "subtree",
216
+ );
217
+ }
193
218
  const updated = written.find((row) => row.mailboxId === mailboxId);
194
219
  if (!updated) throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
195
220
 
196
- this.log.info({ mailboxId, oldPath, newPath }, "Renamed mailbox (local)");
221
+ this.log.info(
222
+ {
223
+ accountId,
224
+ mailboxId,
225
+ intent: "rename",
226
+ from: mailbox.syncStatus,
227
+ to: MailboxSyncStatus.pending,
228
+ oldPath,
229
+ newPath,
230
+ subtreeSize: written.length,
231
+ },
232
+ "Recorded rename intent",
233
+ );
197
234
 
198
235
  // Enqueue IMAP sync
199
236
  await this.enqueueEvent({
@@ -209,6 +246,89 @@ export class MailboxQueueService {
209
246
  return updated;
210
247
  };
211
248
 
249
+ /**
250
+ * The route out of a failed rename or a failed delete that keeps the folder
251
+ * as it is (T10, T11): the recorded target is dropped and the row settles
252
+ * back to `synced`. Nothing is enqueued — `fullPath` was never written, so
253
+ * the server has nothing to undo.
254
+ *
255
+ * Reached without new API surface, from a PATCH whose `fullPath` equals the
256
+ * row's confirmed one. From `synced` it is a no-op; from a state with a
257
+ * mutation in flight it is a 409, because dismissing an intent that is still
258
+ * running would leave the settle with no row to write.
259
+ */
260
+ dismissMailboxIntent = async (
261
+ mailboxId: string,
262
+ accountId: string,
263
+ ): Promise<MailboxItem> => {
264
+ const current = await this.mailboxService.get(accountId, mailboxId);
265
+ if (current.syncStatus === MailboxSyncStatus.synced) return current;
266
+ if (current.syncStatus !== MailboxSyncStatus.failed) {
267
+ return refuseContestedIntent(
268
+ this.mailboxService,
269
+ accountId,
270
+ mailboxId,
271
+ "folder",
272
+ );
273
+ }
274
+
275
+ // A failed rename was recorded over a subtree, so it is dismissed over the
276
+ // same one. A failed delete carries no target and was recorded on the
277
+ // folder alone, so there is nothing else to clear.
278
+ const target = current.pendingPath;
279
+ const alsoRecorded =
280
+ target === undefined
281
+ ? []
282
+ : (
283
+ await this.mailboxService.findBySyncStatus(
284
+ accountId,
285
+ MailboxSyncStatus.failed,
286
+ )
287
+ ).filter(
288
+ (row) =>
289
+ row.mailboxId !== mailboxId &&
290
+ recordedByRename(
291
+ row,
292
+ current.fullPath,
293
+ target,
294
+ current.hierarchyDelimiter,
295
+ ),
296
+ );
297
+
298
+ let dismissed: MailboxItem | undefined;
299
+ // The named folder first, so a caller always gets back the row it asked
300
+ // about. Each write carries its own predicate, so a row somebody else
301
+ // moved between the read and the write is skipped, not overwritten (D3).
302
+ for (const row of [current, ...alsoRecorded]) {
303
+ const written = await this.mailboxService.transition(
304
+ accountId,
305
+ row.mailboxId,
306
+ {
307
+ from: [MailboxSyncStatus.failed],
308
+ wherePendingPath: row.pendingPath ?? null,
309
+ to: MailboxSyncStatus.synced,
310
+ },
311
+ );
312
+ if (row.mailboxId === mailboxId) dismissed = written ?? undefined;
313
+ this.log.info(
314
+ {
315
+ accountId,
316
+ mailboxId: row.mailboxId,
317
+ intent: "dismiss",
318
+ from: MailboxSyncStatus.failed,
319
+ to: MailboxSyncStatus.synced,
320
+ outcome: written ? "settled" : "superseded",
321
+ },
322
+ "Dismissed folder intent",
323
+ );
324
+ }
325
+
326
+ if (dismissed) return dismissed;
327
+ // The row moved between the read and its write. Re-read rather than
328
+ // reporting a dismissal that did not happen.
329
+ return this.mailboxService.get(accountId, mailboxId);
330
+ };
331
+
212
332
  /**
213
333
  * Delete a mailbox.
214
334
  * Marks for deletion (syncStatus=deleting) and enqueues IMAP DELETE.
@@ -4,13 +4,18 @@ import type { IMailboxRepository, MailboxItem } from "@remit/data-ports";
4
4
  import { MailboxSyncStatus } from "@remit/domain-enums";
5
5
  import { MailboxQueueService } from "./mailbox-queue.js";
6
6
 
7
- const row = (mailboxId: string, fullPath: string): MailboxItem =>
7
+ const row = (
8
+ mailboxId: string,
9
+ fullPath: string,
10
+ over: Partial<MailboxItem> = {},
11
+ ): MailboxItem =>
8
12
  ({
9
13
  mailboxId,
10
14
  accountId: "acc-1",
11
15
  fullPath,
12
16
  hierarchyDelimiter: "/",
13
17
  syncStatus: MailboxSyncStatus.synced,
18
+ ...over,
14
19
  }) as MailboxItem;
15
20
 
16
21
  /**
@@ -19,8 +24,21 @@ const row = (mailboxId: string, fullPath: string): MailboxItem =>
19
24
  */
20
25
  const store = (rows: MailboxItem[]) => {
21
26
  const byId = new Map(rows.map((r) => [r.mailboxId, { ...r }]));
27
+ const subtreeOf = (root: MailboxItem) => {
28
+ const prefix = `${root.fullPath}${root.hierarchyDelimiter}`;
29
+ return [...byId.values()].filter(
30
+ (r) => r.mailboxId !== root.mailboxId && r.fullPath.startsWith(prefix),
31
+ );
32
+ };
22
33
 
23
- const repo: Pick<IMailboxRepository, "get" | "transitionSubtree"> = {
34
+ const repo: Pick<
35
+ IMailboxRepository,
36
+ | "get"
37
+ | "transition"
38
+ | "transitionSubtree"
39
+ | "findByPathPrefix"
40
+ | "findBySyncStatus"
41
+ > = {
24
42
  get: (async (_accountId: string, mailboxId: string) => {
25
43
  const found = byId.get(mailboxId as string);
26
44
  if (!found) {
@@ -30,19 +48,45 @@ const store = (rows: MailboxItem[]) => {
30
48
  }
31
49
  return found;
32
50
  }) as IMailboxRepository["get"],
51
+ findByPathPrefix: async (_accountId, pathPrefix) => {
52
+ const root = [...byId.values()].find((r) => r.fullPath === pathPrefix);
53
+ return root ? subtreeOf(root) : [];
54
+ },
55
+ findBySyncStatus: async (_accountId, syncStatus) =>
56
+ [...byId.values()].filter((r) => r.syncStatus === syncStatus),
57
+ transition: async (_accountId, mailboxId, intent) => {
58
+ const current = byId.get(mailboxId);
59
+ if (!current) return null;
60
+ if (!intent.from.includes(current.syncStatus)) return null;
61
+ if (
62
+ intent.wherePendingPath !== undefined &&
63
+ (intent.wherePendingPath ?? undefined) !== current.pendingPath
64
+ ) {
65
+ return null;
66
+ }
67
+ const keepsTarget =
68
+ intent.to === MailboxSyncStatus.pending ||
69
+ intent.to === MailboxSyncStatus.failed;
70
+ const next = {
71
+ ...current,
72
+ ...(intent.set?.fullPath !== undefined
73
+ ? { fullPath: intent.set.fullPath }
74
+ : {}),
75
+ syncStatus: intent.to,
76
+ } as MailboxItem;
77
+ if (!keepsTarget) delete (next as { pendingPath?: string }).pendingPath;
78
+ byId.set(mailboxId, next);
79
+ return next;
80
+ },
33
81
  transitionSubtree: async (_accountId, mailboxId, intent) => {
34
82
  const root = byId.get(mailboxId);
35
83
  if (!root) return null;
36
- const prefix = `${root.fullPath}${root.hierarchyDelimiter}`;
37
- const subtree = [
38
- root,
39
- ...[...byId.values()].filter(
40
- (r) => r !== root && r.fullPath.startsWith(prefix),
41
- ),
42
- ];
84
+ const subtree = [root, ...subtreeOf(root)];
85
+ // All-or-nothing, the way the real transaction is: one row that is not
86
+ // in an accepted from-state refuses the whole intent and writes nothing.
87
+ if (subtree.some((c) => !intent.from.includes(c.syncStatus))) return null;
43
88
  const written: MailboxItem[] = [];
44
89
  for (const candidate of subtree) {
45
- if (!intent.from.includes(candidate.syncStatus)) return null;
46
90
  const next = {
47
91
  ...candidate,
48
92
  ...intent.rowSet(candidate),
@@ -59,7 +103,7 @@ const store = (rows: MailboxItem[]) => {
59
103
 
60
104
  return {
61
105
  repo: repo as IMailboxRepository,
62
- pathOf: (id: string) => byId.get(id)?.fullPath,
106
+ rowOf: (id: string) => byId.get(id),
63
107
  };
64
108
  };
65
109
 
@@ -84,27 +128,40 @@ const queueOver = (repo: IMailboxRepository) => {
84
128
  };
85
129
 
86
130
  describe("MailboxQueueService.renameMailbox — the recorded intent", () => {
131
+ it("records the target and leaves every confirmed path alone", async () => {
132
+ // D2: `fullPath` is always a path the server holds. Writing the target onto
133
+ // the row is what made a failed rename strand a subtree at paths the server
134
+ // never had, which the next sweep then inserted second rows for.
135
+ const { repo, rowOf } = store([
136
+ row("mbx-parent", "Work"),
137
+ row("mbx-child", "Work/2026"),
138
+ ]);
139
+ const { service } = queueOver(repo);
140
+
141
+ await service.renameMailbox("mbx-parent", "Projects", "acc-1");
142
+
143
+ assert.equal(rowOf("mbx-parent")?.fullPath, "Work");
144
+ assert.equal(rowOf("mbx-parent")?.pendingPath, "Projects");
145
+ assert.equal(rowOf("mbx-child")?.fullPath, "Work/2026");
146
+ assert.equal(rowOf("mbx-child")?.pendingPath, "Projects/2026");
147
+ assert.equal(rowOf("mbx-child")?.syncStatus, MailboxSyncStatus.pending);
148
+ });
149
+
87
150
  it("records the path the RENAME will carry, `$` patterns and all", async () => {
88
151
  // `String.prototype.replace` expands `$&`, "$`", `$'` and `$$` in the
89
152
  // replacement, so a folder renamed to a name containing one of them was
90
153
  // stored under a different path from the one the event carries to the
91
- // server. The row and the server then disagree, and the next sweep finds
92
- // no row for the real path and inserts a second one.
93
- const { repo, pathOf } = store([
154
+ // server. The row and the server then disagree.
155
+ const { repo, rowOf } = store([
94
156
  row("mbx-parent", "Work"),
95
157
  row("mbx-child", "Work/2026"),
96
158
  ]);
97
159
  const { service, sent } = queueOver(repo);
98
160
 
99
- const renamed = await service.renameMailbox(
100
- "mbx-parent",
101
- "Q1$&Q2",
102
- "acc-1",
103
- );
161
+ await service.renameMailbox("mbx-parent", "Q1$&Q2", "acc-1");
104
162
 
105
- assert.equal(renamed.fullPath, "Q1$&Q2");
106
- assert.equal(pathOf("mbx-parent"), "Q1$&Q2");
107
- assert.equal(pathOf("mbx-child"), "Q1$&Q2/2026");
163
+ assert.equal(rowOf("mbx-parent")?.pendingPath, "Q1$&Q2");
164
+ assert.equal(rowOf("mbx-child")?.pendingPath, "Q1$&Q2/2026");
108
165
  assert.equal(
109
166
  (JSON.parse(sent[0]) as { newPath: string }).newPath,
110
167
  "Q1$&Q2",
@@ -113,7 +170,7 @@ describe("MailboxQueueService.renameMailbox — the recorded intent", () => {
113
170
  });
114
171
 
115
172
  it("moves only what is under the renamed branch", async () => {
116
- const { repo, pathOf } = store([
173
+ const { repo, rowOf } = store([
117
174
  row("mbx-parent", "Work"),
118
175
  row("mbx-lookalike", "Workshop"),
119
176
  ]);
@@ -121,8 +178,9 @@ describe("MailboxQueueService.renameMailbox — the recorded intent", () => {
121
178
 
122
179
  await service.renameMailbox("mbx-parent", "Projects", "acc-1");
123
180
 
124
- assert.equal(pathOf("mbx-parent"), "Projects");
125
- assert.equal(pathOf("mbx-lookalike"), "Workshop");
181
+ assert.equal(rowOf("mbx-parent")?.pendingPath, "Projects");
182
+ assert.equal(rowOf("mbx-lookalike")?.pendingPath, undefined);
183
+ assert.equal(rowOf("mbx-lookalike")?.syncStatus, MailboxSyncStatus.synced);
126
184
  });
127
185
 
128
186
  it("returns the folder that was asked for, not whichever row came back first", async () => {
@@ -139,5 +197,193 @@ describe("MailboxQueueService.renameMailbox — the recorded intent", () => {
139
197
  );
140
198
 
141
199
  assert.equal(renamed.mailboxId, "mbx-parent");
200
+ assert.equal(renamed.pendingPath, "Projects");
201
+ });
202
+
203
+ it("records a retry from a failed rename onto the same row", async () => {
204
+ const { repo, rowOf } = store([
205
+ row("mbx-parent", "Work", {
206
+ syncStatus: MailboxSyncStatus.failed,
207
+ pendingPath: "Projects",
208
+ }),
209
+ ]);
210
+ const { service } = queueOver(repo);
211
+
212
+ await service.renameMailbox("mbx-parent", "Projects", "acc-1");
213
+
214
+ assert.equal(rowOf("mbx-parent")?.syncStatus, MailboxSyncStatus.pending);
215
+ assert.equal(rowOf("mbx-parent")?.pendingPath, "Projects");
216
+ });
217
+
218
+ it("refuses the whole subtree with a 409 naming the descendant that blocks it", async () => {
219
+ // A subtree cannot be half-renamed (D6), so a descendant mid-mutation
220
+ // refuses the rename of everything above it — and the user has to be told
221
+ // which folder, because it is not the one they named.
222
+ const { repo, rowOf } = store([
223
+ row("mbx-parent", "Archive"),
224
+ row("mbx-child", "Archive/2025/Receipts", {
225
+ syncStatus: MailboxSyncStatus.pending,
226
+ pendingPath: "Archive/2025/Bonnen",
227
+ }),
228
+ ]);
229
+ const { service, sent } = queueOver(repo);
230
+
231
+ await assert.rejects(
232
+ service.renameMailbox("mbx-parent", "Archief", "acc-1"),
233
+ (error: unknown) =>
234
+ (error as { statusCode?: number }).statusCode === 409 &&
235
+ (error as Error).message ===
236
+ 'A rename is in progress for "Archive/2025/Receipts", inside this folder.',
237
+ );
238
+
239
+ assert.equal(rowOf("mbx-parent")?.syncStatus, MailboxSyncStatus.synced);
240
+ assert.equal(rowOf("mbx-parent")?.pendingPath, undefined);
241
+ assert.equal(sent.length, 0, "nothing is enqueued for a refused intent");
242
+ });
243
+
244
+ it("refuses with a 409 naming the folder's own delete when it is the blocker", async () => {
245
+ const { repo } = store([
246
+ row("mbx-parent", "Archive", {
247
+ syncStatus: MailboxSyncStatus.deleting,
248
+ }),
249
+ ]);
250
+ const { service } = queueOver(repo);
251
+
252
+ await assert.rejects(
253
+ service.renameMailbox("mbx-parent", "Archief", "acc-1"),
254
+ (error: unknown) =>
255
+ (error as { statusCode?: number }).statusCode === 409 &&
256
+ (error as Error).message ===
257
+ 'A delete is already in progress for "Archive".',
258
+ );
259
+ });
260
+
261
+ it("answers 404 when the folder went between the read and the intent", async () => {
262
+ const { repo } = store([row("mbx-parent", "Work")]);
263
+ let read = 0;
264
+ // The read that establishes existence sees the row; the classifying read
265
+ // on the lost predicate does not, so the answer is 404, not a conflict.
266
+ const vanishing = {
267
+ ...repo,
268
+ get: (async (accountId: string, mailboxId: string) => {
269
+ read += 1;
270
+ if (read > 1) {
271
+ throw Object.assign(new Error(`Mailbox not found: ${mailboxId}`), {
272
+ name: "NotFoundError",
273
+ });
274
+ }
275
+ return repo.get(accountId, mailboxId as string);
276
+ }) as IMailboxRepository["get"],
277
+ transitionSubtree: async () => null,
278
+ } as IMailboxRepository;
279
+ const { service } = queueOver(vanishing);
280
+
281
+ await assert.rejects(
282
+ service.renameMailbox("mbx-parent", "Projects", "acc-1"),
283
+ (error: unknown) => (error as Error).name === "NotFoundError",
284
+ );
285
+ });
286
+ });
287
+
288
+ describe("MailboxQueueService.dismissMailboxIntent", () => {
289
+ it("clears a failed rename's target and settles the row, enqueuing nothing", async () => {
290
+ const { repo, rowOf } = store([
291
+ row("mbx-1", "Work", {
292
+ syncStatus: MailboxSyncStatus.failed,
293
+ pendingPath: "Projects",
294
+ }),
295
+ ]);
296
+ const { service, sent } = queueOver(repo);
297
+
298
+ const dismissed = await service.dismissMailboxIntent("mbx-1", "acc-1");
299
+
300
+ assert.equal(dismissed.syncStatus, MailboxSyncStatus.synced);
301
+ assert.equal(rowOf("mbx-1")?.pendingPath, undefined);
302
+ assert.equal(rowOf("mbx-1")?.fullPath, "Work");
303
+ assert.equal(sent.length, 0);
304
+ });
305
+
306
+ it("clears the whole subtree the failed rename was recorded over", async () => {
307
+ // The refusal marks every row the intent recorded, so dismissing the named
308
+ // folder alone leaves each descendant `failed` with a stale target on it,
309
+ // offering a retry of a rename the user has already dropped and with
310
+ // nothing left that would ever clear it.
311
+ const { repo, rowOf } = store([
312
+ row("mbx-parent", "Work", {
313
+ syncStatus: MailboxSyncStatus.failed,
314
+ pendingPath: "Projects",
315
+ }),
316
+ row("mbx-child", "Work/2026", {
317
+ syncStatus: MailboxSyncStatus.failed,
318
+ pendingPath: "Projects/2026",
319
+ }),
320
+ ]);
321
+ const { service } = queueOver(repo);
322
+
323
+ await service.dismissMailboxIntent("mbx-parent", "acc-1");
324
+
325
+ for (const id of ["mbx-parent", "mbx-child"]) {
326
+ assert.equal(rowOf(id)?.syncStatus, MailboxSyncStatus.synced);
327
+ assert.equal(rowOf(id)?.pendingPath, undefined);
328
+ }
329
+ });
330
+
331
+ it("leaves a failed folder a different rename recorded alone", async () => {
332
+ const { repo, rowOf } = store([
333
+ row("mbx-parent", "Work", {
334
+ syncStatus: MailboxSyncStatus.failed,
335
+ pendingPath: "Projects",
336
+ }),
337
+ row("mbx-other", "Other", {
338
+ syncStatus: MailboxSyncStatus.failed,
339
+ pendingPath: "Projects/Old",
340
+ }),
341
+ ]);
342
+ const { service } = queueOver(repo);
343
+
344
+ await service.dismissMailboxIntent("mbx-parent", "acc-1");
345
+
346
+ assert.equal(rowOf("mbx-other")?.syncStatus, MailboxSyncStatus.failed);
347
+ assert.equal(rowOf("mbx-other")?.pendingPath, "Projects/Old");
348
+ });
349
+
350
+ it("dismisses a failed delete on the named folder alone", async () => {
351
+ // No recorded target, so there is no subtree the intent was written over.
352
+ const { repo, rowOf } = store([
353
+ row("mbx-parent", "Work", { syncStatus: MailboxSyncStatus.failed }),
354
+ row("mbx-child", "Work/2026", { syncStatus: MailboxSyncStatus.failed }),
355
+ ]);
356
+ const { service } = queueOver(repo);
357
+
358
+ await service.dismissMailboxIntent("mbx-parent", "acc-1");
359
+
360
+ assert.equal(rowOf("mbx-parent")?.syncStatus, MailboxSyncStatus.synced);
361
+ assert.equal(rowOf("mbx-child")?.syncStatus, MailboxSyncStatus.failed);
362
+ });
363
+
364
+ it("is a no-op on a folder with nothing to dismiss", async () => {
365
+ const { repo } = store([row("mbx-1", "Work")]);
366
+ const { service } = queueOver(repo);
367
+
368
+ const dismissed = await service.dismissMailboxIntent("mbx-1", "acc-1");
369
+
370
+ assert.equal(dismissed.syncStatus, MailboxSyncStatus.synced);
371
+ });
372
+
373
+ it("refuses with a 409 while a mutation is still in flight", async () => {
374
+ const { repo } = store([
375
+ row("mbx-1", "Work", {
376
+ syncStatus: MailboxSyncStatus.pending,
377
+ pendingPath: "Projects",
378
+ }),
379
+ ]);
380
+ const { service } = queueOver(repo);
381
+
382
+ await assert.rejects(
383
+ service.dismissMailboxIntent("mbx-1", "acc-1"),
384
+ (error: unknown) =>
385
+ (error as { statusCode?: number }).statusCode === 409 &&
386
+ (error as Error).message === 'A rename is in progress for "Work".',
387
+ );
142
388
  });
143
389
  });