@trayio/commons 5.18.0 → 5.20.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.
- package/dist/http/Http.d.ts.map +1 -1
- package/dist/http/Http.js +61 -12
- package/dist/http/MultipartHttpRequestBody.integration.test.d.ts +2 -0
- package/dist/http/MultipartHttpRequestBody.integration.test.d.ts.map +1 -0
- package/dist/http/MultipartHttpRequestBody.integration.test.js +516 -0
- package/dist/http/MultipartHttpRequestBody.unit.test.d.ts +2 -0
- package/dist/http/MultipartHttpRequestBody.unit.test.d.ts.map +1 -0
- package/dist/http/MultipartHttpRequestBody.unit.test.js +270 -0
- package/package.json +1 -1
package/dist/http/Http.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
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;
|
|
1
|
+
{"version":3,"file":"Http.d.ts","sourceRoot":"","sources":["../../src/http/Http.ts"],"names":[],"mappings":"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"}
|
package/dist/http/Http.js
CHANGED
|
@@ -43,20 +43,69 @@ 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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
+
key: file.reference.key,
|
|
97
|
+
file: {
|
|
98
|
+
...file,
|
|
99
|
+
reference: {
|
|
100
|
+
...file.reference,
|
|
101
|
+
// Preserve metadata from original descriptor (especially contentType)
|
|
102
|
+
metadata: {
|
|
103
|
+
...file.reference.metadata,
|
|
104
|
+
...descriptor.metadata,
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
}))))), TE.map((files) => {
|
|
60
109
|
const filesRecord = {};
|
|
61
110
|
files.forEach(({ key, file }) => {
|
|
62
111
|
filesRecord[key] = file;
|
|
@@ -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,516 @@
|
|
|
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
|
+
expect(Object.keys(multipartBody.files)).toContain('test-image-key');
|
|
172
|
+
expect(multipartBody.fields).toHaveProperty('fileName', 'test-image.png');
|
|
173
|
+
expect(multipartBody.fields).toHaveProperty('overrideIfPresent', 'true');
|
|
174
|
+
// 3. Send HTTP POST with multipart data
|
|
175
|
+
// eslint-disable-next-line global-require
|
|
176
|
+
const FormData = require('form-data');
|
|
177
|
+
const formData = new FormData();
|
|
178
|
+
// Add fields
|
|
179
|
+
for (const [key, value] of Object.entries(multipartBody.fields)) {
|
|
180
|
+
formData.append(key, value);
|
|
181
|
+
}
|
|
182
|
+
// Add files
|
|
183
|
+
for (const [key, file] of Object.entries(multipartBody.files)) {
|
|
184
|
+
const chunks = [];
|
|
185
|
+
// eslint-disable-next-line no-await-in-loop
|
|
186
|
+
for await (const chunk of file.content) {
|
|
187
|
+
chunks.push(Buffer.from(chunk));
|
|
188
|
+
}
|
|
189
|
+
const buffer = Buffer.concat(chunks);
|
|
190
|
+
formData.append(key, buffer, {
|
|
191
|
+
filename: file.reference.metadata.name,
|
|
192
|
+
contentType: file.reference.metadata.contentType,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
const response = await new Promise((resolve, reject) => {
|
|
196
|
+
formData.submit(`${serverUrl}/upload`, (err, res) => {
|
|
197
|
+
if (err) {
|
|
198
|
+
reject(err);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
let body = '';
|
|
202
|
+
res.on('data', (chunk) => {
|
|
203
|
+
body += chunk.toString();
|
|
204
|
+
});
|
|
205
|
+
res.on('end', () => {
|
|
206
|
+
try {
|
|
207
|
+
const parsed = JSON.parse(body);
|
|
208
|
+
if (res.statusCode === 200) {
|
|
209
|
+
resolve(parsed);
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
reject(new Error(`Server error: ${parsed.error}`));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
catch (e) {
|
|
216
|
+
reject(new Error(`Failed to parse response: ${body}`));
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
res.on('error', reject);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
// 4. Verify server received the file correctly
|
|
223
|
+
expect(response.files).toBeDefined();
|
|
224
|
+
expect(response.files['test-image-key']).toBeDefined();
|
|
225
|
+
const receivedFile = response.files['test-image-key'];
|
|
226
|
+
expect(receivedFile.content).toBe(originalContent);
|
|
227
|
+
expect(receivedFile.hash).toBe(originalHash);
|
|
228
|
+
// 5. Verify fields were sent correctly
|
|
229
|
+
expect(response.fields.fileName).toBe('test-image.png');
|
|
230
|
+
expect(response.fields.overrideIfPresent).toBe('true');
|
|
231
|
+
// eslint-disable-next-line no-console
|
|
232
|
+
console.log('✅ Nested FileDescriptor successfully sent and received with identical content!');
|
|
233
|
+
});
|
|
234
|
+
it('should handle multiple nested FileDescriptors at different levels', async () => {
|
|
235
|
+
// Create two test files
|
|
236
|
+
const content1 = 'First file content';
|
|
237
|
+
const content2 = 'Second file content';
|
|
238
|
+
const hash1 = crypto.createHash('sha256').update(content1).digest('hex');
|
|
239
|
+
const hash2 = crypto.createHash('sha256').update(content2).digest('hex');
|
|
240
|
+
const fileDescriptor1 = {
|
|
241
|
+
_tag: 'FileDescriptor',
|
|
242
|
+
key: 'file-1',
|
|
243
|
+
metadata: {
|
|
244
|
+
name: 'file1.txt',
|
|
245
|
+
size: content1.length,
|
|
246
|
+
contentType: 'text/plain',
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
const fileDescriptor2 = {
|
|
250
|
+
_tag: 'FileDescriptor',
|
|
251
|
+
key: 'file-2',
|
|
252
|
+
metadata: {
|
|
253
|
+
name: 'file2.txt',
|
|
254
|
+
size: content2.length,
|
|
255
|
+
contentType: 'text/plain',
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
// Write files to client storage
|
|
259
|
+
await clientStorage.write({ reference: fileDescriptor1, content: stream_1.Readable.from(content1) }, undefined)();
|
|
260
|
+
await clientStorage.write({ reference: fileDescriptor2, content: stream_1.Readable.from(content2) }, undefined)();
|
|
261
|
+
// Create body with multiple nested FileDescriptors
|
|
262
|
+
const bodyWithMultipleFiles = {
|
|
263
|
+
rootFile: fileDescriptor1,
|
|
264
|
+
nested: {
|
|
265
|
+
deepFile: {
|
|
266
|
+
reference: fileDescriptor2, // Nested level
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
description: 'Multiple files test',
|
|
270
|
+
};
|
|
271
|
+
const multipartResult = await Http_1.MultipartHttpRequestBody.create(bodyWithMultipleFiles, clientStorage)();
|
|
272
|
+
expect(multipartResult._tag).toBe('Right');
|
|
273
|
+
if (multipartResult._tag === 'Left') {
|
|
274
|
+
throw new Error(`Failed to create multipart body: ${multipartResult.left.message}`);
|
|
275
|
+
}
|
|
276
|
+
const multipartBody = multipartResult.right;
|
|
277
|
+
// Verify both files were detected
|
|
278
|
+
expect(Object.keys(multipartBody.files)).toHaveLength(2);
|
|
279
|
+
expect(multipartBody.files['file-1']).toBeDefined();
|
|
280
|
+
expect(multipartBody.files['file-2']).toBeDefined();
|
|
281
|
+
// Send to server
|
|
282
|
+
// eslint-disable-next-line global-require
|
|
283
|
+
const FormData = require('form-data');
|
|
284
|
+
const formData = new FormData();
|
|
285
|
+
for (const [key, value] of Object.entries(multipartBody.fields)) {
|
|
286
|
+
formData.append(key, value);
|
|
287
|
+
}
|
|
288
|
+
for (const [key, file] of Object.entries(multipartBody.files)) {
|
|
289
|
+
const chunks = [];
|
|
290
|
+
// eslint-disable-next-line no-await-in-loop
|
|
291
|
+
for await (const chunk of file.content) {
|
|
292
|
+
chunks.push(Buffer.from(chunk));
|
|
293
|
+
}
|
|
294
|
+
const buffer = Buffer.concat(chunks);
|
|
295
|
+
formData.append(key, buffer, {
|
|
296
|
+
filename: file.reference.metadata.name,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
const response = await new Promise((resolve, reject) => {
|
|
300
|
+
formData.submit(`${serverUrl}/upload`, (err, res) => {
|
|
301
|
+
if (err) {
|
|
302
|
+
reject(err);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
let body = '';
|
|
306
|
+
res.on('data', (chunk) => {
|
|
307
|
+
body += chunk.toString();
|
|
308
|
+
});
|
|
309
|
+
res.on('end', () => {
|
|
310
|
+
try {
|
|
311
|
+
const parsed = JSON.parse(body);
|
|
312
|
+
if (res.statusCode === 200) {
|
|
313
|
+
resolve(parsed);
|
|
314
|
+
}
|
|
315
|
+
else {
|
|
316
|
+
reject(new Error(`Server error: ${parsed.error}`));
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
catch (e) {
|
|
320
|
+
reject(new Error(`Failed to parse response: ${body}`));
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
// Verify both files received correctly
|
|
326
|
+
expect(response.files['file-1'].content).toBe(content1);
|
|
327
|
+
expect(response.files['file-1'].hash).toBe(hash1);
|
|
328
|
+
expect(response.files['file-2'].content).toBe(content2);
|
|
329
|
+
expect(response.files['file-2'].hash).toBe(hash2);
|
|
330
|
+
// eslint-disable-next-line no-console
|
|
331
|
+
console.log('✅ Multiple nested files successfully transferred!');
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
describe('Nested Field Structures', () => {
|
|
335
|
+
it('should properly flatten and unflatten nested field structures', async () => {
|
|
336
|
+
// Create a test file
|
|
337
|
+
const fileDescriptor = {
|
|
338
|
+
_tag: 'FileDescriptor',
|
|
339
|
+
key: 'test-doc',
|
|
340
|
+
metadata: { name: 'doc.txt', size: 100, contentType: 'text/plain' },
|
|
341
|
+
};
|
|
342
|
+
await clientStorage.write({
|
|
343
|
+
reference: fileDescriptor,
|
|
344
|
+
content: stream_1.Readable.from('document content'),
|
|
345
|
+
}, undefined)();
|
|
346
|
+
// Create request body with nested structures AND a file
|
|
347
|
+
const bodyWithNestedStructure = {
|
|
348
|
+
title: 'Test Document',
|
|
349
|
+
enabled: true,
|
|
350
|
+
metadata: {
|
|
351
|
+
author: 'John Doe',
|
|
352
|
+
version: '1.0',
|
|
353
|
+
tags: {
|
|
354
|
+
category: 'documentation',
|
|
355
|
+
priority: 'high',
|
|
356
|
+
},
|
|
357
|
+
},
|
|
358
|
+
document: fileDescriptor,
|
|
359
|
+
};
|
|
360
|
+
const multipartResult = await Http_1.MultipartHttpRequestBody.create(bodyWithNestedStructure, clientStorage)();
|
|
361
|
+
expect(multipartResult._tag).toBe('Right');
|
|
362
|
+
if (multipartResult._tag === 'Left') {
|
|
363
|
+
throw new Error(`Failed: ${multipartResult.left.message}`);
|
|
364
|
+
}
|
|
365
|
+
const multipartBody = multipartResult.right;
|
|
366
|
+
// Verify client-side flattening happened
|
|
367
|
+
expect(multipartBody.fields['metadata.author']).toBe('John Doe');
|
|
368
|
+
expect(multipartBody.fields['metadata.version']).toBe('1.0');
|
|
369
|
+
expect(multipartBody.fields['metadata.tags.category']).toBe('documentation');
|
|
370
|
+
expect(multipartBody.fields['metadata.tags.priority']).toBe('high');
|
|
371
|
+
// Send to server
|
|
372
|
+
// eslint-disable-next-line global-require
|
|
373
|
+
const FormData = require('form-data');
|
|
374
|
+
const formData = new FormData();
|
|
375
|
+
for (const [key, value] of Object.entries(multipartBody.fields)) {
|
|
376
|
+
formData.append(key, value);
|
|
377
|
+
}
|
|
378
|
+
for (const [key, file] of Object.entries(multipartBody.files)) {
|
|
379
|
+
const chunks = [];
|
|
380
|
+
// eslint-disable-next-line no-await-in-loop
|
|
381
|
+
for await (const chunk of file.content) {
|
|
382
|
+
chunks.push(Buffer.from(chunk));
|
|
383
|
+
}
|
|
384
|
+
const buffer = Buffer.concat(chunks);
|
|
385
|
+
formData.append(key, buffer, {
|
|
386
|
+
filename: file.reference.metadata.name,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
const response = await new Promise((resolve, reject) => {
|
|
390
|
+
formData.submit(`${serverUrl}/upload`, (err, res) => {
|
|
391
|
+
if (err) {
|
|
392
|
+
reject(err);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
let body = '';
|
|
396
|
+
res.on('data', (chunk) => {
|
|
397
|
+
body += chunk.toString();
|
|
398
|
+
});
|
|
399
|
+
res.on('end', () => {
|
|
400
|
+
try {
|
|
401
|
+
const parsed = JSON.parse(body);
|
|
402
|
+
if (res.statusCode === 200) {
|
|
403
|
+
resolve(parsed);
|
|
404
|
+
}
|
|
405
|
+
else {
|
|
406
|
+
reject(new Error(`Server error: ${parsed.error}`));
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
catch (e) {
|
|
410
|
+
reject(new Error(`Parse error: ${body}`));
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
// Verify fields were sent correctly (flat dot-notation)
|
|
416
|
+
expect(response.fields.title).toBe('Test Document');
|
|
417
|
+
expect(response.fields.enabled).toBe('true');
|
|
418
|
+
// Key assertion: nested fields are flattened with dot notation
|
|
419
|
+
expect(response.fields['metadata.author']).toBe('John Doe');
|
|
420
|
+
expect(response.fields['metadata.version']).toBe('1.0');
|
|
421
|
+
expect(response.fields['metadata.tags.category']).toBe('documentation');
|
|
422
|
+
expect(response.fields['metadata.tags.priority']).toBe('high');
|
|
423
|
+
// eslint-disable-next-line no-console
|
|
424
|
+
console.log('✅ Nested field structure properly flattened with dot notation!');
|
|
425
|
+
});
|
|
426
|
+
it('should handle Library Service uploadAssetImage scenario', async () => {
|
|
427
|
+
// Simulates Library Service uploadAssetImage with additional metadata
|
|
428
|
+
const fileDescriptor = {
|
|
429
|
+
_tag: 'FileDescriptor',
|
|
430
|
+
key: 'asset-123',
|
|
431
|
+
metadata: { name: 'logo.png', size: 2048, contentType: 'image/png' },
|
|
432
|
+
};
|
|
433
|
+
await clientStorage.write({
|
|
434
|
+
reference: fileDescriptor,
|
|
435
|
+
content: stream_1.Readable.from('image data'),
|
|
436
|
+
}, undefined)();
|
|
437
|
+
const bodyLikeLibraryService = {
|
|
438
|
+
fileName: 'logo.png',
|
|
439
|
+
overrideIfPresent: true,
|
|
440
|
+
uploadMetadata: {
|
|
441
|
+
source: 'admin-panel',
|
|
442
|
+
uploadedBy: {
|
|
443
|
+
userId: '123',
|
|
444
|
+
userName: 'admin',
|
|
445
|
+
},
|
|
446
|
+
timestamp: '2026-04-27T00:00:00Z',
|
|
447
|
+
},
|
|
448
|
+
imageContent: fileDescriptor,
|
|
449
|
+
};
|
|
450
|
+
const multipartResult = await Http_1.MultipartHttpRequestBody.create(bodyLikeLibraryService, clientStorage)();
|
|
451
|
+
expect(multipartResult._tag).toBe('Right');
|
|
452
|
+
if (multipartResult._tag === 'Left') {
|
|
453
|
+
throw new Error(`Failed: ${multipartResult.left.message}`);
|
|
454
|
+
}
|
|
455
|
+
const multipartBody = multipartResult.right;
|
|
456
|
+
// Verify flattening
|
|
457
|
+
expect(multipartBody.fields['uploadMetadata.source']).toBe('admin-panel');
|
|
458
|
+
expect(multipartBody.fields['uploadMetadata.uploadedBy.userId']).toBe('123');
|
|
459
|
+
expect(multipartBody.fields['uploadMetadata.uploadedBy.userName']).toBe('admin');
|
|
460
|
+
// Send to server
|
|
461
|
+
// eslint-disable-next-line global-require
|
|
462
|
+
const FormData = require('form-data');
|
|
463
|
+
const formData = new FormData();
|
|
464
|
+
for (const [key, value] of Object.entries(multipartBody.fields)) {
|
|
465
|
+
formData.append(key, value);
|
|
466
|
+
}
|
|
467
|
+
for (const [key, file] of Object.entries(multipartBody.files)) {
|
|
468
|
+
const chunks = [];
|
|
469
|
+
// eslint-disable-next-line no-await-in-loop
|
|
470
|
+
for await (const chunk of file.content) {
|
|
471
|
+
chunks.push(Buffer.from(chunk));
|
|
472
|
+
}
|
|
473
|
+
const buffer = Buffer.concat(chunks);
|
|
474
|
+
formData.append(key, buffer, {
|
|
475
|
+
filename: file.reference.metadata.name,
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
const response = await new Promise((resolve, reject) => {
|
|
479
|
+
formData.submit(`${serverUrl}/upload`, (err, res) => {
|
|
480
|
+
if (err) {
|
|
481
|
+
reject(err);
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
let body = '';
|
|
485
|
+
res.on('data', (chunk) => {
|
|
486
|
+
body += chunk.toString();
|
|
487
|
+
});
|
|
488
|
+
res.on('end', () => {
|
|
489
|
+
try {
|
|
490
|
+
const parsed = JSON.parse(body);
|
|
491
|
+
if (res.statusCode === 200) {
|
|
492
|
+
resolve(parsed);
|
|
493
|
+
}
|
|
494
|
+
else {
|
|
495
|
+
reject(new Error(`Server error: ${parsed.error}`));
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
catch (e) {
|
|
499
|
+
reject(new Error(`Parse error: ${body}`));
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
});
|
|
504
|
+
// Verify fields were sent correctly (flat dot-notation)
|
|
505
|
+
expect(response.fields.fileName).toBe('logo.png');
|
|
506
|
+
expect(response.fields.overrideIfPresent).toBe('true');
|
|
507
|
+
// Nested metadata flattened with dot notation
|
|
508
|
+
expect(response.fields['uploadMetadata.source']).toBe('admin-panel');
|
|
509
|
+
expect(response.fields['uploadMetadata.uploadedBy.userId']).toBe('123');
|
|
510
|
+
expect(response.fields['uploadMetadata.uploadedBy.userName']).toBe('admin');
|
|
511
|
+
expect(response.fields['uploadMetadata.timestamp']).toBe('2026-04-27T00:00:00Z');
|
|
512
|
+
// eslint-disable-next-line no-console
|
|
513
|
+
console.log('✅ Library Service-like nested structure flattened correctly!');
|
|
514
|
+
});
|
|
515
|
+
});
|
|
516
|
+
});
|
|
@@ -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,270 @@
|
|
|
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
|
+
expect(Object.keys(result.right.files)).toContain('root-file');
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
it('should detect FileDescriptor nested one level deep', async () => {
|
|
66
|
+
const fileDescriptor = {
|
|
67
|
+
_tag: 'FileDescriptor',
|
|
68
|
+
key: 'nested-file',
|
|
69
|
+
metadata: { name: 'file.txt', size: 100, contentType: 'text/plain' },
|
|
70
|
+
};
|
|
71
|
+
await clientStorage.write({ reference: fileDescriptor, content: stream_1.Readable.from('content') }, undefined)();
|
|
72
|
+
const result = await Http_1.MultipartHttpRequestBody.create({
|
|
73
|
+
data: {
|
|
74
|
+
file: fileDescriptor,
|
|
75
|
+
},
|
|
76
|
+
}, clientStorage)();
|
|
77
|
+
expect(result._tag).toBe('Right');
|
|
78
|
+
if (result._tag === 'Right') {
|
|
79
|
+
expect(Object.keys(result.right.files)).toContain('nested-file');
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
it('should detect FileDescriptor nested multiple levels deep', async () => {
|
|
83
|
+
const fileDescriptor = {
|
|
84
|
+
_tag: 'FileDescriptor',
|
|
85
|
+
key: 'deep-file',
|
|
86
|
+
metadata: { name: 'file.txt', size: 100, contentType: 'text/plain' },
|
|
87
|
+
};
|
|
88
|
+
await clientStorage.write({ reference: fileDescriptor, content: stream_1.Readable.from('content') }, undefined)();
|
|
89
|
+
const result = await Http_1.MultipartHttpRequestBody.create({
|
|
90
|
+
level1: {
|
|
91
|
+
level2: {
|
|
92
|
+
level3: {
|
|
93
|
+
file: fileDescriptor,
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
}, clientStorage)();
|
|
98
|
+
expect(result._tag).toBe('Right');
|
|
99
|
+
if (result._tag === 'Right') {
|
|
100
|
+
expect(Object.keys(result.right.files)).toContain('deep-file');
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
it('should detect multiple FileDescriptors at different levels', async () => {
|
|
104
|
+
const file1 = {
|
|
105
|
+
_tag: 'FileDescriptor',
|
|
106
|
+
key: 'file-1',
|
|
107
|
+
metadata: { name: 'file1.txt', size: 100, contentType: 'text/plain' },
|
|
108
|
+
};
|
|
109
|
+
const file2 = {
|
|
110
|
+
_tag: 'FileDescriptor',
|
|
111
|
+
key: 'file-2',
|
|
112
|
+
metadata: { name: 'file2.txt', size: 100, contentType: 'text/plain' },
|
|
113
|
+
};
|
|
114
|
+
await clientStorage.write({ reference: file1, content: stream_1.Readable.from('content1') }, undefined)();
|
|
115
|
+
await clientStorage.write({ reference: file2, content: stream_1.Readable.from('content2') }, undefined)();
|
|
116
|
+
const result = await Http_1.MultipartHttpRequestBody.create({
|
|
117
|
+
rootFile: file1,
|
|
118
|
+
nested: {
|
|
119
|
+
deepFile: file2,
|
|
120
|
+
},
|
|
121
|
+
}, clientStorage)();
|
|
122
|
+
expect(result._tag).toBe('Right');
|
|
123
|
+
if (result._tag === 'Right') {
|
|
124
|
+
expect(Object.keys(result.right.files)).toHaveLength(2);
|
|
125
|
+
expect(result.right.files['file-1']).toBeDefined();
|
|
126
|
+
expect(result.right.files['file-2']).toBeDefined();
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
describe('Field Flattening', () => {
|
|
131
|
+
it('should flatten nested fields with dot notation', async () => {
|
|
132
|
+
const result = await Http_1.MultipartHttpRequestBody.create({
|
|
133
|
+
title: 'Test',
|
|
134
|
+
metadata: {
|
|
135
|
+
author: 'John',
|
|
136
|
+
version: '1.0',
|
|
137
|
+
},
|
|
138
|
+
}, clientStorage)();
|
|
139
|
+
expect(result._tag).toBe('Right');
|
|
140
|
+
if (result._tag === 'Right') {
|
|
141
|
+
expect(result.right.fields.title).toBe('Test');
|
|
142
|
+
expect(result.right.fields['metadata.author']).toBe('John');
|
|
143
|
+
expect(result.right.fields['metadata.version']).toBe('1.0');
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
it('should flatten deeply nested fields', async () => {
|
|
147
|
+
const result = await Http_1.MultipartHttpRequestBody.create({
|
|
148
|
+
level1: {
|
|
149
|
+
level2: {
|
|
150
|
+
level3: {
|
|
151
|
+
value: 'deep',
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
}, clientStorage)();
|
|
156
|
+
expect(result._tag).toBe('Right');
|
|
157
|
+
if (result._tag === 'Right') {
|
|
158
|
+
expect(result.right.fields['level1.level2.level3.value']).toBe('deep');
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
it('should handle primitive values at root level', async () => {
|
|
162
|
+
const result = await Http_1.MultipartHttpRequestBody.create({
|
|
163
|
+
string: 'text',
|
|
164
|
+
number: 123,
|
|
165
|
+
boolean: true,
|
|
166
|
+
null: null,
|
|
167
|
+
}, clientStorage)();
|
|
168
|
+
expect(result._tag).toBe('Right');
|
|
169
|
+
if (result._tag === 'Right') {
|
|
170
|
+
expect(result.right.fields.string).toBe('text');
|
|
171
|
+
expect(result.right.fields.number).toBe('123');
|
|
172
|
+
expect(result.right.fields.boolean).toBe('true');
|
|
173
|
+
expect(result.right.fields.null).toBe('null');
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
describe('Array Support', () => {
|
|
178
|
+
it('should serialize arrays as comma-separated strings', async () => {
|
|
179
|
+
const result = await Http_1.MultipartHttpRequestBody.create({
|
|
180
|
+
title: 'Test',
|
|
181
|
+
tags: ['tag1', 'tag2', 'tag3'],
|
|
182
|
+
nested: {
|
|
183
|
+
items: ['a', 'b', 'c'],
|
|
184
|
+
},
|
|
185
|
+
}, clientStorage)();
|
|
186
|
+
expect(result._tag).toBe('Right');
|
|
187
|
+
if (result._tag === 'Right') {
|
|
188
|
+
expect(result.right.fields.title).toBe('Test');
|
|
189
|
+
expect(result.right.fields.tags).toBe('tag1,tag2,tag3');
|
|
190
|
+
expect(result.right.fields['nested.items']).toBe('a,b,c');
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
it('should handle empty arrays', async () => {
|
|
194
|
+
const result = await Http_1.MultipartHttpRequestBody.create({
|
|
195
|
+
title: 'Test',
|
|
196
|
+
tags: [],
|
|
197
|
+
}, clientStorage)();
|
|
198
|
+
expect(result._tag).toBe('Right');
|
|
199
|
+
if (result._tag === 'Right') {
|
|
200
|
+
expect(result.right.fields.title).toBe('Test');
|
|
201
|
+
expect(result.right.fields.tags).toBe(''); // Empty array becomes empty string
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
it('should handle arrays with mixed types', async () => {
|
|
205
|
+
const result = await Http_1.MultipartHttpRequestBody.create({
|
|
206
|
+
values: [1, 'text', true, null],
|
|
207
|
+
}, clientStorage)();
|
|
208
|
+
expect(result._tag).toBe('Right');
|
|
209
|
+
if (result._tag === 'Right') {
|
|
210
|
+
// All values converted to strings and joined
|
|
211
|
+
expect(result.right.fields.values).toBe('1,text,true,null');
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
describe('Metadata Preservation', () => {
|
|
216
|
+
it('should preserve contentType from original descriptor', async () => {
|
|
217
|
+
const fileDescriptor = {
|
|
218
|
+
_tag: 'FileDescriptor',
|
|
219
|
+
key: 'test-file',
|
|
220
|
+
metadata: {
|
|
221
|
+
name: 'image.png',
|
|
222
|
+
size: 1024,
|
|
223
|
+
contentType: 'image/png',
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
await clientStorage.write({ reference: fileDescriptor, content: stream_1.Readable.from('data') }, undefined)();
|
|
227
|
+
const result = await Http_1.MultipartHttpRequestBody.create({ file: fileDescriptor }, clientStorage)();
|
|
228
|
+
expect(result._tag).toBe('Right');
|
|
229
|
+
if (result._tag === 'Right') {
|
|
230
|
+
const file = result.right.files['test-file'];
|
|
231
|
+
expect(file.reference.metadata.contentType).toBe('image/png');
|
|
232
|
+
expect(file.reference.metadata.name).toBe('image.png');
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
describe('Mixed Structures', () => {
|
|
237
|
+
it('should handle body with both fields and FileDescriptors', async () => {
|
|
238
|
+
const fileDescriptor = {
|
|
239
|
+
_tag: 'FileDescriptor',
|
|
240
|
+
key: 'doc',
|
|
241
|
+
metadata: {
|
|
242
|
+
name: 'doc.pdf',
|
|
243
|
+
size: 2048,
|
|
244
|
+
contentType: 'application/pdf',
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
await clientStorage.write({ reference: fileDescriptor, content: stream_1.Readable.from('pdf data') }, undefined)();
|
|
248
|
+
const result = await Http_1.MultipartHttpRequestBody.create({
|
|
249
|
+
title: 'Document',
|
|
250
|
+
category: 'report',
|
|
251
|
+
metadata: {
|
|
252
|
+
author: 'Alice',
|
|
253
|
+
tags: ['important', 'confidential'],
|
|
254
|
+
},
|
|
255
|
+
document: fileDescriptor,
|
|
256
|
+
}, clientStorage)();
|
|
257
|
+
expect(result._tag).toBe('Right');
|
|
258
|
+
if (result._tag === 'Right') {
|
|
259
|
+
// Check fields
|
|
260
|
+
expect(result.right.fields.title).toBe('Document');
|
|
261
|
+
expect(result.right.fields.category).toBe('report');
|
|
262
|
+
expect(result.right.fields['metadata.author']).toBe('Alice');
|
|
263
|
+
expect(result.right.fields['metadata.tags']).toBe('important,confidential');
|
|
264
|
+
// Check file
|
|
265
|
+
expect(result.right.files.doc).toBeDefined();
|
|
266
|
+
expect(result.right.files.doc.reference.metadata.contentType).toBe('application/pdf');
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
});
|