@usequark/quark-core 2.5.4

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.
Files changed (68) hide show
  1. package/README.md +63 -0
  2. package/package.json +80 -0
  3. package/src/admin-auth.js +121 -0
  4. package/src/admin.js +352 -0
  5. package/src/admin.test.js +257 -0
  6. package/src/auth/index.js +160 -0
  7. package/src/auth/middleware.js +106 -0
  8. package/src/auth/middleware.test.js +116 -0
  9. package/src/auth/password.js +9 -0
  10. package/src/auth.test.js +235 -0
  11. package/src/authorization.js +235 -0
  12. package/src/authorization.test.js +314 -0
  13. package/src/cache.js +137 -0
  14. package/src/cache.test.js +217 -0
  15. package/src/core.js +32 -0
  16. package/src/csrf.js +122 -0
  17. package/src/csrf.test.js +169 -0
  18. package/src/db-instrumentation.js +281 -0
  19. package/src/db-instrumentation.test.js +284 -0
  20. package/src/db-metrics.js +26 -0
  21. package/src/db.js +218 -0
  22. package/src/db.test.js +365 -0
  23. package/src/email-templates.js +134 -0
  24. package/src/email-templates.test.js +124 -0
  25. package/src/email.js +314 -0
  26. package/src/email.test.js +498 -0
  27. package/src/error-reporter.js +266 -0
  28. package/src/error-reporter.test.js +236 -0
  29. package/src/errors.js +192 -0
  30. package/src/errors.test.js +128 -0
  31. package/src/exports.test.js +154 -0
  32. package/src/file-validation.js +249 -0
  33. package/src/file-validation.test.js +207 -0
  34. package/src/index.js +58 -0
  35. package/src/locale.js +21 -0
  36. package/src/logger.js +182 -0
  37. package/src/logger.test.js +287 -0
  38. package/src/mail.js +50 -0
  39. package/src/metrics.js +379 -0
  40. package/src/metrics.test.js +380 -0
  41. package/src/multipart.js +144 -0
  42. package/src/multipart.test.js +188 -0
  43. package/src/pagination.js +139 -0
  44. package/src/pagination.test.js +210 -0
  45. package/src/query-builder.js +183 -0
  46. package/src/query-builder.test.js +234 -0
  47. package/src/queue/index.js +446 -0
  48. package/src/queue/queue.test.js +365 -0
  49. package/src/rate-limiter.js +253 -0
  50. package/src/rate-limiter.test.js +130 -0
  51. package/src/redis.js +154 -0
  52. package/src/redis.test.js +150 -0
  53. package/src/request-logger.js +211 -0
  54. package/src/request-logger.test.js +286 -0
  55. package/src/sms.js +208 -0
  56. package/src/storage-s3.js +236 -0
  57. package/src/storage.js +214 -0
  58. package/src/storage.test.js +457 -0
  59. package/src/stripe.js +89 -0
  60. package/src/testing/factories.js +89 -0
  61. package/src/testing/helpers.js +266 -0
  62. package/src/testing/index.js +46 -0
  63. package/src/testing/mocks.js +480 -0
  64. package/src/testing/testing.test.js +543 -0
  65. package/src/types.js +74 -0
  66. package/src/utils.js +219 -0
  67. package/src/utils.test.js +193 -0
  68. package/src/validation.js +26 -0
@@ -0,0 +1,380 @@
1
+ import assert from "node:assert";
2
+ import { describe, test } from "node:test";
3
+ import {
4
+ appErrorsTotal,
5
+ Counter,
6
+ createMetrics,
7
+ Gauge,
8
+ Histogram,
9
+ httpRequestDuration,
10
+ httpRequestsInFlight,
11
+ httpRequestsTotal,
12
+ jobDuration,
13
+ jobQueueDepth,
14
+ jobsProcessedTotal,
15
+ MetricsRegistry,
16
+ metrics,
17
+ } from "./metrics.js";
18
+
19
+ describe("Metrics - Counter", () => {
20
+ test("increments by 1 by default", () => {
21
+ const registry = createMetrics();
22
+ const counter = registry.counter({
23
+ name: "test_counter",
24
+ help: "A test counter",
25
+ });
26
+
27
+ counter.inc();
28
+ counter.inc();
29
+ counter.inc();
30
+
31
+ const output = counter.serialize();
32
+ assert.ok(output.includes("test_counter 3"));
33
+ });
34
+
35
+ test("increments by custom value", () => {
36
+ const registry = createMetrics();
37
+ const counter = registry.counter({
38
+ name: "test_counter_custom",
39
+ help: "A test counter",
40
+ });
41
+
42
+ counter.inc({}, 5);
43
+ counter.inc({}, 3);
44
+
45
+ const output = counter.serialize();
46
+ assert.ok(output.includes("test_counter_custom 8"));
47
+ });
48
+
49
+ test("supports labels", () => {
50
+ const registry = createMetrics();
51
+ const counter = registry.counter({
52
+ name: "labeled_counter",
53
+ help: "Counter with labels",
54
+ labelNames: ["method", "status"],
55
+ });
56
+
57
+ counter.inc({ method: "GET", status: "200" });
58
+ counter.inc({ method: "POST", status: "201" });
59
+ counter.inc({ method: "GET", status: "200" }, 2);
60
+
61
+ const output = counter.serialize();
62
+ assert.ok(output.includes('{method="GET",status="200"} 3'));
63
+ assert.ok(output.includes('{method="POST",status="201"} 1'));
64
+ });
65
+
66
+ test("throws on negative increment", () => {
67
+ const registry = createMetrics();
68
+ const counter = registry.counter({
69
+ name: "no_neg",
70
+ help: "No negatives",
71
+ });
72
+
73
+ assert.throws(() => counter.inc({}, -1), /can only be incremented/);
74
+ });
75
+
76
+ test("reset clears all values", () => {
77
+ const registry = createMetrics();
78
+ const counter = registry.counter({
79
+ name: "reset_counter",
80
+ help: "Resettable",
81
+ });
82
+
83
+ counter.inc();
84
+ counter.inc();
85
+ counter.reset();
86
+
87
+ assert.strictEqual(counter.values.size, 0);
88
+ });
89
+
90
+ test("serializes with HELP and TYPE headers", () => {
91
+ const registry = createMetrics();
92
+ const counter = registry.counter({
93
+ name: "http_total",
94
+ help: "Total requests",
95
+ });
96
+
97
+ counter.inc();
98
+ const output = counter.serialize();
99
+
100
+ assert.ok(output.includes("# HELP http_total Total requests"));
101
+ assert.ok(output.includes("# TYPE http_total counter"));
102
+ });
103
+
104
+ test("escapes special characters in label values", () => {
105
+ const registry = createMetrics();
106
+ const counter = registry.counter({
107
+ name: "escaped_counter",
108
+ help: "Escaping test",
109
+ labelNames: ["path"],
110
+ });
111
+
112
+ counter.inc({ path: '/api/"test"' });
113
+ const output = counter.serialize();
114
+ assert.ok(output.includes('path="/api/\\"test\\""'));
115
+ });
116
+
117
+ test("escapes backslashes and newlines in label values", () => {
118
+ const registry = createMetrics();
119
+ const counter = registry.counter({
120
+ name: "escape_bs",
121
+ help: "Backslash test",
122
+ labelNames: ["msg"],
123
+ });
124
+
125
+ counter.inc({ msg: "line1\nline2" });
126
+ const output = counter.serialize();
127
+ assert.ok(output.includes('msg="line1\\nline2"'));
128
+ });
129
+ });
130
+
131
+ describe("Metrics - Gauge", () => {
132
+ test("set sets a specific value", () => {
133
+ const registry = createMetrics();
134
+ const gauge = registry.gauge({
135
+ name: "test_gauge",
136
+ help: "A test gauge",
137
+ });
138
+
139
+ gauge.set({}, 42);
140
+
141
+ const output = gauge.serialize();
142
+ assert.ok(output.includes("test_gauge 42"));
143
+ });
144
+
145
+ test("inc and dec adjust value", () => {
146
+ const registry = createMetrics();
147
+ const gauge = registry.gauge({
148
+ name: "in_flight",
149
+ help: "In-flight requests",
150
+ });
151
+
152
+ gauge.inc();
153
+ gauge.inc();
154
+ gauge.dec();
155
+
156
+ const output = gauge.serialize();
157
+ assert.ok(output.includes("in_flight 1"));
158
+ });
159
+
160
+ test("supports labels", () => {
161
+ const registry = createMetrics();
162
+ const gauge = registry.gauge({
163
+ name: "labeled_gauge",
164
+ help: "Gauge with labels",
165
+ labelNames: ["method"],
166
+ });
167
+
168
+ gauge.set({ method: "GET" }, 10);
169
+ gauge.set({ method: "POST" }, 5);
170
+
171
+ const output = gauge.serialize();
172
+ assert.ok(output.includes('{method="GET"} 10'));
173
+ assert.ok(output.includes('{method="POST"} 5'));
174
+ });
175
+
176
+ test("reset clears all values", () => {
177
+ const registry = createMetrics();
178
+ const gauge = registry.gauge({
179
+ name: "reset_gauge",
180
+ help: "Resettable",
181
+ });
182
+
183
+ gauge.set({}, 100);
184
+ gauge.reset();
185
+
186
+ assert.strictEqual(gauge.values.size, 0);
187
+ });
188
+ });
189
+
190
+ describe("Metrics - Histogram", () => {
191
+ test("observe records values into buckets", () => {
192
+ const registry = createMetrics();
193
+ const hist = registry.histogram({
194
+ name: "request_duration",
195
+ help: "Duration of requests",
196
+ buckets: [0.01, 0.05, 0.1, 0.5, 1],
197
+ });
198
+
199
+ hist.observe({}, 0.03); // fits in 0.05, 0.1, 0.5, 1
200
+ hist.observe({}, 0.08); // fits in 0.1, 0.5, 1
201
+ hist.observe({}, 0.5); // fits in 0.5, 1
202
+
203
+ const output = hist.serialize();
204
+ assert.ok(output.includes('le="0.01"} 0'));
205
+ assert.ok(output.includes('le="0.05"} 1'));
206
+ assert.ok(output.includes('le="0.1"} 2'));
207
+ assert.ok(output.includes('le="0.5"} 3'));
208
+ assert.ok(output.includes('le="1"} 3'));
209
+ assert.ok(output.includes('le="+Inf"} 3'));
210
+ assert.ok(output.includes("_count 3"));
211
+ });
212
+
213
+ test("startTimer records elapsed time", async () => {
214
+ const registry = createMetrics();
215
+ const hist = registry.histogram({
216
+ name: "timer_test",
217
+ help: "Timer test",
218
+ buckets: [0.001, 0.01, 0.1, 1, 10],
219
+ });
220
+
221
+ const stop = hist.startTimer({ path: "/test" });
222
+ // 50ms delay ensures measurable time across all system loads
223
+ await new Promise((resolve) => setTimeout(resolve, 50));
224
+ const elapsed = stop();
225
+
226
+ assert.ok(elapsed > 0, "Elapsed time should be positive");
227
+ assert.ok(elapsed < 5, "Elapsed time should be less than 5 seconds");
228
+
229
+ const output = hist.serialize();
230
+ assert.ok(output.includes("timer_test_count"));
231
+ assert.ok(output.includes("timer_test_sum"));
232
+ });
233
+
234
+ test("supports labels on histogram", () => {
235
+ const registry = createMetrics();
236
+ const hist = registry.histogram({
237
+ name: "labeled_hist",
238
+ help: "Labeled histogram",
239
+ labelNames: ["method"],
240
+ buckets: [0.1, 1],
241
+ });
242
+
243
+ hist.observe({ method: "GET" }, 0.05);
244
+ hist.observe({ method: "POST" }, 0.5);
245
+
246
+ const output = hist.serialize();
247
+ assert.ok(output.includes('method="GET"'));
248
+ assert.ok(output.includes('method="POST"'));
249
+ });
250
+
251
+ test("uses default buckets when none provided", () => {
252
+ const registry = createMetrics();
253
+ const hist = registry.histogram({
254
+ name: "default_buckets",
255
+ help: "Default buckets test",
256
+ });
257
+
258
+ hist.observe({}, 0.05);
259
+ const output = hist.serialize();
260
+
261
+ // Default buckets include 0.005, 0.01, 0.025, 0.05, etc.
262
+ assert.ok(output.includes('le="0.005"'));
263
+ assert.ok(output.includes('le="10"'));
264
+ });
265
+ });
266
+
267
+ describe("Metrics - Registry", () => {
268
+ test("createMetrics returns a new registry", () => {
269
+ const registry = createMetrics();
270
+ assert.ok(registry instanceof MetricsRegistry);
271
+ assert.strictEqual(registry.size, 0);
272
+ });
273
+
274
+ test("registers and retrieves metrics", () => {
275
+ const registry = createMetrics();
276
+ const counter = registry.counter({ name: "my_counter", help: "test" });
277
+ const gauge = registry.gauge({ name: "my_gauge", help: "test" });
278
+ const hist = registry.histogram({ name: "my_hist", help: "test" });
279
+
280
+ assert.strictEqual(registry.size, 3);
281
+ assert.strictEqual(registry.get("my_counter"), counter);
282
+ assert.strictEqual(registry.get("my_gauge"), gauge);
283
+ assert.strictEqual(registry.get("my_hist"), hist);
284
+ });
285
+
286
+ test("returns existing metric if name already registered", () => {
287
+ const registry = createMetrics();
288
+ const first = registry.counter({ name: "dup", help: "first" });
289
+ const second = registry.counter({ name: "dup", help: "second" });
290
+
291
+ assert.strictEqual(first, second);
292
+ assert.strictEqual(registry.size, 1);
293
+ });
294
+
295
+ test("serialize outputs all metrics in Prometheus format", () => {
296
+ const registry = createMetrics();
297
+ const counter = registry.counter({ name: "req_total", help: "Total reqs" });
298
+ const gauge = registry.gauge({ name: "active", help: "Active conns" });
299
+
300
+ counter.inc({}, 10);
301
+ gauge.set({}, 5);
302
+
303
+ const output = registry.serialize();
304
+ assert.ok(output.includes("# HELP req_total Total reqs"));
305
+ assert.ok(output.includes("# TYPE req_total counter"));
306
+ assert.ok(output.includes("req_total 10"));
307
+ assert.ok(output.includes("# HELP active Active conns"));
308
+ assert.ok(output.includes("# TYPE active gauge"));
309
+ assert.ok(output.includes("active 5"));
310
+ });
311
+
312
+ test("resetAll clears all metric values", () => {
313
+ const registry = createMetrics();
314
+ const counter = registry.counter({ name: "c", help: "test" });
315
+ const gauge = registry.gauge({ name: "g", help: "test" });
316
+
317
+ counter.inc({}, 5);
318
+ gauge.set({}, 10);
319
+ registry.resetAll();
320
+
321
+ const output = registry.serialize();
322
+ assert.ok(!output.includes("c 5"));
323
+ assert.ok(!output.includes("g 10"));
324
+ });
325
+
326
+ test("get returns undefined for unknown metric", () => {
327
+ const registry = createMetrics();
328
+ assert.strictEqual(registry.get("nonexistent"), undefined);
329
+ });
330
+ });
331
+
332
+ describe("Metrics - Default instance", () => {
333
+ test("default metrics registry exists", () => {
334
+ assert.ok(metrics instanceof MetricsRegistry);
335
+ });
336
+
337
+ test("pre-registered HTTP metrics exist", () => {
338
+ assert.ok(httpRequestsTotal instanceof Counter);
339
+ assert.ok(httpRequestDuration instanceof Histogram);
340
+ assert.ok(httpRequestsInFlight instanceof Gauge);
341
+ assert.ok(appErrorsTotal instanceof Counter);
342
+ });
343
+
344
+ test("pre-registered queue metrics exist", () => {
345
+ assert.ok(jobQueueDepth instanceof Gauge);
346
+ assert.ok(jobsProcessedTotal instanceof Counter);
347
+ assert.ok(jobDuration instanceof Histogram);
348
+ });
349
+
350
+ test("pre-registered metrics are in the default registry", () => {
351
+ assert.strictEqual(metrics.get("http_requests_total"), httpRequestsTotal);
352
+ assert.strictEqual(
353
+ metrics.get("http_request_duration_seconds"),
354
+ httpRequestDuration,
355
+ );
356
+ assert.strictEqual(
357
+ metrics.get("http_requests_in_flight"),
358
+ httpRequestsInFlight,
359
+ );
360
+ assert.strictEqual(metrics.get("app_errors_total"), appErrorsTotal);
361
+ });
362
+
363
+ test("pre-registered queue metrics are in the default registry", () => {
364
+ assert.strictEqual(metrics.get("job_queue_depth"), jobQueueDepth);
365
+ assert.strictEqual(metrics.get("jobs_processed_total"), jobsProcessedTotal);
366
+ assert.strictEqual(metrics.get("job_duration_seconds"), jobDuration);
367
+ });
368
+
369
+ test("queue metrics have correct label names", () => {
370
+ assert.deepStrictEqual(jobQueueDepth.labelNames, ["queue"]);
371
+ assert.deepStrictEqual(jobsProcessedTotal.labelNames, ["queue", "status"]);
372
+ assert.deepStrictEqual(jobDuration.labelNames, ["queue", "name"]);
373
+ });
374
+
375
+ test("jobDuration uses job-appropriate buckets", () => {
376
+ // Buckets should cover sub-second to 5-minute range
377
+ assert.ok(jobDuration.buckets.includes(0.1));
378
+ assert.ok(jobDuration.buckets.includes(300));
379
+ });
380
+ });
@@ -0,0 +1,144 @@
1
+ /**
2
+ * @techstream/quark-core - Multipart Parser
3
+ *
4
+ * Stream-based multipart/form-data parsing with early limit enforcement.
5
+ * Works with Next.js App Router Request objects.
6
+ */
7
+
8
+ import { Readable } from "node:stream";
9
+
10
+ /**
11
+ * @typedef {Object} ParsedFile
12
+ * @property {string} fieldName - Form field name
13
+ * @property {string} filename - Original filename
14
+ * @property {string} mimeType - MIME type from Content-Type header
15
+ * @property {Buffer} buffer - Complete file content
16
+ * @property {number} size - File size in bytes
17
+ */
18
+
19
+ /**
20
+ * @typedef {Object} ParseMultipartOptions
21
+ * @property {number} [maxFileSize=10485760] - Max file size in bytes (default: 10MB)
22
+ * @property {number} [maxFiles=5] - Max number of files per request
23
+ * @property {number} [maxFields=20] - Max number of non-file fields
24
+ */
25
+
26
+ /**
27
+ * @typedef {Object} ParseMultipartResult
28
+ * @property {ParsedFile[]} files - Parsed file uploads
29
+ * @property {Record<string, string>} fields - Non-file form fields
30
+ */
31
+
32
+ /**
33
+ * Parse a multipart/form-data request into files and fields.
34
+ *
35
+ * @param {Request} request - Web API Request (Next.js App Router)
36
+ * @param {ParseMultipartOptions} [options]
37
+ * @returns {Promise<ParseMultipartResult>}
38
+ */
39
+ export async function parseMultipart(request, options = {}) {
40
+ const {
41
+ maxFileSize = 10 * 1024 * 1024,
42
+ maxFiles = 5,
43
+ maxFields = 20,
44
+ } = options;
45
+
46
+ const contentType = request.headers.get("content-type");
47
+ if (!contentType?.includes("multipart/form-data")) {
48
+ throw new Error("Request is not multipart/form-data");
49
+ }
50
+
51
+ if (!request.body) {
52
+ throw new Error("Request body is empty");
53
+ }
54
+
55
+ const { default: Busboy } = await import("busboy");
56
+
57
+ return new Promise((resolve, reject) => {
58
+ const files = [];
59
+ const fields = {};
60
+ let settled = false;
61
+
62
+ function resolveOnce(result) {
63
+ if (settled) return;
64
+ settled = true;
65
+ resolve(result);
66
+ }
67
+
68
+ function rejectOnce(error) {
69
+ if (settled) return;
70
+ settled = true;
71
+ reject(error);
72
+ }
73
+
74
+ const busboy = Busboy({
75
+ headers: { "content-type": contentType },
76
+ limits: {
77
+ fileSize: maxFileSize,
78
+ files: maxFiles,
79
+ fields: maxFields,
80
+ },
81
+ });
82
+
83
+ busboy.on("file", (fieldName, stream, info) => {
84
+ const { filename, mimeType } = info;
85
+ const chunks = [];
86
+ let size = 0;
87
+ let truncated = false;
88
+
89
+ stream.on("data", (chunk) => {
90
+ size += chunk.length;
91
+ chunks.push(chunk);
92
+ });
93
+
94
+ stream.on("limit", () => {
95
+ truncated = true;
96
+ stream.resume();
97
+ });
98
+
99
+ stream.on("error", rejectOnce);
100
+
101
+ stream.on("end", () => {
102
+ if (truncated) {
103
+ rejectOnce(
104
+ new Error(
105
+ `File "${filename}" exceeds maximum size of ${(maxFileSize / (1024 * 1024)).toFixed(1)} MB`,
106
+ ),
107
+ );
108
+ return;
109
+ }
110
+
111
+ files.push({
112
+ fieldName,
113
+ filename: filename || "unknown",
114
+ mimeType: mimeType || "application/octet-stream",
115
+ buffer: Buffer.concat(chunks),
116
+ size,
117
+ });
118
+ });
119
+ });
120
+
121
+ busboy.on("field", (name, value) => {
122
+ fields[name] = value;
123
+ });
124
+
125
+ busboy.on("filesLimit", () => {
126
+ rejectOnce(
127
+ new Error(`Request exceeds maximum file count of ${maxFiles}`),
128
+ );
129
+ });
130
+
131
+ busboy.on("fieldsLimit", () => {
132
+ rejectOnce(
133
+ new Error(`Request exceeds maximum field count of ${maxFields}`),
134
+ );
135
+ });
136
+
137
+ busboy.on("error", rejectOnce);
138
+ busboy.on("close", () => resolveOnce({ files, fields }));
139
+
140
+ const nodeStream = Readable.fromWeb(request.body);
141
+ nodeStream.on("error", rejectOnce);
142
+ nodeStream.pipe(busboy);
143
+ });
144
+ }
@@ -0,0 +1,188 @@
1
+ import assert from "node:assert";
2
+ import { test } from "node:test";
3
+ import { parseMultipart } from "../src/multipart.js";
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Helper: build a multipart/form-data body from parts
7
+ // ---------------------------------------------------------------------------
8
+
9
+ function buildMultipartBody(boundary, parts) {
10
+ const lines = [];
11
+ for (const part of parts) {
12
+ lines.push(`--${boundary}`);
13
+ if (part.filename) {
14
+ lines.push(
15
+ `Content-Disposition: form-data; name="${part.name}"; filename="${part.filename}"`,
16
+ );
17
+ lines.push(
18
+ `Content-Type: ${part.contentType || "application/octet-stream"}`,
19
+ );
20
+ } else {
21
+ lines.push(`Content-Disposition: form-data; name="${part.name}"`);
22
+ }
23
+ lines.push("");
24
+ lines.push(part.value);
25
+ }
26
+ lines.push(`--${boundary}--`);
27
+ return lines.join("\r\n");
28
+ }
29
+
30
+ function createMultipartRequest(parts, boundary = "----TestBoundary") {
31
+ const body = buildMultipartBody(boundary, parts);
32
+ return new Request("http://localhost/upload", {
33
+ method: "POST",
34
+ headers: {
35
+ "content-type": `multipart/form-data; boundary=${boundary}`,
36
+ },
37
+ body,
38
+ });
39
+ }
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Tests
43
+ // ---------------------------------------------------------------------------
44
+
45
+ test("Multipart - parses single file upload", async () => {
46
+ const request = createMultipartRequest([
47
+ {
48
+ name: "file",
49
+ filename: "test.txt",
50
+ contentType: "text/plain",
51
+ value: "hello world",
52
+ },
53
+ ]);
54
+
55
+ const result = await parseMultipart(request);
56
+ assert.strictEqual(result.files.length, 1);
57
+ assert.strictEqual(result.files[0].filename, "test.txt");
58
+ assert.strictEqual(result.files[0].mimeType, "text/plain");
59
+ assert.strictEqual(result.files[0].buffer.toString(), "hello world");
60
+ assert.strictEqual(result.files[0].size, 11);
61
+ assert.strictEqual(result.files[0].fieldName, "file");
62
+ });
63
+
64
+ test("Multipart - parses text fields", async () => {
65
+ const request = createMultipartRequest([
66
+ { name: "title", value: "My Upload" },
67
+ { name: "description", value: "A test file" },
68
+ ]);
69
+
70
+ const result = await parseMultipart(request);
71
+ assert.strictEqual(result.files.length, 0);
72
+ assert.strictEqual(result.fields.title, "My Upload");
73
+ assert.strictEqual(result.fields.description, "A test file");
74
+ });
75
+
76
+ test("Multipart - parses mixed files and fields", async () => {
77
+ const request = createMultipartRequest([
78
+ { name: "title", value: "Photo" },
79
+ {
80
+ name: "file",
81
+ filename: "photo.jpg",
82
+ contentType: "image/jpeg",
83
+ value: "jpeg-data",
84
+ },
85
+ ]);
86
+
87
+ const result = await parseMultipart(request);
88
+ assert.strictEqual(result.files.length, 1);
89
+ assert.strictEqual(result.files[0].filename, "photo.jpg");
90
+ assert.strictEqual(result.fields.title, "Photo");
91
+ });
92
+
93
+ test("Multipart - parses multiple files", async () => {
94
+ const request = createMultipartRequest([
95
+ {
96
+ name: "files",
97
+ filename: "a.txt",
98
+ contentType: "text/plain",
99
+ value: "aaa",
100
+ },
101
+ {
102
+ name: "files",
103
+ filename: "b.txt",
104
+ contentType: "text/plain",
105
+ value: "bbb",
106
+ },
107
+ ]);
108
+
109
+ const result = await parseMultipart(request);
110
+ assert.strictEqual(result.files.length, 2);
111
+ assert.strictEqual(result.files[0].filename, "a.txt");
112
+ assert.strictEqual(result.files[1].filename, "b.txt");
113
+ });
114
+
115
+ test("Multipart - rejects non-multipart request", async () => {
116
+ const request = new Request("http://localhost/upload", {
117
+ method: "POST",
118
+ headers: { "content-type": "application/json" },
119
+ body: "{}",
120
+ });
121
+
122
+ await assert.rejects(
123
+ () => parseMultipart(request),
124
+ /not multipart\/form-data/,
125
+ );
126
+ });
127
+
128
+ test("Multipart - rejects request with no body", async () => {
129
+ const request = new Request("http://localhost/upload", {
130
+ method: "POST",
131
+ headers: {
132
+ "content-type": "multipart/form-data; boundary=test",
133
+ },
134
+ });
135
+
136
+ await assert.rejects(() => parseMultipart(request), /body is empty/);
137
+ });
138
+
139
+ test("Multipart - rejects file exceeding maxFileSize", async () => {
140
+ const largeContent = "x".repeat(1024);
141
+ const request = createMultipartRequest([
142
+ {
143
+ name: "file",
144
+ filename: "big.txt",
145
+ contentType: "text/plain",
146
+ value: largeContent,
147
+ },
148
+ ]);
149
+
150
+ await assert.rejects(
151
+ () => parseMultipart(request, { maxFileSize: 100 }),
152
+ /exceeds maximum size/,
153
+ );
154
+ });
155
+
156
+ test("Multipart - rejects request exceeding maxFiles", async () => {
157
+ const request = createMultipartRequest([
158
+ {
159
+ name: "files",
160
+ filename: "a.txt",
161
+ contentType: "text/plain",
162
+ value: "aaa",
163
+ },
164
+ {
165
+ name: "files",
166
+ filename: "b.txt",
167
+ contentType: "text/plain",
168
+ value: "bbb",
169
+ },
170
+ ]);
171
+
172
+ await assert.rejects(
173
+ () => parseMultipart(request, { maxFiles: 1 }),
174
+ /maximum file count/,
175
+ );
176
+ });
177
+
178
+ test("Multipart - rejects request exceeding maxFields", async () => {
179
+ const request = createMultipartRequest([
180
+ { name: "title", value: "My Upload" },
181
+ { name: "description", value: "A test file" },
182
+ ]);
183
+
184
+ await assert.rejects(
185
+ () => parseMultipart(request, { maxFields: 1 }),
186
+ /maximum field count/,
187
+ );
188
+ });