@trayio/commons 5.19.0 → 5.21.0

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.
@@ -1 +1 @@
1
- {"version":3,"file":"Http.d.ts","sourceRoot":"","sources":["../../src/http/Http.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"Http.d.ts","sourceRoot":"","sources":["../../src/http/Http.ts"],"names":[],"mappings":";AAAA,OAAO,KAAK,CAAC,MAAM,kBAAkB,CAAC;AACtC,OAAO,KAAK,EAAE,MAAM,sBAAsB,CAAC;AAE3C,OAAO,EAAE,QAAQ,EAAE,MAAM,QAAQ,CAAC;AAElC,OAAO,EAAE,IAAI,EAAkB,WAAW,EAAE,MAAM,cAAc,CAAC;AACjE,OAAO,EAAE,mBAAmB,EAAE,MAAM,gCAAgC,CAAC;AACrE,OAAO,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAgB3D,MAAM,MAAM,eAAe,GAAG,MAAM,GAAG,MAAM,EAAE,CAAC;AAEhD,MAAM,MAAM,WAAW,GAAG,MAAM,CAAC,MAAM,EAAE,eAAe,GAAG,eAAe,CAAC,CAAC;AAE5E,MAAM,MAAM,0BAA0B,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC;AAEnE,MAAM,MAAM,oBAAoB,GAC7B,0BAA0B,GAC1B,0BAA0B,EAAE,CAAC;AAEhC,MAAM,MAAM,eAAe,GAAG,MAAM,CAAC,MAAM,EAAE,oBAAoB,CAAC,CAAC;AAEnE,MAAM,MAAM,sBAAsB,GAAG,WAAW,CAAC;AAEjD,MAAM,MAAM,wBAAwB,GAAG;IACtC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC/B,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC;CAC5B,CAAC;AAEF,MAAM,MAAM,qBAAqB,GAAG,QAAQ,CAAC;AAE7C,MAAM,WAAW,iCAAiC;IACjD,MAAM,EAAE,CACP,KAAK,EAAE,OAAO,EACd,WAAW,EAAE,WAAW,KACpB,EAAE,CAAC,UAAU,CAAC,KAAK,EAAE,wBAAwB,CAAC,CAAC;CACpD;AAED,eAAO,MAAM,wBAAwB;oBAE5B,OAAO,eACD,WAAW,KACtB,GAAG,UAAU,CAAC,KAAK,EAAE,wBAAwB,CAAC;CAwGjD,CAAC;AAEF,MAAM,MAAM,eAAe,GACxB,sBAAsB,GACtB,wBAAwB,GACxB,qBAAqB,CAAC;AAEzB,MAAM,MAAM,eAAe,GAAG;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;CACZ,CAAC;AAEF,MAAM,MAAM,SAAS,GAAG;IACvB,WAAW,EAAE,CAAC,CAAC,MAAM,CAAC,eAAe,CAAC,CAAC;CACvC,CAAC;AAEF,MAAM,MAAM,aAAa,GAAG;IAC3B,oBAAoB,EAAE,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;CACxC,CAAC;AAEF,MAAM,MAAM,WAAW,GAAG;IACzB,OAAO,EAAE,WAAW,CAAC;IACrB,aAAa,EAAE,CAAC,CAAC,MAAM,CAAC,aAAa,CAAC,CAAC;IACvC,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACnC,WAAW,EAAE,eAAe,CAAC;IAC7B,IAAI,EAAE,eAAe,CAAC;IACtB,KAAK,EAAE,CAAC,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;IAC3B,QAAQ,EAAE,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;IAC3B,OAAO,CAAC,EAAE,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;CAC3B,CAAC;AAEF,MAAM,MAAM,YAAY,GAAG;IAC1B,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,WAAW,CAAC;IACrB,IAAI,EAAE,QAAQ,CAAC;CACf,CAAC;AAEF,oBAAY,UAAU;IACrB,MAAM,WAAW;IACjB,GAAG,QAAQ;IACX,IAAI,SAAS;IACb,OAAO,YAAY;IACnB,KAAK,UAAU;IACf,IAAI,SAAS;IACb,GAAG,QAAQ;CACX;AAED,oBAAY,eAAe;IAC1B,IAAI,qBAAqB;IACzB,WAAW,6BAA6B;IACxC,cAAc,sCAAsC;IACpD,oBAAoB,wBAAwB;IAC5C,IAAI,eAAe;CACnB;AAED,oBAAY,UAAU;IACrB,aAAa,kBAAkB;IAC/B,WAAW,iBAAiB;IAC5B,aAAa,6BAA6B;IAC1C,SAAS,eAAe;IACxB,aAAa,mBAAmB;IAChC,kBAAkB,wBAAwB;IAC1C,IAAI,SAAS;IACb,YAAY,oBAAoB;CAChC;AAED,MAAM,WAAW,oBAAoB;IACpC,MAAM,EAAE,CAAC,WAAW,EAAE,OAAO,CAAC,WAAW,CAAC,KAAK,WAAW,CAAC;CAC3D;AAED,eAAO,MAAM,WAAW,EAAE,oBAYzB,CAAC;AAEF,MAAM,WAAW,oBAAoB;IACpC,GAAG,EAAE,CAAC,OAAO,EAAE,WAAW,EAAE,IAAI,EAAE,MAAM,KAAK,CAAC,CAAC,MAAM,CAAC,eAAe,CAAC,CAAC;IACvE,kBAAkB,EAAE,CAAC,OAAO,EAAE,WAAW,KAAK,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;IAC/D,iBAAiB,EAAE,CAAC,OAAO,EAAE,aAAa,KAAK,WAAW,CAAC;IAC3D,eAAe,EAAE,CAAC,OAAO,EAAE,WAAW,KAAK,aAAa,CAAC;CACzD;AAED,eAAO,MAAM,WAAW,EAAE,oBAyIzB,CAAC;AAEF,eAAO,MAAM,oCAAoC,EAAE,MAAM,CACxD,mBAAmB,EACnB,eAAe,CAIf,CAAC;AAEF,eAAO,MAAM,eAAe,SACrB,eAAe,qCAEkC,CAAC"}
package/dist/http/Http.js CHANGED
@@ -43,20 +43,71 @@ exports.MultipartHttpRequestBody = {
43
43
  if (value === null || typeof value !== 'object') {
44
44
  return TE.left(Error('Multipart body must be an object'));
45
45
  }
46
- const fields = {};
47
- const fileDescriptors = [];
48
- Object.entries(value).forEach(([key, val]) => {
49
- if (File_1.FileDescriptor.isFileDescriptor(val)) {
50
- fileDescriptors.push(val);
51
- }
52
- else if (!_.isObject(val)) {
53
- fields[key] = val;
46
+ /**
47
+ * Recursively traverses an object to find FileDescriptors at any nesting level.
48
+ * Returns an array of paths to FileDescriptors and the descriptors themselves.
49
+ */
50
+ const findFileDescriptors = (obj, currentPath = []) => {
51
+ const result = [];
52
+ for (const [key, val] of Object.entries(obj)) {
53
+ const path = [...currentPath, key];
54
+ if (File_1.FileDescriptor.isFileDescriptor(val)) {
55
+ result.push({ path, descriptor: val });
56
+ }
57
+ else if (_.isObject(val) && !Array.isArray(val)) {
58
+ result.push(...findFileDescriptors(val, path));
59
+ }
54
60
  }
55
- else {
56
- return TE.left(Error(`Multipart body field '${key}' must be a string, number, boolean or file reference`));
61
+ return result;
62
+ };
63
+ /**
64
+ * Converts nested object to flat fields structure, skipping objects that
65
+ * would contain FileDescriptors.
66
+ */
67
+ const extractFields = (obj, currentPath = []) => {
68
+ const fields = {};
69
+ for (const [key, val] of Object.entries(obj)) {
70
+ const path = [...currentPath, key];
71
+ if (File_1.FileDescriptor.isFileDescriptor(val)) {
72
+ // Skip FileDescriptors - do nothing
73
+ }
74
+ else if (Array.isArray(val)) {
75
+ // Serialize arrays as comma-separated strings
76
+ const fieldKey = path.join('.');
77
+ fields[fieldKey] = val.map(String).join(',');
78
+ }
79
+ else if (_.isObject(val)) {
80
+ // Recursively extract fields from nested objects
81
+ const nestedFields = extractFields(val, path);
82
+ Object.assign(fields, nestedFields);
83
+ }
84
+ else {
85
+ // Primitive values: use dot notation for nested paths
86
+ const fieldKey = path.join('.');
87
+ fields[fieldKey] = String(val);
88
+ }
57
89
  }
58
- });
59
- return (0, function_1.pipe)(TE.sequenceArray(fileDescriptors.map((fileReference) => (0, function_1.pipe)(fileStorage.read(fileReference.key), TE.map((file) => ({ key: file.reference.key, file }))))), TE.map((files) => {
90
+ return fields;
91
+ };
92
+ const fileDescriptorPaths = findFileDescriptors(value);
93
+ // Extract fields, using dot notation for nested structures
94
+ const fields = extractFields(value);
95
+ return (0, function_1.pipe)(TE.sequenceArray(fileDescriptorPaths.map(({ path, descriptor }) => (0, function_1.pipe)(fileStorage.read(descriptor.key), TE.map((file) => ({
96
+ // Use the object path (not the file key) as the form field name
97
+ // This tells the server where to place the FileDescriptor in the reconstructed object
98
+ key: path.join('.'),
99
+ file: {
100
+ ...file,
101
+ reference: {
102
+ ...file.reference,
103
+ // Preserve metadata from original descriptor (especially contentType)
104
+ metadata: {
105
+ ...file.reference.metadata,
106
+ ...descriptor.metadata,
107
+ },
108
+ },
109
+ },
110
+ }))))), TE.map((files) => {
60
111
  const filesRecord = {};
61
112
  files.forEach(({ key, file }) => {
62
113
  filesRecord[key] = file;
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=MultipartHttpRequestBody.integration.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"MultipartHttpRequestBody.integration.test.d.ts","sourceRoot":"","sources":["../../src/http/MultipartHttpRequestBody.integration.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,520 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || function (mod) {
19
+ if (mod && mod.__esModule) return mod;
20
+ var result = {};
21
+ if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
22
+ __setModuleDefault(result, mod);
23
+ return result;
24
+ };
25
+ var __importDefault = (this && this.__importDefault) || function (mod) {
26
+ return (mod && mod.__esModule) ? mod : { "default": mod };
27
+ };
28
+ Object.defineProperty(exports, "__esModule", { value: true });
29
+ const stream_1 = require("stream");
30
+ const http_1 = require("http");
31
+ const formidable_1 = __importDefault(require("formidable"));
32
+ const fs = __importStar(require("fs/promises"));
33
+ const path = __importStar(require("path"));
34
+ const crypto = __importStar(require("crypto"));
35
+ const NodeFsFileStorage_1 = require("../file/NodeFsFileStorage");
36
+ const Http_1 = require("./Http");
37
+ /**
38
+ * Tests for MultipartHttpRequestBody using a server to receive the requests.
39
+ *
40
+ * These tests verify the complete roundtrip:
41
+ * 1. Client creates multipart body with nested FileDescriptors and fields (flattened to dot-notation)
42
+ * 2. Client sends HTTP POST with multipart/form-data
43
+ * 3. Server receives and parses the multipart body (fields remain flat)
44
+ * 4. File content is byte-for-byte identical (SHA-256 verified)
45
+ */
46
+ describe('MultipartHttpRequestBody - E2E', () => {
47
+ let clientStorage;
48
+ let clientTmpDir;
49
+ let serverTmpDir;
50
+ let serverPort;
51
+ let serverUrl;
52
+ let server;
53
+ jest.setTimeout(30000);
54
+ beforeAll(async () => {
55
+ // Setup client and server storage directories
56
+ clientTmpDir = path.join('/tmp', `e2e-client-${Date.now()}-${Math.random().toString(36).substring(7)}`);
57
+ serverTmpDir = path.join('/tmp', `e2e-server-${Date.now()}-${Math.random().toString(36).substring(7)}`);
58
+ await fs.mkdir(clientTmpDir, { recursive: true });
59
+ await fs.mkdir(serverTmpDir, { recursive: true });
60
+ clientStorage = new NodeFsFileStorage_1.NodeFsFileStorage(clientTmpDir);
61
+ // Start HTTP server that can receive multipart requests
62
+ serverPort = 3000 + Math.floor(Math.random() * 1000);
63
+ serverUrl = `http://localhost:${serverPort}`;
64
+ server = (0, http_1.createServer)(async (req, res) => {
65
+ if (req.method === 'POST' && req.url === '/upload') {
66
+ try {
67
+ const form = (0, formidable_1.default)({
68
+ uploadDir: serverTmpDir,
69
+ keepExtensions: true,
70
+ maxFileSize: 100 * 1024 * 1024, // 100MB
71
+ });
72
+ const [fields, files] = await form.parse(req);
73
+ // Flatten formidable fields (array values) to simple strings
74
+ const flattenedFields = {};
75
+ for (const [key, value] of Object.entries(fields)) {
76
+ flattenedFields[key] = Array.isArray(value)
77
+ ? value[0]
78
+ : value || '';
79
+ }
80
+ // Read file contents and compute hashes
81
+ const fileData = {};
82
+ for (const [key, fileArray] of Object.entries(files)) {
83
+ if (fileArray && fileArray[0]) {
84
+ const file = fileArray[0];
85
+ // eslint-disable-next-line no-await-in-loop
86
+ const content = await fs.readFile(file.filepath, 'utf-8');
87
+ const hash = crypto
88
+ .createHash('sha256')
89
+ .update(content)
90
+ .digest('hex');
91
+ fileData[key] = { content, hash };
92
+ }
93
+ }
94
+ res.writeHead(200, { 'Content-Type': 'application/json' });
95
+ res.end(JSON.stringify({
96
+ fields: flattenedFields,
97
+ files: fileData,
98
+ }));
99
+ }
100
+ catch (error) {
101
+ res.writeHead(500, { 'Content-Type': 'application/json' });
102
+ res.end(JSON.stringify({ error: error.message }));
103
+ }
104
+ }
105
+ else {
106
+ res.writeHead(404);
107
+ res.end('Not Found');
108
+ }
109
+ });
110
+ await new Promise((resolve) => {
111
+ server.listen(serverPort, () => {
112
+ // eslint-disable-next-line no-console
113
+ console.log(`E2E test server listening on port ${serverPort}`);
114
+ resolve();
115
+ });
116
+ });
117
+ });
118
+ afterAll(async () => {
119
+ // Cleanup
120
+ if (server) {
121
+ await new Promise((resolve) => {
122
+ server.close(() => resolve());
123
+ });
124
+ }
125
+ try {
126
+ await fs.rm(clientTmpDir, { recursive: true, force: true });
127
+ await fs.rm(serverTmpDir, { recursive: true, force: true });
128
+ }
129
+ catch (error) {
130
+ // Ignore cleanup errors
131
+ }
132
+ });
133
+ describe('File Transfer with Nested FileDescriptors', () => {
134
+ it('should send nested FileDescriptor and receive identical file content', async () => {
135
+ // 1. Create test file with known content
136
+ const originalContent = 'This is test file content for E2E validation! 🚀';
137
+ const originalHash = crypto
138
+ .createHash('sha256')
139
+ .update(originalContent)
140
+ .digest('hex');
141
+ const fileDescriptor = {
142
+ _tag: 'FileDescriptor',
143
+ key: 'test-image-key',
144
+ metadata: {
145
+ name: 'test-image.png',
146
+ size: originalContent.length,
147
+ contentType: 'image/png',
148
+ },
149
+ };
150
+ // Write file to client storage
151
+ const writeResult = await clientStorage.write({
152
+ reference: fileDescriptor,
153
+ content: stream_1.Readable.from(originalContent),
154
+ }, undefined)();
155
+ expect(writeResult._tag).toBe('Right');
156
+ // 2. Create multipart body with NESTED FileDescriptor
157
+ const bodyWithNestedFile = {
158
+ fileName: 'test-image.png',
159
+ overrideIfPresent: true,
160
+ imageContent: {
161
+ reference: fileDescriptor, // NESTED structure
162
+ },
163
+ };
164
+ const multipartResult = await Http_1.MultipartHttpRequestBody.create(bodyWithNestedFile, clientStorage)();
165
+ expect(multipartResult._tag).toBe('Right');
166
+ if (multipartResult._tag === 'Left') {
167
+ throw new Error(`Failed to create multipart body: ${multipartResult.left.message}`);
168
+ }
169
+ const multipartBody = multipartResult.right;
170
+ // Verify the file was detected in the nested structure
171
+ // Files are now keyed by their object path, not storage key
172
+ expect(Object.keys(multipartBody.files)).toContain('imageContent.reference');
173
+ expect(multipartBody.fields).toHaveProperty('fileName', 'test-image.png');
174
+ expect(multipartBody.fields).toHaveProperty('overrideIfPresent', 'true');
175
+ // 3. Send HTTP POST with multipart data
176
+ // eslint-disable-next-line global-require
177
+ const FormData = require('form-data');
178
+ const formData = new FormData();
179
+ // Add fields
180
+ for (const [key, value] of Object.entries(multipartBody.fields)) {
181
+ formData.append(key, value);
182
+ }
183
+ // Add files
184
+ for (const [key, file] of Object.entries(multipartBody.files)) {
185
+ const chunks = [];
186
+ // eslint-disable-next-line no-await-in-loop
187
+ for await (const chunk of file.content) {
188
+ chunks.push(Buffer.from(chunk));
189
+ }
190
+ const buffer = Buffer.concat(chunks);
191
+ formData.append(key, buffer, {
192
+ filename: file.reference.metadata.name,
193
+ contentType: file.reference.metadata.contentType,
194
+ });
195
+ }
196
+ const response = await new Promise((resolve, reject) => {
197
+ formData.submit(`${serverUrl}/upload`, (err, res) => {
198
+ if (err) {
199
+ reject(err);
200
+ return;
201
+ }
202
+ let body = '';
203
+ res.on('data', (chunk) => {
204
+ body += chunk.toString();
205
+ });
206
+ res.on('end', () => {
207
+ try {
208
+ const parsed = JSON.parse(body);
209
+ if (res.statusCode === 200) {
210
+ resolve(parsed);
211
+ }
212
+ else {
213
+ reject(new Error(`Server error: ${parsed.error}`));
214
+ }
215
+ }
216
+ catch (e) {
217
+ reject(new Error(`Failed to parse response: ${body}`));
218
+ }
219
+ });
220
+ res.on('error', reject);
221
+ });
222
+ });
223
+ // 4. Verify server received the file correctly
224
+ // Files are now keyed by object path, not storage key
225
+ expect(response.files).toBeDefined();
226
+ expect(response.files['imageContent.reference']).toBeDefined();
227
+ const receivedFile = response.files['imageContent.reference'];
228
+ expect(receivedFile.content).toBe(originalContent);
229
+ expect(receivedFile.hash).toBe(originalHash);
230
+ // 5. Verify fields were sent correctly
231
+ expect(response.fields.fileName).toBe('test-image.png');
232
+ expect(response.fields.overrideIfPresent).toBe('true');
233
+ // eslint-disable-next-line no-console
234
+ console.log('✅ Nested FileDescriptor successfully sent and received with identical content!');
235
+ });
236
+ it('should handle multiple nested FileDescriptors at different levels', async () => {
237
+ // Create two test files
238
+ const content1 = 'First file content';
239
+ const content2 = 'Second file content';
240
+ const hash1 = crypto.createHash('sha256').update(content1).digest('hex');
241
+ const hash2 = crypto.createHash('sha256').update(content2).digest('hex');
242
+ const fileDescriptor1 = {
243
+ _tag: 'FileDescriptor',
244
+ key: 'file-1',
245
+ metadata: {
246
+ name: 'file1.txt',
247
+ size: content1.length,
248
+ contentType: 'text/plain',
249
+ },
250
+ };
251
+ const fileDescriptor2 = {
252
+ _tag: 'FileDescriptor',
253
+ key: 'file-2',
254
+ metadata: {
255
+ name: 'file2.txt',
256
+ size: content2.length,
257
+ contentType: 'text/plain',
258
+ },
259
+ };
260
+ // Write files to client storage
261
+ await clientStorage.write({ reference: fileDescriptor1, content: stream_1.Readable.from(content1) }, undefined)();
262
+ await clientStorage.write({ reference: fileDescriptor2, content: stream_1.Readable.from(content2) }, undefined)();
263
+ // Create body with multiple nested FileDescriptors
264
+ const bodyWithMultipleFiles = {
265
+ rootFile: fileDescriptor1,
266
+ nested: {
267
+ deepFile: {
268
+ reference: fileDescriptor2, // Nested level
269
+ },
270
+ },
271
+ description: 'Multiple files test',
272
+ };
273
+ const multipartResult = await Http_1.MultipartHttpRequestBody.create(bodyWithMultipleFiles, clientStorage)();
274
+ expect(multipartResult._tag).toBe('Right');
275
+ if (multipartResult._tag === 'Left') {
276
+ throw new Error(`Failed to create multipart body: ${multipartResult.left.message}`);
277
+ }
278
+ const multipartBody = multipartResult.right;
279
+ // Verify both files were detected
280
+ // Files are now keyed by their object paths, not storage keys
281
+ expect(Object.keys(multipartBody.files)).toHaveLength(2);
282
+ expect(multipartBody.files['rootFile']).toBeDefined();
283
+ expect(multipartBody.files['nested.deepFile.reference']).toBeDefined();
284
+ // Send to server
285
+ // eslint-disable-next-line global-require
286
+ const FormData = require('form-data');
287
+ const formData = new FormData();
288
+ for (const [key, value] of Object.entries(multipartBody.fields)) {
289
+ formData.append(key, value);
290
+ }
291
+ for (const [key, file] of Object.entries(multipartBody.files)) {
292
+ const chunks = [];
293
+ // eslint-disable-next-line no-await-in-loop
294
+ for await (const chunk of file.content) {
295
+ chunks.push(Buffer.from(chunk));
296
+ }
297
+ const buffer = Buffer.concat(chunks);
298
+ formData.append(key, buffer, {
299
+ filename: file.reference.metadata.name,
300
+ });
301
+ }
302
+ const response = await new Promise((resolve, reject) => {
303
+ formData.submit(`${serverUrl}/upload`, (err, res) => {
304
+ if (err) {
305
+ reject(err);
306
+ return;
307
+ }
308
+ let body = '';
309
+ res.on('data', (chunk) => {
310
+ body += chunk.toString();
311
+ });
312
+ res.on('end', () => {
313
+ try {
314
+ const parsed = JSON.parse(body);
315
+ if (res.statusCode === 200) {
316
+ resolve(parsed);
317
+ }
318
+ else {
319
+ reject(new Error(`Server error: ${parsed.error}`));
320
+ }
321
+ }
322
+ catch (e) {
323
+ reject(new Error(`Failed to parse response: ${body}`));
324
+ }
325
+ });
326
+ });
327
+ });
328
+ // Verify both files received correctly
329
+ // Files are now keyed by object path, not storage key
330
+ expect(response.files['rootFile'].content).toBe(content1);
331
+ expect(response.files['rootFile'].hash).toBe(hash1);
332
+ expect(response.files['nested.deepFile.reference'].content).toBe(content2);
333
+ expect(response.files['nested.deepFile.reference'].hash).toBe(hash2);
334
+ // eslint-disable-next-line no-console
335
+ console.log('✅ Multiple nested files successfully transferred!');
336
+ });
337
+ });
338
+ describe('Nested Field Structures', () => {
339
+ it('should properly flatten and unflatten nested field structures', async () => {
340
+ // Create a test file
341
+ const fileDescriptor = {
342
+ _tag: 'FileDescriptor',
343
+ key: 'test-doc',
344
+ metadata: { name: 'doc.txt', size: 100, contentType: 'text/plain' },
345
+ };
346
+ await clientStorage.write({
347
+ reference: fileDescriptor,
348
+ content: stream_1.Readable.from('document content'),
349
+ }, undefined)();
350
+ // Create request body with nested structures AND a file
351
+ const bodyWithNestedStructure = {
352
+ title: 'Test Document',
353
+ enabled: true,
354
+ metadata: {
355
+ author: 'John Doe',
356
+ version: '1.0',
357
+ tags: {
358
+ category: 'documentation',
359
+ priority: 'high',
360
+ },
361
+ },
362
+ document: fileDescriptor,
363
+ };
364
+ const multipartResult = await Http_1.MultipartHttpRequestBody.create(bodyWithNestedStructure, clientStorage)();
365
+ expect(multipartResult._tag).toBe('Right');
366
+ if (multipartResult._tag === 'Left') {
367
+ throw new Error(`Failed: ${multipartResult.left.message}`);
368
+ }
369
+ const multipartBody = multipartResult.right;
370
+ // Verify client-side flattening happened
371
+ expect(multipartBody.fields['metadata.author']).toBe('John Doe');
372
+ expect(multipartBody.fields['metadata.version']).toBe('1.0');
373
+ expect(multipartBody.fields['metadata.tags.category']).toBe('documentation');
374
+ expect(multipartBody.fields['metadata.tags.priority']).toBe('high');
375
+ // Send to server
376
+ // eslint-disable-next-line global-require
377
+ const FormData = require('form-data');
378
+ const formData = new FormData();
379
+ for (const [key, value] of Object.entries(multipartBody.fields)) {
380
+ formData.append(key, value);
381
+ }
382
+ for (const [key, file] of Object.entries(multipartBody.files)) {
383
+ const chunks = [];
384
+ // eslint-disable-next-line no-await-in-loop
385
+ for await (const chunk of file.content) {
386
+ chunks.push(Buffer.from(chunk));
387
+ }
388
+ const buffer = Buffer.concat(chunks);
389
+ formData.append(key, buffer, {
390
+ filename: file.reference.metadata.name,
391
+ });
392
+ }
393
+ const response = await new Promise((resolve, reject) => {
394
+ formData.submit(`${serverUrl}/upload`, (err, res) => {
395
+ if (err) {
396
+ reject(err);
397
+ return;
398
+ }
399
+ let body = '';
400
+ res.on('data', (chunk) => {
401
+ body += chunk.toString();
402
+ });
403
+ res.on('end', () => {
404
+ try {
405
+ const parsed = JSON.parse(body);
406
+ if (res.statusCode === 200) {
407
+ resolve(parsed);
408
+ }
409
+ else {
410
+ reject(new Error(`Server error: ${parsed.error}`));
411
+ }
412
+ }
413
+ catch (e) {
414
+ reject(new Error(`Parse error: ${body}`));
415
+ }
416
+ });
417
+ });
418
+ });
419
+ // Verify fields were sent correctly (flat dot-notation)
420
+ expect(response.fields.title).toBe('Test Document');
421
+ expect(response.fields.enabled).toBe('true');
422
+ // Key assertion: nested fields are flattened with dot notation
423
+ expect(response.fields['metadata.author']).toBe('John Doe');
424
+ expect(response.fields['metadata.version']).toBe('1.0');
425
+ expect(response.fields['metadata.tags.category']).toBe('documentation');
426
+ expect(response.fields['metadata.tags.priority']).toBe('high');
427
+ // eslint-disable-next-line no-console
428
+ console.log('✅ Nested field structure properly flattened with dot notation!');
429
+ });
430
+ it('should handle Library Service uploadAssetImage scenario', async () => {
431
+ // Simulates Library Service uploadAssetImage with additional metadata
432
+ const fileDescriptor = {
433
+ _tag: 'FileDescriptor',
434
+ key: 'asset-123',
435
+ metadata: { name: 'logo.png', size: 2048, contentType: 'image/png' },
436
+ };
437
+ await clientStorage.write({
438
+ reference: fileDescriptor,
439
+ content: stream_1.Readable.from('image data'),
440
+ }, undefined)();
441
+ const bodyLikeLibraryService = {
442
+ fileName: 'logo.png',
443
+ overrideIfPresent: true,
444
+ uploadMetadata: {
445
+ source: 'admin-panel',
446
+ uploadedBy: {
447
+ userId: '123',
448
+ userName: 'admin',
449
+ },
450
+ timestamp: '2026-04-27T00:00:00Z',
451
+ },
452
+ imageContent: fileDescriptor,
453
+ };
454
+ const multipartResult = await Http_1.MultipartHttpRequestBody.create(bodyLikeLibraryService, clientStorage)();
455
+ expect(multipartResult._tag).toBe('Right');
456
+ if (multipartResult._tag === 'Left') {
457
+ throw new Error(`Failed: ${multipartResult.left.message}`);
458
+ }
459
+ const multipartBody = multipartResult.right;
460
+ // Verify flattening
461
+ expect(multipartBody.fields['uploadMetadata.source']).toBe('admin-panel');
462
+ expect(multipartBody.fields['uploadMetadata.uploadedBy.userId']).toBe('123');
463
+ expect(multipartBody.fields['uploadMetadata.uploadedBy.userName']).toBe('admin');
464
+ // Send to server
465
+ // eslint-disable-next-line global-require
466
+ const FormData = require('form-data');
467
+ const formData = new FormData();
468
+ for (const [key, value] of Object.entries(multipartBody.fields)) {
469
+ formData.append(key, value);
470
+ }
471
+ for (const [key, file] of Object.entries(multipartBody.files)) {
472
+ const chunks = [];
473
+ // eslint-disable-next-line no-await-in-loop
474
+ for await (const chunk of file.content) {
475
+ chunks.push(Buffer.from(chunk));
476
+ }
477
+ const buffer = Buffer.concat(chunks);
478
+ formData.append(key, buffer, {
479
+ filename: file.reference.metadata.name,
480
+ });
481
+ }
482
+ const response = await new Promise((resolve, reject) => {
483
+ formData.submit(`${serverUrl}/upload`, (err, res) => {
484
+ if (err) {
485
+ reject(err);
486
+ return;
487
+ }
488
+ let body = '';
489
+ res.on('data', (chunk) => {
490
+ body += chunk.toString();
491
+ });
492
+ res.on('end', () => {
493
+ try {
494
+ const parsed = JSON.parse(body);
495
+ if (res.statusCode === 200) {
496
+ resolve(parsed);
497
+ }
498
+ else {
499
+ reject(new Error(`Server error: ${parsed.error}`));
500
+ }
501
+ }
502
+ catch (e) {
503
+ reject(new Error(`Parse error: ${body}`));
504
+ }
505
+ });
506
+ });
507
+ });
508
+ // Verify fields were sent correctly (flat dot-notation)
509
+ expect(response.fields.fileName).toBe('logo.png');
510
+ expect(response.fields.overrideIfPresent).toBe('true');
511
+ // Nested metadata flattened with dot notation
512
+ expect(response.fields['uploadMetadata.source']).toBe('admin-panel');
513
+ expect(response.fields['uploadMetadata.uploadedBy.userId']).toBe('123');
514
+ expect(response.fields['uploadMetadata.uploadedBy.userName']).toBe('admin');
515
+ expect(response.fields['uploadMetadata.timestamp']).toBe('2026-04-27T00:00:00Z');
516
+ // eslint-disable-next-line no-console
517
+ console.log('✅ Library Service-like nested structure flattened correctly!');
518
+ });
519
+ });
520
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=MultipartHttpRequestBody.unit.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"MultipartHttpRequestBody.unit.test.d.ts","sourceRoot":"","sources":["../../src/http/MultipartHttpRequestBody.unit.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,426 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || function (mod) {
19
+ if (mod && mod.__esModule) return mod;
20
+ var result = {};
21
+ if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
22
+ __setModuleDefault(result, mod);
23
+ return result;
24
+ };
25
+ Object.defineProperty(exports, "__esModule", { value: true });
26
+ const stream_1 = require("stream");
27
+ const NodeFsFileStorage_1 = require("../file/NodeFsFileStorage");
28
+ const Http_1 = require("./Http");
29
+ /**
30
+ * Unit tests for MultipartHttpRequestBody.create() function.
31
+ * These tests verify the client-side flattening logic without HTTP roundtrips.
32
+ */
33
+ describe('MultipartHttpRequestBody.create', () => {
34
+ let clientStorage;
35
+ let tmpDir;
36
+ beforeAll(async () => {
37
+ tmpDir = `/tmp/multipart-unit-test-${Date.now()}`;
38
+ const fs = await Promise.resolve().then(() => __importStar(require('fs/promises')));
39
+ await fs.mkdir(tmpDir, { recursive: true });
40
+ clientStorage = new NodeFsFileStorage_1.NodeFsFileStorage(tmpDir);
41
+ });
42
+ afterAll(async () => {
43
+ const fs = await Promise.resolve().then(() => __importStar(require('fs/promises')));
44
+ try {
45
+ await fs.rm(tmpDir, { recursive: true, force: true });
46
+ }
47
+ catch (error) {
48
+ // Ignore cleanup errors
49
+ }
50
+ });
51
+ describe('Nested FileDescriptor Detection', () => {
52
+ it('should detect FileDescriptor at root level', async () => {
53
+ const fileDescriptor = {
54
+ _tag: 'FileDescriptor',
55
+ key: 'root-file',
56
+ metadata: { name: 'file.txt', size: 100, contentType: 'text/plain' },
57
+ };
58
+ await clientStorage.write({ reference: fileDescriptor, content: stream_1.Readable.from('content') }, undefined)();
59
+ const result = await Http_1.MultipartHttpRequestBody.create({ file: fileDescriptor }, clientStorage)();
60
+ expect(result._tag).toBe('Right');
61
+ if (result._tag === 'Right') {
62
+ // Files are now keyed by object path, not storage key
63
+ expect(Object.keys(result.right.files)).toContain('file');
64
+ }
65
+ });
66
+ it('should detect FileDescriptor nested one level deep', async () => {
67
+ const fileDescriptor = {
68
+ _tag: 'FileDescriptor',
69
+ key: 'nested-file',
70
+ metadata: { name: 'file.txt', size: 100, contentType: 'text/plain' },
71
+ };
72
+ await clientStorage.write({ reference: fileDescriptor, content: stream_1.Readable.from('content') }, undefined)();
73
+ const result = await Http_1.MultipartHttpRequestBody.create({
74
+ data: {
75
+ file: fileDescriptor,
76
+ },
77
+ }, clientStorage)();
78
+ expect(result._tag).toBe('Right');
79
+ if (result._tag === 'Right') {
80
+ // Files are now keyed by object path, not storage key
81
+ expect(Object.keys(result.right.files)).toContain('data.file');
82
+ }
83
+ });
84
+ it('should detect FileDescriptor nested multiple levels deep', async () => {
85
+ const fileDescriptor = {
86
+ _tag: 'FileDescriptor',
87
+ key: 'deep-file',
88
+ metadata: { name: 'file.txt', size: 100, contentType: 'text/plain' },
89
+ };
90
+ await clientStorage.write({ reference: fileDescriptor, content: stream_1.Readable.from('content') }, undefined)();
91
+ const result = await Http_1.MultipartHttpRequestBody.create({
92
+ level1: {
93
+ level2: {
94
+ level3: {
95
+ file: fileDescriptor,
96
+ },
97
+ },
98
+ },
99
+ }, clientStorage)();
100
+ expect(result._tag).toBe('Right');
101
+ if (result._tag === 'Right') {
102
+ // Files are now keyed by object path, not storage key
103
+ expect(Object.keys(result.right.files)).toContain('level1.level2.level3.file');
104
+ }
105
+ });
106
+ it('should detect multiple FileDescriptors at different levels', async () => {
107
+ const file1 = {
108
+ _tag: 'FileDescriptor',
109
+ key: 'file-1',
110
+ metadata: { name: 'file1.txt', size: 100, contentType: 'text/plain' },
111
+ };
112
+ const file2 = {
113
+ _tag: 'FileDescriptor',
114
+ key: 'file-2',
115
+ metadata: { name: 'file2.txt', size: 100, contentType: 'text/plain' },
116
+ };
117
+ await clientStorage.write({ reference: file1, content: stream_1.Readable.from('content1') }, undefined)();
118
+ await clientStorage.write({ reference: file2, content: stream_1.Readable.from('content2') }, undefined)();
119
+ const result = await Http_1.MultipartHttpRequestBody.create({
120
+ rootFile: file1,
121
+ nested: {
122
+ deepFile: file2,
123
+ },
124
+ }, clientStorage)();
125
+ expect(result._tag).toBe('Right');
126
+ if (result._tag === 'Right') {
127
+ expect(Object.keys(result.right.files)).toHaveLength(2);
128
+ // Files are now keyed by object path, not storage key
129
+ expect(result.right.files['rootFile']).toBeDefined();
130
+ expect(result.right.files['nested.deepFile']).toBeDefined();
131
+ }
132
+ });
133
+ });
134
+ describe('Field Flattening', () => {
135
+ it('should flatten nested fields with dot notation', async () => {
136
+ const result = await Http_1.MultipartHttpRequestBody.create({
137
+ title: 'Test',
138
+ metadata: {
139
+ author: 'John',
140
+ version: '1.0',
141
+ },
142
+ }, clientStorage)();
143
+ expect(result._tag).toBe('Right');
144
+ if (result._tag === 'Right') {
145
+ expect(result.right.fields.title).toBe('Test');
146
+ expect(result.right.fields['metadata.author']).toBe('John');
147
+ expect(result.right.fields['metadata.version']).toBe('1.0');
148
+ }
149
+ });
150
+ it('should flatten deeply nested fields', async () => {
151
+ const result = await Http_1.MultipartHttpRequestBody.create({
152
+ level1: {
153
+ level2: {
154
+ level3: {
155
+ value: 'deep',
156
+ },
157
+ },
158
+ },
159
+ }, clientStorage)();
160
+ expect(result._tag).toBe('Right');
161
+ if (result._tag === 'Right') {
162
+ expect(result.right.fields['level1.level2.level3.value']).toBe('deep');
163
+ }
164
+ });
165
+ it('should handle primitive values at root level', async () => {
166
+ const result = await Http_1.MultipartHttpRequestBody.create({
167
+ string: 'text',
168
+ number: 123,
169
+ boolean: true,
170
+ null: null,
171
+ }, clientStorage)();
172
+ expect(result._tag).toBe('Right');
173
+ if (result._tag === 'Right') {
174
+ expect(result.right.fields.string).toBe('text');
175
+ expect(result.right.fields.number).toBe('123');
176
+ expect(result.right.fields.boolean).toBe('true');
177
+ expect(result.right.fields.null).toBe('null');
178
+ }
179
+ });
180
+ });
181
+ describe('Array Support', () => {
182
+ it('should serialize arrays as comma-separated strings', async () => {
183
+ const result = await Http_1.MultipartHttpRequestBody.create({
184
+ title: 'Test',
185
+ tags: ['tag1', 'tag2', 'tag3'],
186
+ nested: {
187
+ items: ['a', 'b', 'c'],
188
+ },
189
+ }, clientStorage)();
190
+ expect(result._tag).toBe('Right');
191
+ if (result._tag === 'Right') {
192
+ expect(result.right.fields.title).toBe('Test');
193
+ expect(result.right.fields.tags).toBe('tag1,tag2,tag3');
194
+ expect(result.right.fields['nested.items']).toBe('a,b,c');
195
+ }
196
+ });
197
+ it('should handle empty arrays', async () => {
198
+ const result = await Http_1.MultipartHttpRequestBody.create({
199
+ title: 'Test',
200
+ tags: [],
201
+ }, clientStorage)();
202
+ expect(result._tag).toBe('Right');
203
+ if (result._tag === 'Right') {
204
+ expect(result.right.fields.title).toBe('Test');
205
+ expect(result.right.fields.tags).toBe(''); // Empty array becomes empty string
206
+ }
207
+ });
208
+ it('should handle arrays with mixed types', async () => {
209
+ const result = await Http_1.MultipartHttpRequestBody.create({
210
+ values: [1, 'text', true, null],
211
+ }, clientStorage)();
212
+ expect(result._tag).toBe('Right');
213
+ if (result._tag === 'Right') {
214
+ // All values converted to strings and joined
215
+ expect(result.right.fields.values).toBe('1,text,true,null');
216
+ }
217
+ });
218
+ });
219
+ describe('Metadata Preservation', () => {
220
+ it('should preserve contentType from original descriptor', async () => {
221
+ const fileDescriptor = {
222
+ _tag: 'FileDescriptor',
223
+ key: 'test-file',
224
+ metadata: {
225
+ name: 'image.png',
226
+ size: 1024,
227
+ contentType: 'image/png',
228
+ },
229
+ };
230
+ await clientStorage.write({ reference: fileDescriptor, content: stream_1.Readable.from('data') }, undefined)();
231
+ const result = await Http_1.MultipartHttpRequestBody.create({ file: fileDescriptor }, clientStorage)();
232
+ expect(result._tag).toBe('Right');
233
+ if (result._tag === 'Right') {
234
+ // Files are now keyed by object path, not storage key
235
+ const { file } = result.right.files;
236
+ expect(file.reference.metadata.contentType).toBe('image/png');
237
+ expect(file.reference.metadata.name).toBe('image.png');
238
+ }
239
+ });
240
+ });
241
+ describe('Mixed Structures', () => {
242
+ it('should handle body with both fields and FileDescriptors', async () => {
243
+ const fileDescriptor = {
244
+ _tag: 'FileDescriptor',
245
+ key: 'doc',
246
+ metadata: {
247
+ name: 'doc.pdf',
248
+ size: 2048,
249
+ contentType: 'application/pdf',
250
+ },
251
+ };
252
+ await clientStorage.write({ reference: fileDescriptor, content: stream_1.Readable.from('pdf data') }, undefined)();
253
+ const result = await Http_1.MultipartHttpRequestBody.create({
254
+ title: 'Document',
255
+ category: 'report',
256
+ metadata: {
257
+ author: 'Alice',
258
+ tags: ['important', 'confidential'],
259
+ },
260
+ document: fileDescriptor,
261
+ }, clientStorage)();
262
+ expect(result._tag).toBe('Right');
263
+ if (result._tag === 'Right') {
264
+ // Check fields
265
+ expect(result.right.fields.title).toBe('Document');
266
+ expect(result.right.fields.category).toBe('report');
267
+ expect(result.right.fields['metadata.author']).toBe('Alice');
268
+ expect(result.right.fields['metadata.tags']).toBe('important,confidential');
269
+ // Check file - Files are now keyed by object path, not storage key
270
+ expect(result.right.files.document).toBeDefined();
271
+ expect(result.right.files.document.reference.metadata.contentType).toBe('application/pdf');
272
+ }
273
+ });
274
+ });
275
+ describe('File Field Naming with Object Paths', () => {
276
+ it('should use object path (not file key) as the form field name for root-level files', async () => {
277
+ const fileDescriptor = {
278
+ _tag: 'FileDescriptor',
279
+ key: 'some-storage-key-123',
280
+ metadata: {
281
+ name: 'document.pdf',
282
+ size: 1024,
283
+ contentType: 'application/pdf',
284
+ },
285
+ };
286
+ await clientStorage.write({ reference: fileDescriptor, content: stream_1.Readable.from('file content') }, undefined)();
287
+ const result = await Http_1.MultipartHttpRequestBody.create({
288
+ file: fileDescriptor,
289
+ }, clientStorage)();
290
+ expect(result._tag).toBe('Right');
291
+ if (result._tag === 'Right') {
292
+ // The file should be keyed by the object path 'file', not the storage key
293
+ expect(Object.keys(result.right.files)).toContain('file');
294
+ expect(result.right.files['file']).toBeDefined();
295
+ }
296
+ });
297
+ it('should use nested object path as form field name for nested files', async () => {
298
+ const fileDescriptor = {
299
+ _tag: 'FileDescriptor',
300
+ key: 'storage-key-456',
301
+ metadata: {
302
+ name: 'attachment.png',
303
+ size: 2048,
304
+ contentType: 'image/png',
305
+ },
306
+ };
307
+ await clientStorage.write({ reference: fileDescriptor, content: stream_1.Readable.from('image data') }, undefined)();
308
+ const result = await Http_1.MultipartHttpRequestBody.create({
309
+ data: {
310
+ attachment: fileDescriptor,
311
+ },
312
+ }, clientStorage)();
313
+ expect(result._tag).toBe('Right');
314
+ if (result._tag === 'Right') {
315
+ // The file should be keyed by 'data.attachment', not the storage key
316
+ expect(Object.keys(result.right.files)).toContain('data.attachment');
317
+ expect(result.right.files['data.attachment']).toBeDefined();
318
+ }
319
+ });
320
+ it('should use deeply nested object path as form field name', async () => {
321
+ const fileDescriptor = {
322
+ _tag: 'FileDescriptor',
323
+ key: 'deep-storage-key',
324
+ metadata: {
325
+ name: 'report.pdf',
326
+ size: 5000,
327
+ contentType: 'application/pdf',
328
+ },
329
+ };
330
+ await clientStorage.write({ reference: fileDescriptor, content: stream_1.Readable.from('report content') }, undefined)();
331
+ const result = await Http_1.MultipartHttpRequestBody.create({
332
+ project: {
333
+ documents: {
334
+ reports: {
335
+ annual: fileDescriptor,
336
+ },
337
+ },
338
+ },
339
+ }, clientStorage)();
340
+ expect(result._tag).toBe('Right');
341
+ if (result._tag === 'Right') {
342
+ // The file should be keyed by the full object path
343
+ expect(Object.keys(result.right.files)).toContain('project.documents.reports.annual');
344
+ expect(result.right.files['project.documents.reports.annual']).toBeDefined();
345
+ }
346
+ });
347
+ it('should use correct object paths for multiple files at different levels', async () => {
348
+ const file1 = {
349
+ _tag: 'FileDescriptor',
350
+ key: 'key-1',
351
+ metadata: {
352
+ name: 'file1.txt',
353
+ size: 100,
354
+ contentType: 'text/plain',
355
+ },
356
+ };
357
+ const file2 = {
358
+ _tag: 'FileDescriptor',
359
+ key: 'key-2',
360
+ metadata: {
361
+ name: 'file2.txt',
362
+ size: 200,
363
+ contentType: 'text/plain',
364
+ },
365
+ };
366
+ const file3 = {
367
+ _tag: 'FileDescriptor',
368
+ key: 'key-3',
369
+ metadata: {
370
+ name: 'file3.txt',
371
+ size: 300,
372
+ contentType: 'text/plain',
373
+ },
374
+ };
375
+ await clientStorage.write({ reference: file1, content: stream_1.Readable.from('content1') }, undefined)();
376
+ await clientStorage.write({ reference: file2, content: stream_1.Readable.from('content2') }, undefined)();
377
+ await clientStorage.write({ reference: file3, content: stream_1.Readable.from('content3') }, undefined)();
378
+ const result = await Http_1.MultipartHttpRequestBody.create({
379
+ avatar: file1,
380
+ data: {
381
+ document: file2,
382
+ nested: {
383
+ attachment: file3,
384
+ },
385
+ },
386
+ }, clientStorage)();
387
+ expect(result._tag).toBe('Right');
388
+ if (result._tag === 'Right') {
389
+ // Each file should be keyed by its object path, not its storage key
390
+ expect(Object.keys(result.right.files)).toHaveLength(3);
391
+ expect(result.right.files['avatar']).toBeDefined();
392
+ expect(result.right.files['data.document']).toBeDefined();
393
+ expect(result.right.files['data.nested.attachment']).toBeDefined();
394
+ // Verify storage keys are NOT used as field names
395
+ expect(result.right.files['key-1']).toBeUndefined();
396
+ expect(result.right.files['key-2']).toBeUndefined();
397
+ expect(result.right.files['key-3']).toBeUndefined();
398
+ }
399
+ });
400
+ it('should preserve metadata when using object path as field name', async () => {
401
+ const fileDescriptor = {
402
+ _tag: 'FileDescriptor',
403
+ key: 'some-key',
404
+ metadata: {
405
+ name: 'important.pdf',
406
+ size: 999,
407
+ contentType: 'application/pdf',
408
+ },
409
+ };
410
+ await clientStorage.write({ reference: fileDescriptor, content: stream_1.Readable.from('pdf data') }, undefined)();
411
+ const result = await Http_1.MultipartHttpRequestBody.create({
412
+ nested: {
413
+ file: fileDescriptor,
414
+ },
415
+ }, clientStorage)();
416
+ expect(result._tag).toBe('Right');
417
+ if (result._tag === 'Right') {
418
+ const file = result.right.files['nested.file'];
419
+ expect(file).toBeDefined();
420
+ expect(file.reference.metadata.name).toBe('important.pdf');
421
+ expect(file.reference.metadata.size).toBe(999);
422
+ expect(file.reference.metadata.contentType).toBe('application/pdf');
423
+ }
424
+ });
425
+ });
426
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trayio/commons",
3
- "version": "5.19.0",
3
+ "version": "5.21.0",
4
4
  "description": "Extensions to the standard/core libraries and basic features",
5
5
  "exports": {
6
6
  "./*": "./dist/*.js"