@nkmc/agent-fs 0.1.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.
Files changed (42) hide show
  1. package/dist/chunk-7LIZT7L3.js +966 -0
  2. package/dist/index.cjs +1278 -0
  3. package/dist/index.d.cts +96 -0
  4. package/dist/index.d.ts +96 -0
  5. package/dist/index.js +419 -0
  6. package/dist/rpc-D1IHpjF_.d.cts +330 -0
  7. package/dist/rpc-D1IHpjF_.d.ts +330 -0
  8. package/dist/testing.cjs +842 -0
  9. package/dist/testing.d.cts +29 -0
  10. package/dist/testing.d.ts +29 -0
  11. package/dist/testing.js +10 -0
  12. package/package.json +25 -0
  13. package/src/agent-fs.ts +151 -0
  14. package/src/backends/http.ts +835 -0
  15. package/src/backends/memory.ts +183 -0
  16. package/src/backends/rpc.ts +456 -0
  17. package/src/index.ts +36 -0
  18. package/src/mount.ts +84 -0
  19. package/src/parser.ts +162 -0
  20. package/src/server.ts +158 -0
  21. package/src/testing.ts +3 -0
  22. package/src/types.ts +52 -0
  23. package/test/agent-fs.test.ts +325 -0
  24. package/test/http-204.test.ts +102 -0
  25. package/test/http-auth-prefix.test.ts +79 -0
  26. package/test/http-cloudflare.test.ts +533 -0
  27. package/test/http-form-encoding.test.ts +119 -0
  28. package/test/http-github.test.ts +580 -0
  29. package/test/http-listkey.test.ts +128 -0
  30. package/test/http-oauth2.test.ts +174 -0
  31. package/test/http-pagination.test.ts +200 -0
  32. package/test/http-param-styles.test.ts +98 -0
  33. package/test/http-passthrough.test.ts +282 -0
  34. package/test/http-retry.test.ts +132 -0
  35. package/test/http.test.ts +360 -0
  36. package/test/memory.test.ts +120 -0
  37. package/test/mount.test.ts +94 -0
  38. package/test/parser.test.ts +100 -0
  39. package/test/rpc-crud.test.ts +627 -0
  40. package/test/rpc-evm.test.ts +390 -0
  41. package/tsconfig.json +8 -0
  42. package/tsup.config.ts +8 -0
@@ -0,0 +1,533 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
2
+ import { createServer, type Server, type IncomingMessage, type ServerResponse } from "node:http";
3
+ import {
4
+ HttpBackend,
5
+ type HttpBackendConfig,
6
+ type HttpResource,
7
+ } from "../src/backends/http.js";
8
+
9
+ // --- Mock Cloudflare API data ---
10
+
11
+ interface MockRecord {
12
+ id: string;
13
+ [key: string]: unknown;
14
+ }
15
+
16
+ const zones: MockRecord[] = [
17
+ { id: "z1", name: "example.com", status: "active" },
18
+ { id: "z2", name: "test.dev", status: "active" },
19
+ ];
20
+
21
+ const dnsRecords: Record<string, MockRecord[]> = {
22
+ z1: [
23
+ { id: "r1", type: "A", name: "example.com", content: "1.2.3.4" },
24
+ { id: "r2", type: "CNAME", name: "www.example.com", content: "example.com" },
25
+ ],
26
+ z2: [
27
+ { id: "r3", type: "A", name: "test.dev", content: "5.6.7.8" },
28
+ ],
29
+ };
30
+
31
+ const zoneSettings: Record<string, MockRecord[]> = {
32
+ z1: [
33
+ { id: "ssl", value: "full" },
34
+ { id: "minify", value: "on" },
35
+ ],
36
+ };
37
+
38
+ const workerScripts: MockRecord[] = [
39
+ { id: "my-worker", script: "addEventListener('fetch', ...)" },
40
+ { id: "api-gateway", script: "export default { fetch() {} }" },
41
+ ];
42
+
43
+ const kvNamespaces: MockRecord[] = [
44
+ { id: "ns1", title: "MY_KV" },
45
+ { id: "ns2", title: "SESSIONS" },
46
+ ];
47
+
48
+ const kvKeys: Record<string, MockRecord[]> = {
49
+ ns1: [
50
+ { name: "user:1", metadata: {} },
51
+ { name: "user:2", metadata: {} },
52
+ ],
53
+ ns2: [
54
+ { name: "sess:abc", metadata: {} },
55
+ ],
56
+ };
57
+
58
+ const r2Buckets: MockRecord[] = [
59
+ { name: "assets", creation_date: "2024-01-01" },
60
+ { name: "backups", creation_date: "2024-06-01" },
61
+ ];
62
+
63
+ // --- Mock Cloudflare API server ---
64
+
65
+ function json(res: ServerResponse, status: number, data: unknown) {
66
+ res.writeHead(status, { "Content-Type": "application/json" });
67
+ res.end(JSON.stringify(data));
68
+ }
69
+
70
+ function cfResult(data: unknown) {
71
+ return { result: data, success: true };
72
+ }
73
+
74
+ function createMockCfServer(): Promise<{ server: Server; baseUrl: string }> {
75
+ return new Promise((resolve) => {
76
+ const srv = createServer((req: IncomingMessage, res: ServerResponse) => {
77
+ const url = new URL(req.url ?? "/", "http://localhost");
78
+ const path = url.pathname;
79
+ const method = req.method ?? "GET";
80
+
81
+ // Verify auth (Cloudflare uses Bearer token)
82
+ const auth = req.headers.authorization;
83
+ if (auth !== "Bearer cf-test-token") {
84
+ json(res, 401, { success: false, errors: [{ message: "Unauthorized" }] });
85
+ return;
86
+ }
87
+
88
+ // Read body
89
+ const chunks: Buffer[] = [];
90
+ req.on("data", (chunk: Buffer) => chunks.push(chunk));
91
+ req.on("end", () => {
92
+ const body = chunks.length
93
+ ? JSON.parse(Buffer.concat(chunks).toString())
94
+ : undefined;
95
+
96
+ // --- Zones ---
97
+ // GET /zones
98
+ if (path === "/zones" && method === "GET") {
99
+ return json(res, 200, cfResult(zones));
100
+ }
101
+ // POST /zones
102
+ if (path === "/zones" && method === "POST") {
103
+ const newZone = { id: `z${zones.length + 1}`, ...body };
104
+ zones.push(newZone);
105
+ return json(res, 201, newZone);
106
+ }
107
+ // GET/DELETE /zones/:id
108
+ const zoneMatch = path.match(/^\/zones\/([^/]+)$/);
109
+ if (zoneMatch) {
110
+ const [, zoneId] = zoneMatch;
111
+ if (method === "GET") {
112
+ const zone = zones.find((z) => z.id === zoneId);
113
+ if (!zone) return json(res, 404, { success: false });
114
+ return json(res, 200, zone);
115
+ }
116
+ if (method === "DELETE") {
117
+ const idx = zones.findIndex((z) => z.id === zoneId);
118
+ if (idx === -1) return json(res, 404, { success: false });
119
+ zones.splice(idx, 1);
120
+ return json(res, 200, { id: zoneId });
121
+ }
122
+ }
123
+
124
+ // --- DNS Records (nested under zones) ---
125
+ // GET /zones/:id/dns_records
126
+ const dnsListMatch = path.match(/^\/zones\/([^/]+)\/dns_records$/);
127
+ if (dnsListMatch && method === "GET") {
128
+ const [, zoneId] = dnsListMatch;
129
+ const records = dnsRecords[zoneId] ?? [];
130
+ return json(res, 200, cfResult(records));
131
+ }
132
+ // POST /zones/:id/dns_records
133
+ if (dnsListMatch && method === "POST") {
134
+ const [, zoneId] = dnsListMatch;
135
+ if (!dnsRecords[zoneId]) dnsRecords[zoneId] = [];
136
+ const newRecord = { id: `r${Date.now()}`, ...body };
137
+ dnsRecords[zoneId].push(newRecord);
138
+ return json(res, 201, newRecord);
139
+ }
140
+ // GET/PUT/DELETE /zones/:id/dns_records/:rid
141
+ const dnsItemMatch = path.match(/^\/zones\/([^/]+)\/dns_records\/([^/]+)$/);
142
+ if (dnsItemMatch) {
143
+ const [, zoneId, recordId] = dnsItemMatch;
144
+ const records = dnsRecords[zoneId] ?? [];
145
+ if (method === "GET") {
146
+ const record = records.find((r) => r.id === recordId);
147
+ if (!record) return json(res, 404, { success: false });
148
+ return json(res, 200, record);
149
+ }
150
+ if (method === "PUT") {
151
+ const idx = records.findIndex((r) => r.id === recordId);
152
+ if (idx === -1) return json(res, 404, { success: false });
153
+ records[idx] = { ...records[idx], ...body, id: recordId };
154
+ return json(res, 200, records[idx]);
155
+ }
156
+ if (method === "DELETE") {
157
+ const idx = records.findIndex((r) => r.id === recordId);
158
+ if (idx === -1) return json(res, 404, { success: false });
159
+ records.splice(idx, 1);
160
+ return json(res, 200, { id: recordId });
161
+ }
162
+ }
163
+
164
+ // --- Zone Settings (nested under zones) ---
165
+ const settingsListMatch = path.match(/^\/zones\/([^/]+)\/settings$/);
166
+ if (settingsListMatch && method === "GET") {
167
+ const [, zoneId] = settingsListMatch;
168
+ const settings = zoneSettings[zoneId] ?? [];
169
+ return json(res, 200, cfResult(settings));
170
+ }
171
+ const settingsItemMatch = path.match(/^\/zones\/([^/]+)\/settings\/([^/]+)$/);
172
+ if (settingsItemMatch && method === "GET") {
173
+ const [, zoneId, settingId] = settingsItemMatch;
174
+ const settings = zoneSettings[zoneId] ?? [];
175
+ const setting = settings.find((s) => s.id === settingId);
176
+ if (!setting) return json(res, 404, { success: false });
177
+ return json(res, 200, setting);
178
+ }
179
+
180
+ // --- Workers Scripts (account-scoped) ---
181
+ const workersMatch = path.match(/^\/accounts\/([^/]+)\/workers\/scripts$/);
182
+ if (workersMatch && method === "GET") {
183
+ return json(res, 200, cfResult(workerScripts));
184
+ }
185
+
186
+ // --- KV Namespaces (account-scoped) ---
187
+ const kvNsMatch = path.match(/^\/accounts\/([^/]+)\/storage\/kv\/namespaces$/);
188
+ if (kvNsMatch && method === "GET") {
189
+ return json(res, 200, cfResult(kvNamespaces));
190
+ }
191
+
192
+ // --- KV Keys (deeply nested) ---
193
+ const kvKeysMatch = path.match(
194
+ /^\/accounts\/([^/]+)\/storage\/kv\/namespaces\/([^/]+)\/keys$/,
195
+ );
196
+ if (kvKeysMatch && method === "GET") {
197
+ const [, , nsId] = kvKeysMatch;
198
+ const keys = kvKeys[nsId] ?? [];
199
+ return json(res, 200, cfResult(keys));
200
+ }
201
+
202
+ // --- R2 Buckets (account-scoped) ---
203
+ const r2Match = path.match(/^\/accounts\/([^/]+)\/r2\/buckets$/);
204
+ if (r2Match && method === "GET") {
205
+ return json(res, 200, cfResult(r2Buckets));
206
+ }
207
+
208
+ json(res, 404, { success: false, errors: [{ message: "Not found" }] });
209
+ });
210
+ });
211
+
212
+ srv.listen(0, () => {
213
+ const addr = srv.address();
214
+ const port = typeof addr === "object" && addr ? addr.port : 0;
215
+ resolve({ server: srv, baseUrl: `http://localhost:${port}` });
216
+ });
217
+ });
218
+ }
219
+
220
+ // --- Cloudflare resource configuration ---
221
+
222
+ function createCfResources(): HttpResource[] {
223
+ return [
224
+ {
225
+ name: "zones",
226
+ listKey: "result",
227
+ children: [
228
+ {
229
+ name: "dns_records",
230
+ listKey: "result",
231
+ },
232
+ {
233
+ name: "settings",
234
+ listKey: "result",
235
+ },
236
+ ],
237
+ },
238
+ {
239
+ name: "workers_scripts",
240
+ apiPath: "/accounts/:accountId/workers/scripts",
241
+ listKey: "result",
242
+ },
243
+ {
244
+ name: "kv_namespaces",
245
+ apiPath: "/accounts/:accountId/storage/kv/namespaces",
246
+ listKey: "result",
247
+ children: [
248
+ {
249
+ name: "keys",
250
+ listKey: "result",
251
+ idField: "name",
252
+ },
253
+ ],
254
+ },
255
+ {
256
+ name: "r2_buckets",
257
+ apiPath: "/accounts/:accountId/r2/buckets",
258
+ listKey: "result",
259
+ idField: "name",
260
+ },
261
+ ];
262
+ }
263
+
264
+ // --- Tests ---
265
+
266
+ describe("HttpBackend — Cloudflare nested resources", () => {
267
+ let server: Server;
268
+ let backend: HttpBackend;
269
+
270
+ beforeAll(async () => {
271
+ const mock = await createMockCfServer();
272
+ server = mock.server;
273
+
274
+ backend = new HttpBackend({
275
+ baseUrl: mock.baseUrl,
276
+ auth: { type: "bearer", token: "cf-test-token" },
277
+ resources: createCfResources(),
278
+ params: { accountId: "acct-123" },
279
+ });
280
+ });
281
+
282
+ afterAll(
283
+ () => new Promise<void>((resolve) => { server.close(() => resolve()); }),
284
+ );
285
+
286
+ // --- Root listing ---
287
+
288
+ describe("root listing", () => {
289
+ it("should list all top-level resources", async () => {
290
+ const result = await backend.list("/");
291
+ expect(result).toEqual([
292
+ "zones/",
293
+ "workers_scripts/",
294
+ "kv_namespaces/",
295
+ "r2_buckets/",
296
+ ]);
297
+ });
298
+ });
299
+
300
+ // --- Flat resource CRUD (zones) ---
301
+
302
+ describe("flat resource CRUD — zones", () => {
303
+ it("should list zones", async () => {
304
+ const result = await backend.list("/zones/");
305
+ expect(result).toContain("z1.json");
306
+ expect(result).toContain("z2.json");
307
+ });
308
+
309
+ it("should read a single zone", async () => {
310
+ const result = (await backend.read("/zones/z1.json")) as { name: string };
311
+ expect(result.name).toBe("example.com");
312
+ });
313
+
314
+ it("should create a zone", async () => {
315
+ const result = await backend.write("/zones/", {
316
+ name: "new-zone.io",
317
+ status: "pending",
318
+ });
319
+ expect(result.id).toBeDefined();
320
+ });
321
+
322
+ it("should delete a zone", async () => {
323
+ // Delete the zone we just created
324
+ const list = await backend.list("/zones/");
325
+ const lastZone = list[list.length - 1].replace(".json", "");
326
+ await backend.remove(`/zones/${lastZone}.json`);
327
+ const listAfter = await backend.list("/zones/");
328
+ expect(listAfter).not.toContain(`${lastZone}.json`);
329
+ });
330
+ });
331
+
332
+ // --- Nested resource CRUD (zones/z1/dns_records) ---
333
+
334
+ describe("nested resource CRUD — dns_records", () => {
335
+ it("should list DNS records under a zone", async () => {
336
+ const result = await backend.list("/zones/z1/dns_records/");
337
+ expect(result).toContain("r1.json");
338
+ expect(result).toContain("r2.json");
339
+ });
340
+
341
+ it("should read a single DNS record", async () => {
342
+ const result = (await backend.read("/zones/z1/dns_records/r1.json")) as {
343
+ type: string;
344
+ content: string;
345
+ };
346
+ expect(result.type).toBe("A");
347
+ expect(result.content).toBe("1.2.3.4");
348
+ });
349
+
350
+ it("should create a DNS record", async () => {
351
+ const result = await backend.write("/zones/z1/dns_records/", {
352
+ type: "MX",
353
+ name: "example.com",
354
+ content: "mail.example.com",
355
+ });
356
+ expect(result.id).toBeDefined();
357
+ });
358
+
359
+ it("should update a DNS record", async () => {
360
+ const result = await backend.write("/zones/z1/dns_records/r1.json", {
361
+ content: "10.0.0.1",
362
+ });
363
+ expect(result.id).toBe("r1");
364
+
365
+ const updated = (await backend.read("/zones/z1/dns_records/r1.json")) as {
366
+ content: string;
367
+ };
368
+ expect(updated.content).toBe("10.0.0.1");
369
+ });
370
+
371
+ it("should delete a DNS record", async () => {
372
+ await backend.remove("/zones/z1/dns_records/r2.json");
373
+ const list = await backend.list("/zones/z1/dns_records/");
374
+ expect(list).not.toContain("r2.json");
375
+ });
376
+
377
+ it("should list DNS records under a different zone", async () => {
378
+ const result = await backend.list("/zones/z2/dns_records/");
379
+ expect(result).toContain("r3.json");
380
+ });
381
+ });
382
+
383
+ // --- Intermediate node directory listing ---
384
+
385
+ describe("intermediate node listing", () => {
386
+ it("should list child resources of a zone", async () => {
387
+ const result = await backend.list("/zones/z1/");
388
+ expect(result).toEqual(["dns_records/", "settings/"]);
389
+ });
390
+ });
391
+
392
+ // --- Account-scoped resources ---
393
+
394
+ describe("account-scoped resources", () => {
395
+ it("should list worker scripts", async () => {
396
+ const result = await backend.list("/workers_scripts/");
397
+ expect(result).toContain("my-worker.json");
398
+ expect(result).toContain("api-gateway.json");
399
+ });
400
+
401
+ it("should list R2 buckets", async () => {
402
+ const result = await backend.list("/r2_buckets/");
403
+ expect(result).toContain("assets.json");
404
+ expect(result).toContain("backups.json");
405
+ });
406
+
407
+ it("should list KV namespaces", async () => {
408
+ const result = await backend.list("/kv_namespaces/");
409
+ expect(result).toContain("ns1.json");
410
+ expect(result).toContain("ns2.json");
411
+ });
412
+ });
413
+
414
+ // --- Deep nesting (kv_namespaces/ns1/keys) ---
415
+
416
+ describe("deep nesting — KV keys", () => {
417
+ it("should list keys in a KV namespace", async () => {
418
+ const result = await backend.list("/kv_namespaces/ns1/keys/");
419
+ expect(result).toContain("user:1.json");
420
+ expect(result).toContain("user:2.json");
421
+ });
422
+
423
+ it("should list keys in another KV namespace", async () => {
424
+ const result = await backend.list("/kv_namespaces/ns2/keys/");
425
+ expect(result).toContain("sess:abc.json");
426
+ });
427
+
428
+ it("should list child resources of a KV namespace", async () => {
429
+ const result = await backend.list("/kv_namespaces/ns1/");
430
+ expect(result).toEqual(["keys/"]);
431
+ });
432
+ });
433
+
434
+ // --- Static param injection ---
435
+
436
+ describe("static param injection", () => {
437
+ it("should resolve :accountId in API paths", async () => {
438
+ // The workers_scripts resource uses apiPath "/accounts/:accountId/workers/scripts"
439
+ // With params { accountId: "acct-123" }, it should resolve to the correct URL
440
+ // If this fails, the mock server won't match and we'd get an error
441
+ const result = await backend.list("/workers_scripts/");
442
+ expect(result.length).toBeGreaterThan(0);
443
+ });
444
+
445
+ it("should resolve params for deeply nested account-scoped resources", async () => {
446
+ const result = await backend.list("/kv_namespaces/ns1/keys/");
447
+ expect(result.length).toBeGreaterThan(0);
448
+ });
449
+ });
450
+
451
+ // --- Auth pass-through ---
452
+
453
+ describe("auth pass-through", () => {
454
+ it("should fail with wrong auth token", async () => {
455
+ const badBackend = new HttpBackend({
456
+ baseUrl: `http://localhost:${(server.address() as { port: number }).port}`,
457
+ auth: { type: "bearer", token: "wrong-token" },
458
+ resources: createCfResources(),
459
+ params: { accountId: "acct-123" },
460
+ });
461
+
462
+ // The mock returns 401 and non-array body, list returns []
463
+ const result = await badBackend.list("/zones/");
464
+ expect(result).toEqual([]);
465
+ });
466
+ });
467
+
468
+ // --- Zone settings (second child resource) ---
469
+
470
+ describe("zone settings", () => {
471
+ it("should list settings for a zone", async () => {
472
+ const result = await backend.list("/zones/z1/settings/");
473
+ expect(result).toContain("ssl.json");
474
+ expect(result).toContain("minify.json");
475
+ });
476
+
477
+ it("should read a single setting", async () => {
478
+ const result = (await backend.read("/zones/z1/settings/ssl.json")) as {
479
+ id: string;
480
+ value: string;
481
+ };
482
+ expect(result.id).toBe("ssl");
483
+ expect(result.value).toBe("full");
484
+ });
485
+ });
486
+
487
+ // --- AgentFs integration ---
488
+
489
+ describe("AgentFs integration", () => {
490
+ it("should work as a mount in AgentFs", async () => {
491
+ const { AgentFs } = await import("../src/agent-fs.js");
492
+
493
+ const agentFs = new AgentFs({
494
+ mounts: [{ path: "/cf", backend }],
495
+ });
496
+
497
+ // List mount root
498
+ const lsRoot = await agentFs.execute("ls /");
499
+ expect(lsRoot.ok).toBe(true);
500
+ if (lsRoot.ok) {
501
+ expect(lsRoot.data).toContain("cf/");
502
+ }
503
+
504
+ // List resources
505
+ const lsCf = await agentFs.execute("ls /cf/");
506
+ expect(lsCf.ok).toBe(true);
507
+ if (lsCf.ok) {
508
+ expect(lsCf.data).toContain("zones/");
509
+ }
510
+
511
+ // List zones
512
+ const lsZones = await agentFs.execute("ls /cf/zones/");
513
+ expect(lsZones.ok).toBe(true);
514
+ if (lsZones.ok) {
515
+ expect((lsZones.data as string[]).some((e) => e.endsWith(".json"))).toBe(true);
516
+ }
517
+
518
+ // Read a zone
519
+ const catZone = await agentFs.execute("cat /cf/zones/z1.json");
520
+ expect(catZone.ok).toBe(true);
521
+ if (catZone.ok) {
522
+ expect((catZone.data as { name: string }).name).toBe("example.com");
523
+ }
524
+
525
+ // Navigate nested resource
526
+ const lsDns = await agentFs.execute("ls /cf/zones/z1/dns_records/");
527
+ expect(lsDns.ok).toBe(true);
528
+ if (lsDns.ok) {
529
+ expect((lsDns.data as string[]).some((e) => e.endsWith(".json"))).toBe(true);
530
+ }
531
+ });
532
+ });
533
+ });
@@ -0,0 +1,119 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { HttpBackend, encodeFormBody } from "../src/backends/http.js";
3
+
4
+ // --- encodeFormBody unit tests ---
5
+
6
+ describe("encodeFormBody", () => {
7
+ it("should encode flat key-value pairs", () => {
8
+ const result = encodeFormBody({ name: "Alice", age: "30" });
9
+ expect(result).toBe("name=Alice&age=30");
10
+ });
11
+
12
+ it("should encode nested objects with bracket notation", () => {
13
+ const result = encodeFormBody({ metadata: { key: "value", foo: "bar" } });
14
+ expect(result).toContain("metadata%5Bkey%5D=value");
15
+ expect(result).toContain("metadata%5Bfoo%5D=bar");
16
+ });
17
+
18
+ it("should encode arrays with [] suffix", () => {
19
+ const result = encodeFormBody({ items: ["a", "b", "c"] });
20
+ expect(result).toBe("items%5B%5D=a&items%5B%5D=b&items%5B%5D=c");
21
+ });
22
+
23
+ it("should URL-encode special characters", () => {
24
+ const result = encodeFormBody({ q: "hello world&more=yes" });
25
+ expect(result).toBe("q=hello%20world%26more%3Dyes");
26
+ });
27
+
28
+ it("should handle empty object", () => {
29
+ expect(encodeFormBody({})).toBe("");
30
+ });
31
+
32
+ it("should handle null and undefined", () => {
33
+ expect(encodeFormBody(null)).toBe("");
34
+ expect(encodeFormBody(undefined)).toBe("");
35
+ });
36
+
37
+ it("should skip undefined values", () => {
38
+ const result = encodeFormBody({ a: "1", b: undefined, c: "3" });
39
+ expect(result).toBe("a=1&c=3");
40
+ });
41
+
42
+ it("should handle deeply nested objects", () => {
43
+ const result = encodeFormBody({
44
+ card: { address: { city: "Tokyo" } },
45
+ });
46
+ expect(result).toBe("card%5Baddress%5D%5Bcity%5D=Tokyo");
47
+ });
48
+ });
49
+
50
+ // --- HttpBackend bodyEncoding integration tests ---
51
+
52
+ describe("HttpBackend bodyEncoding", () => {
53
+ it("should send Content-Type: application/json by default", async () => {
54
+ let capturedHeaders: Record<string, string> = {};
55
+ let capturedBody = "";
56
+
57
+ const mockFetch = async (_url: string, init: RequestInit) => {
58
+ capturedHeaders = Object.fromEntries(
59
+ Object.entries(init.headers as Record<string, string>),
60
+ );
61
+ capturedBody = init.body as string;
62
+ return new Response(JSON.stringify({ id: "1" }), { status: 200 });
63
+ };
64
+
65
+ const backend = new HttpBackend({
66
+ baseUrl: "https://api.example.com",
67
+ fetch: mockFetch as any,
68
+ });
69
+
70
+ await backend.write("/", { name: "test" });
71
+ expect(capturedHeaders["Content-Type"]).toBe("application/json");
72
+ expect(capturedBody).toBe(JSON.stringify({ name: "test" }));
73
+ });
74
+
75
+ it("should send Content-Type: application/x-www-form-urlencoded when bodyEncoding=form", async () => {
76
+ let capturedHeaders: Record<string, string> = {};
77
+ let capturedBody = "";
78
+
79
+ const mockFetch = async (_url: string, init: RequestInit) => {
80
+ capturedHeaders = Object.fromEntries(
81
+ Object.entries(init.headers as Record<string, string>),
82
+ );
83
+ capturedBody = init.body as string;
84
+ return new Response(JSON.stringify({ id: "cus_123" }), { status: 200 });
85
+ };
86
+
87
+ const backend = new HttpBackend({
88
+ baseUrl: "https://api.stripe.com/v1",
89
+ bodyEncoding: "form",
90
+ fetch: mockFetch as any,
91
+ });
92
+
93
+ await backend.write("/", { name: "Alice", email: "alice@example.com" });
94
+ expect(capturedHeaders["Content-Type"]).toBe("application/x-www-form-urlencoded");
95
+ expect(capturedBody).toBe("name=Alice&email=alice%40example.com");
96
+ });
97
+
98
+ it("should encode nested objects in form mode", async () => {
99
+ let capturedBody = "";
100
+
101
+ const mockFetch = async (_url: string, init: RequestInit) => {
102
+ capturedBody = init.body as string;
103
+ return new Response(JSON.stringify({ id: "sub_1" }), { status: 200 });
104
+ };
105
+
106
+ const backend = new HttpBackend({
107
+ baseUrl: "https://api.stripe.com/v1",
108
+ bodyEncoding: "form",
109
+ fetch: mockFetch as any,
110
+ });
111
+
112
+ await backend.write("/", {
113
+ customer: "cus_123",
114
+ metadata: { order_id: "42" },
115
+ });
116
+ expect(capturedBody).toContain("customer=cus_123");
117
+ expect(capturedBody).toContain("metadata%5Border_id%5D=42");
118
+ });
119
+ });