@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.
Files changed (71) hide show
  1. package/README.md +3 -1
  2. package/package.json +10 -2
  3. package/src/account-check.ts +1 -1
  4. package/src/body-sync-gate.test.ts +1 -1
  5. package/src/body-sync-gate.ts +1 -1
  6. package/src/e2e-processor-shim.ts +6 -21
  7. package/src/emit.ts +1 -1
  8. package/src/failure-kind.ts +1 -1
  9. package/src/handlers/append-sent-message.sqlite.test.ts +176 -0
  10. package/src/handlers/append-sent-message.ts +3 -24
  11. package/src/handlers/delete-account-objects.test.ts +1 -1
  12. package/src/handlers/delete-account-objects.ts +1 -1
  13. package/src/handlers/empty-trash.sqlite.test.ts +209 -0
  14. package/src/handlers/empty-trash.ts +3 -24
  15. package/src/handlers/flag-push.ts +11 -18
  16. package/src/handlers/mailbox-management.sqlite.test.ts +265 -0
  17. package/src/handlers/mailbox-management.ts +12 -48
  18. package/src/handlers/message-copy.sqlite.test.ts +298 -0
  19. package/src/handlers/message-copy.ts +31 -49
  20. package/src/handlers/message-delete-terminal.ts +12 -10
  21. package/src/handlers/message-delete.sqlite.test.ts +291 -0
  22. package/src/handlers/message-delete.ts +26 -32
  23. package/src/handlers/message-move-terminal.ts +12 -10
  24. package/src/handlers/message-move.sqlite.test.ts +271 -0
  25. package/src/handlers/message-move.test.ts +76 -863
  26. package/src/handlers/message-move.ts +49 -39
  27. package/src/handlers/placement-move-push.test.ts +96 -329
  28. package/src/handlers/placement-move-push.ts +75 -29
  29. package/src/handlers/placement-push.sqlite.test.ts +298 -0
  30. package/src/handlers/probe-message-id.sqlite.test.ts +197 -0
  31. package/src/handlers/probe-message-id.ts +15 -0
  32. package/src/handlers/sync-mailboxes.test.ts +0 -1
  33. package/src/handlers/sync-mailboxes.ts +1 -1
  34. package/src/handlers/sync-message-body.sqlite.test.ts +314 -0
  35. package/src/handlers/sync-message-body.test.ts +3 -299
  36. package/src/handlers/sync-message-body.ts +21 -11
  37. package/src/handlers/sync-messages.sqlite.test.ts +183 -0
  38. package/src/handlers/sync-messages.ts +27 -33
  39. package/src/index.ts +5 -3
  40. package/src/mail-sync-selection.sqlite.test.ts +16 -18
  41. package/src/poller.ts +5 -4
  42. package/src/processor.test.ts +1 -1
  43. package/src/processor.ts +1 -1
  44. package/src/scheduler/attachment-sweep.sqlite.test.ts +74 -0
  45. package/src/scheduler/attachment-sweep.ts +1 -1
  46. package/src/scheduler/{calendar-subscriptions.test.ts → calendar-subscriptions.sqlite.test.ts} +43 -55
  47. package/src/scheduler/calendar-subscriptions.ts +6 -6
  48. package/src/scheduler/half-deleted-accounts.sqlite.test.ts +203 -0
  49. package/src/scheduler/handler.ts +12 -1
  50. package/src/scheduler/loop.test.ts +2 -0
  51. package/src/scheduler/run-tick.sqlite.test.ts +192 -0
  52. package/src/scheduler/run-tick.ts +39 -1
  53. package/src/scheduler/runner.ts +21 -1
  54. package/src/scheduler/stranded-placement-redrive.ts +265 -0
  55. package/src/stranded-placement.sqlite.test.ts +795 -0
  56. package/src/test-helpers/test-world.ts +398 -0
  57. package/src/thread-join.sqlite.test.ts +115 -0
  58. package/src/with-oauth-lifecycle-deps.sqlite.test.ts +138 -14
  59. package/src/with-oauth-lifecycle.ts +1 -1
  60. package/src/handlers/append-sent-message.test.ts +0 -529
  61. package/src/handlers/empty-trash.test.ts +0 -728
  62. package/src/handlers/flag-push-placement-race.test.ts +0 -385
  63. package/src/handlers/flag-push.test.ts +0 -325
  64. package/src/handlers/mailbox-management.test.ts +0 -850
  65. package/src/handlers/message-copy.test.ts +0 -654
  66. package/src/handlers/message-delete.test.ts +0 -1869
  67. package/src/handlers/message-move-terminal.test.ts +0 -412
  68. package/src/handlers/sync-messages.test.ts +0 -569
  69. package/src/scheduler/attachment-sweep.test.ts +0 -169
  70. package/src/scheduler/run-tick.test.ts +0 -478
  71. package/src/with-oauth-lifecycle.test.ts +0 -318
@@ -1,10 +1,10 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { describe, it } from "node:test";
3
3
  import type { CalendarFeedFetcher } from "@remit/calendar-service";
4
- import { MemoryCalendarStore } from "@remit/calendar-service/memory-store";
5
4
  import type { CalendarCollectionItem } from "@remit/data-ports";
6
5
  import { CalendarSource } from "@remit/domain-enums";
7
- import type { Logger } from "@remit/logger-lambda";
6
+ import type { Logger } from "@remit/logger";
7
+ import { openStore } from "../test-helpers/test-world.js";
8
8
  import { refreshDueCalendarSubscriptions } from "./calendar-subscriptions.js";
9
9
 
10
10
  const INTERVAL_MS = 15 * 60 * 1000;
@@ -41,27 +41,25 @@ const logged = (): { log: Logger; lines: string[] } => {
41
41
  return { log, lines };
42
42
  };
43
43
 
44
+ type Client = Awaited<ReturnType<typeof openStore>>["client"];
45
+
44
46
  const subscription = async (
45
- store: MemoryCalendarStore,
47
+ client: Client,
46
48
  urlSegment: string,
47
49
  checkedAt: number,
48
50
  ): Promise<CalendarCollectionItem> => {
49
- const created = await store.transaction((repos) =>
50
- repos.calendarCollection.create({
51
- accountConfigId: "account-config-1",
52
- urlSegment,
53
- displayName: urlSegment,
54
- source: CalendarSource.Subscribed,
55
- subscriptionUrl: `https://calendar.example/${urlSegment}/private-feedfacecafe.ics`,
56
- subscriptionEnabled: true,
57
- }),
58
- );
59
- return store.transaction((repos) =>
60
- repos.calendarCollection.update(
61
- created.accountConfigId,
62
- created.calendarId,
63
- { subscriptionCheckedAt: checkedAt },
64
- ),
51
+ const created = await client.calendarCollection.create({
52
+ accountConfigId: "account-config-1",
53
+ urlSegment,
54
+ displayName: urlSegment,
55
+ source: CalendarSource.Subscribed,
56
+ subscriptionUrl: `https://calendar.example/${urlSegment}/private-feedfacecafe.ics`,
57
+ subscriptionEnabled: true,
58
+ });
59
+ return client.calendarCollection.update(
60
+ created.accountConfigId,
61
+ created.calendarId,
62
+ { subscriptionCheckedAt: checkedAt },
65
63
  );
66
64
  };
67
65
 
@@ -73,29 +71,23 @@ const recording =
73
71
  };
74
72
 
75
73
  describe("refreshDueCalendarSubscriptions", () => {
76
- it("fetches the subscriptions that are due and skips the rest", async () => {
77
- const store = new MemoryCalendarStore();
78
- const due = await subscription(store, "due", NOW - INTERVAL_MS);
79
- await subscription(store, "fresh", NOW - 60_000);
80
- const paused = await subscription(store, "paused", 0);
81
- await store.transaction((repos) =>
82
- repos.calendarCollection.update(
83
- paused.accountConfigId,
84
- paused.calendarId,
85
- { subscriptionEnabled: false },
86
- ),
74
+ it("fetches the subscriptions that are due and skips the rest", async (t) => {
75
+ const { client, close } = await openStore();
76
+ t.after(close);
77
+ const due = await subscription(client, "due", NOW - INTERVAL_MS);
78
+ await subscription(client, "fresh", NOW - 60_000);
79
+ const paused = await subscription(client, "paused", 0);
80
+ await client.calendarCollection.update(
81
+ paused.accountConfigId,
82
+ paused.calendarId,
83
+ { subscriptionEnabled: false },
87
84
  );
88
85
  const asked: string[] = [];
89
86
 
90
87
  const tally = await refreshDueCalendarSubscriptions(
91
88
  {
92
- calendarCollection: {
93
- listEnabledSubscriptions: () =>
94
- store.transaction((repos) =>
95
- repos.calendarCollection.listEnabledSubscriptions(),
96
- ),
97
- },
98
- calendarUnitOfWork: store,
89
+ calendarCollection: client.calendarCollection,
90
+ calendarStore: client.calendarStore,
99
91
  log: logged().log,
100
92
  fetcher: recording(asked, () => new Response(FEED, { status: 200 })),
101
93
  },
@@ -105,30 +97,24 @@ describe("refreshDueCalendarSubscriptions", () => {
105
97
 
106
98
  assert.deepEqual(asked, [due.subscriptionUrl]);
107
99
  assert.deepEqual(tally, { refreshed: 1, failed: 0, notDue: 1 });
108
- const objects = await store.transaction((repos) =>
109
- repos.calendarObject.listByCalendar(due.calendarId),
110
- );
100
+ const objects = await client.calendarObject.listByCalendar(due.calendarId);
111
101
  assert.deepEqual(
112
102
  objects.map((object) => object.summary),
113
103
  ["Ferry crossing"],
114
104
  );
115
105
  });
116
106
 
117
- it("counts a feed that fails, keeps refreshing the others, and never logs an address", async () => {
118
- const store = new MemoryCalendarStore();
119
- const broken = await subscription(store, "broken", 0);
120
- const working = await subscription(store, "working", 0);
107
+ it("counts a feed that fails, keeps refreshing the others, and never logs an address", async (t) => {
108
+ const { client, close } = await openStore();
109
+ t.after(close);
110
+ const broken = await subscription(client, "broken", 0);
111
+ const working = await subscription(client, "working", 0);
121
112
  const { log, lines } = logged();
122
113
 
123
114
  const tally = await refreshDueCalendarSubscriptions(
124
115
  {
125
- calendarCollection: {
126
- listEnabledSubscriptions: () =>
127
- store.transaction((repos) =>
128
- repos.calendarCollection.listEnabledSubscriptions(),
129
- ),
130
- },
131
- calendarUnitOfWork: store,
116
+ calendarCollection: client.calendarCollection,
117
+ calendarStore: client.calendarStore,
132
118
  log,
133
119
  fetcher: recording([], (url) =>
134
120
  url === broken.subscriptionUrl
@@ -141,13 +127,15 @@ describe("refreshDueCalendarSubscriptions", () => {
141
127
  );
142
128
 
143
129
  assert.deepEqual(tally, { refreshed: 1, failed: 1, notDue: 0 });
144
- const stored = await store.transaction((repos) =>
145
- repos.calendarCollection.get(broken.accountConfigId, broken.calendarId),
130
+ const stored = await client.calendarCollection.get(
131
+ broken.accountConfigId,
132
+ broken.calendarId,
146
133
  );
147
134
  assert.equal(stored.subscriptionError, "the feed answered HTTP 410");
148
135
  assert.equal(stored.subscriptionCheckedAt, NOW);
149
- const refreshed = await store.transaction((repos) =>
150
- repos.calendarCollection.get(working.accountConfigId, working.calendarId),
136
+ const refreshed = await client.calendarCollection.get(
137
+ working.accountConfigId,
138
+ working.calendarId,
151
139
  );
152
140
  assert.equal(refreshed.subscriptionFetchedAt, NOW);
153
141
  assert.ok(lines.some((line) => line.includes(broken.calendarId)));
@@ -6,9 +6,9 @@ import {
6
6
  } from "@remit/calendar-service";
7
7
  import type {
8
8
  ICalendarCollectionRepository,
9
- ICalendarUnitOfWork,
9
+ ICalendarStore,
10
10
  } from "@remit/data-ports";
11
- import type { Logger } from "@remit/logger-lambda";
11
+ import type { Logger } from "@remit/logger";
12
12
  import pMap from "p-map";
13
13
 
14
14
  const REFRESH_CONCURRENCY = 4;
@@ -24,7 +24,7 @@ export interface CalendarSubscriptionRefreshDeps {
24
24
  ICalendarCollectionRepository,
25
25
  "listEnabledSubscriptions"
26
26
  >;
27
- calendarUnitOfWork: ICalendarUnitOfWork;
27
+ calendarStore: ICalendarStore;
28
28
  log: Logger;
29
29
  fetcher?: CalendarFeedFetcher;
30
30
  }
@@ -52,7 +52,7 @@ export const refreshDueCalendarSubscriptions = async (
52
52
  due,
53
53
  (collection) =>
54
54
  refreshCalendarSubscription(
55
- deps.calendarUnitOfWork,
55
+ deps.calendarStore,
56
56
  collection,
57
57
  now,
58
58
  deps.fetcher,
@@ -85,7 +85,7 @@ export const refreshDueCalendarSubscriptions = async (
85
85
 
86
86
  export const buildCalendarSubscriptionRefresh =
87
87
  (
88
- client: Pick<RemitClient, "calendarCollection" | "calendarUnitOfWork">,
88
+ client: Pick<RemitClient, "calendarCollection" | "calendarStore">,
89
89
  log: Logger,
90
90
  ): ((
91
91
  now: number,
@@ -95,7 +95,7 @@ export const buildCalendarSubscriptionRefresh =
95
95
  refreshDueCalendarSubscriptions(
96
96
  {
97
97
  calendarCollection: client.calendarCollection,
98
- calendarUnitOfWork: client.calendarUnitOfWork,
98
+ calendarStore: client.calendarStore,
99
99
  log,
100
100
  },
101
101
  now,
@@ -0,0 +1,203 @@
1
+ import assert from "node:assert/strict";
2
+ import { randomBytes } from "node:crypto";
3
+ import { after, before, beforeEach, describe, it } from "node:test";
4
+ import type { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs";
5
+ import { finishHalfDeletedAccounts } from "@remit/backend/account-deletion";
6
+ import type { RemitClient } from "@remit/backend/client";
7
+ import { importConfig } from "@remit/config-transfer";
8
+ import type { AccountItem } from "@remit/data-ports";
9
+ import { AccountAuthType, ConnectionState } from "@remit/domain-enums";
10
+ import { noopLogger } from "@remit/logger";
11
+ import { openStore } from "../test-helpers/test-world.js";
12
+
13
+ const FANOUT_URL = "http://queue:9324/000000000000/remit-account-fanout";
14
+
15
+ let store: Awaited<ReturnType<typeof openStore>>;
16
+ let client: RemitClient;
17
+
18
+ before(async () => {
19
+ store = await openStore();
20
+ client = store.client;
21
+ });
22
+
23
+ after(() => store.close());
24
+
25
+ const storedId = (): string => randomBytes(13).toString("hex").slice(0, 25);
26
+
27
+ const configDocument = (accountId: string, email: string) => ({
28
+ kind: "reader.config",
29
+ schemaVersion: 1,
30
+ generator: {
31
+ app: "reader",
32
+ version: "v0.1.0",
33
+ exportedAt: "2026-08-27T09:15:00+02:00",
34
+ },
35
+ provenance: { accountConfigId: storedId(), instance: "reader.example.com" },
36
+ accountConfig: { name: "Imported" },
37
+ accounts: [
38
+ {
39
+ accountId,
40
+ email,
41
+ username: email,
42
+ authType: "password",
43
+ credentials: { required: "password" },
44
+ isActive: true,
45
+ imap: { host: "imap.example.com", port: 993, tls: true, startTls: false },
46
+ smtp: {
47
+ enabled: false,
48
+ host: "",
49
+ port: 587,
50
+ tls: false,
51
+ startTls: false,
52
+ username: "",
53
+ },
54
+ displayName: "",
55
+ muted: null,
56
+ composeLanguages: [],
57
+ signature: { plainText: "", html: "" },
58
+ folderRoles: [],
59
+ folderOverrides: [],
60
+ pinnedFolders: [],
61
+ },
62
+ ],
63
+ labels: [],
64
+ filters: [],
65
+ addressFlags: [],
66
+ });
67
+
68
+ const seedAccount = (
69
+ accountConfigId: string,
70
+ name: string,
71
+ extra: { deletedAt?: number } = {},
72
+ ): Promise<AccountItem> =>
73
+ client.account.create({
74
+ accountConfigId,
75
+ username: `${name}@example.com`,
76
+ email: `${name}@example.com`,
77
+ authType: AccountAuthType.Password,
78
+ passwordHash: '{"ciphertext":"x"}',
79
+ imapHost: "imap.example.com",
80
+ imapPort: 993,
81
+ imapTls: true,
82
+ imapStartTls: false,
83
+ isActive: true,
84
+ connectionState: ConnectionState.Authenticated,
85
+ ...extra,
86
+ });
87
+
88
+ const importAccount = async (email: string): Promise<AccountItem> => {
89
+ const accountConfigId = storedId();
90
+ const accountId = storedId();
91
+ const outcome = await importConfig(
92
+ {
93
+ repositories: client,
94
+ appointFolderRole: async () => {},
95
+ },
96
+ {
97
+ accountConfigId,
98
+ userId: accountConfigId,
99
+ document: configDocument(accountId, email),
100
+ mode: "apply",
101
+ onExisting: "abort",
102
+ },
103
+ );
104
+ assert.equal(outcome.outcome, "report");
105
+ return client.account.get(accountId);
106
+ };
107
+
108
+ const recordingSqs = (sent: SendMessageCommand[], fail = false): SQSClient =>
109
+ ({
110
+ send: async (command: SendMessageCommand) => {
111
+ if (fail) throw new Error("queue unreachable");
112
+ sent.push(command);
113
+ return {};
114
+ },
115
+ }) as unknown as SQSClient;
116
+
117
+ const heal = (sqsClient: SQSClient) =>
118
+ finishHalfDeletedAccounts({
119
+ account: client.account,
120
+ configImport: client.configImport,
121
+ sqsClient,
122
+ accountFanoutQueueUrl: FANOUT_URL,
123
+ log: noopLogger,
124
+ });
125
+
126
+ describe("finishing an account deleted before #1384 on scheduler start", () => {
127
+ let halfDeleted: AccountItem;
128
+ let live: AccountItem;
129
+ let deleted: AccountItem;
130
+ let imported: AccountItem;
131
+
132
+ beforeEach(async () => {
133
+ const accountConfigId = storedId();
134
+ live = await seedAccount(accountConfigId, `live-${storedId()}`);
135
+ halfDeleted = await seedAccount(accountConfigId, `gone-${storedId()}`);
136
+ await client.account.update(halfDeleted.accountId, { isActive: false });
137
+ deleted = await seedAccount(accountConfigId, `deleted-${storedId()}`, {
138
+ deletedAt: 1_700_000_000_000,
139
+ });
140
+ await client.account.update(deleted.accountId, { isActive: false });
141
+ imported = await importAccount(`imported-${storedId()}@example.com`);
142
+ });
143
+
144
+ const allAccounts = () =>
145
+ client.account.get([
146
+ halfDeleted.accountId,
147
+ live.accountId,
148
+ deleted.accountId,
149
+ imported.accountId,
150
+ ]);
151
+
152
+ it("tombstones it and asks for the same purge a delete asks for", async () => {
153
+ const sent: SendMessageCommand[] = [];
154
+
155
+ await heal(recordingSqs(sent));
156
+
157
+ const purges = sent
158
+ .filter((command) => command.input.QueueUrl === FANOUT_URL)
159
+ .map((command) => JSON.parse(command.input.MessageBody ?? "{}"));
160
+ assert.ok(
161
+ purges.some(
162
+ (body) =>
163
+ body.type === "AccountDataPurge" &&
164
+ body.accountId === halfDeleted.accountId &&
165
+ body.accountConfigId === halfDeleted.accountConfigId,
166
+ ),
167
+ );
168
+ const byId = new Map(
169
+ (await allAccounts()).map((account) => [account.accountId, account]),
170
+ );
171
+ assert.ok(byId.get(halfDeleted.accountId)?.deletedAt);
172
+ assert.equal(byId.get(live.accountId)?.deletedAt, undefined);
173
+ assert.equal(byId.get(imported.accountId)?.deletedAt, undefined);
174
+ assert.equal(byId.get(deleted.accountId)?.deletedAt, 1_700_000_000_000);
175
+ assert.ok(
176
+ !purges.some((body) =>
177
+ [live.accountId, imported.accountId, deleted.accountId].includes(
178
+ body.accountId,
179
+ ),
180
+ ),
181
+ );
182
+ });
183
+
184
+ it("does nothing for it on the next start", async () => {
185
+ await heal(recordingSqs([]));
186
+ const sent: SendMessageCommand[] = [];
187
+
188
+ await heal(recordingSqs(sent));
189
+
190
+ assert.ok(
191
+ !sent.some((command) =>
192
+ (command.input.MessageBody ?? "").includes(halfDeleted.accountId),
193
+ ),
194
+ );
195
+ });
196
+
197
+ it("fails the start and leaves it to be found again when the purge cannot be asked for", async () => {
198
+ await assert.rejects(heal(recordingSqs([], true)), /queue unreachable/);
199
+
200
+ const after = await client.account.get(halfDeleted.accountId);
201
+ assert.equal(after.deletedAt, undefined);
202
+ });
203
+ });
@@ -1,12 +1,17 @@
1
1
  import { getClient } from "@remit/backend/client";
2
- import { createLogger, withTelemetry } from "@remit/logger-lambda";
2
+ import { createLogger } from "@remit/logger";
3
+ import { installNodeLogger, withTelemetry } from "@remit/logger-lambda/node";
3
4
  import { createQueueProducer } from "@remit/sqs-client/producer";
4
5
  import type { ScheduledHandler } from "aws-lambda";
5
6
  import { env } from "expect-env";
7
+ import { emitEvent } from "../emit.js";
6
8
  import { buildAttachmentSweep } from "./attachment-sweep.js";
7
9
  import { buildCalendarSubscriptionRefresh } from "./calendar-subscriptions.js";
8
10
  import { getOfflineIntervalMs, getTickIntervalMs } from "./config.js";
9
11
  import { runSchedulerTick } from "./run-tick.js";
12
+ import { redriveStrandedPlacements } from "./stranded-placement-redrive.js";
13
+
14
+ installNodeLogger();
10
15
 
11
16
  const log = createLogger();
12
17
 
@@ -47,5 +52,11 @@ export const handler: ScheduledHandler = withTelemetry(async (event) => {
47
52
  // already gone and this sweep is the only thing that collects it.
48
53
  sweepAttachments: buildAttachmentSweep(client, log),
49
54
  refreshCalendarSubscriptions: buildCalendarSubscriptionRefresh(client, log),
55
+ redriveStrandedPlacements: (account, tickNow) =>
56
+ redriveStrandedPlacements(
57
+ { client, emit: emitEvent, log },
58
+ account,
59
+ tickNow,
60
+ ),
50
61
  });
51
62
  });
@@ -11,6 +11,8 @@ const RESULT: SchedulerTickResult = {
11
11
  sweepFailed: 0,
12
12
  subscriptionsRefreshed: 0,
13
13
  subscriptionsFailed: 0,
14
+ redriven: 0,
15
+ redriveFailed: 0,
14
16
  };
15
17
 
16
18
  // Only the loop's own two calls matter here; the tick is a stub, so what it is
@@ -0,0 +1,192 @@
1
+ import assert from "node:assert/strict";
2
+ import { afterEach, beforeEach, describe, it } from "node:test";
3
+ import type { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs";
4
+ import type { AccountItem } from "@remit/data-ports";
5
+ import {
6
+ AccountAuthType,
7
+ AccountService,
8
+ ConnectionState,
9
+ } from "@remit/domain-enums";
10
+ import { AccountRepo } from "@remit/drizzle-service";
11
+ import { createShippedSqliteDb } from "@remit/drizzle-service/test-sqlite";
12
+ import { capturingLogger, type LogLine } from "../test-helpers/test-world.js";
13
+ import { runSchedulerTick } from "./run-tick.js";
14
+
15
+ const TICK_INTERVAL_MS = 60 * 60 * 1000;
16
+ const OFFLINE_INTERVAL_MS = 12 * 60 * 60 * 1000;
17
+ const NOW = 1_700_000_000_000;
18
+
19
+ let accounts: AccountRepo;
20
+ let close: () => void;
21
+
22
+ beforeEach(() => {
23
+ const store = createShippedSqliteDb();
24
+ close = store.close;
25
+ accounts = new AccountRepo(store.db, store.db);
26
+ });
27
+
28
+ afterEach(() => close());
29
+
30
+ const account = async (
31
+ name: string,
32
+ overrides: Partial<AccountItem> = {},
33
+ stored: { credential: boolean } = { credential: true },
34
+ ): Promise<AccountItem> => {
35
+ const created = await accounts.create({
36
+ accountConfigId: `cfg-${name}`,
37
+ username: `${name}@example.com`,
38
+ email: `${name}@example.com`,
39
+ authType: AccountAuthType.Password,
40
+ passwordHash: stored.credential ? '{"ciphertext":"x"}' : undefined,
41
+ imapHost: "imap.example.com",
42
+ imapPort: 993,
43
+ imapTls: true,
44
+ imapStartTls: false,
45
+ isActive: true,
46
+ connectionState: ConnectionState.Authenticated,
47
+ } as Parameters<AccountRepo["create"]>[0]);
48
+ if (Object.keys(overrides).length === 0) return created;
49
+ await accounts.update(created.accountId, overrides);
50
+ return accounts.get(created.accountId);
51
+ };
52
+
53
+ const tick = async (
54
+ options: Partial<Parameters<typeof runSchedulerTick>[0]> = {},
55
+ ) => {
56
+ const sent: SendMessageCommand[] = [];
57
+ const logs: LogLine[] = [];
58
+ const result = await runSchedulerTick({
59
+ accountService: accounts,
60
+ sqsClient: {
61
+ send: async (command: SendMessageCommand) => {
62
+ sent.push(command);
63
+ return {};
64
+ },
65
+ } as unknown as SQSClient,
66
+ queueUrl:
67
+ "https://sqs.eu-west-1.amazonaws.com/123/remit-dev-mailboxes.fifo",
68
+ log: capturingLogger(logs),
69
+ tickIntervalMs: TICK_INTERVAL_MS,
70
+ offlineIntervalMs: OFFLINE_INTERVAL_MS,
71
+ now: NOW,
72
+ redriveStrandedPlacements: async () => 0,
73
+ ...options,
74
+ });
75
+ const enqueued = sent.map(
76
+ (command) => JSON.parse(command.input.MessageBody ?? "{}").accountId,
77
+ );
78
+ return { result, sent, enqueued, logs };
79
+ };
80
+
81
+ describe("a scheduler tick", () => {
82
+ it("enqueues the accounts that are due and only those", async () => {
83
+ const due = await account("due", {
84
+ lastSyncAt: NOW - OFFLINE_INTERVAL_MS - 1,
85
+ });
86
+ const never = await account("never");
87
+ await account("fresh", { lastSyncAt: NOW - 60_000 });
88
+
89
+ const { enqueued } = await tick();
90
+
91
+ assert.deepEqual(enqueued.sort(), [due.accountId, never.accountId].sort());
92
+ });
93
+
94
+ it("skips an account it cannot or should not sync, and says so once in aggregate", async () => {
95
+ const live = await account("live");
96
+ await account("unsyncable", { imapHost: "mail.invalid" });
97
+ await account("reauth", {
98
+ connectionState: ConnectionState.ReauthRequired,
99
+ });
100
+ await account("calendar", { syncedServices: [AccountService.Calendar] });
101
+
102
+ const { enqueued, result, logs } = await tick();
103
+
104
+ assert.deepEqual(enqueued, [live.accountId]);
105
+ assert.equal(result.skipped, 3);
106
+ assert.equal(logs.length, 1, "one aggregate line, no per-account noise");
107
+ });
108
+
109
+ // Issue #1120: a sync for an account with no stored credential fenced the
110
+ // account's FIFO group for its whole visibility timeout.
111
+ it("skips an account that stores no credential", async () => {
112
+ const live = await account("live");
113
+ await account("missing", {}, { credential: false });
114
+
115
+ const { enqueued } = await tick();
116
+
117
+ assert.deepEqual(enqueued, [live.accountId]);
118
+ });
119
+
120
+ it("gives each enqueued account a scheduler-namespaced, time-bucketed dedup id", async () => {
121
+ const live = await account("live");
122
+
123
+ const { sent } = await tick();
124
+
125
+ const dedupId = sent[0]?.input.MessageDeduplicationId;
126
+ assert.ok(
127
+ dedupId?.startsWith(`SYNC_MAILBOXES:scheduled:${live.accountId}:`),
128
+ );
129
+ });
130
+
131
+ it("sweeps every account's attachments, due a sync or not", async () => {
132
+ const due = await account("due");
133
+ const fresh = await account("fresh", { lastSyncAt: NOW - 60_000 });
134
+ const swept: string[] = [];
135
+
136
+ const { result } = await tick({
137
+ sweepAttachments: async (item) => {
138
+ swept.push(item.accountId);
139
+ },
140
+ });
141
+
142
+ assert.deepEqual(swept.sort(), [due.accountId, fresh.accountId].sort());
143
+ assert.equal(result.swept, 2);
144
+ });
145
+
146
+ // Uncontained, one storage error reaches the loop, which exits, and the
147
+ // container restarts without ever enqueuing an account again.
148
+ it("keeps enqueuing mail when the attachment sweep throws", async () => {
149
+ const live = await account("live");
150
+
151
+ const { enqueued, result } = await tick({
152
+ sweepAttachments: async () => {
153
+ throw new Error("AccessDenied on ListObjectsV2");
154
+ },
155
+ });
156
+
157
+ assert.deepEqual(enqueued, [live.accountId]);
158
+ assert.equal(result.sweepFailed, 1);
159
+ });
160
+
161
+ it("keeps the tick alive when the calendar subscription refresh throws", async () => {
162
+ const live = await account("live");
163
+
164
+ const { enqueued, logs } = await tick({
165
+ refreshCalendarSubscriptions: async () => {
166
+ throw new Error("database is locked");
167
+ },
168
+ });
169
+
170
+ assert.deepEqual(enqueued, [live.accountId]);
171
+ assert.ok(logs.some((line) => line.level === "error"));
172
+ });
173
+
174
+ it("counts what each account re-drove and keeps enqueuing past one that fails", async () => {
175
+ const live = await account("live");
176
+ const busy = await account("busy");
177
+
178
+ const { enqueued, result, logs } = await tick({
179
+ redriveStrandedPlacements: async (redriven) => {
180
+ if (redriven.accountId === busy.accountId) {
181
+ throw new Error("SQLITE_BUSY");
182
+ }
183
+ return 2;
184
+ },
185
+ });
186
+
187
+ assert.deepEqual(enqueued.sort(), [live.accountId, busy.accountId].sort());
188
+ assert.equal(result.redriven, 2);
189
+ assert.equal(result.redriveFailed, 1);
190
+ assert.ok(logs.some((line) => line.level === "error"));
191
+ });
192
+ });