@remit/storage-service 0.0.1
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 +267 -0
- package/package.json +44 -0
- package/src/backends/filesystem.ts +399 -0
- package/src/backends/s3.test.ts +463 -0
- package/src/backends/s3.ts +524 -0
- package/src/filesystem.ts +1 -0
- package/src/index.ts +34 -0
- package/src/s3.ts +20 -0
- package/src/storage.test.ts +750 -0
- package/src/storage.ts +589 -0
- package/src/uri.ts +47 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,750 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { rm } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { Readable } from "node:stream";
|
|
7
|
+
import { describe, test } from "node:test";
|
|
8
|
+
import { ContentEncoding, StorageType } from "@remit/domain-enums";
|
|
9
|
+
import { createFilesystemStorageService } from "./backends/filesystem.js";
|
|
10
|
+
import {
|
|
11
|
+
buildBodyPartKey,
|
|
12
|
+
buildDeduplicatedKey,
|
|
13
|
+
buildExtractedPrefix,
|
|
14
|
+
buildExtractedSkippedKey,
|
|
15
|
+
buildExtractedTextKey,
|
|
16
|
+
buildMessageBodyKey,
|
|
17
|
+
buildParsedBodyKey,
|
|
18
|
+
computeChecksum,
|
|
19
|
+
createMockStorageService,
|
|
20
|
+
isStorageNotFoundError,
|
|
21
|
+
parseContentStorageKey,
|
|
22
|
+
} from "./storage.js";
|
|
23
|
+
import { buildStorageUri, parseStorageUri } from "./uri.js";
|
|
24
|
+
|
|
25
|
+
describe("parseStorageUri", () => {
|
|
26
|
+
test("parses S3 URIs", () => {
|
|
27
|
+
const result = parseStorageUri("s3://my-bucket/path/to/object");
|
|
28
|
+
assert.strictEqual(result.storageType, StorageType.S3);
|
|
29
|
+
assert.strictEqual(result.storageLocation, "my-bucket");
|
|
30
|
+
assert.strictEqual(result.storageKey, "path/to/object");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("parses file URIs", () => {
|
|
34
|
+
const result = parseStorageUri("file:///var/data/remit/path/to/file");
|
|
35
|
+
assert.strictEqual(result.storageType, StorageType.Filesystem);
|
|
36
|
+
assert.strictEqual(result.storageKey, "/var/data/remit/path/to/file");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("throws for unsupported schemes", () => {
|
|
40
|
+
assert.throws(
|
|
41
|
+
() => parseStorageUri("http://example.com/file"),
|
|
42
|
+
/Unsupported storage URI scheme/,
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("buildStorageUri", () => {
|
|
48
|
+
test("builds S3 URIs", () => {
|
|
49
|
+
const uri = buildStorageUri(StorageType.S3, "my-bucket", "path/to/object");
|
|
50
|
+
assert.strictEqual(uri, "s3://my-bucket/path/to/object");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("builds file URIs", () => {
|
|
54
|
+
const uri = buildStorageUri(
|
|
55
|
+
StorageType.Filesystem,
|
|
56
|
+
"/var/data",
|
|
57
|
+
"path/to/file",
|
|
58
|
+
);
|
|
59
|
+
assert.strictEqual(uri, "file:///var/data/path/to/file");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("path builders", () => {
|
|
64
|
+
test("buildMessageBodyKey nests accountId under accountConfigId for tenant-scoped CloudFront caching", () => {
|
|
65
|
+
const key = buildMessageBodyKey("cfg1", "acc123", "msg456");
|
|
66
|
+
assert.strictEqual(key, "accounts/cfg1/acc123/messages/msg456/body.eml");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("buildBodyPartKey nests accountId under accountConfigId", () => {
|
|
70
|
+
const key = buildBodyPartKey("cfg1", "acc123", "msg456", "1.2");
|
|
71
|
+
assert.strictEqual(key, "accounts/cfg1/acc123/messages/msg456/parts/1.2");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("buildDeduplicatedKey nests accountId under accountConfigId", () => {
|
|
75
|
+
const hash = "abcdef1234567890";
|
|
76
|
+
const key = buildDeduplicatedKey("cfg1", "acc123", hash);
|
|
77
|
+
assert.strictEqual(key, "accounts/cfg1/acc123/dedup/ab/abcdef1234567890");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("buildParsedBodyKey nests accountId under accountConfigId", () => {
|
|
81
|
+
const key = buildParsedBodyKey("cfg1", "acc123", "msg456");
|
|
82
|
+
assert.strictEqual(
|
|
83
|
+
key,
|
|
84
|
+
"accounts/cfg1/acc123/messages/msg456/parsed.json.gz",
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("computeChecksum returns SHA-256 hex", () => {
|
|
89
|
+
const content = Buffer.from("test content");
|
|
90
|
+
const expected = createHash("sha256").update(content).digest("hex");
|
|
91
|
+
assert.strictEqual(computeChecksum(content), expected);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("path order is accountConfigId before accountId so /content/accounts/{configId}/* matches at the edge", () => {
|
|
95
|
+
const key = buildMessageBodyKey("CFG", "ACC", "MSG");
|
|
96
|
+
const segments = key.split("/");
|
|
97
|
+
assert.strictEqual(segments[0], "accounts");
|
|
98
|
+
assert.strictEqual(segments[1], "CFG");
|
|
99
|
+
assert.strictEqual(segments[2], "ACC");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("buildExtractedTextKey nests accountId under accountConfigId and suffixes .txt.gz", () => {
|
|
103
|
+
const key = buildExtractedTextKey("cfg1", "acc123", "msg456", "1.2");
|
|
104
|
+
assert.strictEqual(
|
|
105
|
+
key,
|
|
106
|
+
"accounts/cfg1/acc123/messages/msg456/extracted/1.2.txt.gz",
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("buildExtractedSkippedKey nests accountId under accountConfigId and suffixes .skipped.json", () => {
|
|
111
|
+
const key = buildExtractedSkippedKey("cfg1", "acc123", "msg456", "1.2");
|
|
112
|
+
assert.strictEqual(
|
|
113
|
+
key,
|
|
114
|
+
"accounts/cfg1/acc123/messages/msg456/extracted/1.2.skipped.json",
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("buildExtractedTextKey and buildExtractedSkippedKey share the same prefix", () => {
|
|
119
|
+
const prefix = buildExtractedPrefix("cfg1", "acc123", "msg456");
|
|
120
|
+
assert.strictEqual(
|
|
121
|
+
prefix,
|
|
122
|
+
"accounts/cfg1/acc123/messages/msg456/extracted/",
|
|
123
|
+
);
|
|
124
|
+
assert.ok(
|
|
125
|
+
buildExtractedTextKey("cfg1", "acc123", "msg456", "1").startsWith(prefix),
|
|
126
|
+
);
|
|
127
|
+
assert.ok(
|
|
128
|
+
buildExtractedSkippedKey("cfg1", "acc123", "msg456", "1").startsWith(
|
|
129
|
+
prefix,
|
|
130
|
+
),
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("parseContentStorageKey", () => {
|
|
136
|
+
test("parses accountConfigId/accountId/messageId out of a body key", () => {
|
|
137
|
+
const key = buildMessageBodyKey("cfg1", "acc123", "msg456");
|
|
138
|
+
assert.deepEqual(parseContentStorageKey(key), {
|
|
139
|
+
accountConfigId: "cfg1",
|
|
140
|
+
accountId: "acc123",
|
|
141
|
+
messageId: "msg456",
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("parses a part key the same way as a body key", () => {
|
|
146
|
+
const key = buildBodyPartKey("cfg1", "acc123", "msg456", "1.2");
|
|
147
|
+
assert.deepEqual(parseContentStorageKey(key), {
|
|
148
|
+
accountConfigId: "cfg1",
|
|
149
|
+
accountId: "acc123",
|
|
150
|
+
messageId: "msg456",
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("returns null for a key that doesn't match the accounts/.../messages/... layout", () => {
|
|
155
|
+
assert.strictEqual(parseContentStorageKey("nope/whatever"), null);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("isStorageNotFoundError", () => {
|
|
160
|
+
test("matches S3 NoSuchKey via name", () => {
|
|
161
|
+
assert.equal(
|
|
162
|
+
isStorageNotFoundError(
|
|
163
|
+
Object.assign(new Error("missing"), { name: "NoSuchKey" }),
|
|
164
|
+
),
|
|
165
|
+
true,
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("matches S3 NoSuchKey via Code", () => {
|
|
170
|
+
assert.equal(
|
|
171
|
+
isStorageNotFoundError({ Code: "NoSuchKey", message: "missing" }),
|
|
172
|
+
true,
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("matches filesystem ENOENT", () => {
|
|
177
|
+
assert.equal(
|
|
178
|
+
isStorageNotFoundError(
|
|
179
|
+
Object.assign(new Error("nope"), { code: "ENOENT" }),
|
|
180
|
+
),
|
|
181
|
+
true,
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("does not match generic errors", () => {
|
|
186
|
+
assert.equal(isStorageNotFoundError(new Error("boom")), false);
|
|
187
|
+
assert.equal(isStorageNotFoundError(null), false);
|
|
188
|
+
assert.equal(isStorageNotFoundError("oops"), false);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe("createMockStorageService", () => {
|
|
193
|
+
test("storeMessageBody stores and retrieves content with nested accountConfigId/accountId path", async () => {
|
|
194
|
+
const storage = createMockStorageService();
|
|
195
|
+
const content = Buffer.from("Hello, world!");
|
|
196
|
+
|
|
197
|
+
const ref = await storage.storeMessageBody({
|
|
198
|
+
accountConfigId: "cfg1",
|
|
199
|
+
accountId: "acc123",
|
|
200
|
+
messageId: "msg456",
|
|
201
|
+
content,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
assert.strictEqual(
|
|
205
|
+
ref.storageKey,
|
|
206
|
+
"accounts/cfg1/acc123/messages/msg456/body.eml",
|
|
207
|
+
);
|
|
208
|
+
assert.strictEqual(ref.sizeBytes, content.length);
|
|
209
|
+
assert.strictEqual(ref.contentEncoding, ContentEncoding.None);
|
|
210
|
+
|
|
211
|
+
const retrieved = await storage.retrieve(ref.uri);
|
|
212
|
+
assert.deepStrictEqual(retrieved, content);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("storeBodyPart stores with part path", async () => {
|
|
216
|
+
const storage = createMockStorageService();
|
|
217
|
+
const content = Buffer.from("attachment content");
|
|
218
|
+
|
|
219
|
+
const ref = await storage.storeBodyPart({
|
|
220
|
+
accountConfigId: "cfg1",
|
|
221
|
+
accountId: "acc123",
|
|
222
|
+
messageId: "msg456",
|
|
223
|
+
partPath: "1.2",
|
|
224
|
+
content,
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
assert.strictEqual(
|
|
228
|
+
ref.storageKey,
|
|
229
|
+
"accounts/cfg1/acc123/messages/msg456/parts/1.2",
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
const retrieved = await storage.retrieve(ref.uri);
|
|
233
|
+
assert.deepStrictEqual(retrieved, content);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("storeDeduplicated uses content hash", async () => {
|
|
237
|
+
const storage = createMockStorageService();
|
|
238
|
+
const content = Buffer.from("deduplicate me");
|
|
239
|
+
const hash = computeChecksum(content);
|
|
240
|
+
|
|
241
|
+
const ref = await storage.storeDeduplicated({
|
|
242
|
+
accountConfigId: "cfg1",
|
|
243
|
+
accountId: "acc123",
|
|
244
|
+
content,
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
assert.strictEqual(
|
|
248
|
+
ref.storageKey,
|
|
249
|
+
`accounts/cfg1/acc123/dedup/${hash.slice(0, 2)}/${hash}`,
|
|
250
|
+
);
|
|
251
|
+
assert.strictEqual(ref.checksumSha256, hash);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("checks existence", async () => {
|
|
255
|
+
const storage = createMockStorageService();
|
|
256
|
+
const content = Buffer.from("test");
|
|
257
|
+
|
|
258
|
+
const ref = await storage.storeMessageBody({
|
|
259
|
+
accountConfigId: "cfg1",
|
|
260
|
+
accountId: "acc123",
|
|
261
|
+
messageId: "msg456",
|
|
262
|
+
content,
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
assert.strictEqual(await storage.exists(ref.uri), true);
|
|
266
|
+
assert.strictEqual(await storage.exists("mock://does-not-exist"), false);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("deletes content", async () => {
|
|
270
|
+
const storage = createMockStorageService();
|
|
271
|
+
const content = Buffer.from("to delete");
|
|
272
|
+
|
|
273
|
+
const ref = await storage.storeMessageBody({
|
|
274
|
+
accountConfigId: "cfg1",
|
|
275
|
+
accountId: "acc123",
|
|
276
|
+
messageId: "msg456",
|
|
277
|
+
content,
|
|
278
|
+
});
|
|
279
|
+
assert.strictEqual(await storage.exists(ref.uri), true);
|
|
280
|
+
|
|
281
|
+
await storage.delete(ref.uri);
|
|
282
|
+
assert.strictEqual(await storage.exists(ref.uri), false);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("storeParsedBody / retrieveParsedBody round-trip", async () => {
|
|
286
|
+
const storage = createMockStorageService();
|
|
287
|
+
const parsed = {
|
|
288
|
+
text: "hello",
|
|
289
|
+
html: "<p>hello</p>",
|
|
290
|
+
attachments: [
|
|
291
|
+
{
|
|
292
|
+
filename: "a.pdf",
|
|
293
|
+
contentType: "application/pdf",
|
|
294
|
+
contentDisposition: "attachment",
|
|
295
|
+
contentId: null,
|
|
296
|
+
size: 1234,
|
|
297
|
+
},
|
|
298
|
+
],
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
const ref = await storage.storeParsedBody({
|
|
302
|
+
accountConfigId: "cfg1",
|
|
303
|
+
accountId: "acc123",
|
|
304
|
+
messageId: "msg456",
|
|
305
|
+
parsed,
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
assert.strictEqual(
|
|
309
|
+
ref.storageKey,
|
|
310
|
+
"accounts/cfg1/acc123/messages/msg456/parsed.json.gz",
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
const retrieved = await storage.retrieveParsedBody(
|
|
314
|
+
"cfg1",
|
|
315
|
+
"acc123",
|
|
316
|
+
"msg456",
|
|
317
|
+
);
|
|
318
|
+
assert.deepStrictEqual(retrieved, parsed);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("retrieveParsedBody returns null on miss", async () => {
|
|
322
|
+
const storage = createMockStorageService();
|
|
323
|
+
const result = await storage.retrieveParsedBody("cfg1", "acc123", "nope");
|
|
324
|
+
assert.strictEqual(result, null);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("retrieveBodyPart round-trips stored content and returns null on miss", async () => {
|
|
328
|
+
const storage = createMockStorageService();
|
|
329
|
+
const content = Buffer.from("attachment bytes");
|
|
330
|
+
|
|
331
|
+
await storage.storeBodyPart({
|
|
332
|
+
accountConfigId: "cfg1",
|
|
333
|
+
accountId: "acc123",
|
|
334
|
+
messageId: "msg456",
|
|
335
|
+
partPath: "1.2",
|
|
336
|
+
content,
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
const retrieved = await storage.retrieveBodyPart(
|
|
340
|
+
"cfg1",
|
|
341
|
+
"acc123",
|
|
342
|
+
"msg456",
|
|
343
|
+
"1.2",
|
|
344
|
+
);
|
|
345
|
+
assert.deepStrictEqual(retrieved, content);
|
|
346
|
+
|
|
347
|
+
const miss = await storage.retrieveBodyPart(
|
|
348
|
+
"cfg1",
|
|
349
|
+
"acc123",
|
|
350
|
+
"msg456",
|
|
351
|
+
"9",
|
|
352
|
+
);
|
|
353
|
+
assert.strictEqual(miss, null);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("storeExtractedText / retrieveExtractedText round-trip", async () => {
|
|
357
|
+
const storage = createMockStorageService();
|
|
358
|
+
|
|
359
|
+
const ref = await storage.storeExtractedText({
|
|
360
|
+
accountConfigId: "cfg1",
|
|
361
|
+
accountId: "acc123",
|
|
362
|
+
messageId: "msg456",
|
|
363
|
+
partPath: "1.2",
|
|
364
|
+
text: "extracted attachment text",
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
assert.strictEqual(
|
|
368
|
+
ref.storageKey,
|
|
369
|
+
"accounts/cfg1/acc123/messages/msg456/extracted/1.2.txt.gz",
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
const retrieved = await storage.retrieveExtractedText(
|
|
373
|
+
"cfg1",
|
|
374
|
+
"acc123",
|
|
375
|
+
"msg456",
|
|
376
|
+
"1.2",
|
|
377
|
+
);
|
|
378
|
+
assert.strictEqual(retrieved, "extracted attachment text");
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test("retrieveExtractedText returns null on miss", async () => {
|
|
382
|
+
const storage = createMockStorageService();
|
|
383
|
+
const result = await storage.retrieveExtractedText(
|
|
384
|
+
"cfg1",
|
|
385
|
+
"acc123",
|
|
386
|
+
"msg456",
|
|
387
|
+
"nope",
|
|
388
|
+
);
|
|
389
|
+
assert.strictEqual(result, null);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
test("extractedResultExists is true after either the text artifact or the skip marker is stored", async () => {
|
|
393
|
+
const storage = createMockStorageService();
|
|
394
|
+
|
|
395
|
+
assert.strictEqual(
|
|
396
|
+
await storage.extractedResultExists("cfg1", "acc123", "msg456", "1"),
|
|
397
|
+
false,
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
await storage.storeExtractedText({
|
|
401
|
+
accountConfigId: "cfg1",
|
|
402
|
+
accountId: "acc123",
|
|
403
|
+
messageId: "msg456",
|
|
404
|
+
partPath: "1",
|
|
405
|
+
text: "hi",
|
|
406
|
+
});
|
|
407
|
+
assert.strictEqual(
|
|
408
|
+
await storage.extractedResultExists("cfg1", "acc123", "msg456", "1"),
|
|
409
|
+
true,
|
|
410
|
+
);
|
|
411
|
+
|
|
412
|
+
await storage.storeExtractedSkipped({
|
|
413
|
+
accountConfigId: "cfg1",
|
|
414
|
+
accountId: "acc123",
|
|
415
|
+
messageId: "msg456",
|
|
416
|
+
partPath: "2",
|
|
417
|
+
marker: { status: "skipped", reason: "type-not-allowed" },
|
|
418
|
+
});
|
|
419
|
+
assert.strictEqual(
|
|
420
|
+
await storage.extractedResultExists("cfg1", "acc123", "msg456", "2"),
|
|
421
|
+
true,
|
|
422
|
+
);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
test("listExtractedTexts returns only .txt.gz artifacts, not skip markers", async () => {
|
|
426
|
+
const storage = createMockStorageService();
|
|
427
|
+
|
|
428
|
+
await storage.storeExtractedText({
|
|
429
|
+
accountConfigId: "cfg1",
|
|
430
|
+
accountId: "acc123",
|
|
431
|
+
messageId: "msg456",
|
|
432
|
+
partPath: "1",
|
|
433
|
+
text: "first",
|
|
434
|
+
});
|
|
435
|
+
await storage.storeExtractedText({
|
|
436
|
+
accountConfigId: "cfg1",
|
|
437
|
+
accountId: "acc123",
|
|
438
|
+
messageId: "msg456",
|
|
439
|
+
partPath: "2.1",
|
|
440
|
+
text: "second",
|
|
441
|
+
});
|
|
442
|
+
await storage.storeExtractedSkipped({
|
|
443
|
+
accountConfigId: "cfg1",
|
|
444
|
+
accountId: "acc123",
|
|
445
|
+
messageId: "msg456",
|
|
446
|
+
partPath: "3",
|
|
447
|
+
marker: { status: "failed", reason: "pdf: corrupt" },
|
|
448
|
+
});
|
|
449
|
+
// A different message must not leak into the list.
|
|
450
|
+
await storage.storeExtractedText({
|
|
451
|
+
accountConfigId: "cfg1",
|
|
452
|
+
accountId: "acc123",
|
|
453
|
+
messageId: "other-msg",
|
|
454
|
+
partPath: "1",
|
|
455
|
+
text: "unrelated",
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
const items = await storage.listExtractedTexts("cfg1", "acc123", "msg456");
|
|
459
|
+
assert.deepStrictEqual(items.map((i) => i.partPath).sort(), ["1", "2.1"]);
|
|
460
|
+
for (const item of items) {
|
|
461
|
+
assert.ok(item.key.endsWith(".txt.gz"));
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
describe("createFilesystemStorageService", () => {
|
|
467
|
+
const testBasePath = join(tmpdir(), `remit-storage-test-${Date.now()}`);
|
|
468
|
+
|
|
469
|
+
test("storeMessageBody stores and retrieves content", async () => {
|
|
470
|
+
const storage = createFilesystemStorageService(testBasePath);
|
|
471
|
+
const content = Buffer.from("Filesystem test content");
|
|
472
|
+
|
|
473
|
+
const ref = await storage.storeMessageBody({
|
|
474
|
+
accountConfigId: "cfg1",
|
|
475
|
+
accountId: "acc123",
|
|
476
|
+
messageId: "msg456",
|
|
477
|
+
content,
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
assert.strictEqual(ref.storageType, StorageType.Filesystem);
|
|
481
|
+
assert.strictEqual(ref.storageLocation, testBasePath);
|
|
482
|
+
assert.strictEqual(
|
|
483
|
+
ref.storageKey,
|
|
484
|
+
"accounts/cfg1/acc123/messages/msg456/body.eml",
|
|
485
|
+
);
|
|
486
|
+
assert.ok(ref.uri.startsWith("file://"));
|
|
487
|
+
|
|
488
|
+
const retrieved = await storage.retrieve(ref.uri);
|
|
489
|
+
assert.deepStrictEqual(retrieved, content);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
test("storeMessageBodyStream streams content to disk and round-trips via retrieve", async () => {
|
|
493
|
+
const storage = createFilesystemStorageService(testBasePath);
|
|
494
|
+
const content = Buffer.from("streamed filesystem body");
|
|
495
|
+
|
|
496
|
+
const ref = await storage.storeMessageBodyStream({
|
|
497
|
+
accountConfigId: "cfg1",
|
|
498
|
+
accountId: "acc123",
|
|
499
|
+
messageId: "msg-stream",
|
|
500
|
+
content: Readable.from(content),
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
assert.strictEqual(ref.storageType, StorageType.Filesystem);
|
|
504
|
+
assert.strictEqual(ref.contentEncoding, ContentEncoding.Gzip);
|
|
505
|
+
assert.strictEqual(ref.checksumSha256, computeChecksum(content));
|
|
506
|
+
|
|
507
|
+
const retrieved = await storage.retrieve(ref.uri);
|
|
508
|
+
assert.deepStrictEqual(retrieved, content);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
test("storeBodyPart stores with part path", async () => {
|
|
512
|
+
const storage = createFilesystemStorageService(testBasePath);
|
|
513
|
+
const content = Buffer.from("attachment content");
|
|
514
|
+
|
|
515
|
+
const ref = await storage.storeBodyPart({
|
|
516
|
+
accountConfigId: "cfg1",
|
|
517
|
+
accountId: "acc123",
|
|
518
|
+
messageId: "msg456",
|
|
519
|
+
partPath: "1.2",
|
|
520
|
+
content,
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
assert.strictEqual(
|
|
524
|
+
ref.storageKey,
|
|
525
|
+
"accounts/cfg1/acc123/messages/msg456/parts/1.2",
|
|
526
|
+
);
|
|
527
|
+
|
|
528
|
+
const retrieved = await storage.retrieve(ref.uri);
|
|
529
|
+
assert.deepStrictEqual(retrieved, content);
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
test("storeDeduplicated uses content hash", async () => {
|
|
533
|
+
const storage = createFilesystemStorageService(testBasePath);
|
|
534
|
+
const content = Buffer.from("dedupe fs content");
|
|
535
|
+
const hash = computeChecksum(content);
|
|
536
|
+
|
|
537
|
+
const ref = await storage.storeDeduplicated({
|
|
538
|
+
accountConfigId: "cfg1",
|
|
539
|
+
accountId: "acc123",
|
|
540
|
+
content,
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
assert.strictEqual(
|
|
544
|
+
ref.storageKey,
|
|
545
|
+
`accounts/cfg1/acc123/dedup/${hash.slice(0, 2)}/${hash}`,
|
|
546
|
+
);
|
|
547
|
+
assert.strictEqual(ref.checksumSha256, hash);
|
|
548
|
+
|
|
549
|
+
const retrieved = await storage.retrieve(ref.uri);
|
|
550
|
+
assert.deepStrictEqual(retrieved, content);
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
test("checks existence", async () => {
|
|
554
|
+
const storage = createFilesystemStorageService(testBasePath);
|
|
555
|
+
const content = Buffer.from("exists test");
|
|
556
|
+
|
|
557
|
+
const ref = await storage.storeMessageBody({
|
|
558
|
+
accountConfigId: "cfg1",
|
|
559
|
+
accountId: "acc123",
|
|
560
|
+
messageId: "msg789",
|
|
561
|
+
content,
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
assert.strictEqual(await storage.exists(ref.uri), true);
|
|
565
|
+
assert.strictEqual(await storage.exists("file:///nonexistent/path"), false);
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
test("deletes content", async () => {
|
|
569
|
+
const storage = createFilesystemStorageService(testBasePath);
|
|
570
|
+
const content = Buffer.from("to be deleted");
|
|
571
|
+
|
|
572
|
+
const ref = await storage.storeMessageBody({
|
|
573
|
+
accountConfigId: "cfg1",
|
|
574
|
+
accountId: "acc123",
|
|
575
|
+
messageId: "msg-delete",
|
|
576
|
+
content,
|
|
577
|
+
});
|
|
578
|
+
assert.strictEqual(await storage.exists(ref.uri), true);
|
|
579
|
+
|
|
580
|
+
await storage.delete(ref.uri);
|
|
581
|
+
assert.strictEqual(await storage.exists(ref.uri), false);
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
test("storeParsedBody / retrieveParsedBody round-trip with gzip", async () => {
|
|
585
|
+
const storage = createFilesystemStorageService(testBasePath);
|
|
586
|
+
const parsed = {
|
|
587
|
+
text: "hello fs",
|
|
588
|
+
html: "<p>hello fs</p>",
|
|
589
|
+
attachments: [],
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
const ref = await storage.storeParsedBody({
|
|
593
|
+
accountConfigId: "cfg1",
|
|
594
|
+
accountId: "acc123",
|
|
595
|
+
messageId: "msg-parsed",
|
|
596
|
+
parsed,
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
assert.strictEqual(
|
|
600
|
+
ref.storageKey,
|
|
601
|
+
"accounts/cfg1/acc123/messages/msg-parsed/parsed.json.gz",
|
|
602
|
+
);
|
|
603
|
+
|
|
604
|
+
const retrieved = await storage.retrieveParsedBody(
|
|
605
|
+
"cfg1",
|
|
606
|
+
"acc123",
|
|
607
|
+
"msg-parsed",
|
|
608
|
+
);
|
|
609
|
+
assert.deepStrictEqual(retrieved, parsed);
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
test("retrieveParsedBody returns null on miss (ENOENT)", async () => {
|
|
613
|
+
const storage = createFilesystemStorageService(testBasePath);
|
|
614
|
+
const result = await storage.retrieveParsedBody(
|
|
615
|
+
"cfg1",
|
|
616
|
+
"acc123",
|
|
617
|
+
"missing",
|
|
618
|
+
);
|
|
619
|
+
assert.strictEqual(result, null);
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
test("retrieveBodyPart round-trips gzipped content and returns null on miss (ENOENT)", async () => {
|
|
623
|
+
const storage = createFilesystemStorageService(testBasePath);
|
|
624
|
+
const content = Buffer.from("fs attachment bytes");
|
|
625
|
+
|
|
626
|
+
await storage.storeBodyPart({
|
|
627
|
+
accountConfigId: "cfg1",
|
|
628
|
+
accountId: "acc123",
|
|
629
|
+
messageId: "msg-part-fs",
|
|
630
|
+
partPath: "1.2",
|
|
631
|
+
content,
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
const retrieved = await storage.retrieveBodyPart(
|
|
635
|
+
"cfg1",
|
|
636
|
+
"acc123",
|
|
637
|
+
"msg-part-fs",
|
|
638
|
+
"1.2",
|
|
639
|
+
);
|
|
640
|
+
assert.deepStrictEqual(retrieved, content);
|
|
641
|
+
|
|
642
|
+
const miss = await storage.retrieveBodyPart(
|
|
643
|
+
"cfg1",
|
|
644
|
+
"acc123",
|
|
645
|
+
"msg-part-fs",
|
|
646
|
+
"nope",
|
|
647
|
+
);
|
|
648
|
+
assert.strictEqual(miss, null);
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
test("storeExtractedText / retrieveExtractedText round-trip with gzip", async () => {
|
|
652
|
+
const storage = createFilesystemStorageService(testBasePath);
|
|
653
|
+
|
|
654
|
+
const ref = await storage.storeExtractedText({
|
|
655
|
+
accountConfigId: "cfg1",
|
|
656
|
+
accountId: "acc123",
|
|
657
|
+
messageId: "msg-extracted-fs",
|
|
658
|
+
partPath: "1",
|
|
659
|
+
text: "fs extracted text",
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
assert.strictEqual(
|
|
663
|
+
ref.storageKey,
|
|
664
|
+
"accounts/cfg1/acc123/messages/msg-extracted-fs/extracted/1.txt.gz",
|
|
665
|
+
);
|
|
666
|
+
assert.strictEqual(ref.contentEncoding, ContentEncoding.Gzip);
|
|
667
|
+
|
|
668
|
+
const retrieved = await storage.retrieveExtractedText(
|
|
669
|
+
"cfg1",
|
|
670
|
+
"acc123",
|
|
671
|
+
"msg-extracted-fs",
|
|
672
|
+
"1",
|
|
673
|
+
);
|
|
674
|
+
assert.strictEqual(retrieved, "fs extracted text");
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
test("storeExtractedSkipped writes an uncompressed JSON marker that satisfies extractedResultExists", async () => {
|
|
678
|
+
const storage = createFilesystemStorageService(testBasePath);
|
|
679
|
+
|
|
680
|
+
assert.strictEqual(
|
|
681
|
+
await storage.extractedResultExists("cfg1", "acc123", "msg-skip-fs", "1"),
|
|
682
|
+
false,
|
|
683
|
+
);
|
|
684
|
+
|
|
685
|
+
const ref = await storage.storeExtractedSkipped({
|
|
686
|
+
accountConfigId: "cfg1",
|
|
687
|
+
accountId: "acc123",
|
|
688
|
+
messageId: "msg-skip-fs",
|
|
689
|
+
partPath: "1",
|
|
690
|
+
marker: { status: "skipped", reason: "too-large" },
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
assert.strictEqual(
|
|
694
|
+
ref.storageKey,
|
|
695
|
+
"accounts/cfg1/acc123/messages/msg-skip-fs/extracted/1.skipped.json",
|
|
696
|
+
);
|
|
697
|
+
assert.strictEqual(ref.contentEncoding, ContentEncoding.None);
|
|
698
|
+
assert.strictEqual(
|
|
699
|
+
await storage.extractedResultExists("cfg1", "acc123", "msg-skip-fs", "1"),
|
|
700
|
+
true,
|
|
701
|
+
);
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
test("listExtractedTexts lists only .txt.gz artifacts for the given message", async () => {
|
|
705
|
+
const storage = createFilesystemStorageService(testBasePath);
|
|
706
|
+
|
|
707
|
+
await storage.storeExtractedText({
|
|
708
|
+
accountConfigId: "cfg1",
|
|
709
|
+
accountId: "acc123",
|
|
710
|
+
messageId: "msg-list-fs",
|
|
711
|
+
partPath: "1",
|
|
712
|
+
text: "one",
|
|
713
|
+
});
|
|
714
|
+
await storage.storeExtractedText({
|
|
715
|
+
accountConfigId: "cfg1",
|
|
716
|
+
accountId: "acc123",
|
|
717
|
+
messageId: "msg-list-fs",
|
|
718
|
+
partPath: "2.1",
|
|
719
|
+
text: "two",
|
|
720
|
+
});
|
|
721
|
+
await storage.storeExtractedSkipped({
|
|
722
|
+
accountConfigId: "cfg1",
|
|
723
|
+
accountId: "acc123",
|
|
724
|
+
messageId: "msg-list-fs",
|
|
725
|
+
partPath: "3",
|
|
726
|
+
marker: { status: "failed", reason: "doc: corrupt" },
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
const items = await storage.listExtractedTexts(
|
|
730
|
+
"cfg1",
|
|
731
|
+
"acc123",
|
|
732
|
+
"msg-list-fs",
|
|
733
|
+
);
|
|
734
|
+
assert.deepStrictEqual(items.map((i) => i.partPath).sort(), ["1", "2.1"]);
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
test("listExtractedTexts returns an empty array when no extraction has happened yet", async () => {
|
|
738
|
+
const storage = createFilesystemStorageService(testBasePath);
|
|
739
|
+
const items = await storage.listExtractedTexts(
|
|
740
|
+
"cfg1",
|
|
741
|
+
"acc123",
|
|
742
|
+
"msg-never-scanned",
|
|
743
|
+
);
|
|
744
|
+
assert.deepStrictEqual(items, []);
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
test("cleanup test directory", async () => {
|
|
748
|
+
await rm(testBasePath, { recursive: true, force: true });
|
|
749
|
+
});
|
|
750
|
+
});
|