@remit/account-worker 0.0.9 → 0.0.11

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/account-worker",
3
- "version": "0.0.9",
3
+ "version": "0.0.11",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -28,7 +28,7 @@
28
28
  "scripts": {
29
29
  "bundle": "esbuild src/index.ts --sourcemap --bundle --platform=node --format=esm --outfile=dist/index.js",
30
30
  "test:typecheck": "tsgo --noEmit",
31
- "test:run": "node --env-file=../../localhost-test-unit.env --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='src/**/*.test.tsx' --test-coverage-lines=50 --test 'src/**/*.test.ts'",
31
+ "test:run": "node --env-file=../../localhost-test-unit.env --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='src/**/*.test.tsx' --test-coverage-lines=75 --test 'src/**/*.test.ts'",
32
32
  "test": "npm run test:typecheck && npm run test:run"
33
33
  },
34
34
  "dependencies": {
@@ -0,0 +1,216 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type { MessageDescription } from "@remit/data-ports";
4
+ import type { Logger } from "@remit/logger-lambda";
5
+ import {
6
+ type CascadeEntity,
7
+ type CascadeServices,
8
+ collectMessageChildEntities,
9
+ enumerateCascadeEntities,
10
+ } from "./cascade.js";
11
+
12
+ const noopLog = {
13
+ info: () => {},
14
+ warn: () => {},
15
+ error: () => {},
16
+ debug: () => {},
17
+ fatal: () => {},
18
+ trace: () => {},
19
+ child: () => noopLog,
20
+ } as unknown as Logger;
21
+
22
+ const messageWithChildren = {
23
+ messageFlag: [{ messageFlagId: "flag-1" }],
24
+ envelope: [{ envelopeId: "env-1" }],
25
+ messageReference: [{ messageReferenceId: "ref-1" }],
26
+ envelopeAddress: [{ envelopeAddressId: "ea-1" }],
27
+ bodyPart: [{ bodyPartId: "bp-1" }],
28
+ bodyPartParameter: [{ bodyPartParameterId: "bpp-1" }],
29
+ rawMessageStorage: [{ rawStorageId: "rms-1" }],
30
+ bodyPartStorage: [{ bodyPartStorageId: "bps-1" }],
31
+ bodyPartContent: [{ bodyPartContentId: "bpc-1" }],
32
+ } as unknown as MessageDescription;
33
+
34
+ describe("collectMessageChildEntities", () => {
35
+ it("expands the nine child entities of one message", () => {
36
+ const entities: CascadeEntity[] = [];
37
+ collectMessageChildEntities(entities, messageWithChildren);
38
+
39
+ const byType = entities.map((e) => e.entityType);
40
+ assert.deepEqual(byType, [
41
+ "MessageFlag",
42
+ "Envelope",
43
+ "MessageReference",
44
+ "EnvelopeAddress",
45
+ "BodyPart",
46
+ "BodyPartParameter",
47
+ "RawMessageStorage",
48
+ "BodyPartStorage",
49
+ "BodyPartContent",
50
+ ]);
51
+ assert.equal(
52
+ entities.find((e) => e.entityType === "BodyPartContent")?.key
53
+ .bodyPartContentId,
54
+ "bpc-1",
55
+ );
56
+ });
57
+
58
+ it("appends nothing when every child list is empty", () => {
59
+ const entities: CascadeEntity[] = [];
60
+ collectMessageChildEntities(entities, {
61
+ messageFlag: [],
62
+ envelope: [],
63
+ messageReference: [],
64
+ envelopeAddress: [],
65
+ bodyPart: [],
66
+ bodyPartParameter: [],
67
+ rawMessageStorage: [],
68
+ bodyPartStorage: [],
69
+ bodyPartContent: [],
70
+ } as unknown as MessageDescription);
71
+ assert.equal(entities.length, 0);
72
+ });
73
+ });
74
+
75
+ const fullServices = (): CascadeServices =>
76
+ ({
77
+ accountConfigService: {
78
+ describe: async () => ({
79
+ account: [{ accountId: "acc-1" }],
80
+ address: [{ addressId: "addr-1" }],
81
+ }),
82
+ },
83
+ accountService: {
84
+ describe: async () => ({ mailbox: [{ mailboxId: "mbx-1" }] }),
85
+ },
86
+ messageService: {
87
+ listAllByMailbox: async () => [{ messageId: "msg-1" }],
88
+ describe: async () => messageWithChildren,
89
+ },
90
+ outboxMessageService: {
91
+ listByAccount: async () => ({ items: [{ outboxMessageId: "out-1" }] }),
92
+ },
93
+ mailboxLockService: {
94
+ listByAccount: async () => [{ mailboxId: "mbx-1", eventName: "expunge" }],
95
+ },
96
+ messagePlacementMoveService: {
97
+ listByAccountId: async () => [{ messageId: "msg-1" }],
98
+ },
99
+ messageFlagPushService: {
100
+ listByAccountId: async () => [{ messageId: "msg-1", flagName: "\\Seen" }],
101
+ },
102
+ threadMessageService: {
103
+ listAllByAccount: async () => [{ threadMessageId: "tm-1" }],
104
+ },
105
+ accountSettingService: {
106
+ listByAccountConfig: async () => [{ accountSettingId: "set-1" }],
107
+ },
108
+ filterService: {
109
+ listByAccountConfig: async () => [
110
+ { filterId: "flt-1", hasAnchor: true },
111
+ { filterId: "flt-2", hasAnchor: false },
112
+ ],
113
+ },
114
+ filterAnchorService: {
115
+ get: async () => ({ filterId: "flt-1" }),
116
+ },
117
+ labelService: {
118
+ listByAccountConfig: async () => [{ labelId: "lbl-1" }],
119
+ },
120
+ messageLabelService: {
121
+ listByLabelId: async () => [{ messageLabelId: "ml-1" }],
122
+ },
123
+ }) as unknown as CascadeServices;
124
+
125
+ describe("enumerateCascadeEntities", () => {
126
+ it("walks the whole account tree into a flat cascade plan", async () => {
127
+ const { entities, messageIds } = await enumerateCascadeEntities(
128
+ "cfg-1",
129
+ fullServices(),
130
+ noopLog,
131
+ );
132
+
133
+ assert.deepEqual(messageIds, ["msg-1"]);
134
+
135
+ const types = entities.map((e) => e.entityType);
136
+ for (const expected of [
137
+ "Account",
138
+ "Mailbox",
139
+ "Message",
140
+ "MessageFlag",
141
+ "BodyPartContent",
142
+ "OutboxMessage",
143
+ "MailboxLock",
144
+ "MessagePlacementMove",
145
+ "MessageFlagPush",
146
+ "ThreadMessage",
147
+ "AccountSetting",
148
+ "Filter",
149
+ "FilterAnchor",
150
+ "Label",
151
+ "MessageLabel",
152
+ "Address",
153
+ "AccountConfig",
154
+ ]) {
155
+ assert.ok(types.includes(expected), `missing ${expected}`);
156
+ }
157
+
158
+ assert.equal(types[types.length - 1], "AccountConfig");
159
+ });
160
+
161
+ it("emits a Filter without a FilterAnchor when the filter has no anchor", async () => {
162
+ const services = fullServices();
163
+ services.filterService.listByAccountConfig = async () =>
164
+ [{ filterId: "flt-2", hasAnchor: false }] as never;
165
+
166
+ const { entities } = await enumerateCascadeEntities(
167
+ "cfg-1",
168
+ services,
169
+ noopLog,
170
+ );
171
+
172
+ assert.equal(entities.filter((e) => e.entityType === "Filter").length, 1);
173
+ assert.equal(
174
+ entities.filter((e) => e.entityType === "FilterAnchor").length,
175
+ 0,
176
+ );
177
+ });
178
+
179
+ it("skips the FilterAnchor when the anchor lookup returns null", async () => {
180
+ const services = fullServices();
181
+ services.filterAnchorService.get = async () => null as never;
182
+
183
+ const { entities } = await enumerateCascadeEntities(
184
+ "cfg-1",
185
+ services,
186
+ noopLog,
187
+ );
188
+
189
+ assert.equal(
190
+ entities.filter((e) => e.entityType === "FilterAnchor").length,
191
+ 0,
192
+ );
193
+ });
194
+
195
+ it("yields only the AccountConfig row for an empty tenant", async () => {
196
+ const services = fullServices();
197
+ services.accountConfigService.describe = async () =>
198
+ ({ account: [], address: [] }) as never;
199
+ services.threadMessageService.listAllByAccount = async () => [];
200
+ services.accountSettingService.listByAccountConfig = async () => [];
201
+ services.filterService.listByAccountConfig = async () => [];
202
+ services.labelService.listByAccountConfig = async () => [];
203
+
204
+ const { entities, messageIds } = await enumerateCascadeEntities(
205
+ "cfg-empty",
206
+ services,
207
+ noopLog,
208
+ );
209
+
210
+ assert.equal(messageIds.length, 0);
211
+ assert.deepEqual(
212
+ entities.map((e) => e.entityType),
213
+ ["AccountConfig"],
214
+ );
215
+ });
216
+ });
@@ -0,0 +1,150 @@
1
+ import assert from "node:assert/strict";
2
+ import { Readable } from "node:stream";
3
+ import { describe, it } from "node:test";
4
+ import type { Logger } from "@remit/logger-lambda";
5
+ import type { StorageService } from "@remit/storage-service";
6
+ import type { CascadeServices } from "../cascade.js";
7
+ import type { AccountExportEvent } from "../events.js";
8
+ import {
9
+ type ProcessAccountExportDeps,
10
+ processAccountExport,
11
+ } from "./account-export.js";
12
+
13
+ const noopLog = {
14
+ info: () => {},
15
+ warn: () => {},
16
+ error: () => {},
17
+ debug: () => {},
18
+ fatal: () => {},
19
+ trace: () => {},
20
+ child: () => noopLog,
21
+ } as unknown as Logger;
22
+
23
+ interface Update {
24
+ state: string;
25
+ objectKey?: string;
26
+ expiresAt?: number;
27
+ errorMessage?: string;
28
+ }
29
+
30
+ const bodyOf = (text: string): Readable => Readable.from([Buffer.from(text)]);
31
+
32
+ const buildDeps = (
33
+ updates: Update[],
34
+ overrides: {
35
+ retrieveMessageBodyStream?: StorageService["retrieveMessageBodyStream"];
36
+ storeExportArchiveStream?: StorageService["storeExportArchiveStream"];
37
+ } = {},
38
+ ): ProcessAccountExportDeps => ({
39
+ services: {
40
+ accountExportRequestService: {
41
+ get: async () => ({ accountExportRequestId: "exp-1" }),
42
+ update: async (_id: string, patch: Update) => {
43
+ updates.push(patch);
44
+ },
45
+ },
46
+ accountConfigService: {
47
+ describe: async () => ({ account: [{ accountId: "acc-1" }] }),
48
+ },
49
+ accountService: {
50
+ describe: async () => ({
51
+ mailbox: [{ mailboxId: "mbx-1", fullPath: "INBOX" }],
52
+ }),
53
+ },
54
+ messageService: {
55
+ listAllByMailbox: async () => [
56
+ { messageId: "msg-1" },
57
+ { messageId: "msg-2" },
58
+ ],
59
+ },
60
+ } as unknown as CascadeServices,
61
+ storageService: {
62
+ retrieveMessageBodyStream:
63
+ overrides.retrieveMessageBodyStream ??
64
+ (async (_cfg: string, _acc: string, messageId: string) =>
65
+ messageId === "msg-2" ? null : bodyOf("raw-eml")),
66
+ storeExportArchiveStream:
67
+ overrides.storeExportArchiveStream ??
68
+ (async (_cfg: string, _req: string, stream: NodeJS.ReadableStream) => {
69
+ await new Promise<void>((resolve) => {
70
+ stream.on("data", () => {});
71
+ stream.on("end", resolve);
72
+ });
73
+ return "exports/exp-1.zip";
74
+ }),
75
+ } as unknown as StorageService,
76
+ });
77
+
78
+ const event: AccountExportEvent = {
79
+ type: "AccountExport",
80
+ accountConfigId: "cfg-1",
81
+ accountExportRequestId: "exp-1",
82
+ };
83
+
84
+ describe("processAccountExport", () => {
85
+ it("drives the request Processing then Ready and stores the archive key", async () => {
86
+ const updates: Update[] = [];
87
+ await processAccountExport(event, noopLog, buildDeps(updates));
88
+
89
+ assert.deepEqual(
90
+ updates.map((u) => u.state),
91
+ ["Processing", "Ready"],
92
+ );
93
+ const ready = updates[1];
94
+ assert.equal(ready?.objectKey, "exports/exp-1.zip");
95
+ assert.ok(
96
+ ready?.expiresAt && ready.expiresAt > Date.now(),
97
+ "expiresAt is set in the future",
98
+ );
99
+ });
100
+
101
+ it("skips messages that have no raw body without failing the export", async () => {
102
+ const updates: Update[] = [];
103
+ let retrieved = 0;
104
+ await processAccountExport(
105
+ event,
106
+ noopLog,
107
+ buildDeps(updates, {
108
+ retrieveMessageBodyStream: async (
109
+ _cfg: string,
110
+ _acc: string,
111
+ messageId: string,
112
+ ) => {
113
+ retrieved += 1;
114
+ return messageId === "msg-2" ? null : bodyOf("raw");
115
+ },
116
+ }),
117
+ );
118
+
119
+ assert.equal(retrieved, 2, "both messages were consulted");
120
+ assert.equal(updates.at(-1)?.state, "Ready");
121
+ });
122
+
123
+ it("marks the request Failed and rethrows when storage upload rejects", async () => {
124
+ const updates: Update[] = [];
125
+ await assert.rejects(
126
+ processAccountExport(
127
+ event,
128
+ noopLog,
129
+ buildDeps(updates, {
130
+ storeExportArchiveStream: async (
131
+ _cfg: string,
132
+ _req: string,
133
+ stream: NodeJS.ReadableStream,
134
+ ) => {
135
+ await new Promise<void>((resolve) => {
136
+ stream.on("data", () => {});
137
+ stream.on("end", resolve);
138
+ });
139
+ throw new Error("s3 down");
140
+ },
141
+ }),
142
+ ),
143
+ /s3 down/,
144
+ );
145
+
146
+ const failed = updates.at(-1);
147
+ assert.equal(failed?.state, "Failed");
148
+ assert.equal(failed?.errorMessage, "s3 down");
149
+ });
150
+ });
@@ -0,0 +1,191 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type { SQSClient } from "@aws-sdk/client-sqs";
4
+ import type { Logger } from "@remit/logger-lambda";
5
+ import type { CascadeServices } from "../cascade.js";
6
+ import type { AccountDataPurgeEvent } from "../events.js";
7
+ import {
8
+ type ProcessPurgeFanoutDeps,
9
+ processAccountDataPurge,
10
+ } from "./account-purge.js";
11
+
12
+ const noopLog = {
13
+ info: () => {},
14
+ warn: () => {},
15
+ error: () => {},
16
+ debug: () => {},
17
+ fatal: () => {},
18
+ trace: () => {},
19
+ child: () => noopLog,
20
+ } as unknown as Logger;
21
+
22
+ interface Sent {
23
+ queueUrl: string | undefined;
24
+ body: Record<string, unknown>;
25
+ entries?: Array<{ Id: string; MessageBody: string }>;
26
+ }
27
+
28
+ const recordingSqs = (sent: Sent[], failBatch = false): SQSClient =>
29
+ ({
30
+ send: async (command: {
31
+ input: {
32
+ QueueUrl?: string;
33
+ MessageBody?: string;
34
+ Entries?: Array<{ Id: string; MessageBody: string }>;
35
+ };
36
+ }) => {
37
+ sent.push({
38
+ queueUrl: command.input.QueueUrl,
39
+ body: command.input.MessageBody
40
+ ? JSON.parse(command.input.MessageBody)
41
+ : {},
42
+ entries: command.input.Entries,
43
+ });
44
+ if (command.input.Entries && failBatch) {
45
+ return { Failed: [{ Id: "0" }] };
46
+ }
47
+ return {};
48
+ },
49
+ }) as unknown as SQSClient;
50
+
51
+ const buildServices = (
52
+ manifest: Array<{
53
+ threadMessageId: string;
54
+ messageId: string;
55
+ mailboxId: string;
56
+ }>,
57
+ mailboxIds: string[] = ["mbx-1"],
58
+ ): CascadeServices =>
59
+ ({
60
+ accountService: {
61
+ describe: async () => ({
62
+ mailbox: mailboxIds.map((mailboxId) => ({ mailboxId })),
63
+ }),
64
+ },
65
+ threadMessageService: {
66
+ listAllByAccount: async () => manifest,
67
+ },
68
+ }) as unknown as CascadeServices;
69
+
70
+ const event: AccountDataPurgeEvent = {
71
+ type: "AccountDataPurge",
72
+ accountId: "acc-1",
73
+ accountConfigId: "cfg-1",
74
+ };
75
+
76
+ const baseDeps = (
77
+ sent: Sent[],
78
+ overrides: Partial<ProcessPurgeFanoutDeps> = {},
79
+ ): ProcessPurgeFanoutDeps => ({
80
+ services: buildServices([
81
+ { threadMessageId: "tm-1", messageId: "msg-1", mailboxId: "mbx-1" },
82
+ { threadMessageId: "tm-2", messageId: "msg-2", mailboxId: "mbx-1" },
83
+ ]),
84
+ sqs: recordingSqs(sent),
85
+ accountPurgeDeleteQueueUrl: "http://queue/purge",
86
+ searchIndexQueueUrl: "http://queue/search",
87
+ dataBackend: "dynamodb",
88
+ ...overrides,
89
+ });
90
+
91
+ describe("processAccountDataPurge", () => {
92
+ it("enqueues vector deletes then a subtrees batch and one container leftover", async () => {
93
+ const sent: Sent[] = [];
94
+ await processAccountDataPurge(event, noopLog, baseDeps(sent));
95
+
96
+ const vectorBatch = sent.find((m) => m.queueUrl === "http://queue/search");
97
+ assert.equal(vectorBatch?.entries?.length, 2, "one entry per message");
98
+
99
+ const finalize = sent.filter((m) => m.queueUrl === "http://queue/purge");
100
+ const kinds = finalize.map((m) => m.body.kind);
101
+ assert.deepEqual(kinds, ["subtrees", "container"]);
102
+ assert.equal(
103
+ (finalize[0]?.body.items as unknown[])?.length,
104
+ 2,
105
+ "both subtrees ride the one batch",
106
+ );
107
+ });
108
+
109
+ it("skips vector deletes on the postgres backend, still enqueuing finalize", async () => {
110
+ const sent: Sent[] = [];
111
+ await processAccountDataPurge(
112
+ event,
113
+ noopLog,
114
+ baseDeps(sent, { dataBackend: "postgres" }),
115
+ );
116
+
117
+ assert.equal(
118
+ sent.some((m) => m.queueUrl === "http://queue/search"),
119
+ false,
120
+ "no search-index enqueue on postgres",
121
+ );
122
+ assert.deepEqual(
123
+ sent
124
+ .filter((m) => m.queueUrl === "http://queue/purge")
125
+ .map((m) => m.body.kind),
126
+ ["subtrees", "container"],
127
+ );
128
+ });
129
+
130
+ it("drops manifest rows outside the account's mailbox set", async () => {
131
+ const sent: Sent[] = [];
132
+ await processAccountDataPurge(event, noopLog, {
133
+ ...baseDeps(sent),
134
+ services: buildServices([
135
+ { threadMessageId: "tm-1", messageId: "msg-1", mailboxId: "mbx-1" },
136
+ { threadMessageId: "tm-9", messageId: "msg-9", mailboxId: "other" },
137
+ ]),
138
+ });
139
+
140
+ const subtrees = sent.find((m) => m.body.kind === "subtrees");
141
+ assert.equal((subtrees?.body.items as unknown[])?.length, 1);
142
+ });
143
+
144
+ it("no-ops when the account is already gone", async () => {
145
+ const sent: Sent[] = [];
146
+ await processAccountDataPurge(event, noopLog, {
147
+ ...baseDeps(sent),
148
+ services: {
149
+ accountService: {
150
+ describe: async () => {
151
+ throw Object.assign(new Error("gone"), {
152
+ name: "NotFoundError",
153
+ });
154
+ },
155
+ },
156
+ threadMessageService: { listAllByAccount: async () => [] },
157
+ } as unknown as CascadeServices,
158
+ });
159
+
160
+ assert.equal(sent.length, 0, "nothing enqueued for a missing account");
161
+ });
162
+
163
+ it("rethrows a non-NotFound describe error", async () => {
164
+ const sent: Sent[] = [];
165
+ await assert.rejects(
166
+ processAccountDataPurge(event, noopLog, {
167
+ ...baseDeps(sent),
168
+ services: {
169
+ accountService: {
170
+ describe: async () => {
171
+ throw new Error("dynamo throttled");
172
+ },
173
+ },
174
+ threadMessageService: { listAllByAccount: async () => [] },
175
+ } as unknown as CascadeServices,
176
+ }),
177
+ /dynamo throttled/,
178
+ );
179
+ });
180
+
181
+ it("throws when a vector-delete batch reports failed entries", async () => {
182
+ const sent: Sent[] = [];
183
+ await assert.rejects(
184
+ processAccountDataPurge(event, noopLog, {
185
+ ...baseDeps(sent),
186
+ sqs: recordingSqs(sent, true),
187
+ }),
188
+ /failed entries/,
189
+ );
190
+ });
191
+ });