@remit/imap-worker 0.0.86 → 0.0.87
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/README.md +3 -1
- package/package.json +10 -2
- package/src/account-check.ts +1 -1
- package/src/body-sync-gate.test.ts +1 -1
- package/src/body-sync-gate.ts +1 -1
- package/src/e2e-processor-shim.ts +6 -21
- package/src/emit.ts +1 -1
- package/src/failure-kind.ts +1 -1
- package/src/handlers/append-sent-message.sqlite.test.ts +176 -0
- package/src/handlers/append-sent-message.ts +3 -24
- package/src/handlers/delete-account-objects.test.ts +1 -1
- package/src/handlers/delete-account-objects.ts +1 -1
- package/src/handlers/empty-trash.sqlite.test.ts +209 -0
- package/src/handlers/empty-trash.ts +3 -24
- package/src/handlers/flag-push.ts +11 -18
- package/src/handlers/mailbox-management.sqlite.test.ts +265 -0
- package/src/handlers/mailbox-management.ts +12 -48
- package/src/handlers/message-copy.sqlite.test.ts +298 -0
- package/src/handlers/message-copy.ts +31 -49
- package/src/handlers/message-delete-terminal.ts +12 -10
- package/src/handlers/message-delete.sqlite.test.ts +291 -0
- package/src/handlers/message-delete.ts +26 -32
- package/src/handlers/message-move-terminal.ts +12 -10
- package/src/handlers/message-move.sqlite.test.ts +271 -0
- package/src/handlers/message-move.test.ts +76 -863
- package/src/handlers/message-move.ts +49 -39
- package/src/handlers/placement-move-push.test.ts +96 -329
- package/src/handlers/placement-move-push.ts +75 -29
- package/src/handlers/placement-push.sqlite.test.ts +298 -0
- package/src/handlers/probe-message-id.sqlite.test.ts +197 -0
- package/src/handlers/probe-message-id.ts +15 -0
- package/src/handlers/sync-mailboxes.test.ts +0 -1
- package/src/handlers/sync-mailboxes.ts +1 -1
- package/src/handlers/sync-message-body.sqlite.test.ts +314 -0
- package/src/handlers/sync-message-body.test.ts +3 -299
- package/src/handlers/sync-message-body.ts +21 -11
- package/src/handlers/sync-messages.sqlite.test.ts +183 -0
- package/src/handlers/sync-messages.ts +27 -33
- package/src/index.ts +5 -3
- package/src/mail-sync-selection.sqlite.test.ts +16 -18
- package/src/poller.ts +5 -4
- package/src/processor.test.ts +1 -1
- package/src/processor.ts +1 -1
- package/src/scheduler/attachment-sweep.sqlite.test.ts +74 -0
- package/src/scheduler/attachment-sweep.ts +1 -1
- package/src/scheduler/{calendar-subscriptions.test.ts → calendar-subscriptions.sqlite.test.ts} +43 -55
- package/src/scheduler/calendar-subscriptions.ts +6 -6
- package/src/scheduler/half-deleted-accounts.sqlite.test.ts +203 -0
- package/src/scheduler/handler.ts +12 -1
- package/src/scheduler/loop.test.ts +2 -0
- package/src/scheduler/run-tick.sqlite.test.ts +192 -0
- package/src/scheduler/run-tick.ts +39 -1
- package/src/scheduler/runner.ts +21 -1
- package/src/scheduler/stranded-placement-redrive.ts +265 -0
- package/src/stranded-placement.sqlite.test.ts +795 -0
- package/src/test-helpers/test-world.ts +398 -0
- package/src/thread-join.sqlite.test.ts +115 -0
- package/src/with-oauth-lifecycle-deps.sqlite.test.ts +138 -14
- package/src/with-oauth-lifecycle.ts +1 -1
- package/src/handlers/append-sent-message.test.ts +0 -529
- package/src/handlers/empty-trash.test.ts +0 -728
- package/src/handlers/flag-push-placement-race.test.ts +0 -385
- package/src/handlers/flag-push.test.ts +0 -325
- package/src/handlers/mailbox-management.test.ts +0 -850
- package/src/handlers/message-copy.test.ts +0 -654
- package/src/handlers/message-delete.test.ts +0 -1869
- package/src/handlers/message-move-terminal.test.ts +0 -412
- package/src/handlers/sync-messages.test.ts +0 -569
- package/src/scheduler/attachment-sweep.test.ts +0 -169
- package/src/scheduler/run-tick.test.ts +0 -478
- package/src/with-oauth-lifecycle.test.ts +0 -318
|
@@ -1,169 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Both scheduler entry points must hand the tick a sweep.
|
|
3
|
-
*
|
|
4
|
-
* Only the hosted one strictly needs it — a browser PUTs straight to block
|
|
5
|
-
* storage there, so nothing is in the path to refuse an upload against a draft
|
|
6
|
-
* that is already gone — but a deployment that quietly stops collecting is
|
|
7
|
-
* indistinguishable from one that has nothing to collect. Deleting either wiring
|
|
8
|
-
* used to leave the suite green.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import assert from "node:assert/strict";
|
|
12
|
-
import { readFileSync } from "node:fs";
|
|
13
|
-
import { dirname, join } from "node:path";
|
|
14
|
-
import { describe, it } from "node:test";
|
|
15
|
-
import { fileURLToPath } from "node:url";
|
|
16
|
-
import type { AccountItem } from "@remit/data-ports";
|
|
17
|
-
import type { Logger } from "@remit/logger-lambda";
|
|
18
|
-
import type { StorageService } from "@remit/storage-service";
|
|
19
|
-
import { buildAttachmentSweep } from "./attachment-sweep.js";
|
|
20
|
-
|
|
21
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
22
|
-
const TICK_TIME = 1_700_000_000_000;
|
|
23
|
-
|
|
24
|
-
const silentLogger = (): Logger => {
|
|
25
|
-
const noop = () => {};
|
|
26
|
-
const log = {
|
|
27
|
-
info: noop,
|
|
28
|
-
warn: noop,
|
|
29
|
-
error: noop,
|
|
30
|
-
debug: noop,
|
|
31
|
-
fatal: noop,
|
|
32
|
-
trace: noop,
|
|
33
|
-
child: () => log,
|
|
34
|
-
} as unknown as Logger;
|
|
35
|
-
return log;
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
/** Enough of an account for the tick's eligibility checks to run. */
|
|
39
|
-
const eligibleAccount = (accountId: string) =>
|
|
40
|
-
({
|
|
41
|
-
accountId,
|
|
42
|
-
accountConfigId: "cfg-1",
|
|
43
|
-
email: `${accountId}@example.com`,
|
|
44
|
-
username: accountId,
|
|
45
|
-
authType: "password",
|
|
46
|
-
syncedServices: ["Mail"],
|
|
47
|
-
imapHost: "imap.example.com",
|
|
48
|
-
imapPort: 993,
|
|
49
|
-
imapTls: true,
|
|
50
|
-
imapStartTls: false,
|
|
51
|
-
isActive: true,
|
|
52
|
-
connectionState: "authenticated",
|
|
53
|
-
// Freshly synced, so nothing is due and no SQS send is attempted. The
|
|
54
|
-
// sweep must still run: abandoned bytes have nothing to do with how
|
|
55
|
-
// recently mail was fetched.
|
|
56
|
-
lastSyncAt: TICK_TIME,
|
|
57
|
-
createdAt: 0,
|
|
58
|
-
updatedAt: 0,
|
|
59
|
-
}) as unknown as AccountItem;
|
|
60
|
-
|
|
61
|
-
describe("the hosted tick sweeps", () => {
|
|
62
|
-
it("runs the sweep for every account, due a sync or not", async () => {
|
|
63
|
-
process.env.SQS_QUEUE_URL_MAILBOXES = "https://queue.test/mailboxes";
|
|
64
|
-
|
|
65
|
-
const swept: string[] = [];
|
|
66
|
-
const { setClient, _resetForTest } = await import("@remit/backend/client");
|
|
67
|
-
setClient({
|
|
68
|
-
account: {
|
|
69
|
-
listAllAccountsPage: async () => ({
|
|
70
|
-
items: [eligibleAccount("acc-1"), eligibleAccount("acc-2")],
|
|
71
|
-
cursor: null,
|
|
72
|
-
}),
|
|
73
|
-
},
|
|
74
|
-
storage: {
|
|
75
|
-
listOutboxDraftsWithAttachments: async (
|
|
76
|
-
_cfg: string,
|
|
77
|
-
accountId: string,
|
|
78
|
-
) => {
|
|
79
|
-
swept.push(accountId);
|
|
80
|
-
return [];
|
|
81
|
-
},
|
|
82
|
-
},
|
|
83
|
-
outboxAttachment: { reapAndListLive: async () => [] },
|
|
84
|
-
} as never);
|
|
85
|
-
|
|
86
|
-
try {
|
|
87
|
-
const { handler } = await import("./handler.js");
|
|
88
|
-
await handler(
|
|
89
|
-
{ time: new Date(TICK_TIME).toISOString() } as never,
|
|
90
|
-
{} as never,
|
|
91
|
-
() => {},
|
|
92
|
-
);
|
|
93
|
-
assert.deepEqual(swept.sort(), ["acc-1", "acc-2"]);
|
|
94
|
-
} finally {
|
|
95
|
-
_resetForTest();
|
|
96
|
-
}
|
|
97
|
-
});
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
describe("the self-host runner wires the sweep", () => {
|
|
101
|
-
it("hands runSchedulerTick a sweep", () => {
|
|
102
|
-
// runner.ts starts its loop on import, so it cannot be invoked from a test
|
|
103
|
-
// the way handler.ts can. This guards the wiring itself: without it,
|
|
104
|
-
// deleting the line leaves the suite green and self-host silently stops
|
|
105
|
-
// collecting.
|
|
106
|
-
const source = readFileSync(join(here, "runner.ts"), "utf8");
|
|
107
|
-
assert.match(source, /sweepAttachments:\s*buildAttachmentSweep\(/);
|
|
108
|
-
});
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
describe("buildAttachmentSweep", () => {
|
|
112
|
-
const account = {
|
|
113
|
-
accountId: "acc-1",
|
|
114
|
-
accountConfigId: "cfg-1",
|
|
115
|
-
} as unknown as AccountItem;
|
|
116
|
-
|
|
117
|
-
it("collects the objects the database does not name", async () => {
|
|
118
|
-
const deleted: string[] = [];
|
|
119
|
-
const storage = {
|
|
120
|
-
listOutboxDraftsWithAttachments: async () => ["draft-1"],
|
|
121
|
-
listOutboxAttachments: async () => [
|
|
122
|
-
{ outboxAttachmentId: "known", key: "k1", sizeBytes: 1 },
|
|
123
|
-
{ outboxAttachmentId: "orphan", key: "k2", sizeBytes: 1 },
|
|
124
|
-
],
|
|
125
|
-
deleteOutboxAttachment: async (
|
|
126
|
-
_cfg: string,
|
|
127
|
-
_acc: string,
|
|
128
|
-
_draft: string,
|
|
129
|
-
id: string,
|
|
130
|
-
) => {
|
|
131
|
-
deleted.push(id);
|
|
132
|
-
},
|
|
133
|
-
} as unknown as StorageService;
|
|
134
|
-
|
|
135
|
-
await buildAttachmentSweep(
|
|
136
|
-
{
|
|
137
|
-
storage,
|
|
138
|
-
outboxAttachment: {
|
|
139
|
-
reapAndListLive: async () => ["known"],
|
|
140
|
-
},
|
|
141
|
-
} as never,
|
|
142
|
-
silentLogger(),
|
|
143
|
-
)(account);
|
|
144
|
-
|
|
145
|
-
assert.deepEqual(deleted, ["orphan"]);
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
it("lets a storage failure out, so the tick can count it", async () => {
|
|
149
|
-
// Containment belongs to the tick, which keeps enqueuing mail regardless.
|
|
150
|
-
// Swallowing here would make a broken sweep look like a working one.
|
|
151
|
-
const storage = {
|
|
152
|
-
listOutboxDraftsWithAttachments: async () => {
|
|
153
|
-
throw new Error("storage root does not exist");
|
|
154
|
-
},
|
|
155
|
-
} as unknown as StorageService;
|
|
156
|
-
|
|
157
|
-
await assert.rejects(
|
|
158
|
-
() =>
|
|
159
|
-
buildAttachmentSweep(
|
|
160
|
-
{
|
|
161
|
-
storage,
|
|
162
|
-
outboxAttachment: { reapAndListLive: async () => [] },
|
|
163
|
-
} as never,
|
|
164
|
-
silentLogger(),
|
|
165
|
-
)(account),
|
|
166
|
-
/storage root does not exist/,
|
|
167
|
-
);
|
|
168
|
-
});
|
|
169
|
-
});
|
|
@@ -1,478 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { describe, it } from "node:test";
|
|
3
|
-
import type { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs";
|
|
4
|
-
import type { AccountItem, AccountSchedulerPage } from "@remit/data-ports";
|
|
5
|
-
import { AccountService } from "@remit/domain-enums";
|
|
6
|
-
import type { Logger } from "@remit/logger-lambda";
|
|
7
|
-
import { runSchedulerTick } from "./run-tick.js";
|
|
8
|
-
|
|
9
|
-
const TICK_INTERVAL_MS = 60 * 60 * 1000;
|
|
10
|
-
const OFFLINE_INTERVAL_MS = 12 * 60 * 60 * 1000;
|
|
11
|
-
const NOW = 1_700_000_000_000;
|
|
12
|
-
|
|
13
|
-
const createNoopLogger = (): Logger => {
|
|
14
|
-
const noop = () => {};
|
|
15
|
-
const log = {
|
|
16
|
-
info: noop,
|
|
17
|
-
warn: noop,
|
|
18
|
-
error: noop,
|
|
19
|
-
debug: noop,
|
|
20
|
-
fatal: noop,
|
|
21
|
-
trace: noop,
|
|
22
|
-
child: () => log,
|
|
23
|
-
} as unknown as Logger;
|
|
24
|
-
return log;
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
const createCapturingLogger = (): {
|
|
28
|
-
log: Logger;
|
|
29
|
-
calls: { level: string; args: unknown[] }[];
|
|
30
|
-
} => {
|
|
31
|
-
const calls: { level: string; args: unknown[] }[] = [];
|
|
32
|
-
const capture =
|
|
33
|
-
(level: string) =>
|
|
34
|
-
(...args: unknown[]) =>
|
|
35
|
-
calls.push({ level, args });
|
|
36
|
-
const log = {
|
|
37
|
-
info: capture("info"),
|
|
38
|
-
warn: capture("warn"),
|
|
39
|
-
error: capture("error"),
|
|
40
|
-
debug: capture("debug"),
|
|
41
|
-
fatal: capture("fatal"),
|
|
42
|
-
trace: capture("trace"),
|
|
43
|
-
child: () => log,
|
|
44
|
-
} as unknown as Logger;
|
|
45
|
-
return { log, calls };
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
const baseAccount = (overrides: Partial<AccountItem>): AccountItem =>
|
|
49
|
-
({
|
|
50
|
-
accountId: "acct_1",
|
|
51
|
-
accountConfigId: "acfg_1",
|
|
52
|
-
username: "user",
|
|
53
|
-
email: "user@example.com",
|
|
54
|
-
authType: "password",
|
|
55
|
-
passwordHash: '{"ciphertext":"x"}',
|
|
56
|
-
imapHost: "imap.example.com",
|
|
57
|
-
imapPort: 993,
|
|
58
|
-
imapTls: true,
|
|
59
|
-
imapStartTls: false,
|
|
60
|
-
smtpEnabled: false,
|
|
61
|
-
smtpHost: "",
|
|
62
|
-
smtpPort: 587,
|
|
63
|
-
smtpTls: false,
|
|
64
|
-
smtpStartTls: true,
|
|
65
|
-
smtpUsername: "",
|
|
66
|
-
syncedServices: [AccountService.Mail],
|
|
67
|
-
isActive: true,
|
|
68
|
-
connectionState: "authenticated",
|
|
69
|
-
createdAt: NOW - 1_000_000,
|
|
70
|
-
updatedAt: NOW - 1_000_000,
|
|
71
|
-
...overrides,
|
|
72
|
-
}) as AccountItem;
|
|
73
|
-
|
|
74
|
-
const fakeAccountService = (pages: AccountSchedulerPage[]) => {
|
|
75
|
-
let call = 0;
|
|
76
|
-
return {
|
|
77
|
-
listAllAccountsPage: async (): Promise<AccountSchedulerPage> => {
|
|
78
|
-
const page = pages[call];
|
|
79
|
-
call++;
|
|
80
|
-
if (!page) throw new Error("no more pages configured");
|
|
81
|
-
return page;
|
|
82
|
-
},
|
|
83
|
-
};
|
|
84
|
-
};
|
|
85
|
-
|
|
86
|
-
const fakeSqsClient = (): {
|
|
87
|
-
sqsClient: SQSClient;
|
|
88
|
-
sent: SendMessageCommand[];
|
|
89
|
-
} => {
|
|
90
|
-
const sent: SendMessageCommand[] = [];
|
|
91
|
-
const sqsClient = {
|
|
92
|
-
send: async (cmd: SendMessageCommand) => {
|
|
93
|
-
sent.push(cmd);
|
|
94
|
-
return {};
|
|
95
|
-
},
|
|
96
|
-
} as unknown as SQSClient;
|
|
97
|
-
return { sqsClient, sent };
|
|
98
|
-
};
|
|
99
|
-
|
|
100
|
-
describe("runSchedulerTick", () => {
|
|
101
|
-
it("enqueues only accounts that are due, and pages through every account", async () => {
|
|
102
|
-
const due = baseAccount({
|
|
103
|
-
accountId: "acct_due",
|
|
104
|
-
lastSyncAt: NOW - OFFLINE_INTERVAL_MS - 1,
|
|
105
|
-
});
|
|
106
|
-
const notDue = baseAccount({
|
|
107
|
-
accountId: "acct_not_due",
|
|
108
|
-
lastSyncAt: NOW - 60_000,
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
const accountService = fakeAccountService([
|
|
112
|
-
{ items: [due], cursor: "page2" },
|
|
113
|
-
{ items: [notDue], cursor: null },
|
|
114
|
-
]);
|
|
115
|
-
const { sqsClient, sent } = fakeSqsClient();
|
|
116
|
-
|
|
117
|
-
const result = await runSchedulerTick({
|
|
118
|
-
accountService,
|
|
119
|
-
sqsClient,
|
|
120
|
-
queueUrl:
|
|
121
|
-
"https://sqs.eu-west-1.amazonaws.com/123/remit-dev-mailboxes.fifo",
|
|
122
|
-
log: createNoopLogger(),
|
|
123
|
-
tickIntervalMs: TICK_INTERVAL_MS,
|
|
124
|
-
offlineIntervalMs: OFFLINE_INTERVAL_MS,
|
|
125
|
-
now: NOW,
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
assert.equal(result.scanned, 2);
|
|
129
|
-
assert.equal(result.enqueued, 1);
|
|
130
|
-
assert.equal(result.skipped, 1);
|
|
131
|
-
assert.equal(sent.length, 1);
|
|
132
|
-
const body = JSON.parse(sent[0]?.input.MessageBody ?? "{}");
|
|
133
|
-
assert.equal(body.accountId, "acct_due");
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
it("skips deleted, unsyncable-host, and reauth-required accounts", async () => {
|
|
137
|
-
const deleted = baseAccount({ accountId: "acct_deleted", deletedAt: NOW });
|
|
138
|
-
const unsyncable = baseAccount({
|
|
139
|
-
accountId: "acct_unsyncable",
|
|
140
|
-
imapHost: "mail.invalid",
|
|
141
|
-
});
|
|
142
|
-
const reauth = baseAccount({
|
|
143
|
-
accountId: "acct_reauth",
|
|
144
|
-
connectionState: "reauth_required",
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
const accountService = fakeAccountService([
|
|
148
|
-
{ items: [deleted, unsyncable, reauth], cursor: null },
|
|
149
|
-
]);
|
|
150
|
-
const { sqsClient, sent } = fakeSqsClient();
|
|
151
|
-
|
|
152
|
-
const result = await runSchedulerTick({
|
|
153
|
-
accountService,
|
|
154
|
-
sqsClient,
|
|
155
|
-
queueUrl:
|
|
156
|
-
"https://sqs.eu-west-1.amazonaws.com/123/remit-dev-mailboxes.fifo",
|
|
157
|
-
log: createNoopLogger(),
|
|
158
|
-
tickIntervalMs: TICK_INTERVAL_MS,
|
|
159
|
-
offlineIntervalMs: OFFLINE_INTERVAL_MS,
|
|
160
|
-
now: NOW,
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
assert.equal(result.enqueued, 0);
|
|
164
|
-
assert.equal(result.skipped, 3);
|
|
165
|
-
assert.equal(sent.length, 0);
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
it("skips an account storing no credential (issue #1120)", async () => {
|
|
169
|
-
const due = baseAccount({ accountId: "acct_due" });
|
|
170
|
-
const credentialless = baseAccount({
|
|
171
|
-
accountId: "acct_no_credential",
|
|
172
|
-
passwordHash: undefined,
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
const accountService = fakeAccountService([
|
|
176
|
-
{ items: [due, credentialless], cursor: null },
|
|
177
|
-
]);
|
|
178
|
-
const { sqsClient, sent } = fakeSqsClient();
|
|
179
|
-
|
|
180
|
-
const result = await runSchedulerTick({
|
|
181
|
-
accountService,
|
|
182
|
-
sqsClient,
|
|
183
|
-
queueUrl:
|
|
184
|
-
"https://sqs.eu-west-1.amazonaws.com/123/remit-dev-mailboxes.fifo",
|
|
185
|
-
log: createNoopLogger(),
|
|
186
|
-
tickIntervalMs: TICK_INTERVAL_MS,
|
|
187
|
-
offlineIntervalMs: OFFLINE_INTERVAL_MS,
|
|
188
|
-
now: NOW,
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
assert.equal(result.enqueued, 1);
|
|
192
|
-
assert.equal(result.skipped, 1);
|
|
193
|
-
assert.equal(sent.length, 1);
|
|
194
|
-
const body = JSON.parse(sent[0]?.input.MessageBody ?? "{}");
|
|
195
|
-
assert.equal(body.accountId, "acct_due");
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
it("never logs per-account for ineligible accounts — only the aggregate tick summary (review #1250)", async () => {
|
|
199
|
-
const deleted = baseAccount({ accountId: "acct_deleted", deletedAt: NOW });
|
|
200
|
-
const unsyncable = baseAccount({
|
|
201
|
-
accountId: "acct_unsyncable",
|
|
202
|
-
imapHost: "mail.invalid",
|
|
203
|
-
});
|
|
204
|
-
const reauth = baseAccount({
|
|
205
|
-
accountId: "acct_reauth",
|
|
206
|
-
connectionState: "reauth_required",
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
const accountService = fakeAccountService([
|
|
210
|
-
{ items: [deleted, unsyncable, reauth], cursor: null },
|
|
211
|
-
]);
|
|
212
|
-
const { sqsClient } = fakeSqsClient();
|
|
213
|
-
const { log, calls } = createCapturingLogger();
|
|
214
|
-
|
|
215
|
-
await runSchedulerTick({
|
|
216
|
-
accountService,
|
|
217
|
-
sqsClient,
|
|
218
|
-
queueUrl:
|
|
219
|
-
"https://sqs.eu-west-1.amazonaws.com/123/remit-dev-mailboxes.fifo",
|
|
220
|
-
log,
|
|
221
|
-
tickIntervalMs: TICK_INTERVAL_MS,
|
|
222
|
-
offlineIntervalMs: OFFLINE_INTERVAL_MS,
|
|
223
|
-
now: NOW,
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
// Sweeping 3 ineligible accounts must not produce 3 (or more) log
|
|
227
|
-
// lines through the tick's own logger — only the one aggregate
|
|
228
|
-
// "tick complete" summary. Per-account noise from the shared
|
|
229
|
-
// isAccountDeleted/isUnsyncableHost/isAccountReauthRequired helpers
|
|
230
|
-
// must be swallowed by a silent logger inside the sweep.
|
|
231
|
-
assert.equal(
|
|
232
|
-
calls.length,
|
|
233
|
-
1,
|
|
234
|
-
`expected exactly one aggregate log line, got ${calls.length}: ${JSON.stringify(calls)}`,
|
|
235
|
-
);
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
it("gives each enqueued account a scheduler-namespaced, time-bucketed dedup id", async () => {
|
|
239
|
-
const due = baseAccount({ accountId: "acct_due" });
|
|
240
|
-
const accountService = fakeAccountService([{ items: [due], cursor: null }]);
|
|
241
|
-
const { sqsClient, sent } = fakeSqsClient();
|
|
242
|
-
|
|
243
|
-
await runSchedulerTick({
|
|
244
|
-
accountService,
|
|
245
|
-
sqsClient,
|
|
246
|
-
queueUrl:
|
|
247
|
-
"https://sqs.eu-west-1.amazonaws.com/123/remit-dev-mailboxes.fifo",
|
|
248
|
-
log: createNoopLogger(),
|
|
249
|
-
tickIntervalMs: TICK_INTERVAL_MS,
|
|
250
|
-
offlineIntervalMs: OFFLINE_INTERVAL_MS,
|
|
251
|
-
now: NOW,
|
|
252
|
-
});
|
|
253
|
-
|
|
254
|
-
const dedupId = sent[0]?.input.MessageDeduplicationId;
|
|
255
|
-
assert.ok(dedupId?.startsWith("SYNC_MAILBOXES:scheduled:acct_due:"));
|
|
256
|
-
assert.notEqual(dedupId, "SYNC_MAILBOXES:acct_due");
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
it("terminates pagination and never revisits a page", async () => {
|
|
260
|
-
const accountService = fakeAccountService([
|
|
261
|
-
{ items: [], cursor: "page2" },
|
|
262
|
-
{ items: [], cursor: "page3" },
|
|
263
|
-
{ items: [], cursor: null },
|
|
264
|
-
]);
|
|
265
|
-
const { sqsClient } = fakeSqsClient();
|
|
266
|
-
|
|
267
|
-
const result = await runSchedulerTick({
|
|
268
|
-
accountService,
|
|
269
|
-
sqsClient,
|
|
270
|
-
queueUrl:
|
|
271
|
-
"https://sqs.eu-west-1.amazonaws.com/123/remit-dev-mailboxes.fifo",
|
|
272
|
-
log: createNoopLogger(),
|
|
273
|
-
tickIntervalMs: TICK_INTERVAL_MS,
|
|
274
|
-
offlineIntervalMs: OFFLINE_INTERVAL_MS,
|
|
275
|
-
now: NOW,
|
|
276
|
-
});
|
|
277
|
-
|
|
278
|
-
assert.equal(result.scanned, 0);
|
|
279
|
-
assert.equal(result.enqueued, 0);
|
|
280
|
-
});
|
|
281
|
-
});
|
|
282
|
-
|
|
283
|
-
describe("the attachment sweep inside a tick", () => {
|
|
284
|
-
const twoAccounts = () => [
|
|
285
|
-
{
|
|
286
|
-
items: [
|
|
287
|
-
baseAccount({ accountId: "acct_1", lastSyncAt: NOW - 1_000 }),
|
|
288
|
-
baseAccount({ accountId: "acct_2", lastSyncAt: 0 }),
|
|
289
|
-
],
|
|
290
|
-
cursor: null,
|
|
291
|
-
} as unknown as AccountSchedulerPage,
|
|
292
|
-
];
|
|
293
|
-
|
|
294
|
-
it("sweeps every account, not only the ones due a sync", async () => {
|
|
295
|
-
const { sqsClient } = fakeSqsClient();
|
|
296
|
-
const swept: string[] = [];
|
|
297
|
-
|
|
298
|
-
const result = await runSchedulerTick({
|
|
299
|
-
accountService: fakeAccountService(twoAccounts()),
|
|
300
|
-
sqsClient,
|
|
301
|
-
queueUrl: "https://queue.test/mailboxes",
|
|
302
|
-
log: createNoopLogger(),
|
|
303
|
-
tickIntervalMs: TICK_INTERVAL_MS,
|
|
304
|
-
offlineIntervalMs: OFFLINE_INTERVAL_MS,
|
|
305
|
-
now: NOW,
|
|
306
|
-
sweepAttachments: async (account) => {
|
|
307
|
-
swept.push(account.accountId);
|
|
308
|
-
},
|
|
309
|
-
});
|
|
310
|
-
|
|
311
|
-
assert.deepEqual(swept.sort(), ["acct_1", "acct_2"]);
|
|
312
|
-
assert.equal(result.swept, 2);
|
|
313
|
-
assert.equal(result.sweepFailed, 0);
|
|
314
|
-
});
|
|
315
|
-
|
|
316
|
-
it("keeps enqueuing mail when the sweep throws", async () => {
|
|
317
|
-
// The sweep is housekeeping; enqueuing mail is not. Uncontained, one
|
|
318
|
-
// storage error here reaches the loop, which exits, and the container
|
|
319
|
-
// restarts every few seconds without ever enqueuing an account again.
|
|
320
|
-
const { sqsClient, sent } = fakeSqsClient();
|
|
321
|
-
const { log, calls } = createCapturingLogger();
|
|
322
|
-
|
|
323
|
-
const result = await runSchedulerTick({
|
|
324
|
-
accountService: fakeAccountService(twoAccounts()),
|
|
325
|
-
sqsClient,
|
|
326
|
-
queueUrl: "https://queue.test/mailboxes",
|
|
327
|
-
log,
|
|
328
|
-
tickIntervalMs: TICK_INTERVAL_MS,
|
|
329
|
-
offlineIntervalMs: OFFLINE_INTERVAL_MS,
|
|
330
|
-
now: NOW,
|
|
331
|
-
sweepAttachments: async () => {
|
|
332
|
-
throw new Error("AccessDenied on ListObjectsV2");
|
|
333
|
-
},
|
|
334
|
-
});
|
|
335
|
-
|
|
336
|
-
assert.equal(result.sweepFailed, 2);
|
|
337
|
-
assert.equal(result.swept, 0);
|
|
338
|
-
// The tick still did its actual job.
|
|
339
|
-
assert.ok(sent.length > 0);
|
|
340
|
-
assert.equal(result.enqueued, sent.length);
|
|
341
|
-
assert.ok(
|
|
342
|
-
calls.some(
|
|
343
|
-
(entry) =>
|
|
344
|
-
entry.level === "error" &&
|
|
345
|
-
entry.args.some((arg) =>
|
|
346
|
-
String(arg).includes("Outbox attachment sweep failed"),
|
|
347
|
-
),
|
|
348
|
-
),
|
|
349
|
-
);
|
|
350
|
-
});
|
|
351
|
-
|
|
352
|
-
it("ticks without a sweep at all", async () => {
|
|
353
|
-
const { sqsClient } = fakeSqsClient();
|
|
354
|
-
|
|
355
|
-
const result = await runSchedulerTick({
|
|
356
|
-
accountService: fakeAccountService(twoAccounts()),
|
|
357
|
-
sqsClient,
|
|
358
|
-
queueUrl: "https://queue.test/mailboxes",
|
|
359
|
-
log: createNoopLogger(),
|
|
360
|
-
tickIntervalMs: TICK_INTERVAL_MS,
|
|
361
|
-
offlineIntervalMs: OFFLINE_INTERVAL_MS,
|
|
362
|
-
now: NOW,
|
|
363
|
-
});
|
|
364
|
-
|
|
365
|
-
assert.equal(result.swept, 0);
|
|
366
|
-
assert.equal(result.sweepFailed, 0);
|
|
367
|
-
});
|
|
368
|
-
});
|
|
369
|
-
|
|
370
|
-
describe("runSchedulerTick and the account's synced services", () => {
|
|
371
|
-
const tickWith = async (syncedServices: AccountItem["syncedServices"]) => {
|
|
372
|
-
const accountService = fakeAccountService([
|
|
373
|
-
{
|
|
374
|
-
items: [baseAccount({ accountId: "acct_1", syncedServices })],
|
|
375
|
-
cursor: null,
|
|
376
|
-
},
|
|
377
|
-
]);
|
|
378
|
-
const { sqsClient, sent } = fakeSqsClient();
|
|
379
|
-
const result = await runSchedulerTick({
|
|
380
|
-
accountService,
|
|
381
|
-
sqsClient,
|
|
382
|
-
queueUrl: "https://queue.test/mailboxes",
|
|
383
|
-
log: createNoopLogger(),
|
|
384
|
-
tickIntervalMs: TICK_INTERVAL_MS,
|
|
385
|
-
offlineIntervalMs: OFFLINE_INTERVAL_MS,
|
|
386
|
-
now: NOW,
|
|
387
|
-
});
|
|
388
|
-
return { result, sent };
|
|
389
|
-
};
|
|
390
|
-
|
|
391
|
-
it("enqueues mail sync for a mail-only account", async () => {
|
|
392
|
-
const { result, sent } = await tickWith([AccountService.Mail]);
|
|
393
|
-
|
|
394
|
-
assert.equal(result.enqueued, 1);
|
|
395
|
-
assert.equal(sent.length, 1);
|
|
396
|
-
});
|
|
397
|
-
|
|
398
|
-
it("enqueues mail sync for an account syncing mail and calendar", async () => {
|
|
399
|
-
const { result, sent } = await tickWith([
|
|
400
|
-
AccountService.Mail,
|
|
401
|
-
AccountService.Calendar,
|
|
402
|
-
]);
|
|
403
|
-
|
|
404
|
-
assert.equal(result.enqueued, 1);
|
|
405
|
-
assert.equal(sent.length, 1);
|
|
406
|
-
});
|
|
407
|
-
|
|
408
|
-
it("skips a calendar-only account", async () => {
|
|
409
|
-
const { result, sent } = await tickWith([AccountService.Calendar]);
|
|
410
|
-
|
|
411
|
-
assert.equal(result.enqueued, 0);
|
|
412
|
-
assert.equal(result.skipped, 1);
|
|
413
|
-
assert.equal(sent.length, 0);
|
|
414
|
-
});
|
|
415
|
-
});
|
|
416
|
-
|
|
417
|
-
describe("the calendar subscription refresh inside a tick", () => {
|
|
418
|
-
const oneAccount = () => [
|
|
419
|
-
{
|
|
420
|
-
items: [baseAccount({ accountId: "acct_1", lastSyncAt: 0 })],
|
|
421
|
-
cursor: null,
|
|
422
|
-
} as unknown as AccountSchedulerPage,
|
|
423
|
-
];
|
|
424
|
-
|
|
425
|
-
it("runs once per tick, against the offline interval", async () => {
|
|
426
|
-
const { sqsClient } = fakeSqsClient();
|
|
427
|
-
const calls: [number, number][] = [];
|
|
428
|
-
|
|
429
|
-
const result = await runSchedulerTick({
|
|
430
|
-
accountService: fakeAccountService(oneAccount()),
|
|
431
|
-
sqsClient,
|
|
432
|
-
queueUrl: "https://queue.test/mailboxes",
|
|
433
|
-
log: createNoopLogger(),
|
|
434
|
-
tickIntervalMs: TICK_INTERVAL_MS,
|
|
435
|
-
offlineIntervalMs: OFFLINE_INTERVAL_MS,
|
|
436
|
-
now: NOW,
|
|
437
|
-
refreshCalendarSubscriptions: async (now, intervalMs) => {
|
|
438
|
-
calls.push([now, intervalMs]);
|
|
439
|
-
return { refreshed: 2, failed: 1, notDue: 4 };
|
|
440
|
-
},
|
|
441
|
-
});
|
|
442
|
-
|
|
443
|
-
assert.deepEqual(calls, [[NOW, OFFLINE_INTERVAL_MS]]);
|
|
444
|
-
assert.equal(result.subscriptionsRefreshed, 2);
|
|
445
|
-
assert.equal(result.subscriptionsFailed, 1);
|
|
446
|
-
assert.equal(result.enqueued, 1);
|
|
447
|
-
});
|
|
448
|
-
|
|
449
|
-
it("keeps the tick alive when the refresh throws", async () => {
|
|
450
|
-
const { sqsClient, sent } = fakeSqsClient();
|
|
451
|
-
const { log, calls } = createCapturingLogger();
|
|
452
|
-
|
|
453
|
-
const result = await runSchedulerTick({
|
|
454
|
-
accountService: fakeAccountService(oneAccount()),
|
|
455
|
-
sqsClient,
|
|
456
|
-
queueUrl: "https://queue.test/mailboxes",
|
|
457
|
-
log,
|
|
458
|
-
tickIntervalMs: TICK_INTERVAL_MS,
|
|
459
|
-
offlineIntervalMs: OFFLINE_INTERVAL_MS,
|
|
460
|
-
now: NOW,
|
|
461
|
-
refreshCalendarSubscriptions: async () => {
|
|
462
|
-
throw new Error("database is locked");
|
|
463
|
-
},
|
|
464
|
-
});
|
|
465
|
-
|
|
466
|
-
assert.equal(sent.length, 1);
|
|
467
|
-
assert.equal(result.subscriptionsRefreshed, 0);
|
|
468
|
-
assert.ok(
|
|
469
|
-
calls.some(
|
|
470
|
-
(entry) =>
|
|
471
|
-
entry.level === "error" &&
|
|
472
|
-
entry.args.some((arg) =>
|
|
473
|
-
String(arg).includes("Calendar subscription refresh failed"),
|
|
474
|
-
),
|
|
475
|
-
),
|
|
476
|
-
);
|
|
477
|
-
});
|
|
478
|
-
});
|