jazz-tools 0.15.14 → 0.15.16

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 (37) hide show
  1. package/.turbo/turbo-build.log +40 -44
  2. package/CHANGELOG.md +19 -0
  3. package/dist/{chunk-6CCJYSYQ.js → chunk-OSVAAVWQ.js} +107 -11
  4. package/dist/chunk-OSVAAVWQ.js.map +1 -0
  5. package/dist/index.js +357 -3
  6. package/dist/index.js.map +1 -1
  7. package/dist/testing.js +1 -1
  8. package/dist/tools/coValues/group.d.ts +1 -0
  9. package/dist/tools/coValues/group.d.ts.map +1 -1
  10. package/dist/tools/coValues/inbox.d.ts.map +1 -1
  11. package/dist/tools/coValues/interfaces.d.ts +58 -2
  12. package/dist/tools/coValues/interfaces.d.ts.map +1 -1
  13. package/dist/tools/coValues/request.d.ts +82 -0
  14. package/dist/tools/coValues/request.d.ts.map +1 -0
  15. package/dist/tools/exports.d.ts +2 -1
  16. package/dist/tools/exports.d.ts.map +1 -1
  17. package/dist/tools/lib/id.d.ts +2 -0
  18. package/dist/tools/lib/id.d.ts.map +1 -0
  19. package/dist/tools/subscribe/SubscriptionScope.d.ts +3 -2
  20. package/dist/tools/subscribe/SubscriptionScope.d.ts.map +1 -1
  21. package/dist/tools/tests/exportImport.test.d.ts +2 -0
  22. package/dist/tools/tests/exportImport.test.d.ts.map +1 -0
  23. package/dist/tools/tests/request.test.d.ts +2 -0
  24. package/dist/tools/tests/request.test.d.ts.map +1 -0
  25. package/package.json +6 -5
  26. package/src/tools/coValues/group.ts +1 -0
  27. package/src/tools/coValues/inbox.ts +4 -3
  28. package/src/tools/coValues/interfaces.ts +177 -2
  29. package/src/tools/coValues/request.ts +631 -0
  30. package/src/tools/exports.ts +8 -0
  31. package/src/tools/lib/id.ts +3 -0
  32. package/src/tools/subscribe/SubscriptionScope.ts +17 -10
  33. package/src/tools/tests/coMap.test.ts +141 -0
  34. package/src/tools/tests/exportImport.test.ts +526 -0
  35. package/src/tools/tests/request.test.ts +951 -0
  36. package/tsup.config.ts +0 -2
  37. package/dist/chunk-6CCJYSYQ.js.map +0 -1
@@ -0,0 +1,951 @@
1
+ import { http } from "msw";
2
+ import { setupServer } from "msw/node";
3
+ import { assert, describe, expect, it, vi } from "vitest";
4
+ import { afterAll, afterEach, beforeAll } from "vitest";
5
+ import { z } from "zod/v4";
6
+ import {
7
+ JazzRequestError,
8
+ experimental_defineRequest,
9
+ isJazzRequestError,
10
+ } from "../coValues/request.js";
11
+ import { Account, CoPlainText, Group, co } from "../index.js";
12
+ import { exportCoValue, importContentPieces } from "../internal.js";
13
+ import { createJazzTestAccount, linkAccounts } from "../testing.js";
14
+
15
+ const server = setupServer();
16
+
17
+ beforeAll(() => server.listen());
18
+ afterEach(() => server.resetHandlers());
19
+ afterEach(() => vi.restoreAllMocks());
20
+ afterAll(() => server.close());
21
+
22
+ async function setupAccounts() {
23
+ const me = await createJazzTestAccount();
24
+ const worker = await createJazzTestAccount();
25
+
26
+ const workerPieces = await exportCoValue(Account, worker.id, {
27
+ loadAs: worker,
28
+ });
29
+
30
+ importContentPieces(workerPieces ?? [], me);
31
+
32
+ return { me, worker };
33
+ }
34
+
35
+ describe("experimental_defineRequest", () => {
36
+ describe("full request/response cycle", () => {
37
+ it("should accept the CoMap init as the request payload and as response callback return value", async () => {
38
+ const { me, worker } = await setupAccounts();
39
+
40
+ const group = Group.create(me);
41
+ group.addMember("everyone", "writer");
42
+
43
+ const userRequest = experimental_defineRequest({
44
+ url: "https://api.example.com/api/user",
45
+ workerId: worker.id,
46
+ request: {
47
+ name: z.string(),
48
+ email: z.string(),
49
+ age: z.number(),
50
+ },
51
+ response: {
52
+ bio: z.string(),
53
+ avatar: z.string().optional(),
54
+ },
55
+ });
56
+
57
+ let receivedUser: unknown;
58
+ let receivedMadeBy: unknown;
59
+ let requestOwner: Account | Group;
60
+
61
+ server.use(
62
+ http.post("https://api.example.com/api/user", async ({ request }) => {
63
+ try {
64
+ return await userRequest.handle(
65
+ request,
66
+ worker,
67
+ async (user, madeBy) => {
68
+ receivedUser = user.toJSON();
69
+ requestOwner = user._owner;
70
+ receivedMadeBy = madeBy.id;
71
+
72
+ // Return a plain object (CoMapInit) instead of a CoMap instance
73
+ return {
74
+ bio: `Profile for ${user.name}`,
75
+ avatar: `https://example.com/avatars/${user.email}.jpg`,
76
+ };
77
+ },
78
+ );
79
+ } catch (error) {
80
+ console.error(error);
81
+ throw error;
82
+ }
83
+ }),
84
+ );
85
+
86
+ // Send a plain object (CoMapInit) instead of a CoMap instance
87
+ const response = await userRequest.send(
88
+ {
89
+ name: "John Doe",
90
+ email: "john@example.com",
91
+ age: 30,
92
+ },
93
+ { owner: me },
94
+ );
95
+
96
+ // Verify the response is a proper CoMap instance
97
+ expect(response.bio).toEqual("Profile for John Doe");
98
+ expect(response.avatar).toEqual(
99
+ "https://example.com/avatars/john@example.com.jpg",
100
+ );
101
+
102
+ expect(requestOwner!.members.map((m) => [m.account.id, m.role])).toEqual([
103
+ [me.id, "admin"],
104
+ [worker.id, "writer"],
105
+ ]);
106
+
107
+ expect(
108
+ response._owner.members.map((m) => [m.account.id, m.role]),
109
+ ).toEqual([
110
+ [worker.id, "admin"],
111
+ [me.id, "reader"],
112
+ ]);
113
+
114
+ // Verify the server received the correct data
115
+ expect(receivedUser).toMatchObject({
116
+ _type: "CoMap",
117
+ name: "John Doe",
118
+ email: "john@example.com",
119
+ age: 30,
120
+ });
121
+ expect(receivedMadeBy).toEqual(me.id);
122
+ });
123
+
124
+ it("should push the response content directly to the client", async () => {
125
+ const { me, worker } = await setupAccounts();
126
+
127
+ const group = Group.create(me);
128
+ group.addMember("everyone", "writer");
129
+
130
+ const Address = co.map({
131
+ street: co.plainText(),
132
+ city: co.plainText(),
133
+ });
134
+
135
+ const Person = co.map({
136
+ name: z.string(),
137
+ address: Address,
138
+ });
139
+
140
+ const userRequest = experimental_defineRequest({
141
+ url: "https://api.example.com/api/user",
142
+ workerId: worker.id,
143
+ request: {
144
+ name: z.string(),
145
+ email: z.string(),
146
+ age: z.number(),
147
+ },
148
+ response: {
149
+ schema: {
150
+ person: Person,
151
+ },
152
+ resolve: {
153
+ person: {
154
+ address: {
155
+ street: true,
156
+ city: true,
157
+ },
158
+ },
159
+ },
160
+ },
161
+ });
162
+
163
+ server.use(
164
+ http.post("https://api.example.com/api/user", async ({ request }) => {
165
+ try {
166
+ return await userRequest.handle(
167
+ request,
168
+ worker,
169
+ async (user, madeBy) => {
170
+ const group = Group.create(me);
171
+ group.addMember(madeBy, "writer");
172
+
173
+ const person = Person.create(
174
+ {
175
+ name: user.name,
176
+ address: Address.create(
177
+ {
178
+ street: CoPlainText.create("123 Main St", group),
179
+ city: CoPlainText.create("New York", group),
180
+ },
181
+ group,
182
+ ),
183
+ },
184
+ group,
185
+ );
186
+
187
+ return {
188
+ person,
189
+ };
190
+ },
191
+ );
192
+ } catch (error) {
193
+ console.error(error);
194
+ throw error;
195
+ }
196
+ }),
197
+ );
198
+
199
+ // Send a plain object (CoMapInit) instead of a CoMap instance
200
+ const response = await userRequest.send(
201
+ {
202
+ name: "John Doe",
203
+ email: "john@example.com",
204
+ age: 30,
205
+ },
206
+ { owner: me },
207
+ );
208
+
209
+ // Verify the response is a proper CoMap instance
210
+ expect(response.person.name).toEqual("John Doe");
211
+ expect(response.person.address.street.toString()).toEqual("123 Main St");
212
+ expect(response.person.address.city.toString()).toEqual("New York");
213
+ });
214
+ });
215
+
216
+ it("should handle errors on child covalues gracefully", async () => {
217
+ const { me, worker } = await setupAccounts();
218
+
219
+ await linkAccounts(me, worker);
220
+
221
+ const Address = co.map({
222
+ street: co.plainText(),
223
+ city: co.plainText(),
224
+ });
225
+
226
+ const Person = co.map({
227
+ name: z.string(),
228
+ address: Address,
229
+ });
230
+
231
+ const privateToWorker = Group.create(worker);
232
+ const privateToMe = Group.create(me);
233
+ const publicGroup = Group.create(me).makePublic();
234
+ const address = Address.create(
235
+ {
236
+ street: CoPlainText.create("123 Main St", privateToWorker),
237
+ city: CoPlainText.create("New York", privateToMe),
238
+ },
239
+ publicGroup,
240
+ );
241
+ const person = Person.create(
242
+ {
243
+ name: "John",
244
+ address,
245
+ },
246
+ publicGroup,
247
+ );
248
+
249
+ const personRequest = experimental_defineRequest({
250
+ url: "https://api.example.com/api/person",
251
+ workerId: worker.id,
252
+ request: {
253
+ schema: {
254
+ person: Person,
255
+ },
256
+ resolve: { person: { address: { street: true } } },
257
+ },
258
+ response: {
259
+ schema: {
260
+ person: Person,
261
+ },
262
+ resolve: { person: { address: { street: true, city: true } } },
263
+ },
264
+ });
265
+
266
+ server.use(
267
+ http.post("https://api.example.com/api/person", async ({ request }) => {
268
+ return personRequest.handle(
269
+ request,
270
+ worker,
271
+ async ({ person }, madeBy) => {
272
+ person.address.street._owner
273
+ .castAs(Group)
274
+ .addMember(madeBy, "reader");
275
+
276
+ // The request should handle the error gracefully when trying to resolve
277
+ // child covalues that the worker doesn't have access to
278
+ return { person };
279
+ },
280
+ );
281
+ }),
282
+ );
283
+
284
+ // Send the request - this should not throw even though the worker
285
+ // doesn't have access to the address's child covalues
286
+ const response = await personRequest.send({ person }, { owner: me });
287
+
288
+ // Verify the response is still a proper Person instance
289
+ expect(response.person.name).toEqual("John");
290
+ expect(response.person.address.street.toString()).toBe("123 Main St");
291
+ expect(response.person.address.city.toString()).toBe("New York");
292
+ });
293
+
294
+ it("should accept void responses", async () => {
295
+ const { me, worker } = await setupAccounts();
296
+
297
+ const group = Group.create(me);
298
+ group.addMember("everyone", "writer");
299
+
300
+ const userRequest = experimental_defineRequest({
301
+ url: "https://api.example.com/api/user",
302
+ workerId: worker.id,
303
+ request: {
304
+ name: z.string(),
305
+ email: z.string(),
306
+ age: z.number(),
307
+ },
308
+ response: {},
309
+ });
310
+
311
+ let receivedUser: unknown;
312
+ let receivedMadeBy: unknown;
313
+
314
+ server.use(
315
+ http.post("https://api.example.com/api/user", async ({ request }) => {
316
+ try {
317
+ return await userRequest.handle(
318
+ request,
319
+ worker,
320
+ async (user, madeBy) => {
321
+ receivedUser = user.toJSON();
322
+ receivedMadeBy = madeBy.id;
323
+ },
324
+ );
325
+ } catch (error) {
326
+ console.error(error);
327
+ throw error;
328
+ }
329
+ }),
330
+ );
331
+
332
+ // Send a plain object (CoMapInit) instead of a CoMap instance
333
+ await userRequest.send(
334
+ {
335
+ name: "John Doe",
336
+ email: "john@example.com",
337
+ age: 30,
338
+ },
339
+ { owner: me },
340
+ );
341
+
342
+ // Verify the server received the correct data
343
+ expect(receivedUser).toMatchObject({
344
+ _type: "CoMap",
345
+ name: "John Doe",
346
+ email: "john@example.com",
347
+ age: 30,
348
+ });
349
+ expect(receivedMadeBy).toEqual(me.id);
350
+ });
351
+
352
+ it("should accept group as workerId", async () => {
353
+ const { me, worker } = await setupAccounts();
354
+
355
+ await linkAccounts(me, worker);
356
+
357
+ // Create a group that will act as the worker
358
+ const workerGroup = Group.create(worker);
359
+
360
+ const userRequest = experimental_defineRequest({
361
+ url: "https://api.example.com/api/user",
362
+ workerId: workerGroup.id, // Use group ID instead of account ID
363
+ request: {
364
+ name: z.string(),
365
+ email: z.string(),
366
+ age: z.number(),
367
+ },
368
+ response: {
369
+ bio: z.string(),
370
+ avatar: z.string().optional(),
371
+ },
372
+ });
373
+
374
+ let receivedUser: unknown;
375
+ let receivedMadeBy: unknown;
376
+
377
+ server.use(
378
+ http.post("https://api.example.com/api/user", async ({ request }) => {
379
+ try {
380
+ return await userRequest.handle(
381
+ request,
382
+ worker, // The worker account handles the request
383
+ async (user, madeBy) => {
384
+ receivedUser = user.toJSON();
385
+ receivedMadeBy = madeBy.id;
386
+
387
+ return {
388
+ bio: `Profile for ${user.name}`,
389
+ avatar: `https://example.com/avatars/${user.email}.jpg`,
390
+ };
391
+ },
392
+ );
393
+ } catch (error) {
394
+ console.error(error);
395
+ throw error;
396
+ }
397
+ }),
398
+ );
399
+
400
+ // Send a request - this should work with group as workerId
401
+ const response = await userRequest.send(
402
+ {
403
+ name: "John Doe",
404
+ email: "john@example.com",
405
+ age: 30,
406
+ },
407
+ { owner: me },
408
+ );
409
+
410
+ // Verify the response is a proper CoMap instance
411
+ expect(response.bio).toEqual("Profile for John Doe");
412
+ expect(response.avatar).toEqual(
413
+ "https://example.com/avatars/john@example.com.jpg",
414
+ );
415
+
416
+ // Verify the response owner structure - should include the worker account
417
+ expect(response._owner.members.map((m) => [m.account.id, m.role])).toEqual([
418
+ [worker.id, "admin"],
419
+ [me.id, "reader"],
420
+ ]);
421
+
422
+ // Verify the server received the correct data
423
+ expect(receivedUser).toMatchObject({
424
+ _type: "CoMap",
425
+ name: "John Doe",
426
+ email: "john@example.com",
427
+ age: 30,
428
+ });
429
+ expect(receivedMadeBy).toEqual(me.id);
430
+ });
431
+ });
432
+
433
+ describe("JazzRequestError handling", () => {
434
+ describe("System-defined errors in request.ts", () => {
435
+ it("should throw error when request payload is invalid", async () => {
436
+ const { me, worker } = await setupAccounts();
437
+
438
+ const userRequest = experimental_defineRequest({
439
+ url: "https://api.example.com/api/user",
440
+ workerId: worker.id,
441
+ request: {
442
+ name: z.string(),
443
+ email: z.string(),
444
+ },
445
+ response: {
446
+ bio: z.string(),
447
+ },
448
+ });
449
+
450
+ server.use(
451
+ http.post("https://api.example.com/api/user", async ({ request }) => {
452
+ return userRequest.handle(request, worker, async (user, madeBy) => {
453
+ return { bio: "test" };
454
+ });
455
+ }),
456
+ );
457
+
458
+ // Mock fetch to return invalid JSON
459
+ const originalFetch = global.fetch;
460
+ global.fetch = vi.fn().mockResolvedValue({
461
+ ok: true,
462
+ json: () => Promise.resolve({ invalid: "payload" }),
463
+ });
464
+
465
+ await expect(
466
+ userRequest.send(
467
+ {
468
+ name: "John Doe",
469
+ email: "john@example.com",
470
+ },
471
+ { owner: me },
472
+ ),
473
+ ).rejects.toMatchInlineSnapshot(`
474
+ {
475
+ "code": 400,
476
+ "details": [ZodError: [
477
+ {
478
+ "code": "invalid_value",
479
+ "values": [
480
+ "success"
481
+ ],
482
+ "path": [
483
+ "type"
484
+ ],
485
+ "message": "Invalid input: expected \\"success\\""
486
+ }
487
+ ]],
488
+ "message": "Response payload is not valid",
489
+ }
490
+ `);
491
+
492
+ global.fetch = originalFetch;
493
+ });
494
+
495
+ it("should throw error when request payload is already handled", async () => {
496
+ const { me, worker } = await setupAccounts();
497
+
498
+ const userRequest = experimental_defineRequest({
499
+ url: "https://api.example.com/api/user",
500
+ workerId: worker.id,
501
+ request: {
502
+ name: z.string(),
503
+ email: z.string(),
504
+ },
505
+ response: {
506
+ bio: z.string(),
507
+ },
508
+ });
509
+
510
+ server.use(
511
+ http.post("https://api.example.com/api/user", async ({ request }) => {
512
+ // Mock to make it possible to call json() twice
513
+ const body = await request.json();
514
+ vi.spyOn(request, "json").mockResolvedValue(body);
515
+
516
+ // First call should succeed
517
+ await userRequest.handle(request, worker, async () => {
518
+ return { bio: "test" };
519
+ });
520
+
521
+ // Second call with same ID should fail
522
+ return userRequest.handle(request, worker, async () => {
523
+ return { bio: "test" };
524
+ });
525
+ }),
526
+ );
527
+
528
+ await expect(
529
+ userRequest.send(
530
+ {
531
+ name: "John Doe",
532
+ email: "john@example.com",
533
+ },
534
+ { owner: me },
535
+ ),
536
+ ).rejects.toMatchInlineSnapshot(`
537
+ {
538
+ "code": 400,
539
+ "details": undefined,
540
+ "message": "Request payload is already handled",
541
+ }
542
+ `);
543
+ });
544
+
545
+ it("should throw error when authentication token is expired", async () => {
546
+ const { me, worker } = await setupAccounts();
547
+
548
+ const userRequest = experimental_defineRequest({
549
+ url: "https://api.example.com/api/user",
550
+ workerId: worker.id,
551
+ request: {
552
+ name: z.string(),
553
+ email: z.string(),
554
+ },
555
+ response: {
556
+ bio: z.string(),
557
+ },
558
+ });
559
+
560
+ server.use(
561
+ http.post("https://api.example.com/api/user", async ({ request }) => {
562
+ const body = await request.json();
563
+
564
+ assert(typeof body === "object");
565
+ assert(body);
566
+
567
+ body.createdAt = Date.now() - 1000 * 70; // 70 seconds ago
568
+ vi.spyOn(request, "json").mockResolvedValue(body);
569
+
570
+ return userRequest.handle(request, worker, async () => {
571
+ return { bio: "test" };
572
+ });
573
+ }),
574
+ );
575
+
576
+ await expect(
577
+ userRequest.send(
578
+ {
579
+ name: "John Doe",
580
+ email: "john@example.com",
581
+ },
582
+ { owner: me },
583
+ ),
584
+ ).rejects.toMatchInlineSnapshot(`
585
+ {
586
+ "code": 401,
587
+ "details": undefined,
588
+ "message": "Authentication token is expired",
589
+ }
590
+ `);
591
+ });
592
+
593
+ it("should throw error when signature is invalid", async () => {
594
+ const { me, worker } = await setupAccounts();
595
+
596
+ const userRequest = experimental_defineRequest({
597
+ url: "https://api.example.com/api/user",
598
+ workerId: worker.id,
599
+ request: {
600
+ name: z.string(),
601
+ email: z.string(),
602
+ },
603
+ response: {
604
+ bio: z.string(),
605
+ },
606
+ });
607
+
608
+ server.use(
609
+ http.post("https://api.example.com/api/user", async ({ request }) => {
610
+ const body = await request.json();
611
+
612
+ assert(typeof body === "object");
613
+ assert(body);
614
+
615
+ body.authToken = "signature_zinvalid";
616
+ vi.spyOn(request, "json").mockResolvedValue(body);
617
+
618
+ return userRequest.handle(request, worker, async () => {
619
+ return { bio: "test" };
620
+ });
621
+ }),
622
+ );
623
+
624
+ await expect(
625
+ userRequest.send(
626
+ {
627
+ name: "John Doe",
628
+ email: "john@example.com",
629
+ },
630
+ { owner: me },
631
+ ),
632
+ ).rejects.toMatchInlineSnapshot(`
633
+ {
634
+ "code": 401,
635
+ "details": undefined,
636
+ "message": "Invalid signature",
637
+ }
638
+ `);
639
+ });
640
+
641
+ it("should throw error when creator account not found", async () => {
642
+ const { me, worker } = await setupAccounts();
643
+
644
+ const userRequest = experimental_defineRequest({
645
+ url: "https://api.example.com/api/user",
646
+ workerId: worker.id,
647
+ request: {
648
+ name: z.string(),
649
+ email: z.string(),
650
+ },
651
+ response: {
652
+ bio: z.string(),
653
+ },
654
+ });
655
+
656
+ server.use(
657
+ http.post("https://api.example.com/api/user", async ({ request }) => {
658
+ vi.spyOn(Account, "load").mockResolvedValue(null);
659
+
660
+ return userRequest.handle(request, worker, async () => {
661
+ return { bio: "test" };
662
+ });
663
+ }),
664
+ );
665
+
666
+ await expect(
667
+ userRequest.send(
668
+ {
669
+ name: "John Doe",
670
+ email: "john@example.com",
671
+ },
672
+ { owner: me },
673
+ ),
674
+ ).rejects.toMatchInlineSnapshot(`
675
+ {
676
+ "code": 400,
677
+ "details": undefined,
678
+ "message": "Creator account not found",
679
+ }
680
+ `);
681
+
682
+ vi.restoreAllMocks();
683
+ });
684
+
685
+ it("should throw error when there are not enough permissions to resolve the request payload", async () => {
686
+ const { me, worker } = await setupAccounts();
687
+
688
+ // Link the accounts to ensure that the request payload is loaded
689
+ await linkAccounts(me, worker);
690
+
691
+ const User = co.map({
692
+ name: z.string(),
693
+ email: z.string(),
694
+ });
695
+
696
+ const userRequest = experimental_defineRequest({
697
+ url: "https://api.example.com/api/user",
698
+ workerId: worker.id,
699
+ request: {
700
+ schema: {
701
+ user: User,
702
+ },
703
+ resolve: {
704
+ user: true,
705
+ },
706
+ },
707
+ response: {
708
+ bio: z.string(),
709
+ },
710
+ });
711
+
712
+ server.use(
713
+ http.post("https://api.example.com/api/user", async ({ request }) => {
714
+ return userRequest.handle(request, worker, async (user, madeBy) => {
715
+ return { bio: "test" };
716
+ });
717
+ }),
718
+ );
719
+
720
+ await expect(
721
+ userRequest.send(
722
+ {
723
+ user: User.create(
724
+ {
725
+ name: "John Doe",
726
+ email: "john@example.com",
727
+ },
728
+ me,
729
+ ),
730
+ },
731
+ { owner: me },
732
+ ),
733
+ ).rejects.toMatchInlineSnapshot(`
734
+ {
735
+ "code": 400,
736
+ "details": undefined,
737
+ "message": "Value not found",
738
+ }
739
+ `);
740
+
741
+ vi.restoreAllMocks();
742
+ });
743
+
744
+ it("should throw error when the request payload is not found", async () => {
745
+ const { me, worker } = await setupAccounts();
746
+
747
+ const User = co.map({
748
+ name: z.string(),
749
+ email: z.string(),
750
+ });
751
+
752
+ const userRequest = experimental_defineRequest({
753
+ url: "https://api.example.com/api/user",
754
+ workerId: worker.id,
755
+ request: {
756
+ schema: {
757
+ user: User,
758
+ },
759
+ resolve: {
760
+ user: true,
761
+ },
762
+ },
763
+ response: {
764
+ bio: z.string(),
765
+ },
766
+ });
767
+
768
+ server.use(
769
+ http.post("https://api.example.com/api/user", async ({ request }) => {
770
+ return userRequest.handle(request, worker, async (user, madeBy) => {
771
+ return { bio: "test" };
772
+ });
773
+ }),
774
+ );
775
+
776
+ const group = Group.create(me);
777
+ group.makePublic();
778
+
779
+ const user = User.create(
780
+ {
781
+ name: "John Doe",
782
+ email: "john@example.com",
783
+ },
784
+ group,
785
+ );
786
+
787
+ await expect(
788
+ userRequest.send(
789
+ {
790
+ user,
791
+ },
792
+ { owner: me },
793
+ ),
794
+ ).rejects.toMatchInlineSnapshot(`
795
+ {
796
+ "code": 400,
797
+ "details": undefined,
798
+ "message": "Value not found",
799
+ }
800
+ `);
801
+
802
+ vi.restoreAllMocks();
803
+ });
804
+
805
+ it("should throw error when the server returns a non-200 status code", async () => {
806
+ const { me, worker } = await setupAccounts();
807
+
808
+ const userRequest = experimental_defineRequest({
809
+ url: "https://api.example.com/api/user",
810
+ workerId: worker.id,
811
+ request: {
812
+ name: z.string(),
813
+ email: z.string(),
814
+ },
815
+ response: {
816
+ bio: z.string(),
817
+ },
818
+ });
819
+
820
+ server.use(
821
+ http.post("https://api.example.com/api/user", async ({ request }) => {
822
+ return new Response("Request failed", { status: 500 });
823
+ }),
824
+ );
825
+
826
+ await expect(
827
+ userRequest.send(
828
+ {
829
+ name: "John Doe",
830
+ email: "john@example.com",
831
+ },
832
+ { owner: me },
833
+ ),
834
+ ).rejects.toMatchInlineSnapshot(`
835
+ {
836
+ "code": 500,
837
+ "details": undefined,
838
+ "message": "Request failed",
839
+ }
840
+ `);
841
+ });
842
+
843
+ it("should throw error when HTTP request fails", async () => {
844
+ const { me, worker } = await setupAccounts();
845
+
846
+ const userRequest = experimental_defineRequest({
847
+ url: "https://api.example.com/api/user",
848
+ workerId: worker.id,
849
+ request: {
850
+ name: z.string(),
851
+ email: z.string(),
852
+ },
853
+ response: {
854
+ bio: z.string(),
855
+ },
856
+ });
857
+
858
+ server.close();
859
+
860
+ await expect(
861
+ userRequest.send(
862
+ {
863
+ name: "John Doe",
864
+ email: "john@example.com",
865
+ },
866
+ { owner: me },
867
+ ),
868
+ ).rejects.toThrow("fetch failed");
869
+
870
+ server.listen();
871
+ });
872
+ });
873
+
874
+ describe("User-defined errors from examples", () => {
875
+ it("should handle user-defined errors", async () => {
876
+ const { me, worker } = await setupAccounts();
877
+
878
+ const userRequest = experimental_defineRequest({
879
+ url: "https://api.example.com/api/user",
880
+ workerId: worker.id,
881
+ request: {
882
+ name: z.string(),
883
+ email: z.string(),
884
+ },
885
+ response: {
886
+ bio: z.string(),
887
+ },
888
+ });
889
+
890
+ server.use(
891
+ http.post("https://api.example.com/api/user", async ({ request }) => {
892
+ return userRequest.handle(request, worker, async (user, madeBy) => {
893
+ throw new JazzRequestError("Custom server error", 400, {
894
+ detail: "Some details",
895
+ });
896
+ });
897
+ }),
898
+ );
899
+
900
+ await expect(
901
+ userRequest.send(
902
+ {
903
+ name: "John Doe",
904
+ email: "john@example.com",
905
+ },
906
+ { owner: me },
907
+ ),
908
+ ).rejects.toMatchInlineSnapshot(`
909
+ {
910
+ "code": 400,
911
+ "details": {
912
+ "detail": "Some details",
913
+ },
914
+ "message": "Custom server error",
915
+ }
916
+ `);
917
+ });
918
+ });
919
+
920
+ describe("JazzRequestError class", () => {
921
+ it("should create JazzRequestError with correct properties", () => {
922
+ const error = new JazzRequestError("Test error", 400, { detail: "test" });
923
+
924
+ expect(error.message).toBe("Test error");
925
+ expect(error.code).toBe(400);
926
+ expect(error.details).toEqual({ detail: "test" });
927
+ expect(error.isJazzRequestError).toBe(true);
928
+ });
929
+
930
+ it("should serialize to JSON correctly", () => {
931
+ const error = new JazzRequestError("Test error", 400, { detail: "test" });
932
+ const json = error.toJSON();
933
+
934
+ expect(json).toEqual({
935
+ message: "Test error",
936
+ code: 400,
937
+ details: { detail: "test" },
938
+ });
939
+ });
940
+
941
+ it("should be identified by isJazzRequestError function", () => {
942
+ const error = new JazzRequestError("Test error", 400);
943
+ const regularError = new Error("Regular error");
944
+
945
+ expect(isJazzRequestError(error)).toBe(true);
946
+ expect(isJazzRequestError(regularError)).toBe(false);
947
+ expect(isJazzRequestError({ isJazzRequestError: true })).toBe(true);
948
+ expect(isJazzRequestError(null)).toBe(false);
949
+ });
950
+ });
951
+ });