@remit/drizzle-service 0.0.82 → 0.0.84
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 +1 -1
- package/src/mailbox-sync-status-backfill.sqlite.test.ts +118 -0
- package/src/repair/junk-only-address.sqlite.test.ts +7 -1
- package/src/repos/i4-mailbox.sqlite.test.ts +57 -21
- package/src/repos/i4-mailbox.test.ts +508 -13
- package/src/repos/i4-mailbox.ts +226 -24
- package/src/repos/search-index-shape.test.ts +82 -0
- package/src/repos/search-index-shape.ts +56 -0
- package/src/repos/thread-message-body-muted.sqlite.test.ts +353 -0
- package/src/repos/thread-message.ts +29 -4
- package/src/repos/thread-search-predicates.ts +19 -1
- package/src/schema/i4-mailbox.ts +2 -0
|
@@ -1,7 +1,31 @@
|
|
|
1
1
|
import assert from "node:assert";
|
|
2
2
|
import { after, before, describe, test } from "node:test";
|
|
3
|
+
import { MailboxSyncStatus } from "@remit/domain-enums";
|
|
4
|
+
import { eq } from "drizzle-orm";
|
|
5
|
+
import {
|
|
6
|
+
envelopeId as deriveEnvelopeId,
|
|
7
|
+
rootBodyPartId as deriveRootBodyPartId,
|
|
8
|
+
} from "../id.js";
|
|
9
|
+
import {
|
|
10
|
+
filterTable,
|
|
11
|
+
mailboxSpecialUseTable,
|
|
12
|
+
mailboxTable,
|
|
13
|
+
messageTable,
|
|
14
|
+
outboxTable,
|
|
15
|
+
threadMessageTable,
|
|
16
|
+
} from "../schema.js";
|
|
3
17
|
import { createTestDb, randomId, type TestDb } from "../test-db.js";
|
|
18
|
+
import { runInTransaction } from "../tx.js";
|
|
4
19
|
import { MailboxRepo } from "./i4-mailbox.js";
|
|
20
|
+
import { DrizzleMessageRepository, deleteMessageSubtree } from "./message.js";
|
|
21
|
+
|
|
22
|
+
/** Every state a row can carry, for a transition that decides against none. */
|
|
23
|
+
const EVERY_STATE = [
|
|
24
|
+
MailboxSyncStatus.synced,
|
|
25
|
+
MailboxSyncStatus.pending,
|
|
26
|
+
MailboxSyncStatus.failed,
|
|
27
|
+
MailboxSyncStatus.deleting,
|
|
28
|
+
] as const;
|
|
5
29
|
|
|
6
30
|
function makeMailboxInput(accountId: string, fullPath = "INBOX") {
|
|
7
31
|
return {
|
|
@@ -143,19 +167,6 @@ describe("MailboxRepo", () => {
|
|
|
143
167
|
assert.deepEqual(await repo.findByPathPrefix(accountId, "Work", ""), []);
|
|
144
168
|
});
|
|
145
169
|
|
|
146
|
-
test("renameChildPaths updates all children", async () => {
|
|
147
|
-
const accountId = randomId();
|
|
148
|
-
await repo.create(makeMailboxInput(accountId, "OldName/Sub1"));
|
|
149
|
-
await repo.create(makeMailboxInput(accountId, "OldName/Sub2"));
|
|
150
|
-
|
|
151
|
-
await repo.renameChildPaths(accountId, "OldName", "NewName");
|
|
152
|
-
|
|
153
|
-
const sub1 = await repo.findByPath(accountId, "NewName/Sub1");
|
|
154
|
-
const sub2 = await repo.findByPath(accountId, "NewName/Sub2");
|
|
155
|
-
assert.ok(sub1, "Sub1 renamed");
|
|
156
|
-
assert.ok(sub2, "Sub2 renamed");
|
|
157
|
-
});
|
|
158
|
-
|
|
159
170
|
test("cross-tenant: get refuses a foreign account", async () => {
|
|
160
171
|
const accountId = randomId();
|
|
161
172
|
const other = randomId();
|
|
@@ -217,6 +228,490 @@ describe("MailboxRepo", () => {
|
|
|
217
228
|
await repo.delete(accountB, b.mailboxId);
|
|
218
229
|
});
|
|
219
230
|
|
|
231
|
+
describe("transition — the conditional write (D3)", () => {
|
|
232
|
+
test("an accepted from-state applies the new state and the set fields", async () => {
|
|
233
|
+
const accountId = randomId();
|
|
234
|
+
const mailbox = await repo.create(makeMailboxInput(accountId, "Archive"));
|
|
235
|
+
|
|
236
|
+
const written = await repo.transition(accountId, mailbox.mailboxId, {
|
|
237
|
+
from: [MailboxSyncStatus.synced, MailboxSyncStatus.failed],
|
|
238
|
+
to: MailboxSyncStatus.pending,
|
|
239
|
+
set: { pendingPath: "Records" },
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
assert.equal(written?.syncStatus, MailboxSyncStatus.pending);
|
|
243
|
+
assert.equal(written?.pendingPath, "Records");
|
|
244
|
+
assert.equal(written?.fullPath, "Archive", "fullPath stays confirmed");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("a state outside `from` writes nothing and leaves the row byte-identical", async () => {
|
|
248
|
+
const accountId = randomId();
|
|
249
|
+
const mailbox = await repo.create(makeMailboxInput(accountId, "Work"));
|
|
250
|
+
const before = await repo.get(accountId, mailbox.mailboxId);
|
|
251
|
+
|
|
252
|
+
const lost = await repo.transition(accountId, mailbox.mailboxId, {
|
|
253
|
+
from: [MailboxSyncStatus.deleting],
|
|
254
|
+
to: MailboxSyncStatus.synced,
|
|
255
|
+
set: { fullPath: "Nope" },
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
assert.equal(lost, null);
|
|
259
|
+
assert.deepEqual(await repo.get(accountId, mailbox.mailboxId), before);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("an absent id is a null, not a throw", async () => {
|
|
263
|
+
assert.equal(
|
|
264
|
+
await repo.transition(randomId(), "no-such-mailbox", {
|
|
265
|
+
from: EVERY_STATE,
|
|
266
|
+
to: MailboxSyncStatus.synced,
|
|
267
|
+
}),
|
|
268
|
+
null,
|
|
269
|
+
);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("wherePendingPath as a string matches only that target", async () => {
|
|
273
|
+
const accountId = randomId();
|
|
274
|
+
const mailbox = await repo.create(makeMailboxInput(accountId, "Bills"));
|
|
275
|
+
await repo.transition(accountId, mailbox.mailboxId, {
|
|
276
|
+
from: [MailboxSyncStatus.synced],
|
|
277
|
+
to: MailboxSyncStatus.pending,
|
|
278
|
+
set: { pendingPath: "Invoices" },
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
assert.equal(
|
|
282
|
+
await repo.transition(accountId, mailbox.mailboxId, {
|
|
283
|
+
from: [MailboxSyncStatus.pending],
|
|
284
|
+
wherePendingPath: "Somewhere else",
|
|
285
|
+
to: MailboxSyncStatus.synced,
|
|
286
|
+
}),
|
|
287
|
+
null,
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
const settled = await repo.transition(accountId, mailbox.mailboxId, {
|
|
291
|
+
from: [MailboxSyncStatus.pending],
|
|
292
|
+
wherePendingPath: "Invoices",
|
|
293
|
+
to: MailboxSyncStatus.synced,
|
|
294
|
+
set: { fullPath: "Invoices", pendingPath: null },
|
|
295
|
+
});
|
|
296
|
+
assert.equal(settled?.fullPath, "Invoices");
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("wherePendingPath: null refuses a row a rename has claimed", async () => {
|
|
300
|
+
// The seventh-state hole (D3): a create settle that predicates on
|
|
301
|
+
// `pending` alone matches a row a rename has since claimed, writes
|
|
302
|
+
// `synced` with the rename target still on it, and the rename then
|
|
303
|
+
// never runs and is never marked failed.
|
|
304
|
+
const accountId = randomId();
|
|
305
|
+
const claimed = await repo.create(makeMailboxInput(accountId, "Notes"));
|
|
306
|
+
await repo.transition(accountId, claimed.mailboxId, {
|
|
307
|
+
from: [MailboxSyncStatus.synced],
|
|
308
|
+
to: MailboxSyncStatus.pending,
|
|
309
|
+
set: { pendingPath: "Journal" },
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
const createSettle = await repo.transition(accountId, claimed.mailboxId, {
|
|
313
|
+
from: [MailboxSyncStatus.pending],
|
|
314
|
+
wherePendingPath: null,
|
|
315
|
+
to: MailboxSyncStatus.synced,
|
|
316
|
+
});
|
|
317
|
+
assert.equal(createSettle, null);
|
|
318
|
+
|
|
319
|
+
const still = await repo.get(accountId, claimed.mailboxId);
|
|
320
|
+
assert.equal(still.syncStatus, MailboxSyncStatus.pending);
|
|
321
|
+
assert.equal(still.pendingPath, "Journal");
|
|
322
|
+
|
|
323
|
+
const creating = await repo.create({
|
|
324
|
+
...makeMailboxInput(accountId, "Fresh"),
|
|
325
|
+
syncStatus: MailboxSyncStatus.pending,
|
|
326
|
+
});
|
|
327
|
+
const settled = await repo.transition(accountId, creating.mailboxId, {
|
|
328
|
+
from: [MailboxSyncStatus.pending],
|
|
329
|
+
wherePendingPath: null,
|
|
330
|
+
to: MailboxSyncStatus.synced,
|
|
331
|
+
});
|
|
332
|
+
assert.equal(settled?.syncStatus, MailboxSyncStatus.synced);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("omitting wherePendingPath predicates on syncStatus alone", async () => {
|
|
336
|
+
const accountId = randomId();
|
|
337
|
+
const mailbox = await repo.create(makeMailboxInput(accountId, "Old"));
|
|
338
|
+
await repo.transition(accountId, mailbox.mailboxId, {
|
|
339
|
+
from: [MailboxSyncStatus.synced],
|
|
340
|
+
to: MailboxSyncStatus.pending,
|
|
341
|
+
set: { pendingPath: "New" },
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
const written = await repo.transition(accountId, mailbox.mailboxId, {
|
|
345
|
+
from: [MailboxSyncStatus.pending],
|
|
346
|
+
to: MailboxSyncStatus.failed,
|
|
347
|
+
});
|
|
348
|
+
assert.equal(written?.syncStatus, MailboxSyncStatus.failed);
|
|
349
|
+
assert.equal(written?.pendingPath, "New");
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("a rename target survives only under pending and failed", async () => {
|
|
353
|
+
// The invariant, over the only writer of either field: a caller that
|
|
354
|
+
// asks for the seventh combination does not get it. `synced` with a
|
|
355
|
+
// rename target on it is what strands a folder whose rename then never
|
|
356
|
+
// runs and is never marked failed.
|
|
357
|
+
const accountId = randomId();
|
|
358
|
+
const mailbox = await repo.create(makeMailboxInput(accountId, "Trips"));
|
|
359
|
+
|
|
360
|
+
for (const to of EVERY_STATE) {
|
|
361
|
+
const written = await repo.transition(accountId, mailbox.mailboxId, {
|
|
362
|
+
from: EVERY_STATE,
|
|
363
|
+
to,
|
|
364
|
+
set: { pendingPath: "Holidays" },
|
|
365
|
+
});
|
|
366
|
+
assert.equal(
|
|
367
|
+
written?.pendingPath,
|
|
368
|
+
to === MailboxSyncStatus.pending || to === MailboxSyncStatus.failed
|
|
369
|
+
? "Holidays"
|
|
370
|
+
: undefined,
|
|
371
|
+
`transition to ${to}`,
|
|
372
|
+
);
|
|
373
|
+
assert.deepEqual(
|
|
374
|
+
await repo.get(accountId, mailbox.mailboxId),
|
|
375
|
+
written,
|
|
376
|
+
"what the transition returned is what the row now says",
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test("two overlapping transitions with the same from: exactly one wins", async () => {
|
|
382
|
+
const accountId = randomId();
|
|
383
|
+
const mailbox = await repo.create(makeMailboxInput(accountId, "Races"));
|
|
384
|
+
|
|
385
|
+
const outcomes = await Promise.all([
|
|
386
|
+
repo.transition(accountId, mailbox.mailboxId, {
|
|
387
|
+
from: [MailboxSyncStatus.synced],
|
|
388
|
+
to: MailboxSyncStatus.deleting,
|
|
389
|
+
}),
|
|
390
|
+
repo.transition(accountId, mailbox.mailboxId, {
|
|
391
|
+
from: [MailboxSyncStatus.synced],
|
|
392
|
+
to: MailboxSyncStatus.pending,
|
|
393
|
+
}),
|
|
394
|
+
]);
|
|
395
|
+
|
|
396
|
+
assert.equal(outcomes.filter((outcome) => outcome !== null).length, 1);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test("cross-tenant: a foreign account transitions nothing", async () => {
|
|
400
|
+
const accountId = randomId();
|
|
401
|
+
const mailbox = await repo.create(makeMailboxInput(accountId, "Private"));
|
|
402
|
+
|
|
403
|
+
assert.equal(
|
|
404
|
+
await repo.transition(randomId(), mailbox.mailboxId, {
|
|
405
|
+
from: EVERY_STATE,
|
|
406
|
+
to: MailboxSyncStatus.deleting,
|
|
407
|
+
}),
|
|
408
|
+
null,
|
|
409
|
+
);
|
|
410
|
+
assert.equal(
|
|
411
|
+
(await repo.get(accountId, mailbox.mailboxId)).syncStatus,
|
|
412
|
+
MailboxSyncStatus.synced,
|
|
413
|
+
);
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
describe("transitionSubtree — the intent, all-or-nothing (D6)", () => {
|
|
418
|
+
const seedSubtree = async (accountId: string) => ({
|
|
419
|
+
parent: await repo.create(makeMailboxInput(accountId, "Work")),
|
|
420
|
+
child: await repo.create(makeMailboxInput(accountId, "Work/Projects")),
|
|
421
|
+
grandchild: await repo.create(
|
|
422
|
+
makeMailboxInput(accountId, "Work/Projects/Alpha"),
|
|
423
|
+
),
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test("transitions the folder and every descendant, each from its own path", async () => {
|
|
427
|
+
const accountId = randomId();
|
|
428
|
+
const { parent, child, grandchild } = await seedSubtree(accountId);
|
|
429
|
+
|
|
430
|
+
const written = await repo.transitionSubtree(
|
|
431
|
+
accountId,
|
|
432
|
+
parent.mailboxId,
|
|
433
|
+
{
|
|
434
|
+
from: [MailboxSyncStatus.synced, MailboxSyncStatus.failed],
|
|
435
|
+
to: MailboxSyncStatus.pending,
|
|
436
|
+
rowSet: (row) => ({
|
|
437
|
+
pendingPath: row.fullPath.replace("Work", "Archive"),
|
|
438
|
+
}),
|
|
439
|
+
},
|
|
440
|
+
);
|
|
441
|
+
|
|
442
|
+
assert.equal(written?.length, 3);
|
|
443
|
+
assert.equal(
|
|
444
|
+
(await repo.get(accountId, parent.mailboxId)).pendingPath,
|
|
445
|
+
"Archive",
|
|
446
|
+
);
|
|
447
|
+
assert.equal(
|
|
448
|
+
(await repo.get(accountId, child.mailboxId)).pendingPath,
|
|
449
|
+
"Archive/Projects",
|
|
450
|
+
);
|
|
451
|
+
assert.equal(
|
|
452
|
+
(await repo.get(accountId, grandchild.mailboxId)).pendingPath,
|
|
453
|
+
"Archive/Projects/Alpha",
|
|
454
|
+
);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
test("one descendant outside `from` refuses the intent and writes nothing", async () => {
|
|
458
|
+
const accountId = randomId();
|
|
459
|
+
const { parent, child, grandchild } = await seedSubtree(accountId);
|
|
460
|
+
await repo.transition(accountId, grandchild.mailboxId, {
|
|
461
|
+
from: [MailboxSyncStatus.synced],
|
|
462
|
+
to: MailboxSyncStatus.deleting,
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
const refused = await repo.transitionSubtree(
|
|
466
|
+
accountId,
|
|
467
|
+
parent.mailboxId,
|
|
468
|
+
{
|
|
469
|
+
from: [MailboxSyncStatus.synced],
|
|
470
|
+
to: MailboxSyncStatus.pending,
|
|
471
|
+
rowSet: (row) => ({ pendingPath: `Moved/${row.fullPath}` }),
|
|
472
|
+
},
|
|
473
|
+
);
|
|
474
|
+
|
|
475
|
+
assert.equal(refused, null);
|
|
476
|
+
for (const row of [parent, child]) {
|
|
477
|
+
const after = await repo.get(accountId, row.mailboxId);
|
|
478
|
+
assert.equal(after.syncStatus, MailboxSyncStatus.synced);
|
|
479
|
+
assert.equal(after.pendingPath, undefined);
|
|
480
|
+
}
|
|
481
|
+
assert.equal(
|
|
482
|
+
(await repo.get(accountId, grandchild.mailboxId)).syncStatus,
|
|
483
|
+
MailboxSyncStatus.deleting,
|
|
484
|
+
);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
test("the from-state predicate rides each UPDATE, not a prior read", async () => {
|
|
488
|
+
// A read-then-check-then-write version passes the case above and still
|
|
489
|
+
// misses a row that leaves an accepted state after the read. The
|
|
490
|
+
// predicate is on the UPDATE, so the affected-row count catches it.
|
|
491
|
+
const accountId = randomId();
|
|
492
|
+
const { parent, child } = await seedSubtree(accountId);
|
|
493
|
+
|
|
494
|
+
const refused = await repo.transitionSubtree(
|
|
495
|
+
accountId,
|
|
496
|
+
parent.mailboxId,
|
|
497
|
+
{
|
|
498
|
+
from: [MailboxSyncStatus.synced],
|
|
499
|
+
to: MailboxSyncStatus.pending,
|
|
500
|
+
rowSet: (row) => {
|
|
501
|
+
if (row.mailboxId === parent.mailboxId) {
|
|
502
|
+
// Not a real concurrent writer — the shape of one. The row
|
|
503
|
+
// leaves `synced` after the subtree was resolved.
|
|
504
|
+
db.update(mailboxTable)
|
|
505
|
+
.set({ syncStatus: MailboxSyncStatus.deleting })
|
|
506
|
+
.where(eq(mailboxTable.mailboxId, child.mailboxId))
|
|
507
|
+
.run();
|
|
508
|
+
}
|
|
509
|
+
return { pendingPath: row.fullPath };
|
|
510
|
+
},
|
|
511
|
+
},
|
|
512
|
+
);
|
|
513
|
+
|
|
514
|
+
assert.equal(refused, null);
|
|
515
|
+
assert.equal(
|
|
516
|
+
(await repo.get(accountId, parent.mailboxId)).syncStatus,
|
|
517
|
+
MailboxSyncStatus.synced,
|
|
518
|
+
);
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
test("an absent folder is a null", async () => {
|
|
522
|
+
assert.equal(
|
|
523
|
+
await repo.transitionSubtree(randomId(), "no-such-mailbox", {
|
|
524
|
+
from: EVERY_STATE,
|
|
525
|
+
to: MailboxSyncStatus.pending,
|
|
526
|
+
rowSet: () => ({}),
|
|
527
|
+
}),
|
|
528
|
+
null,
|
|
529
|
+
);
|
|
530
|
+
});
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
describe("deleteMailboxWithMail — the folder's mail goes with it (D8)", () => {
|
|
534
|
+
const seedMessage = async (mailboxId: string, uid: number) => {
|
|
535
|
+
const messageId = randomId();
|
|
536
|
+
await new DrizzleMessageRepository(db as never).create({
|
|
537
|
+
messageId,
|
|
538
|
+
mailboxId,
|
|
539
|
+
uid,
|
|
540
|
+
sequenceNumber: uid,
|
|
541
|
+
rfc822Size: 10,
|
|
542
|
+
internalDate: 1700000000000,
|
|
543
|
+
envelopeId: deriveEnvelopeId(messageId),
|
|
544
|
+
rootBodyPartId: deriveRootBodyPartId(messageId),
|
|
545
|
+
});
|
|
546
|
+
await db.insert(threadMessageTable).values({
|
|
547
|
+
threadMessageId: randomId(),
|
|
548
|
+
accountConfigId: "acct",
|
|
549
|
+
threadId: randomId(),
|
|
550
|
+
messageId,
|
|
551
|
+
mailboxId,
|
|
552
|
+
uid,
|
|
553
|
+
referenceOrder: 0,
|
|
554
|
+
internalDate: 1700000000000,
|
|
555
|
+
sentDate: 1700000000000,
|
|
556
|
+
isRead: false,
|
|
557
|
+
hasAttachment: false,
|
|
558
|
+
hasStars: false,
|
|
559
|
+
isDeleted: false,
|
|
560
|
+
createdAt: 1700000000000,
|
|
561
|
+
updatedAt: 1700000000000,
|
|
562
|
+
});
|
|
563
|
+
return messageId;
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
const outboxEventsFor = async (messageId: string) =>
|
|
567
|
+
db.select().from(outboxTable).where(eq(outboxTable.messageId, messageId));
|
|
568
|
+
|
|
569
|
+
test("removes the folder, its mail and its own child rows, and nothing else's", async () => {
|
|
570
|
+
const accountId = randomId();
|
|
571
|
+
const doomed = await repo.create(makeMailboxInput(accountId, "Receipts"));
|
|
572
|
+
const spared = await repo.create(makeMailboxInput(accountId, "Keep"));
|
|
573
|
+
const doomedMessage = await seedMessage(doomed.mailboxId, 1);
|
|
574
|
+
const sparedMessage = await seedMessage(spared.mailboxId, 1);
|
|
575
|
+
await db.insert(mailboxSpecialUseTable).values({
|
|
576
|
+
mailboxSpecialUseId: randomId(),
|
|
577
|
+
mailboxId: doomed.mailboxId,
|
|
578
|
+
specialUse: "Archive",
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
await repo.deleteMailboxWithMail(accountId, doomed.mailboxId);
|
|
582
|
+
|
|
583
|
+
await assert.rejects(
|
|
584
|
+
() => repo.get(accountId, doomed.mailboxId),
|
|
585
|
+
/Mailbox not found/,
|
|
586
|
+
);
|
|
587
|
+
assert.deepEqual(
|
|
588
|
+
await db
|
|
589
|
+
.select()
|
|
590
|
+
.from(messageTable)
|
|
591
|
+
.where(eq(messageTable.mailboxId, doomed.mailboxId)),
|
|
592
|
+
[],
|
|
593
|
+
);
|
|
594
|
+
assert.deepEqual(
|
|
595
|
+
await db
|
|
596
|
+
.select()
|
|
597
|
+
.from(threadMessageTable)
|
|
598
|
+
.where(eq(threadMessageTable.mailboxId, doomed.mailboxId)),
|
|
599
|
+
[],
|
|
600
|
+
);
|
|
601
|
+
assert.deepEqual(
|
|
602
|
+
await db
|
|
603
|
+
.select()
|
|
604
|
+
.from(mailboxSpecialUseTable)
|
|
605
|
+
.where(eq(mailboxSpecialUseTable.mailboxId, doomed.mailboxId)),
|
|
606
|
+
[],
|
|
607
|
+
);
|
|
608
|
+
|
|
609
|
+
// The search index is cleared by the outbox row, not by the delete.
|
|
610
|
+
const removals = await outboxEventsFor(doomedMessage);
|
|
611
|
+
assert.deepEqual(
|
|
612
|
+
removals.map((row) => row.event),
|
|
613
|
+
["message.removed"],
|
|
614
|
+
);
|
|
615
|
+
|
|
616
|
+
const survivors = await db
|
|
617
|
+
.select()
|
|
618
|
+
.from(messageTable)
|
|
619
|
+
.where(eq(messageTable.mailboxId, spared.mailboxId));
|
|
620
|
+
assert.equal(survivors.length, 1);
|
|
621
|
+
assert.equal(
|
|
622
|
+
(await outboxEventsFor(sparedMessage)).some(
|
|
623
|
+
(row) => row.event === "message.removed",
|
|
624
|
+
),
|
|
625
|
+
false,
|
|
626
|
+
);
|
|
627
|
+
assert.equal(
|
|
628
|
+
(await repo.get(accountId, spared.mailboxId)).mailboxId,
|
|
629
|
+
spared.mailboxId,
|
|
630
|
+
);
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
test("leaves a filter bound to the folder alone", async () => {
|
|
634
|
+
// D16 refuses the delete while a binding stands, so there is nothing to
|
|
635
|
+
// unbind — and deleting a user's filters as a side effect of a folder
|
|
636
|
+
// delete is the outcome the design rules out. This is the test that
|
|
637
|
+
// stops a future refactor doing it.
|
|
638
|
+
const accountId = randomId();
|
|
639
|
+
const mailbox = await repo.create(makeMailboxInput(accountId, "Bound"));
|
|
640
|
+
const filterId = randomId();
|
|
641
|
+
await db.insert(filterTable).values({
|
|
642
|
+
filterId,
|
|
643
|
+
accountConfigId: accountId,
|
|
644
|
+
name: "Invoices → Bound",
|
|
645
|
+
scope: "Standing",
|
|
646
|
+
ruleChangedAt: 0,
|
|
647
|
+
actionChangedAt: 0,
|
|
648
|
+
actionMailboxId: mailbox.mailboxId,
|
|
649
|
+
createdAt: 0,
|
|
650
|
+
updatedAt: 0,
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
await repo.deleteMailboxWithMail(accountId, mailbox.mailboxId);
|
|
654
|
+
|
|
655
|
+
const rows = await db
|
|
656
|
+
.select()
|
|
657
|
+
.from(filterTable)
|
|
658
|
+
.where(eq(filterTable.filterId, filterId));
|
|
659
|
+
assert.equal(rows.length, 1);
|
|
660
|
+
assert.equal(rows[0].actionMailboxId, mailbox.mailboxId);
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
test("re-running after a partial removal completes", async () => {
|
|
664
|
+
const accountId = randomId();
|
|
665
|
+
const mailbox = await repo.create(makeMailboxInput(accountId, "Resume"));
|
|
666
|
+
const first = await seedMessage(mailbox.mailboxId, 1);
|
|
667
|
+
await seedMessage(mailbox.mailboxId, 2);
|
|
668
|
+
|
|
669
|
+
// The shape an interruption leaves: one message's rows already gone,
|
|
670
|
+
// the mailbox row still there and still `deleting`.
|
|
671
|
+
await runInTransaction(db, (tx) => deleteMessageSubtree(tx, [first]));
|
|
672
|
+
await repo.transition(accountId, mailbox.mailboxId, {
|
|
673
|
+
from: EVERY_STATE,
|
|
674
|
+
to: MailboxSyncStatus.deleting,
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
await repo.deleteMailboxWithMail(accountId, mailbox.mailboxId);
|
|
678
|
+
|
|
679
|
+
await assert.rejects(
|
|
680
|
+
() => repo.get(accountId, mailbox.mailboxId),
|
|
681
|
+
/Mailbox not found/,
|
|
682
|
+
);
|
|
683
|
+
assert.deepEqual(
|
|
684
|
+
await db
|
|
685
|
+
.select()
|
|
686
|
+
.from(messageTable)
|
|
687
|
+
.where(eq(messageTable.mailboxId, mailbox.mailboxId)),
|
|
688
|
+
[],
|
|
689
|
+
);
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
test("cross-tenant: a foreign account removes neither the row nor its mail", async () => {
|
|
693
|
+
const accountId = randomId();
|
|
694
|
+
const mailbox = await repo.create(makeMailboxInput(accountId, "Mine"));
|
|
695
|
+
await seedMessage(mailbox.mailboxId, 1);
|
|
696
|
+
|
|
697
|
+
await repo.deleteMailboxWithMail(randomId(), mailbox.mailboxId);
|
|
698
|
+
|
|
699
|
+
assert.equal(
|
|
700
|
+
(await repo.get(accountId, mailbox.mailboxId)).mailboxId,
|
|
701
|
+
mailbox.mailboxId,
|
|
702
|
+
);
|
|
703
|
+
assert.equal(
|
|
704
|
+
(
|
|
705
|
+
await db
|
|
706
|
+
.select()
|
|
707
|
+
.from(messageTable)
|
|
708
|
+
.where(eq(messageTable.mailboxId, mailbox.mailboxId))
|
|
709
|
+
).length,
|
|
710
|
+
1,
|
|
711
|
+
);
|
|
712
|
+
});
|
|
713
|
+
});
|
|
714
|
+
|
|
220
715
|
describe("continuation token rejection (#172)", () => {
|
|
221
716
|
for (const [label, token] of [
|
|
222
717
|
["an unparseable", "not-a-cursor"],
|