@uploadista/data-store-s3 0.0.3
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/.turbo/turbo-build.log +5 -0
- package/.turbo/turbo-check.log +5 -0
- package/LICENSE +21 -0
- package/README.md +588 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1 -0
- package/dist/observability.d.ts +45 -0
- package/dist/observability.d.ts.map +1 -0
- package/dist/observability.js +155 -0
- package/dist/s3-store-old.d.ts +51 -0
- package/dist/s3-store-old.d.ts.map +1 -0
- package/dist/s3-store-old.js +765 -0
- package/dist/s3-store.d.ts +9 -0
- package/dist/s3-store.d.ts.map +1 -0
- package/dist/s3-store.js +666 -0
- package/dist/services/__mocks__/s3-client-mock.service.d.ts +44 -0
- package/dist/services/__mocks__/s3-client-mock.service.d.ts.map +1 -0
- package/dist/services/__mocks__/s3-client-mock.service.js +379 -0
- package/dist/services/index.d.ts +2 -0
- package/dist/services/index.d.ts.map +1 -0
- package/dist/services/index.js +1 -0
- package/dist/services/s3-client.service.d.ts +68 -0
- package/dist/services/s3-client.service.d.ts.map +1 -0
- package/dist/services/s3-client.service.js +209 -0
- package/dist/test-observability.d.ts +6 -0
- package/dist/test-observability.d.ts.map +1 -0
- package/dist/test-observability.js +62 -0
- package/dist/types.d.ts +81 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/dist/utils/calculations.d.ts +7 -0
- package/dist/utils/calculations.d.ts.map +1 -0
- package/dist/utils/calculations.js +41 -0
- package/dist/utils/error-handling.d.ts +7 -0
- package/dist/utils/error-handling.d.ts.map +1 -0
- package/dist/utils/error-handling.js +29 -0
- package/dist/utils/index.d.ts +4 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +3 -0
- package/dist/utils/stream-adapter.d.ts +14 -0
- package/dist/utils/stream-adapter.d.ts.map +1 -0
- package/dist/utils/stream-adapter.js +41 -0
- package/package.json +36 -0
- package/src/__tests__/integration/s3-store.integration.test.ts +548 -0
- package/src/__tests__/multipart-logic.test.ts +395 -0
- package/src/__tests__/s3-store.edge-cases.test.ts +681 -0
- package/src/__tests__/s3-store.performance.test.ts +622 -0
- package/src/__tests__/s3-store.test.ts +662 -0
- package/src/__tests__/utils/performance-helpers.ts +459 -0
- package/src/__tests__/utils/test-data-generator.ts +331 -0
- package/src/__tests__/utils/test-setup.ts +256 -0
- package/src/index.ts +1 -0
- package/src/s3-store.ts +1059 -0
- package/src/services/__mocks__/s3-client-mock.service.ts +604 -0
- package/src/services/index.ts +1 -0
- package/src/services/s3-client.service.ts +359 -0
- package/src/types.ts +96 -0
- package/src/utils/calculations.ts +61 -0
- package/src/utils/error-handling.ts +52 -0
- package/src/utils/index.ts +3 -0
- package/src/utils/stream-adapter.ts +50 -0
- package/tsconfig.json +19 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vitest.config.ts +15 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import type { UploadistaError } from "@uploadista/core/errors";
|
|
2
|
+
import { Option, Stream } from "effect";
|
|
3
|
+
|
|
4
|
+
export interface TestFileSize {
|
|
5
|
+
name: string;
|
|
6
|
+
size: number;
|
|
7
|
+
description: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const TEST_FILE_SIZES: Record<string, TestFileSize> = {
|
|
11
|
+
TINY: {
|
|
12
|
+
name: "tiny",
|
|
13
|
+
size: 1024, // 1KB
|
|
14
|
+
description: "Tiny file for edge cases",
|
|
15
|
+
},
|
|
16
|
+
SMALL_BASIC: {
|
|
17
|
+
name: "small-basic",
|
|
18
|
+
size: 1024 * 1024, // 1MB
|
|
19
|
+
description: "Basic small file",
|
|
20
|
+
},
|
|
21
|
+
SMALL_LARGE: {
|
|
22
|
+
name: "small-large",
|
|
23
|
+
size: Math.floor(4.9 * 1024 * 1024), // 4.9MB (just under S3 multipart threshold)
|
|
24
|
+
description: "Large small file (single part)",
|
|
25
|
+
},
|
|
26
|
+
MEDIUM_MIN: {
|
|
27
|
+
name: "medium-min",
|
|
28
|
+
size: 5 * 1024 * 1024, // 5MB (S3 minimum multipart size)
|
|
29
|
+
description: "Minimum medium file (multipart threshold)",
|
|
30
|
+
},
|
|
31
|
+
MEDIUM: {
|
|
32
|
+
name: "medium",
|
|
33
|
+
size: 10 * 1024 * 1024, // 10MB
|
|
34
|
+
description: "Standard medium file",
|
|
35
|
+
},
|
|
36
|
+
MEDIUM_LARGE: {
|
|
37
|
+
name: "medium-large",
|
|
38
|
+
size: 49 * 1024 * 1024, // 49MB
|
|
39
|
+
description: "Large medium file",
|
|
40
|
+
},
|
|
41
|
+
LARGE: {
|
|
42
|
+
name: "large",
|
|
43
|
+
size: 50 * 1024 * 1024, // 50MB
|
|
44
|
+
description: "Standard large file",
|
|
45
|
+
},
|
|
46
|
+
LARGE_XL: {
|
|
47
|
+
name: "large-xl",
|
|
48
|
+
size: 100 * 1024 * 1024, // 100MB
|
|
49
|
+
description: "Extra large file",
|
|
50
|
+
},
|
|
51
|
+
STRESS_TEST: {
|
|
52
|
+
name: "stress-test",
|
|
53
|
+
size: 200 * 1024 * 1024, // 200MB
|
|
54
|
+
description: "Stress test file",
|
|
55
|
+
},
|
|
56
|
+
} as const;
|
|
57
|
+
|
|
58
|
+
export interface TestFilePattern {
|
|
59
|
+
type: "random" | "zeros" | "ones" | "pattern" | "text";
|
|
60
|
+
pattern?: Uint8Array;
|
|
61
|
+
seed?: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Generate test data with different patterns for comprehensive testing
|
|
66
|
+
*/
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Generate random data with optional seed for reproducibility
|
|
70
|
+
*/
|
|
71
|
+
export function generateRandomData(size: number, seed?: number): Uint8Array {
|
|
72
|
+
const data = new Uint8Array(size);
|
|
73
|
+
|
|
74
|
+
if (seed !== undefined) {
|
|
75
|
+
// Simple LCG for reproducible randomness
|
|
76
|
+
let rng = seed;
|
|
77
|
+
for (let i = 0; i < size; i++) {
|
|
78
|
+
rng = (rng * 1664525 + 1013904223) >>> 0;
|
|
79
|
+
data[i] = (rng >>> 24) & 0xff;
|
|
80
|
+
}
|
|
81
|
+
} else {
|
|
82
|
+
// crypto.getRandomValues has a 65,536 byte limit, so we need to generate in chunks
|
|
83
|
+
const maxChunkSize = 65536;
|
|
84
|
+
let offset = 0;
|
|
85
|
+
|
|
86
|
+
while (offset < size) {
|
|
87
|
+
const chunkSize = Math.min(maxChunkSize, size - offset);
|
|
88
|
+
const chunk = data.subarray(offset, offset + chunkSize);
|
|
89
|
+
crypto.getRandomValues(chunk);
|
|
90
|
+
offset += chunkSize;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return data;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Generate data filled with zeros (good for compression testing)
|
|
99
|
+
*/
|
|
100
|
+
export function generateZeroData(size: number): Uint8Array {
|
|
101
|
+
return new Uint8Array(size);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Generate data filled with ones
|
|
106
|
+
*/
|
|
107
|
+
export function generateOnesData(size: number): Uint8Array {
|
|
108
|
+
const data = new Uint8Array(size);
|
|
109
|
+
data.fill(255);
|
|
110
|
+
return data;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Generate data with repeating pattern
|
|
115
|
+
*/
|
|
116
|
+
export function generatePatternData(
|
|
117
|
+
size: number,
|
|
118
|
+
pattern: Uint8Array,
|
|
119
|
+
): Uint8Array {
|
|
120
|
+
const data = new Uint8Array(size);
|
|
121
|
+
for (let i = 0; i < size; i++) {
|
|
122
|
+
data[i] = pattern[i % pattern.length];
|
|
123
|
+
}
|
|
124
|
+
return data;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Generate text-like data
|
|
129
|
+
*/
|
|
130
|
+
export function generateTextData(size: number): Uint8Array {
|
|
131
|
+
const text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. ";
|
|
132
|
+
const encoder = new TextEncoder();
|
|
133
|
+
const baseData = encoder.encode(text);
|
|
134
|
+
const data = new Uint8Array(size);
|
|
135
|
+
|
|
136
|
+
for (let i = 0; i < size; i++) {
|
|
137
|
+
data[i] = baseData[i % baseData.length];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return data;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Generate data based on pattern configuration
|
|
145
|
+
*/
|
|
146
|
+
export function generateData(
|
|
147
|
+
size: number,
|
|
148
|
+
pattern: TestFilePattern,
|
|
149
|
+
): Uint8Array {
|
|
150
|
+
switch (pattern.type) {
|
|
151
|
+
case "random":
|
|
152
|
+
return generateRandomData(size, pattern.seed);
|
|
153
|
+
case "zeros":
|
|
154
|
+
return generateZeroData(size);
|
|
155
|
+
case "ones":
|
|
156
|
+
return generateOnesData(size);
|
|
157
|
+
case "pattern":
|
|
158
|
+
if (!pattern.pattern) {
|
|
159
|
+
throw new Error("Pattern must be provided for pattern type");
|
|
160
|
+
}
|
|
161
|
+
return generatePatternData(size, pattern.pattern);
|
|
162
|
+
case "text":
|
|
163
|
+
return generateTextData(size);
|
|
164
|
+
default:
|
|
165
|
+
throw new Error(`Unknown pattern type: ${pattern.type}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Create a stream from test data
|
|
171
|
+
*/
|
|
172
|
+
export const createTestDataStream = (
|
|
173
|
+
size: number,
|
|
174
|
+
pattern: TestFilePattern = { type: "random" },
|
|
175
|
+
chunkSize: number = 64 * 1024, // 64KB chunks
|
|
176
|
+
): Stream.Stream<Uint8Array, UploadistaError> => {
|
|
177
|
+
const data = generateData(size, pattern);
|
|
178
|
+
|
|
179
|
+
return Stream.unfold(0, (offset) => {
|
|
180
|
+
if (offset >= data.length) {
|
|
181
|
+
return Option.none();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const end = Math.min(offset + chunkSize, data.length);
|
|
185
|
+
const chunk = data.slice(offset, end);
|
|
186
|
+
|
|
187
|
+
return Option.some([chunk, end] as const);
|
|
188
|
+
});
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Create multiple streams for concurrent testing
|
|
193
|
+
*/
|
|
194
|
+
export const createMultipleTestStreams = (
|
|
195
|
+
count: number,
|
|
196
|
+
size: number,
|
|
197
|
+
pattern: TestFilePattern = { type: "random" },
|
|
198
|
+
): Stream.Stream<Uint8Array, UploadistaError>[] => {
|
|
199
|
+
return Array.from({ length: count }, (_, i) =>
|
|
200
|
+
createTestDataStream(size, {
|
|
201
|
+
...pattern,
|
|
202
|
+
seed: pattern.seed ? pattern.seed + i : i,
|
|
203
|
+
}),
|
|
204
|
+
);
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Utility to compare two Uint8Arrays for testing
|
|
209
|
+
*/
|
|
210
|
+
export const compareArrays = (a: Uint8Array, b: Uint8Array): boolean => {
|
|
211
|
+
if (a.length !== b.length) {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
for (let i = 0; i < a.length; i++) {
|
|
216
|
+
if (a[i] !== b[i]) {
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return true;
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Read all data from a stream for comparison
|
|
226
|
+
*/
|
|
227
|
+
export const streamToArray = async (
|
|
228
|
+
stream: ReadableStream<Uint8Array>,
|
|
229
|
+
): Promise<Uint8Array> => {
|
|
230
|
+
const reader = stream.getReader();
|
|
231
|
+
const chunks: Uint8Array[] = [];
|
|
232
|
+
let totalLength = 0;
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
while (true) {
|
|
236
|
+
const { done, value } = await reader.read();
|
|
237
|
+
if (done) break;
|
|
238
|
+
chunks.push(value);
|
|
239
|
+
totalLength += value.length;
|
|
240
|
+
}
|
|
241
|
+
} finally {
|
|
242
|
+
reader.releaseLock();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const result = new Uint8Array(totalLength);
|
|
246
|
+
let offset = 0;
|
|
247
|
+
for (const chunk of chunks) {
|
|
248
|
+
result.set(chunk, offset);
|
|
249
|
+
offset += chunk.length;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return result;
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Create test files with metadata for comprehensive testing
|
|
257
|
+
*/
|
|
258
|
+
export interface TestFile {
|
|
259
|
+
id: string;
|
|
260
|
+
name: string;
|
|
261
|
+
size: number;
|
|
262
|
+
data: Uint8Array;
|
|
263
|
+
stream: Stream.Stream<Uint8Array, UploadistaError>;
|
|
264
|
+
pattern: TestFilePattern;
|
|
265
|
+
metadata?: {
|
|
266
|
+
contentType?: string;
|
|
267
|
+
cacheControl?: string;
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export const createTestFile = (
|
|
272
|
+
id: string,
|
|
273
|
+
testSize: TestFileSize,
|
|
274
|
+
pattern: TestFilePattern = { type: "random" },
|
|
275
|
+
metadata?: TestFile["metadata"],
|
|
276
|
+
): TestFile => {
|
|
277
|
+
const data = generateData(testSize.size, pattern);
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
id,
|
|
281
|
+
name: testSize.name,
|
|
282
|
+
size: testSize.size,
|
|
283
|
+
data,
|
|
284
|
+
stream: createTestDataStream(testSize.size, pattern),
|
|
285
|
+
pattern,
|
|
286
|
+
metadata,
|
|
287
|
+
};
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Create a set of standard test files for comprehensive testing
|
|
292
|
+
*/
|
|
293
|
+
export const createStandardTestFiles = (): TestFile[] => {
|
|
294
|
+
return [
|
|
295
|
+
createTestFile("tiny-random", TEST_FILE_SIZES.TINY, {
|
|
296
|
+
type: "random",
|
|
297
|
+
seed: 1,
|
|
298
|
+
}),
|
|
299
|
+
createTestFile("tiny-zeros", TEST_FILE_SIZES.TINY, { type: "zeros" }),
|
|
300
|
+
createTestFile("small-basic", TEST_FILE_SIZES.SMALL_BASIC, {
|
|
301
|
+
type: "random",
|
|
302
|
+
seed: 2,
|
|
303
|
+
}),
|
|
304
|
+
createTestFile("small-large", TEST_FILE_SIZES.SMALL_LARGE, {
|
|
305
|
+
type: "random",
|
|
306
|
+
seed: 3,
|
|
307
|
+
}),
|
|
308
|
+
createTestFile("medium-min", TEST_FILE_SIZES.MEDIUM_MIN, {
|
|
309
|
+
type: "random",
|
|
310
|
+
seed: 4,
|
|
311
|
+
}),
|
|
312
|
+
createTestFile("medium", TEST_FILE_SIZES.MEDIUM, {
|
|
313
|
+
type: "random",
|
|
314
|
+
seed: 5,
|
|
315
|
+
}),
|
|
316
|
+
createTestFile("large", TEST_FILE_SIZES.LARGE, { type: "random", seed: 6 }),
|
|
317
|
+
createTestFile(
|
|
318
|
+
"text-file",
|
|
319
|
+
TEST_FILE_SIZES.MEDIUM,
|
|
320
|
+
{ type: "text" },
|
|
321
|
+
{
|
|
322
|
+
contentType: "text/plain",
|
|
323
|
+
cacheControl: "no-cache",
|
|
324
|
+
},
|
|
325
|
+
),
|
|
326
|
+
createTestFile("pattern-file", TEST_FILE_SIZES.MEDIUM, {
|
|
327
|
+
type: "pattern",
|
|
328
|
+
pattern: new Uint8Array([0xde, 0xad, 0xbe, 0xef]),
|
|
329
|
+
}),
|
|
330
|
+
];
|
|
331
|
+
};
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import type { UploadFile } from "@uploadista/core/types";
|
|
2
|
+
import { UploadFileKVStore } from "@uploadista/core/types";
|
|
3
|
+
import { memoryKvStore } from "@uploadista/kv-store-memory";
|
|
4
|
+
import { Effect, Layer } from "effect";
|
|
5
|
+
import {
|
|
6
|
+
MockS3ClientLayer,
|
|
7
|
+
type MockS3Config,
|
|
8
|
+
type MockS3TestMethods,
|
|
9
|
+
} from "../../services/__mocks__/s3-client-mock.service";
|
|
10
|
+
import { S3ClientService } from "../../services/s3-client.service";
|
|
11
|
+
|
|
12
|
+
// Re-export types for tests
|
|
13
|
+
export type { MockS3TestMethods };
|
|
14
|
+
|
|
15
|
+
import type { S3StoreConfig } from "../../types";
|
|
16
|
+
|
|
17
|
+
// Default test configuration
|
|
18
|
+
export const DEFAULT_TEST_CONFIG: MockS3Config = {
|
|
19
|
+
simulateLatency: 0, // No latency by default for faster tests
|
|
20
|
+
errorRate: 0,
|
|
21
|
+
uploadFailureRate: 0,
|
|
22
|
+
enableErrorInjection: true,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// Test bucket configuration
|
|
26
|
+
export const TEST_BUCKET = "test-uploadista-bucket";
|
|
27
|
+
export const TEST_DELIVERY_URL = "https://test-cdn.example.com";
|
|
28
|
+
|
|
29
|
+
// Common S3 store configuration for tests
|
|
30
|
+
export const createTestS3StoreConfig = (
|
|
31
|
+
overrides: Partial<S3StoreConfig> = {},
|
|
32
|
+
): Omit<S3StoreConfig, "kvStore"> => ({
|
|
33
|
+
deliveryUrl: TEST_DELIVERY_URL,
|
|
34
|
+
partSize: 8 * 1024 * 1024, // 8MB default part size
|
|
35
|
+
minPartSize: 5 * 1024 * 1024, // 5MB S3 minimum
|
|
36
|
+
maxMultipartParts: 10_000,
|
|
37
|
+
useTags: true,
|
|
38
|
+
maxConcurrentPartUploads: 10,
|
|
39
|
+
expirationPeriodInMilliseconds: 7 * 24 * 60 * 60 * 1000, // 1 week
|
|
40
|
+
s3ClientConfig: {
|
|
41
|
+
region: "us-east-1",
|
|
42
|
+
bucket: TEST_BUCKET,
|
|
43
|
+
},
|
|
44
|
+
...overrides,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Layer that provides both KV store and S3 client for testing
|
|
48
|
+
// Creates a fresh isolated KV store for each test
|
|
49
|
+
export const TestLayersWithMockS3 = (
|
|
50
|
+
mockConfig: MockS3Config = DEFAULT_TEST_CONFIG,
|
|
51
|
+
) =>
|
|
52
|
+
Layer.mergeAll(
|
|
53
|
+
Layer.effect(
|
|
54
|
+
UploadFileKVStore,
|
|
55
|
+
Effect.sync(() => memoryKvStore<UploadFile>()),
|
|
56
|
+
),
|
|
57
|
+
MockS3ClientLayer(TEST_BUCKET, mockConfig),
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
// Not implemented : Layer with real S3 client (for integration tests with LocalStack/Minio)
|
|
61
|
+
export const TestLayersWithRealS3 = () =>
|
|
62
|
+
Layer.mergeAll(
|
|
63
|
+
Layer.effect(
|
|
64
|
+
UploadFileKVStore,
|
|
65
|
+
Effect.sync(() => memoryKvStore<UploadFile>()),
|
|
66
|
+
),
|
|
67
|
+
Layer.succeed(S3ClientService, {
|
|
68
|
+
bucket: TEST_BUCKET,
|
|
69
|
+
// Add dummy implementations for the interface
|
|
70
|
+
getObject: () => Effect.die("Not implemented in test setup"),
|
|
71
|
+
headObject: () => Effect.die("Not implemented in test setup"),
|
|
72
|
+
putObject: () => Effect.die("Not implemented in test setup"),
|
|
73
|
+
deleteObject: () => Effect.die("Not implemented in test setup"),
|
|
74
|
+
deleteObjects: () => Effect.die("Not implemented in test setup"),
|
|
75
|
+
createMultipartUpload: () => Effect.die("Not implemented in test setup"),
|
|
76
|
+
uploadPart: () => Effect.die("Not implemented in test setup"),
|
|
77
|
+
completeMultipartUpload: () =>
|
|
78
|
+
Effect.die("Not implemented in test setup"),
|
|
79
|
+
abortMultipartUpload: () => Effect.die("Not implemented in test setup"),
|
|
80
|
+
listParts: () => Effect.die("Not implemented in test setup"),
|
|
81
|
+
listMultipartUploads: () => Effect.die("Not implemented in test setup"),
|
|
82
|
+
getIncompletePart: () => Effect.die("Not implemented in test setup"),
|
|
83
|
+
getIncompletePartSize: () => Effect.die("Not implemented in test setup"),
|
|
84
|
+
putIncompletePart: () => Effect.die("Not implemented in test setup"),
|
|
85
|
+
deleteIncompletePart: () => Effect.die("Not implemented in test setup"),
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
// Helper to get the mock service with testing methods
|
|
90
|
+
export const getMockS3Service = (): Effect.Effect<
|
|
91
|
+
S3ClientService["Type"] & MockS3TestMethods,
|
|
92
|
+
never,
|
|
93
|
+
S3ClientService
|
|
94
|
+
> =>
|
|
95
|
+
Effect.gen(function* () {
|
|
96
|
+
const service = yield* S3ClientService;
|
|
97
|
+
// Type assertion since we know this is our mock when using TestLayersWithMockS3
|
|
98
|
+
return service as S3ClientService["Type"] & MockS3TestMethods;
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Helper to create a test upload file
|
|
102
|
+
export const createTestUploadFile = (
|
|
103
|
+
id: string,
|
|
104
|
+
size: number,
|
|
105
|
+
overrides: Partial<UploadFile> = {},
|
|
106
|
+
): UploadFile => ({
|
|
107
|
+
id,
|
|
108
|
+
offset: 0,
|
|
109
|
+
size,
|
|
110
|
+
metadata: {
|
|
111
|
+
contentType: "application/octet-stream",
|
|
112
|
+
...overrides.metadata,
|
|
113
|
+
},
|
|
114
|
+
storage: {
|
|
115
|
+
id: id,
|
|
116
|
+
type: "s3",
|
|
117
|
+
path: id,
|
|
118
|
+
...overrides.storage,
|
|
119
|
+
},
|
|
120
|
+
url: `${TEST_DELIVERY_URL}/${id}`,
|
|
121
|
+
...overrides,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// Test environment setup that can be used in beforeEach
|
|
125
|
+
export const setupTestEnvironment = (
|
|
126
|
+
mockConfig: MockS3Config = DEFAULT_TEST_CONFIG,
|
|
127
|
+
) =>
|
|
128
|
+
Effect.gen(function* () {
|
|
129
|
+
const mockService = yield* getMockS3Service();
|
|
130
|
+
|
|
131
|
+
// Clear storage and reset configuration
|
|
132
|
+
yield* mockService.clearStorage();
|
|
133
|
+
yield* mockService.setConfig({ ...DEFAULT_TEST_CONFIG, ...mockConfig });
|
|
134
|
+
|
|
135
|
+
return mockService;
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// Helper to run tests with proper error handling and logging
|
|
139
|
+
export const runTestWithTimeout = <A, E>(
|
|
140
|
+
effect: Effect.Effect<A, E>,
|
|
141
|
+
timeoutMs: number = 10000,
|
|
142
|
+
): Promise<A> =>
|
|
143
|
+
Effect.runPromise(
|
|
144
|
+
effect.pipe(
|
|
145
|
+
Effect.timeout(`${timeoutMs} millis`),
|
|
146
|
+
Effect.tapError((error) =>
|
|
147
|
+
Effect.logError("Test failed").pipe(
|
|
148
|
+
Effect.annotateLogs({ error: String(error) }),
|
|
149
|
+
),
|
|
150
|
+
),
|
|
151
|
+
),
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
// Assertion helpers for common test patterns
|
|
155
|
+
export const assertFileUploaded = (
|
|
156
|
+
mockService: S3ClientService["Type"] & MockS3TestMethods,
|
|
157
|
+
fileId: string,
|
|
158
|
+
expectedSize: number,
|
|
159
|
+
) =>
|
|
160
|
+
Effect.gen(function* () {
|
|
161
|
+
const storage = yield* mockService.getStorage();
|
|
162
|
+
const uploadedFile = storage.objects.get(fileId);
|
|
163
|
+
|
|
164
|
+
if (!uploadedFile) {
|
|
165
|
+
return yield* Effect.fail(new Error(`File ${fileId} was not uploaded`));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (uploadedFile.length !== expectedSize) {
|
|
169
|
+
yield* Effect.fail(
|
|
170
|
+
new Error(
|
|
171
|
+
`File ${fileId} size ${uploadedFile.length} does not match expected ${expectedSize}`,
|
|
172
|
+
),
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return uploadedFile;
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
export const assertMultipartUploadExists = (
|
|
180
|
+
mockService: S3ClientService["Type"] & MockS3TestMethods,
|
|
181
|
+
uploadId: string,
|
|
182
|
+
) =>
|
|
183
|
+
Effect.gen(function* () {
|
|
184
|
+
const storage = yield* mockService.getStorage();
|
|
185
|
+
const upload = storage.multipartUploads.get(uploadId);
|
|
186
|
+
|
|
187
|
+
if (!upload) {
|
|
188
|
+
yield* Effect.fail(new Error(`Multipart upload ${uploadId} not found`));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return upload;
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
export const assertMetricsRecorded = (
|
|
195
|
+
mockService: S3ClientService["Type"] & MockS3TestMethods,
|
|
196
|
+
operation: string,
|
|
197
|
+
expectedCount: number = 1,
|
|
198
|
+
) =>
|
|
199
|
+
Effect.gen(function* () {
|
|
200
|
+
const metrics = yield* mockService.getMetrics();
|
|
201
|
+
const actualCount = metrics.operationCounts.get(operation) || 0;
|
|
202
|
+
|
|
203
|
+
if (actualCount !== expectedCount) {
|
|
204
|
+
yield* Effect.fail(
|
|
205
|
+
new Error(
|
|
206
|
+
`Operation ${operation} was called ${actualCount} times, expected ${expectedCount}`,
|
|
207
|
+
),
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// Helper to create test scenarios with different configurations
|
|
213
|
+
export interface TestScenario {
|
|
214
|
+
name: string;
|
|
215
|
+
config: MockS3Config;
|
|
216
|
+
description: string;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export const createTestScenarios = (): TestScenario[] => [
|
|
220
|
+
{
|
|
221
|
+
name: "normal",
|
|
222
|
+
config: { ...DEFAULT_TEST_CONFIG },
|
|
223
|
+
description: "Normal operation without errors or latency",
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
name: "with-latency",
|
|
227
|
+
config: { ...DEFAULT_TEST_CONFIG, simulateLatency: 10 },
|
|
228
|
+
description: "Operation with 10ms simulated latency",
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
name: "with-errors",
|
|
232
|
+
config: { ...DEFAULT_TEST_CONFIG, errorRate: 0.1 },
|
|
233
|
+
description: "Operation with 10% random error rate",
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
name: "upload-failures",
|
|
237
|
+
config: { ...DEFAULT_TEST_CONFIG, uploadFailureRate: 0.05 },
|
|
238
|
+
description: "Operation with 5% upload failure rate",
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
name: "high-latency",
|
|
242
|
+
config: { ...DEFAULT_TEST_CONFIG, simulateLatency: 100 },
|
|
243
|
+
description: "Operation with 100ms simulated latency",
|
|
244
|
+
},
|
|
245
|
+
];
|
|
246
|
+
|
|
247
|
+
// Helper for parameterized tests
|
|
248
|
+
export const runParameterizedTest = <T>(
|
|
249
|
+
scenarios: T[],
|
|
250
|
+
testFn: (scenario: T) => Effect.Effect<void, unknown>,
|
|
251
|
+
) =>
|
|
252
|
+
Effect.gen(function* () {
|
|
253
|
+
for (const scenario of scenarios) {
|
|
254
|
+
yield* testFn(scenario);
|
|
255
|
+
}
|
|
256
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./s3-store";
|