@remit/mailbox-service 0.0.110 → 0.0.112
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 +21 -6
- package/src/body-part-mapper.ts +51 -1
- package/src/body-sync-queue.test.ts +39 -99
- package/src/body-sync-terminal.test.ts +115 -277
- package/src/body-sync-terminal.ts +10 -12
- package/src/body-sync.test.ts +925 -0
- package/src/body-sync.ts +226 -200
- package/src/classification-backfill.test.ts +197 -527
- package/src/classify-message.test.ts +11 -74
- package/src/external-move.test.ts +102 -209
- package/src/external-move.ts +13 -0
- package/src/filter-operations.test.ts +319 -0
- package/src/filter-operations.ts +377 -0
- package/src/filter-update.test.ts +313 -0
- package/src/filters/filter-config.test.ts +55 -197
- package/src/filters/filter-config.ts +4 -39
- package/src/filters/match.test.ts +1 -38
- package/src/filters/match.ts +3 -52
- package/src/filters/pipeline.test.ts +158 -289
- package/src/filters/pipeline.ts +56 -170
- package/src/flag-push-terminal.ts +9 -11
- package/src/flag-push.test.ts +76 -372
- package/src/flag-queue.test.ts +197 -0
- package/src/folder-operations.test.ts +273 -0
- package/src/folder-operations.ts +217 -0
- package/src/forwarded-message.test.ts +122 -0
- package/src/imapflow-connect-retry.test.ts +76 -113
- package/src/imapflow-connection.test.ts +34 -0
- package/src/imapflow-connection.ts +19 -15
- package/src/index.ts +59 -12
- package/src/list-id-backfill.test.ts +133 -499
- package/src/mailbox-cursor.test.ts +81 -189
- package/src/mailbox-delete-intent.test.ts +78 -205
- package/src/mailbox-intent.ts +5 -3
- package/src/mailbox-management.test.ts +107 -220
- package/src/mailbox-management.ts +1 -2
- package/src/mailbox-presence.test.ts +36 -34
- package/src/mailbox-queue.ts +2 -20
- package/src/mailbox-rename-intent.test.ts +156 -325
- package/src/mailbox-rename-subtree.test.ts +205 -582
- package/src/mailbox-sync.test.ts +182 -744
- package/src/message-move.test.ts +726 -0
- package/src/message-move.ts +227 -57
- package/src/message-read.ts +87 -0
- package/src/message-save-retry.test.ts +90 -0
- package/src/message-sync-flags.test.ts +10 -1
- package/src/message-sync.test.ts +762 -0
- package/src/message-sync.ts +409 -239
- package/src/mime-walker.ts +16 -15
- package/src/organize-job.ts +93 -0
- package/src/outbox-attachment.test.ts +97 -209
- package/src/outbox-failed-recovery.test.ts +198 -269
- package/src/outbox-queue.test.ts +24 -0
- package/src/outbox-queue.ts +25 -8
- package/src/ownership.ts +140 -0
- package/src/placement-move-terminal.ts +9 -12
- package/src/placement-move.ts +33 -6
- package/src/placement-settled.ts +28 -4
- package/src/placement.test.ts +404 -0
- package/src/quarantine.test.ts +90 -148
- package/src/quarantine.ts +3 -2
- package/src/refusal.ts +29 -0
- package/src/restore-source-placement.ts +0 -4
- package/src/spam-report.test.ts +238 -710
- package/src/sync-warning.test.ts +213 -0
- package/src/sync-warning.ts +56 -0
- package/src/test-helpers/fake-imap.ts +690 -0
- package/src/test-helpers/test-queue.ts +56 -0
- package/src/test-helpers/test-store.ts +299 -0
- package/src/thread-root.test.ts +29 -0
- package/src/thread-root.ts +51 -0
- package/src/types/mailparser-augment.d.ts +8 -0
- package/src/types.ts +1 -1
- package/src/adversarial-mime.e2e.test.ts +0 -307
- package/src/body-sync-auto-read-writeonce.test.ts +0 -219
- package/src/body-sync-calendar-suggestion.test.ts +0 -433
- package/src/body-sync-category-denormalize.test.ts +0 -352
- package/src/body-sync-category-override.test.ts +0 -501
- package/src/body-sync-category-writeonce.test.ts +0 -234
- package/src/body-sync-filter.test.ts +0 -237
- package/src/body-sync-placement-flags.test.ts +0 -347
- package/src/body-sync-placement-writeonce.test.ts +0 -337
- package/src/body-sync-quarantine.test.ts +0 -276
- package/src/body-sync-resync.test.ts +0 -605
- package/src/body-sync-unclassifiable-once.test.ts +0 -260
- package/src/body-sync-unsubscribed-auto-read.test.ts +0 -176
- package/src/filters/anchor-drift.ts +0 -55
- package/src/filters/message-embedder.test.ts +0 -39
- package/src/filters/message-embedder.ts +0 -33
- package/src/flag-push-terminal.test.ts +0 -209
- package/src/flag-queue-stars.test.ts +0 -171
- package/src/imapflow-connection.e2e.test.ts +0 -335
- package/src/imapflow-connection.integ.test.ts +0 -316
- package/src/message-delete-junk-reconcile.test.ts +0 -117
- package/src/message-delete-no-trash.test.ts +0 -245
- package/src/message-delete-unsettled-placement.test.ts +0 -396
- package/src/message-move-copy.test.ts +0 -562
- package/src/message-move-empty-trash.test.ts +0 -326
- package/src/message-move-same-mailbox.test.ts +0 -139
- package/src/message-move-unsettled-placement.test.ts +0 -378
- package/src/message-sync-address-spoof.test.ts +0 -293
- package/src/message-sync-category.test.ts +0 -142
- package/src/message-sync-changedsince.test.ts +0 -840
- package/src/message-sync-junk-harvest.test.ts +0 -244
- package/src/message-sync-quarantine.test.ts +0 -416
- package/src/message-sync-repoint.test.ts +0 -419
- package/src/outbox-queue-settled-row.test.ts +0 -140
- package/src/pass-through-unit-of-work.ts +0 -17
- package/src/placement-move-terminal.test.ts +0 -300
- package/src/placement-move-unsettled.test.ts +0 -275
- package/src/placement-transition-race.test.ts +0 -482
- package/src/stale-message-reconcile.test.ts +0 -82
- package/src/stale-message-reconcile.ts +0 -55
- package/src/test-helpers/isolated-mailbox.ts +0 -44
- package/src/test-helpers/mailfuzz-connection.ts +0 -27
- package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +0 -21
- package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +0 -31
- package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +0 -33
- package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +0 -42
- package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +0 -25
- package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +0 -15
- package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +0 -17
- package/test/fixtures/mime-adversarial-e2e/08-alternative-empty-text.eml +0 -18
|
@@ -1,389 +1,220 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { describe, it } from "node:test";
|
|
3
|
-
import type {
|
|
2
|
+
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
3
|
+
import type { AccountItem, MailboxItem } from "@remit/data-ports";
|
|
4
4
|
import { MailboxSyncStatus } from "@remit/domain-enums";
|
|
5
5
|
import { MailboxQueueService } from "./mailbox-queue.js";
|
|
6
|
+
import {
|
|
7
|
+
beforeNext,
|
|
8
|
+
captureQueue,
|
|
9
|
+
openStore,
|
|
10
|
+
type QueueCapture,
|
|
11
|
+
type QueuedEvent,
|
|
12
|
+
type Store,
|
|
13
|
+
seedAccount,
|
|
14
|
+
seedMailbox,
|
|
15
|
+
} from "./test-helpers/test-store.js";
|
|
16
|
+
|
|
17
|
+
let store: Store;
|
|
18
|
+
let capture: QueueCapture;
|
|
19
|
+
let queue: QueuedEvent[];
|
|
20
|
+
let account: AccountItem;
|
|
21
|
+
|
|
22
|
+
beforeEach(async () => {
|
|
23
|
+
capture = captureQueue();
|
|
24
|
+
queue = capture.sent;
|
|
25
|
+
store = await openStore();
|
|
26
|
+
account = await seedAccount(store);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterEach(async () => {
|
|
30
|
+
capture.restore();
|
|
31
|
+
await store.close();
|
|
32
|
+
});
|
|
6
33
|
|
|
7
|
-
const
|
|
8
|
-
mailboxId: string,
|
|
34
|
+
const folder = async (
|
|
9
35
|
fullPath: string,
|
|
10
|
-
|
|
11
|
-
): MailboxItem =>
|
|
12
|
-
(
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* A repository that resolves the subtree and applies `rowSet` the way the real
|
|
23
|
-
* one does, so the paths the intent records are the paths under test.
|
|
24
|
-
*/
|
|
25
|
-
const store = (rows: MailboxItem[]) => {
|
|
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
|
-
};
|
|
33
|
-
|
|
34
|
-
const repo: Pick<
|
|
35
|
-
IMailboxRepository,
|
|
36
|
-
| "get"
|
|
37
|
-
| "transition"
|
|
38
|
-
| "transitionSubtree"
|
|
39
|
-
| "findByPathPrefix"
|
|
40
|
-
| "findBySyncStatus"
|
|
41
|
-
> = {
|
|
42
|
-
get: (async (_accountId: string, mailboxId: string) => {
|
|
43
|
-
const found = byId.get(mailboxId as string);
|
|
44
|
-
if (!found) {
|
|
45
|
-
throw Object.assign(new Error(`Mailbox not found: ${mailboxId}`), {
|
|
46
|
-
name: "NotFoundError",
|
|
47
|
-
});
|
|
48
|
-
}
|
|
49
|
-
return found;
|
|
50
|
-
}) as IMailboxRepository["get"],
|
|
51
|
-
findByPathPrefix: async (_accountId, pathPrefix) => {
|
|
52
|
-
const root = [...byId.values()].find((r) => r.fullPath === pathPrefix);
|
|
53
|
-
return root ? subtreeOf(root) : [];
|
|
36
|
+
state?: { syncStatus: MailboxItem["syncStatus"]; pendingPath?: string },
|
|
37
|
+
): Promise<MailboxItem> => {
|
|
38
|
+
const mailbox = await seedMailbox(store, account, fullPath);
|
|
39
|
+
if (!state) return mailbox;
|
|
40
|
+
const moved = await store.mailbox.transition(
|
|
41
|
+
account.accountId,
|
|
42
|
+
mailbox.mailboxId,
|
|
43
|
+
{
|
|
44
|
+
from: [MailboxSyncStatus.synced],
|
|
45
|
+
to: state.syncStatus,
|
|
46
|
+
set: state.pendingPath ? { pendingPath: state.pendingPath } : {},
|
|
54
47
|
},
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
},
|
|
81
|
-
transitionSubtree: async (_accountId, mailboxId, intent) => {
|
|
82
|
-
const root = byId.get(mailboxId);
|
|
83
|
-
if (!root) return null;
|
|
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;
|
|
88
|
-
const written: MailboxItem[] = [];
|
|
89
|
-
for (const candidate of subtree) {
|
|
90
|
-
const next = {
|
|
91
|
-
...candidate,
|
|
92
|
-
...intent.rowSet(candidate),
|
|
93
|
-
syncStatus: intent.to,
|
|
94
|
-
} as MailboxItem;
|
|
95
|
-
byId.set(candidate.mailboxId, next);
|
|
96
|
-
written.push(next);
|
|
97
|
-
}
|
|
98
|
-
// The port promises no ordering beyond "the named folder first", so
|
|
99
|
-
// hand the caller the least convenient order it is allowed to return.
|
|
100
|
-
return written.reverse();
|
|
101
|
-
},
|
|
102
|
-
};
|
|
103
|
-
|
|
104
|
-
return {
|
|
105
|
-
repo: repo as IMailboxRepository,
|
|
106
|
-
rowOf: (id: string) => byId.get(id),
|
|
107
|
-
};
|
|
48
|
+
);
|
|
49
|
+
if (!moved) throw new Error(`could not put ${fullPath} in its state`);
|
|
50
|
+
return moved;
|
|
108
51
|
};
|
|
109
52
|
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
mailboxService: repo,
|
|
53
|
+
const queueService = () =>
|
|
54
|
+
new MailboxQueueService({
|
|
55
|
+
mailboxService: store.mailbox,
|
|
114
56
|
sqsQueueUrl: "http://localhost:4566/000000000000/mailbox",
|
|
115
57
|
});
|
|
116
|
-
(
|
|
117
|
-
service as unknown as {
|
|
118
|
-
sqs: {
|
|
119
|
-
send: (command: { input: { MessageBody: string } }) => Promise<void>;
|
|
120
|
-
};
|
|
121
|
-
}
|
|
122
|
-
).sqs = {
|
|
123
|
-
send: async (command) => {
|
|
124
|
-
sent.push(command.input.MessageBody);
|
|
125
|
-
},
|
|
126
|
-
};
|
|
127
|
-
return { service, sent };
|
|
128
|
-
};
|
|
129
58
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
});
|
|
59
|
+
const rename = (mailbox: MailboxItem, newPath: string) =>
|
|
60
|
+
queueService().renameMailbox(mailbox.mailboxId, newPath, account.accountId);
|
|
61
|
+
|
|
62
|
+
const dismiss = (mailbox: MailboxItem) =>
|
|
63
|
+
queueService().dismissMailboxIntent(mailbox.mailboxId, account.accountId);
|
|
64
|
+
|
|
65
|
+
const current = (mailbox: MailboxItem) =>
|
|
66
|
+
store.mailbox.get(account.accountId, mailbox.mailboxId);
|
|
67
|
+
|
|
68
|
+
const rejectsWith409 = (message: string) => (error: unknown) =>
|
|
69
|
+
(error as { statusCode?: number }).statusCode === 409 &&
|
|
70
|
+
(error as Error).message === message;
|
|
71
|
+
|
|
72
|
+
describe("MailboxQueueService.renameMailbox records the intent", () => {
|
|
73
|
+
it("records the target over the subtree and leaves every confirmed path alone", async () => {
|
|
74
|
+
const parent = await folder("Work");
|
|
75
|
+
const child = await folder("Work/2026");
|
|
76
|
+
const lookalike = await folder("Workshop");
|
|
149
77
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
const { service, sent } = queueOver(repo);
|
|
160
|
-
|
|
161
|
-
await service.renameMailbox("mbx-parent", "Q1$&Q2", "acc-1");
|
|
162
|
-
|
|
163
|
-
assert.equal(rowOf("mbx-parent")?.pendingPath, "Q1$&Q2");
|
|
164
|
-
assert.equal(rowOf("mbx-child")?.pendingPath, "Q1$&Q2/2026");
|
|
78
|
+
const renamed = await rename(parent, "Projects");
|
|
79
|
+
|
|
80
|
+
assert.equal(renamed.mailboxId, parent.mailboxId);
|
|
81
|
+
assert.equal((await current(parent)).fullPath, "Work");
|
|
82
|
+
assert.equal((await current(parent)).pendingPath, "Projects");
|
|
83
|
+
const movedChild = await current(child);
|
|
84
|
+
assert.equal(movedChild.fullPath, "Work/2026");
|
|
85
|
+
assert.equal(movedChild.pendingPath, "Projects/2026");
|
|
86
|
+
assert.equal(movedChild.syncStatus, MailboxSyncStatus.pending);
|
|
165
87
|
assert.equal(
|
|
166
|
-
(
|
|
167
|
-
|
|
168
|
-
"the event and the row name the same folder",
|
|
88
|
+
(await current(lookalike)).syncStatus,
|
|
89
|
+
MailboxSyncStatus.synced,
|
|
169
90
|
);
|
|
91
|
+
assert.equal((await current(lookalike)).pendingPath, undefined);
|
|
170
92
|
});
|
|
171
93
|
|
|
172
|
-
it("
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
row("mbx-lookalike", "Workshop"),
|
|
176
|
-
]);
|
|
177
|
-
const { service } = queueOver(repo);
|
|
178
|
-
|
|
179
|
-
await service.renameMailbox("mbx-parent", "Projects", "acc-1");
|
|
94
|
+
it("records the path the RENAME carries, `$` patterns and all", async () => {
|
|
95
|
+
const parent = await folder("Work");
|
|
96
|
+
const child = await folder("Work/2026");
|
|
180
97
|
|
|
181
|
-
|
|
182
|
-
assert.equal(rowOf("mbx-lookalike")?.pendingPath, undefined);
|
|
183
|
-
assert.equal(rowOf("mbx-lookalike")?.syncStatus, MailboxSyncStatus.synced);
|
|
184
|
-
});
|
|
98
|
+
await rename(parent, "Q1$&Q2");
|
|
185
99
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
]);
|
|
191
|
-
const { service } = queueOver(repo);
|
|
192
|
-
|
|
193
|
-
const renamed = await service.renameMailbox(
|
|
194
|
-
"mbx-parent",
|
|
195
|
-
"Projects",
|
|
196
|
-
"acc-1",
|
|
100
|
+
assert.equal((await current(child)).pendingPath, "Q1$&Q2/2026");
|
|
101
|
+
assert.deepEqual(
|
|
102
|
+
queue.map((event) => event.body.newPath),
|
|
103
|
+
["Q1$&Q2"],
|
|
197
104
|
);
|
|
198
|
-
|
|
199
|
-
assert.equal(renamed.mailboxId, "mbx-parent");
|
|
200
|
-
assert.equal(renamed.pendingPath, "Projects");
|
|
201
105
|
});
|
|
202
106
|
|
|
203
107
|
it("records a retry from a failed rename onto the same row", async () => {
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
assert.equal(rowOf("mbx-parent")?.syncStatus, MailboxSyncStatus.pending);
|
|
215
|
-
assert.equal(rowOf("mbx-parent")?.pendingPath, "Projects");
|
|
108
|
+
const parent = await folder("Work", {
|
|
109
|
+
syncStatus: MailboxSyncStatus.failed,
|
|
110
|
+
pendingPath: "Projects",
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
await rename(parent, "Projects");
|
|
114
|
+
|
|
115
|
+
const retried = await current(parent);
|
|
116
|
+
assert.equal(retried.syncStatus, MailboxSyncStatus.pending);
|
|
117
|
+
assert.equal(retried.pendingPath, "Projects");
|
|
216
118
|
});
|
|
217
119
|
|
|
218
120
|
it("refuses the whole subtree with a 409 naming the descendant that blocks it", async () => {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
row("mbx-child", "Archive/2025/Receipts", {
|
|
225
|
-
syncStatus: MailboxSyncStatus.pending,
|
|
226
|
-
pendingPath: "Archive/2025/Bonnen",
|
|
227
|
-
}),
|
|
228
|
-
]);
|
|
229
|
-
const { service, sent } = queueOver(repo);
|
|
121
|
+
const parent = await folder("Archive");
|
|
122
|
+
await folder("Archive/2025/Receipts", {
|
|
123
|
+
syncStatus: MailboxSyncStatus.pending,
|
|
124
|
+
pendingPath: "Archive/2025/Bonnen",
|
|
125
|
+
});
|
|
230
126
|
|
|
231
127
|
await assert.rejects(
|
|
232
|
-
|
|
233
|
-
(
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
'A rename is in progress for "Archive/2025/Receipts", inside this folder.',
|
|
128
|
+
rename(parent, "Archief"),
|
|
129
|
+
rejectsWith409(
|
|
130
|
+
'A rename is in progress for "Archive/2025/Receipts", inside this folder.',
|
|
131
|
+
),
|
|
237
132
|
);
|
|
238
133
|
|
|
239
|
-
assert.equal(
|
|
240
|
-
assert.equal(
|
|
241
|
-
assert.equal(sent.length, 0, "nothing is enqueued for a refused intent");
|
|
134
|
+
assert.equal((await current(parent)).syncStatus, MailboxSyncStatus.synced);
|
|
135
|
+
assert.equal(queue.length, 0);
|
|
242
136
|
});
|
|
243
137
|
|
|
244
|
-
it("refuses with a 409 naming the folder's own delete
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
}),
|
|
249
|
-
]);
|
|
250
|
-
const { service } = queueOver(repo);
|
|
138
|
+
it("refuses with a 409 naming the folder's own delete", async () => {
|
|
139
|
+
const parent = await folder("Archive", {
|
|
140
|
+
syncStatus: MailboxSyncStatus.deleting,
|
|
141
|
+
});
|
|
251
142
|
|
|
252
143
|
await assert.rejects(
|
|
253
|
-
|
|
254
|
-
(
|
|
255
|
-
(error as { statusCode?: number }).statusCode === 409 &&
|
|
256
|
-
(error as Error).message ===
|
|
257
|
-
'A delete is already in progress for "Archive".',
|
|
144
|
+
rename(parent, "Archief"),
|
|
145
|
+
rejectsWith409('A delete is already in progress for "Archive".'),
|
|
258
146
|
);
|
|
259
147
|
});
|
|
260
148
|
|
|
261
149
|
it("answers 404 when the folder went between the read and the intent", async () => {
|
|
262
|
-
const
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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);
|
|
150
|
+
const parent = await folder("Work");
|
|
151
|
+
beforeNext(store.mailbox, "transitionSubtree", () =>
|
|
152
|
+
store.mailbox.delete(account.accountId, parent.mailboxId),
|
|
153
|
+
);
|
|
280
154
|
|
|
281
155
|
await assert.rejects(
|
|
282
|
-
|
|
156
|
+
rename(parent, "Projects"),
|
|
283
157
|
(error: unknown) => (error as Error).name === "NotFoundError",
|
|
284
158
|
);
|
|
285
159
|
});
|
|
286
160
|
});
|
|
287
161
|
|
|
288
162
|
describe("MailboxQueueService.dismissMailboxIntent", () => {
|
|
289
|
-
it("clears a failed rename
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
const
|
|
163
|
+
it("clears a failed rename over its whole subtree and enqueues nothing", async () => {
|
|
164
|
+
const parent = await folder("Work", {
|
|
165
|
+
syncStatus: MailboxSyncStatus.failed,
|
|
166
|
+
pendingPath: "Projects",
|
|
167
|
+
});
|
|
168
|
+
const child = await folder("Work/2026", {
|
|
169
|
+
syncStatus: MailboxSyncStatus.failed,
|
|
170
|
+
pendingPath: "Projects/2026",
|
|
171
|
+
});
|
|
172
|
+
const other = await folder("Other", {
|
|
173
|
+
syncStatus: MailboxSyncStatus.failed,
|
|
174
|
+
pendingPath: "Projects/Old",
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const dismissed = await dismiss(parent);
|
|
299
178
|
|
|
300
179
|
assert.equal(dismissed.syncStatus, MailboxSyncStatus.synced);
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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);
|
|
180
|
+
for (const mailbox of [parent, child]) {
|
|
181
|
+
const row = await current(mailbox);
|
|
182
|
+
assert.equal(row.syncStatus, MailboxSyncStatus.synced);
|
|
183
|
+
assert.equal(row.pendingPath, undefined);
|
|
328
184
|
}
|
|
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");
|
|
185
|
+
assert.equal((await current(other)).pendingPath, "Projects/Old");
|
|
186
|
+
assert.equal(queue.length, 0);
|
|
348
187
|
});
|
|
349
188
|
|
|
350
189
|
it("dismisses a failed delete on the named folder alone", async () => {
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
190
|
+
const parent = await folder("Work", {
|
|
191
|
+
syncStatus: MailboxSyncStatus.failed,
|
|
192
|
+
});
|
|
193
|
+
const child = await folder("Work/2026", {
|
|
194
|
+
syncStatus: MailboxSyncStatus.failed,
|
|
195
|
+
});
|
|
357
196
|
|
|
358
|
-
await
|
|
197
|
+
await dismiss(parent);
|
|
359
198
|
|
|
360
|
-
assert.equal(
|
|
361
|
-
assert.equal(
|
|
199
|
+
assert.equal((await current(parent)).syncStatus, MailboxSyncStatus.synced);
|
|
200
|
+
assert.equal((await current(child)).syncStatus, MailboxSyncStatus.failed);
|
|
362
201
|
});
|
|
363
202
|
|
|
364
|
-
it("
|
|
365
|
-
const
|
|
366
|
-
const { service } = queueOver(repo);
|
|
367
|
-
|
|
368
|
-
const dismissed = await service.dismissMailboxIntent("mbx-1", "acc-1");
|
|
203
|
+
it("leaves a folder with nothing to dismiss as it is", async () => {
|
|
204
|
+
const work = await folder("Work");
|
|
369
205
|
|
|
370
|
-
assert.equal(
|
|
206
|
+
assert.equal((await dismiss(work)).syncStatus, MailboxSyncStatus.synced);
|
|
371
207
|
});
|
|
372
208
|
|
|
373
209
|
it("refuses with a 409 while a mutation is still in flight", async () => {
|
|
374
|
-
const
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
}),
|
|
379
|
-
]);
|
|
380
|
-
const { service } = queueOver(repo);
|
|
210
|
+
const work = await folder("Work", {
|
|
211
|
+
syncStatus: MailboxSyncStatus.pending,
|
|
212
|
+
pendingPath: "Projects",
|
|
213
|
+
});
|
|
381
214
|
|
|
382
215
|
await assert.rejects(
|
|
383
|
-
|
|
384
|
-
(
|
|
385
|
-
(error as { statusCode?: number }).statusCode === 409 &&
|
|
386
|
-
(error as Error).message === 'A rename is in progress for "Work".',
|
|
216
|
+
dismiss(work),
|
|
217
|
+
rejectsWith409('A rename is in progress for "Work".'),
|
|
387
218
|
);
|
|
388
219
|
});
|
|
389
220
|
});
|