@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,463 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { Readable } from "node:stream";
|
|
3
|
+
import { describe, test } from "node:test";
|
|
4
|
+
import { gunzipSync } from "node:zlib";
|
|
5
|
+
import {
|
|
6
|
+
GetObjectCommand,
|
|
7
|
+
HeadObjectCommand,
|
|
8
|
+
ListObjectsV2Command,
|
|
9
|
+
PutObjectCommand,
|
|
10
|
+
S3Client,
|
|
11
|
+
} from "@aws-sdk/client-s3";
|
|
12
|
+
import { ContentEncoding, StorageType } from "@remit/domain-enums";
|
|
13
|
+
import { mockClient } from "aws-sdk-client-mock";
|
|
14
|
+
import { computeChecksum } from "../storage.js";
|
|
15
|
+
import { createS3StorageService } from "./s3.js";
|
|
16
|
+
|
|
17
|
+
interface CapturedCommand {
|
|
18
|
+
name: string;
|
|
19
|
+
input: Record<string, unknown>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const createFakeS3Client = (): {
|
|
23
|
+
client: S3Client;
|
|
24
|
+
commands: CapturedCommand[];
|
|
25
|
+
stored: Map<string, { body: Buffer; contentEncoding?: string }>;
|
|
26
|
+
} => {
|
|
27
|
+
const commands: CapturedCommand[] = [];
|
|
28
|
+
const stored = new Map<string, { body: Buffer; contentEncoding?: string }>();
|
|
29
|
+
|
|
30
|
+
const send = async (command: unknown): Promise<unknown> => {
|
|
31
|
+
if (command instanceof PutObjectCommand) {
|
|
32
|
+
const input = command.input as unknown as Record<string, unknown>;
|
|
33
|
+
commands.push({ name: "PutObjectCommand", input });
|
|
34
|
+
|
|
35
|
+
if (input.ChecksumSHA256 !== undefined) {
|
|
36
|
+
throw Object.assign(
|
|
37
|
+
new Error(
|
|
38
|
+
"BadDigest: The SHA256 you specified did not match the calculated checksum.",
|
|
39
|
+
),
|
|
40
|
+
{ name: "BadDigest" },
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const key = String(input.Key);
|
|
45
|
+
const body = input.Body;
|
|
46
|
+
if (!Buffer.isBuffer(body)) {
|
|
47
|
+
throw new Error("expected Body to be a Buffer");
|
|
48
|
+
}
|
|
49
|
+
stored.set(key, {
|
|
50
|
+
body,
|
|
51
|
+
contentEncoding:
|
|
52
|
+
typeof input.ContentEncoding === "string"
|
|
53
|
+
? input.ContentEncoding
|
|
54
|
+
: undefined,
|
|
55
|
+
});
|
|
56
|
+
return {};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (command instanceof GetObjectCommand) {
|
|
60
|
+
const input = command.input as unknown as Record<string, unknown>;
|
|
61
|
+
const key = String(input.Key);
|
|
62
|
+
const entry = stored.get(key);
|
|
63
|
+
if (!entry) {
|
|
64
|
+
throw Object.assign(new Error(`not found: ${key}`), {
|
|
65
|
+
name: "NoSuchKey",
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
Body: {
|
|
70
|
+
transformToByteArray: async () => new Uint8Array(entry.body),
|
|
71
|
+
},
|
|
72
|
+
ContentEncoding: entry.contentEncoding,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (command instanceof HeadObjectCommand) {
|
|
77
|
+
const input = command.input as unknown as Record<string, unknown>;
|
|
78
|
+
const key = String(input.Key);
|
|
79
|
+
if (!stored.has(key)) {
|
|
80
|
+
throw Object.assign(new Error(`not found: ${key}`), {
|
|
81
|
+
name: "NotFound",
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return {};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (command instanceof ListObjectsV2Command) {
|
|
88
|
+
const input = command.input as unknown as Record<string, unknown>;
|
|
89
|
+
const prefix = String(input.Prefix ?? "");
|
|
90
|
+
const Contents = [...stored.keys()]
|
|
91
|
+
.filter((key) => key.startsWith(prefix))
|
|
92
|
+
.map((Key) => ({ Key }));
|
|
93
|
+
return { Contents, IsTruncated: false };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
throw new Error(`unexpected command: ${String(command)}`);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
client: { send } as unknown as S3Client,
|
|
101
|
+
commands,
|
|
102
|
+
stored,
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
describe("createS3StorageService", () => {
|
|
107
|
+
test("storeMessageBody sends gzipped body keyed under accounts/{configId}/{accountId}/...", async () => {
|
|
108
|
+
const { client, commands, stored } = createFakeS3Client();
|
|
109
|
+
const storage = createS3StorageService(client, "bucket");
|
|
110
|
+
const content = Buffer.from("Hello, world! This is a message body.");
|
|
111
|
+
|
|
112
|
+
const ref = await storage.storeMessageBody({
|
|
113
|
+
accountConfigId: "cfg1",
|
|
114
|
+
accountId: "acc123",
|
|
115
|
+
messageId: "msg456",
|
|
116
|
+
content,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
assert.strictEqual(commands.length, 1);
|
|
120
|
+
const put = commands[0];
|
|
121
|
+
assert.strictEqual(put.name, "PutObjectCommand");
|
|
122
|
+
assert.strictEqual(put.input.ChecksumSHA256, undefined);
|
|
123
|
+
assert.strictEqual(put.input.ContentEncoding, "gzip");
|
|
124
|
+
|
|
125
|
+
const entry = stored.get("accounts/cfg1/acc123/messages/msg456/body.eml");
|
|
126
|
+
assert.ok(entry, "object should be stored");
|
|
127
|
+
assert.deepStrictEqual(gunzipSync(entry.body), content);
|
|
128
|
+
|
|
129
|
+
assert.strictEqual(ref.storageType, StorageType.S3);
|
|
130
|
+
assert.strictEqual(ref.contentEncoding, ContentEncoding.Gzip);
|
|
131
|
+
assert.strictEqual(ref.checksumSha256, computeChecksum(content));
|
|
132
|
+
assert.strictEqual(ref.sizeBytes, entry.body.length);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("storeMessageBodyStream streams a gzipped body to S3 with the pre-gzip checksum", async () => {
|
|
136
|
+
// lib-storage's Upload needs a real S3Client surface (config, request
|
|
137
|
+
// handler), so mock the client rather than hand-rolling a `send` stub.
|
|
138
|
+
const s3Mock = mockClient(S3Client);
|
|
139
|
+
const captured: Buffer[] = [];
|
|
140
|
+
s3Mock.on(PutObjectCommand).callsFake((input) => {
|
|
141
|
+
const body = input.Body;
|
|
142
|
+
captured.push(Buffer.isBuffer(body) ? body : Buffer.from(body));
|
|
143
|
+
assert.strictEqual(input.ContentEncoding, "gzip");
|
|
144
|
+
assert.strictEqual(input.ContentType, "message/rfc822");
|
|
145
|
+
return {};
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const storage = createS3StorageService(
|
|
149
|
+
new S3Client({ region: "us-east-1" }),
|
|
150
|
+
"bucket",
|
|
151
|
+
);
|
|
152
|
+
const content = Buffer.from("streamed message body bytes");
|
|
153
|
+
|
|
154
|
+
const ref = await storage.storeMessageBodyStream({
|
|
155
|
+
accountConfigId: "cfg1",
|
|
156
|
+
accountId: "acc123",
|
|
157
|
+
messageId: "msg-stream",
|
|
158
|
+
content: Readable.from(content),
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// One PutObject, gzipped, decoding back to the original bytes — proves the
|
|
162
|
+
// stream was uploaded, not silently dropped.
|
|
163
|
+
assert.strictEqual(captured.length, 1);
|
|
164
|
+
assert.deepStrictEqual(gunzipSync(captured[0]), content);
|
|
165
|
+
|
|
166
|
+
assert.strictEqual(ref.storageType, StorageType.S3);
|
|
167
|
+
assert.strictEqual(ref.contentEncoding, ContentEncoding.Gzip);
|
|
168
|
+
// Checksum is over the logical pre-gzip content, matching storeMessageBody.
|
|
169
|
+
assert.strictEqual(ref.checksumSha256, computeChecksum(content));
|
|
170
|
+
assert.strictEqual(ref.sizeBytes, captured[0].length);
|
|
171
|
+
|
|
172
|
+
s3Mock.restore();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("storeDeduplicated places object at content-hash key based on pre-gzip checksum", async () => {
|
|
176
|
+
const { client, stored } = createFakeS3Client();
|
|
177
|
+
const storage = createS3StorageService(client, "bucket");
|
|
178
|
+
const content = Buffer.from("dedupable content");
|
|
179
|
+
const hash = computeChecksum(content);
|
|
180
|
+
|
|
181
|
+
const ref = await storage.storeDeduplicated({
|
|
182
|
+
accountConfigId: "cfg1",
|
|
183
|
+
accountId: "acc123",
|
|
184
|
+
content,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const expectedKey = `accounts/cfg1/acc123/dedup/${hash.slice(0, 2)}/${hash}`;
|
|
188
|
+
assert.strictEqual(ref.storageKey, expectedKey);
|
|
189
|
+
assert.strictEqual(ref.checksumSha256, hash);
|
|
190
|
+
|
|
191
|
+
const entry = stored.get(expectedKey);
|
|
192
|
+
assert.ok(entry, "dedup object should be stored");
|
|
193
|
+
assert.deepStrictEqual(gunzipSync(entry.body), content);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("round-trips content through retrieve with gzip decoding", async () => {
|
|
197
|
+
const { client } = createFakeS3Client();
|
|
198
|
+
const storage = createS3StorageService(client, "bucket");
|
|
199
|
+
const content = Buffer.from("round trip payload");
|
|
200
|
+
|
|
201
|
+
const ref = await storage.storeMessageBody({
|
|
202
|
+
accountConfigId: "cfg1",
|
|
203
|
+
accountId: "acc123",
|
|
204
|
+
messageId: "msg789",
|
|
205
|
+
content,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const retrieved = await storage.retrieve(ref.uri);
|
|
209
|
+
assert.deepStrictEqual(retrieved, content);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("storeParsedBody writes gzipped JSON keyed under accounts/{configId}/{accountId}/...", async () => {
|
|
213
|
+
const { client, commands, stored } = createFakeS3Client();
|
|
214
|
+
const storage = createS3StorageService(client, "bucket");
|
|
215
|
+
const parsed = {
|
|
216
|
+
text: "hi",
|
|
217
|
+
html: "<p>hi</p>",
|
|
218
|
+
attachments: [
|
|
219
|
+
{
|
|
220
|
+
filename: "a.txt",
|
|
221
|
+
contentType: "text/plain",
|
|
222
|
+
contentDisposition: "attachment",
|
|
223
|
+
contentId: null,
|
|
224
|
+
size: 12,
|
|
225
|
+
},
|
|
226
|
+
],
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const ref = await storage.storeParsedBody({
|
|
230
|
+
accountConfigId: "cfg-x",
|
|
231
|
+
accountId: "acc-x",
|
|
232
|
+
messageId: "msg-x",
|
|
233
|
+
parsed,
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
assert.strictEqual(commands.length, 1);
|
|
237
|
+
const put = commands[0];
|
|
238
|
+
assert.strictEqual(put.input.ChecksumSHA256, undefined);
|
|
239
|
+
assert.strictEqual(put.input.ContentEncoding, "gzip");
|
|
240
|
+
assert.strictEqual(put.input.ContentType, "application/json");
|
|
241
|
+
assert.strictEqual(
|
|
242
|
+
put.input.Key,
|
|
243
|
+
"accounts/cfg-x/acc-x/messages/msg-x/parsed.json.gz",
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
const entry = stored.get(
|
|
247
|
+
"accounts/cfg-x/acc-x/messages/msg-x/parsed.json.gz",
|
|
248
|
+
);
|
|
249
|
+
assert.ok(entry, "parsed body should be stored");
|
|
250
|
+
assert.deepStrictEqual(
|
|
251
|
+
JSON.parse(gunzipSync(entry.body).toString("utf8")),
|
|
252
|
+
parsed,
|
|
253
|
+
);
|
|
254
|
+
assert.strictEqual(ref.storageType, StorageType.S3);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("retrieveParsedBody returns parsed JSON on hit and null on miss", async () => {
|
|
258
|
+
const { client } = createFakeS3Client();
|
|
259
|
+
const storage = createS3StorageService(client, "bucket");
|
|
260
|
+
const parsed = {
|
|
261
|
+
text: "hello",
|
|
262
|
+
html: null,
|
|
263
|
+
attachments: [],
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
await storage.storeParsedBody({
|
|
267
|
+
accountConfigId: "cfg-r",
|
|
268
|
+
accountId: "acc-r",
|
|
269
|
+
messageId: "msg-r",
|
|
270
|
+
parsed,
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
const hit = await storage.retrieveParsedBody("cfg-r", "acc-r", "msg-r");
|
|
274
|
+
assert.deepStrictEqual(hit, parsed);
|
|
275
|
+
|
|
276
|
+
const miss = await storage.retrieveParsedBody("cfg-r", "acc-r", "nope");
|
|
277
|
+
assert.strictEqual(miss, null);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("retrieveBodyPart round-trips gzipped content and returns null on miss", async () => {
|
|
281
|
+
const { client } = createFakeS3Client();
|
|
282
|
+
const storage = createS3StorageService(client, "bucket");
|
|
283
|
+
const content = Buffer.from("attachment bytes");
|
|
284
|
+
|
|
285
|
+
await storage.storeBodyPart({
|
|
286
|
+
accountConfigId: "cfg-p",
|
|
287
|
+
accountId: "acc-p",
|
|
288
|
+
messageId: "msg-p",
|
|
289
|
+
partPath: "1.2",
|
|
290
|
+
content,
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
const retrieved = await storage.retrieveBodyPart(
|
|
294
|
+
"cfg-p",
|
|
295
|
+
"acc-p",
|
|
296
|
+
"msg-p",
|
|
297
|
+
"1.2",
|
|
298
|
+
);
|
|
299
|
+
assert.deepStrictEqual(retrieved, content);
|
|
300
|
+
|
|
301
|
+
const miss = await storage.retrieveBodyPart(
|
|
302
|
+
"cfg-p",
|
|
303
|
+
"acc-p",
|
|
304
|
+
"msg-p",
|
|
305
|
+
"nope",
|
|
306
|
+
);
|
|
307
|
+
assert.strictEqual(miss, null);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("storeExtractedText writes gzipped text keyed under .../extracted/{partPath}.txt.gz and round-trips", async () => {
|
|
311
|
+
const { client, commands, stored } = createFakeS3Client();
|
|
312
|
+
const storage = createS3StorageService(client, "bucket");
|
|
313
|
+
|
|
314
|
+
const ref = await storage.storeExtractedText({
|
|
315
|
+
accountConfigId: "cfg-x",
|
|
316
|
+
accountId: "acc-x",
|
|
317
|
+
messageId: "msg-x",
|
|
318
|
+
partPath: "1.2",
|
|
319
|
+
text: "extracted attachment text",
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
assert.strictEqual(
|
|
323
|
+
ref.storageKey,
|
|
324
|
+
"accounts/cfg-x/acc-x/messages/msg-x/extracted/1.2.txt.gz",
|
|
325
|
+
);
|
|
326
|
+
const put = commands.find((c) => c.name === "PutObjectCommand");
|
|
327
|
+
assert.strictEqual(put?.input.ContentEncoding, "gzip");
|
|
328
|
+
assert.strictEqual(put?.input.ContentType, "text/plain; charset=utf-8");
|
|
329
|
+
|
|
330
|
+
const entry = stored.get(
|
|
331
|
+
"accounts/cfg-x/acc-x/messages/msg-x/extracted/1.2.txt.gz",
|
|
332
|
+
);
|
|
333
|
+
assert.ok(entry);
|
|
334
|
+
assert.strictEqual(
|
|
335
|
+
gunzipSync(entry.body).toString("utf8"),
|
|
336
|
+
"extracted attachment text",
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
const retrieved = await storage.retrieveExtractedText(
|
|
340
|
+
"cfg-x",
|
|
341
|
+
"acc-x",
|
|
342
|
+
"msg-x",
|
|
343
|
+
"1.2",
|
|
344
|
+
);
|
|
345
|
+
assert.strictEqual(retrieved, "extracted attachment text");
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("retrieveExtractedText returns null on miss", async () => {
|
|
349
|
+
const { client } = createFakeS3Client();
|
|
350
|
+
const storage = createS3StorageService(client, "bucket");
|
|
351
|
+
const result = await storage.retrieveExtractedText(
|
|
352
|
+
"cfg-x",
|
|
353
|
+
"acc-x",
|
|
354
|
+
"msg-x",
|
|
355
|
+
"nope",
|
|
356
|
+
);
|
|
357
|
+
assert.strictEqual(result, null);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test("storeExtractedSkipped writes an uncompressed JSON marker keyed under .../extracted/{partPath}.skipped.json", async () => {
|
|
361
|
+
const { client, commands, stored } = createFakeS3Client();
|
|
362
|
+
const storage = createS3StorageService(client, "bucket");
|
|
363
|
+
|
|
364
|
+
const ref = await storage.storeExtractedSkipped({
|
|
365
|
+
accountConfigId: "cfg-s",
|
|
366
|
+
accountId: "acc-s",
|
|
367
|
+
messageId: "msg-s",
|
|
368
|
+
partPath: "3",
|
|
369
|
+
marker: { status: "failed", reason: "pdf: corrupt" },
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
assert.strictEqual(
|
|
373
|
+
ref.storageKey,
|
|
374
|
+
"accounts/cfg-s/acc-s/messages/msg-s/extracted/3.skipped.json",
|
|
375
|
+
);
|
|
376
|
+
const put = commands.find((c) => c.name === "PutObjectCommand");
|
|
377
|
+
assert.strictEqual(put?.input.ContentEncoding, undefined);
|
|
378
|
+
assert.strictEqual(put?.input.ContentType, "application/json");
|
|
379
|
+
|
|
380
|
+
const entry = stored.get(
|
|
381
|
+
"accounts/cfg-s/acc-s/messages/msg-s/extracted/3.skipped.json",
|
|
382
|
+
);
|
|
383
|
+
assert.ok(entry);
|
|
384
|
+
assert.deepStrictEqual(JSON.parse(entry.body.toString("utf8")), {
|
|
385
|
+
status: "failed",
|
|
386
|
+
reason: "pdf: corrupt",
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test("extractedResultExists is true once either the text artifact or the skip marker is stored", async () => {
|
|
391
|
+
const { client } = createFakeS3Client();
|
|
392
|
+
const storage = createS3StorageService(client, "bucket");
|
|
393
|
+
|
|
394
|
+
assert.strictEqual(
|
|
395
|
+
await storage.extractedResultExists("cfg-e", "acc-e", "msg-e", "1"),
|
|
396
|
+
false,
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
await storage.storeExtractedText({
|
|
400
|
+
accountConfigId: "cfg-e",
|
|
401
|
+
accountId: "acc-e",
|
|
402
|
+
messageId: "msg-e",
|
|
403
|
+
partPath: "1",
|
|
404
|
+
text: "hi",
|
|
405
|
+
});
|
|
406
|
+
assert.strictEqual(
|
|
407
|
+
await storage.extractedResultExists("cfg-e", "acc-e", "msg-e", "1"),
|
|
408
|
+
true,
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
await storage.storeExtractedSkipped({
|
|
412
|
+
accountConfigId: "cfg-e",
|
|
413
|
+
accountId: "acc-e",
|
|
414
|
+
messageId: "msg-e",
|
|
415
|
+
partPath: "2",
|
|
416
|
+
marker: { status: "skipped", reason: "too-large" },
|
|
417
|
+
});
|
|
418
|
+
assert.strictEqual(
|
|
419
|
+
await storage.extractedResultExists("cfg-e", "acc-e", "msg-e", "2"),
|
|
420
|
+
true,
|
|
421
|
+
);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test("listExtractedTexts returns only .txt.gz artifacts for the given message, excluding skip markers and other messages", async () => {
|
|
425
|
+
const { client } = createFakeS3Client();
|
|
426
|
+
const storage = createS3StorageService(client, "bucket");
|
|
427
|
+
|
|
428
|
+
await storage.storeExtractedText({
|
|
429
|
+
accountConfigId: "cfg-l",
|
|
430
|
+
accountId: "acc-l",
|
|
431
|
+
messageId: "msg-l",
|
|
432
|
+
partPath: "1",
|
|
433
|
+
text: "one",
|
|
434
|
+
});
|
|
435
|
+
await storage.storeExtractedText({
|
|
436
|
+
accountConfigId: "cfg-l",
|
|
437
|
+
accountId: "acc-l",
|
|
438
|
+
messageId: "msg-l",
|
|
439
|
+
partPath: "2.1",
|
|
440
|
+
text: "two",
|
|
441
|
+
});
|
|
442
|
+
await storage.storeExtractedSkipped({
|
|
443
|
+
accountConfigId: "cfg-l",
|
|
444
|
+
accountId: "acc-l",
|
|
445
|
+
messageId: "msg-l",
|
|
446
|
+
partPath: "3",
|
|
447
|
+
marker: { status: "failed", reason: "doc: corrupt" },
|
|
448
|
+
});
|
|
449
|
+
await storage.storeExtractedText({
|
|
450
|
+
accountConfigId: "cfg-l",
|
|
451
|
+
accountId: "acc-l",
|
|
452
|
+
messageId: "other-msg",
|
|
453
|
+
partPath: "1",
|
|
454
|
+
text: "unrelated",
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
const items = await storage.listExtractedTexts("cfg-l", "acc-l", "msg-l");
|
|
458
|
+
assert.deepStrictEqual(items.map((i) => i.partPath).sort(), ["1", "2.1"]);
|
|
459
|
+
for (const item of items) {
|
|
460
|
+
assert.ok(item.key.endsWith(".txt.gz"));
|
|
461
|
+
}
|
|
462
|
+
});
|
|
463
|
+
});
|