@fragno-dev/core 0.0.1

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 (108) hide show
  1. package/.turbo/turbo-build.log +61 -0
  2. package/.turbo/turbo-types$colon$check.log +2 -0
  3. package/dist/api/api.d.ts +2 -0
  4. package/dist/api/api.js +3 -0
  5. package/dist/api-CBDGZiLC.d.ts +278 -0
  6. package/dist/api-CBDGZiLC.d.ts.map +1 -0
  7. package/dist/api-DgHfYjq2.js +54 -0
  8. package/dist/api-DgHfYjq2.js.map +1 -0
  9. package/dist/client/client.d.ts +3 -0
  10. package/dist/client/client.js +6 -0
  11. package/dist/client/client.svelte.d.ts +33 -0
  12. package/dist/client/client.svelte.d.ts.map +1 -0
  13. package/dist/client/client.svelte.js +123 -0
  14. package/dist/client/client.svelte.js.map +1 -0
  15. package/dist/client/react.d.ts +58 -0
  16. package/dist/client/react.d.ts.map +1 -0
  17. package/dist/client/react.js +80 -0
  18. package/dist/client/react.js.map +1 -0
  19. package/dist/client/vanilla.d.ts +61 -0
  20. package/dist/client/vanilla.d.ts.map +1 -0
  21. package/dist/client/vanilla.js +136 -0
  22. package/dist/client/vanilla.js.map +1 -0
  23. package/dist/client/vue.d.ts +39 -0
  24. package/dist/client/vue.d.ts.map +1 -0
  25. package/dist/client/vue.js +108 -0
  26. package/dist/client/vue.js.map +1 -0
  27. package/dist/client-DWjxKDnE.js +703 -0
  28. package/dist/client-DWjxKDnE.js.map +1 -0
  29. package/dist/client-XFdAy-IQ.d.ts +287 -0
  30. package/dist/client-XFdAy-IQ.d.ts.map +1 -0
  31. package/dist/integrations/astro.d.ts +18 -0
  32. package/dist/integrations/astro.d.ts.map +1 -0
  33. package/dist/integrations/astro.js +16 -0
  34. package/dist/integrations/astro.js.map +1 -0
  35. package/dist/integrations/next-js.d.ts +15 -0
  36. package/dist/integrations/next-js.d.ts.map +1 -0
  37. package/dist/integrations/next-js.js +17 -0
  38. package/dist/integrations/next-js.js.map +1 -0
  39. package/dist/integrations/react-ssr.d.ts +19 -0
  40. package/dist/integrations/react-ssr.d.ts.map +1 -0
  41. package/dist/integrations/react-ssr.js +38 -0
  42. package/dist/integrations/react-ssr.js.map +1 -0
  43. package/dist/integrations/svelte-kit.d.ts +21 -0
  44. package/dist/integrations/svelte-kit.d.ts.map +1 -0
  45. package/dist/integrations/svelte-kit.js +18 -0
  46. package/dist/integrations/svelte-kit.js.map +1 -0
  47. package/dist/mod.d.ts +3 -0
  48. package/dist/mod.js +177 -0
  49. package/dist/mod.js.map +1 -0
  50. package/dist/route-Bp6eByhz.js +331 -0
  51. package/dist/route-Bp6eByhz.js.map +1 -0
  52. package/dist/ssr-tJHqcNSw.js +48 -0
  53. package/dist/ssr-tJHqcNSw.js.map +1 -0
  54. package/package.json +127 -0
  55. package/src/api/api.test.ts +140 -0
  56. package/src/api/api.ts +106 -0
  57. package/src/api/error.ts +47 -0
  58. package/src/api/fragment.test.ts +509 -0
  59. package/src/api/fragment.ts +277 -0
  60. package/src/api/internal/path-runtime.test.ts +121 -0
  61. package/src/api/internal/path-type.test.ts +602 -0
  62. package/src/api/internal/path.ts +322 -0
  63. package/src/api/internal/response-stream.ts +118 -0
  64. package/src/api/internal/route.test.ts +56 -0
  65. package/src/api/internal/route.ts +9 -0
  66. package/src/api/request-input-context.test.ts +437 -0
  67. package/src/api/request-input-context.ts +201 -0
  68. package/src/api/request-middleware.test.ts +544 -0
  69. package/src/api/request-middleware.ts +126 -0
  70. package/src/api/request-output-context.test.ts +626 -0
  71. package/src/api/request-output-context.ts +175 -0
  72. package/src/api/route.test.ts +176 -0
  73. package/src/api/route.ts +152 -0
  74. package/src/client/client-builder.test.ts +264 -0
  75. package/src/client/client-error.test.ts +15 -0
  76. package/src/client/client-error.ts +141 -0
  77. package/src/client/client-types.test.ts +493 -0
  78. package/src/client/client.ssr.test.ts +173 -0
  79. package/src/client/client.svelte.test.ts +837 -0
  80. package/src/client/client.svelte.ts +278 -0
  81. package/src/client/client.test.ts +1690 -0
  82. package/src/client/client.ts +1035 -0
  83. package/src/client/component.test.svelte +21 -0
  84. package/src/client/internal/ndjson-streaming.test.ts +457 -0
  85. package/src/client/internal/ndjson-streaming.ts +248 -0
  86. package/src/client/react.test.ts +947 -0
  87. package/src/client/react.ts +241 -0
  88. package/src/client/vanilla.test.ts +867 -0
  89. package/src/client/vanilla.ts +265 -0
  90. package/src/client/vue.test.ts +754 -0
  91. package/src/client/vue.ts +242 -0
  92. package/src/http/http-status.ts +60 -0
  93. package/src/integrations/astro.ts +17 -0
  94. package/src/integrations/next-js.ts +31 -0
  95. package/src/integrations/react-ssr.ts +40 -0
  96. package/src/integrations/svelte-kit.ts +41 -0
  97. package/src/mod.ts +20 -0
  98. package/src/util/async.test.ts +85 -0
  99. package/src/util/async.ts +96 -0
  100. package/src/util/content-type.test.ts +136 -0
  101. package/src/util/content-type.ts +84 -0
  102. package/src/util/nanostores.test.ts +28 -0
  103. package/src/util/nanostores.ts +65 -0
  104. package/src/util/ssr.ts +75 -0
  105. package/src/util/types-util.ts +16 -0
  106. package/tsconfig.json +10 -0
  107. package/tsdown.config.ts +21 -0
  108. package/vitest.config.ts +10 -0
@@ -0,0 +1,754 @@
1
+ import { test, expect, describe, vi, beforeEach, afterEach, assert } from "vitest";
2
+ import { type FragnoPublicClientConfig } from "../mod";
3
+ import { createClientBuilder } from "./client";
4
+ import { defineRoute } from "../api/route";
5
+ import { defineFragment } from "../api/fragment";
6
+ import { z } from "zod";
7
+ import { refToAtom, useFragno } from "./vue";
8
+ import { waitFor } from "@testing-library/vue";
9
+ import { nextTick, ref, watch } from "vue";
10
+ import { FragnoClientUnknownApiError } from "./client-error";
11
+ import { atom } from "nanostores";
12
+
13
+ global.fetch = vi.fn();
14
+
15
+ async function sleep(ms: number) {
16
+ return new Promise((resolve) => setTimeout(resolve, ms));
17
+ }
18
+
19
+ describe("refToAtom", () => {
20
+ test("should create an atom from a ref", () => {
21
+ const r = ref(123);
22
+ const a = refToAtom(r);
23
+ expect(a.get()).toBe(123);
24
+ });
25
+
26
+ test("should update the atom when the ref changes", async () => {
27
+ const r = ref(123);
28
+ const a = refToAtom(r);
29
+ expect(a.get()).toBe(123);
30
+ r.value = 456;
31
+ await sleep(0);
32
+ expect(a.get()).toBe(456);
33
+ });
34
+
35
+ test("ref nested in object", async () => {
36
+ const obj = ref({ a: 1, nested: { b: 2 } });
37
+
38
+ let listenExecuted = false;
39
+ watch(
40
+ obj,
41
+ (newObj) => {
42
+ expect(newObj.a).toBe(3);
43
+ expect(newObj.nested.b).toBe(5);
44
+ listenExecuted = true;
45
+ },
46
+ { deep: true },
47
+ );
48
+
49
+ obj.value.a = 3;
50
+ obj.value.nested.b = 5;
51
+
52
+ await nextTick();
53
+ expect(listenExecuted).toBe(true);
54
+ });
55
+ });
56
+
57
+ describe("createVueHook", () => {
58
+ const clientConfig: FragnoPublicClientConfig = {
59
+ baseUrl: "http://localhost:3000",
60
+ };
61
+
62
+ beforeEach(() => {
63
+ vi.clearAllMocks();
64
+ (global.fetch as ReturnType<typeof vi.fn>).mockReset();
65
+ });
66
+
67
+ afterEach(() => {
68
+ vi.restoreAllMocks();
69
+ });
70
+
71
+ test("Hook should function", async () => {
72
+ const testFragmentDefinition = defineFragment("test-fragment");
73
+ const testRoutes = [
74
+ defineRoute({
75
+ method: "GET",
76
+ path: "/users",
77
+ outputSchema: z.array(z.object({ id: z.number(), name: z.string() })),
78
+ handler: async (_ctx, { json }) => json([{ id: 1, name: "John" }]),
79
+ }),
80
+ ] as const;
81
+
82
+ vi.mocked(global.fetch).mockImplementationOnce(
83
+ async () =>
84
+ ({
85
+ headers: new Headers(),
86
+ ok: true,
87
+ json: async () => [{ id: 1, name: "John" }],
88
+ }) as Response,
89
+ );
90
+
91
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
92
+ const clientObj = {
93
+ useUsers: client.createHook("/users"),
94
+ };
95
+
96
+ const { useUsers } = useFragno(clientObj);
97
+ const { loading, data, error } = useUsers();
98
+
99
+ await waitFor(() => {
100
+ expect(loading.value).toBe(false);
101
+ });
102
+
103
+ expect(data.value).toEqual([{ id: 1, name: "John" }]);
104
+ expect(error.value).toBeUndefined();
105
+ });
106
+
107
+ test("Should support path parameters and update reactively when Vue ref changes", async () => {
108
+ const testFragmentDefinition = defineFragment("test-fragment");
109
+ const testRoutes = [
110
+ defineRoute({
111
+ method: "GET",
112
+ path: "/users/:id",
113
+ outputSchema: z.object({ id: z.number(), name: z.string() }),
114
+ handler: async ({ pathParams }, { json }) =>
115
+ json({ id: Number(pathParams["id"]), name: "John" }),
116
+ }),
117
+ ] as const;
118
+
119
+ // Mock fetch to extract the user ID from the URL and return a user object with that ID.
120
+ vi.mocked(global.fetch).mockImplementation(async (input) => {
121
+ assert(typeof input === "string");
122
+
123
+ // Regex to extract id value from a URL string, matching only on /users/:id
124
+ const [, id] = String(input).match(/\/users\/([^/]+)/) ?? [];
125
+
126
+ expect(id).toBeDefined();
127
+ expect(+id).not.toBeNaN();
128
+
129
+ return {
130
+ headers: new Headers(),
131
+ ok: true,
132
+ json: async () => ({ id: Number(id), name: "John" }),
133
+ } as Response;
134
+ });
135
+
136
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
137
+ const clientObj = {
138
+ useUser: client.createHook("/users/:id"),
139
+ };
140
+
141
+ const id = ref("123");
142
+
143
+ const { useUser } = useFragno(clientObj);
144
+ const { loading, data, error } = useUser({ path: { id } });
145
+
146
+ await waitFor(() => {
147
+ expect(loading.value).toBe(false);
148
+ });
149
+
150
+ expect(data.value).toEqual({ id: 123, name: "John" });
151
+ expect(error.value).toBeUndefined();
152
+
153
+ // Update the id value
154
+ id.value = "456";
155
+
156
+ await waitFor(() => {
157
+ expect(data.value).toEqual({ id: 456, name: "John" });
158
+ });
159
+
160
+ expect(fetch).toHaveBeenCalledTimes(2);
161
+ });
162
+
163
+ test("Should support path parameters and update reactively when Nanostores Atom changes", async () => {
164
+ const testFragmentDefinition = defineFragment("test-fragment");
165
+ const testRoutes = [
166
+ defineRoute({
167
+ method: "GET",
168
+ path: "/users/:id",
169
+ outputSchema: z.object({ id: z.number(), name: z.string() }),
170
+ handler: async ({ pathParams }, { json }) =>
171
+ json({ id: Number(pathParams["id"]), name: "John" }),
172
+ }),
173
+ ] as const;
174
+
175
+ // Mock fetch to extract the user ID from the URL and return a user object with that ID.
176
+ vi.mocked(global.fetch).mockImplementation(async (input) => {
177
+ assert(typeof input === "string");
178
+
179
+ // Regex to extract id value from a URL string, matching only on /users/:id
180
+ const [, id] = String(input).match(/\/users\/([^/]+)/) ?? [];
181
+
182
+ expect(id).toBeDefined();
183
+ expect(+id).not.toBeNaN();
184
+
185
+ return {
186
+ headers: new Headers(),
187
+ ok: true,
188
+ json: async () => ({ id: Number(id), name: "John" }),
189
+ } as Response;
190
+ });
191
+
192
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
193
+ const clientObj = {
194
+ useUser: client.createHook("/users/:id"),
195
+ };
196
+
197
+ const id = atom("123");
198
+
199
+ const { useUser } = useFragno(clientObj);
200
+ const { loading, data, error } = useUser({ path: { id } });
201
+
202
+ await waitFor(() => {
203
+ expect(loading.value).toBe(false);
204
+ });
205
+
206
+ expect(data.value).toEqual({ id: 123, name: "John" });
207
+ expect(error.value).toBeUndefined();
208
+
209
+ // Update the id value
210
+ id.set("456");
211
+
212
+ await waitFor(() => {
213
+ expect(data.value).toEqual({ id: 456, name: "John" });
214
+ });
215
+
216
+ expect(fetch).toHaveBeenCalledTimes(2);
217
+ });
218
+
219
+ test("Should handle errors gracefully", async () => {
220
+ const testFragmentDefinition = defineFragment("test-fragment");
221
+ const testRoutes = [
222
+ defineRoute({
223
+ method: "GET",
224
+ path: "/users",
225
+ outputSchema: z.array(z.object({ id: z.number(), name: z.string() })),
226
+ handler: async (_ctx, { json }) => json([{ id: 1, name: "John" }]),
227
+ }),
228
+ ] as const;
229
+
230
+ vi.mocked(global.fetch).mockImplementationOnce(
231
+ async () =>
232
+ ({
233
+ headers: new Headers(),
234
+ ok: false,
235
+ status: 500,
236
+ statusText: "Internal Server Error",
237
+ json: async () => ({ message: "Server error" }),
238
+ }) as Response,
239
+ );
240
+
241
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
242
+ const clientObj = {
243
+ useUsers: client.createHook("/users"),
244
+ };
245
+
246
+ const { useUsers } = useFragno(clientObj);
247
+ const { loading, data, error } = useUsers();
248
+
249
+ await waitFor(() => {
250
+ expect(loading.value).toBe(false);
251
+ });
252
+
253
+ expect(data.value).toBeUndefined();
254
+ expect(error.value).toBeDefined();
255
+ expect(error.value).toBeInstanceOf(FragnoClientUnknownApiError);
256
+ });
257
+
258
+ test("Should track loading states correctly", async () => {
259
+ const testFragmentDefinition = defineFragment("test-fragment");
260
+ const testRoutes = [
261
+ defineRoute({
262
+ method: "GET",
263
+ path: "/users",
264
+ outputSchema: z.array(z.object({ id: z.number(), name: z.string() })),
265
+ handler: async (_ctx, { json }) => json([{ id: 1, name: "John" }]),
266
+ }),
267
+ ] as const;
268
+
269
+ let resolvePromise: (value: Response) => void;
270
+ const fetchPromise = new Promise<Response>((resolve) => {
271
+ resolvePromise = resolve;
272
+ });
273
+
274
+ vi.mocked(global.fetch).mockImplementationOnce(() => fetchPromise);
275
+
276
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
277
+ const clientObj = {
278
+ useUsers: client.createHook("/users"),
279
+ };
280
+
281
+ const { useUsers } = useFragno(clientObj);
282
+ const { loading, data, error } = useUsers();
283
+
284
+ // Initially loading should be true
285
+ expect(loading.value).toBe(true);
286
+ expect(data.value).toBeUndefined();
287
+ expect(error.value).toBeUndefined();
288
+
289
+ // Resolve the fetch
290
+ resolvePromise!({
291
+ headers: new Headers(),
292
+ ok: true,
293
+ json: async () => [{ id: 1, name: "John" }],
294
+ } as Response);
295
+
296
+ await waitFor(() => {
297
+ expect(loading.value).toBe(false);
298
+ });
299
+
300
+ expect(data.value).toEqual([{ id: 1, name: "John" }]);
301
+ expect(error.value).toBeUndefined();
302
+ });
303
+
304
+ test("Should handle mutator hooks", async () => {
305
+ const testFragmentDefinition = defineFragment("test-fragment");
306
+ const testRoutes = [
307
+ defineRoute({
308
+ method: "POST",
309
+ path: "/users",
310
+ inputSchema: z.object({ name: z.string(), email: z.string() }),
311
+ outputSchema: z.object({ id: z.number(), name: z.string(), email: z.string() }),
312
+ handler: async ({ input }, { json }) => {
313
+ const { name, email } = await input.valid();
314
+ return json({ id: 1, name, email });
315
+ },
316
+ }),
317
+ ] as const;
318
+
319
+ vi.mocked(global.fetch).mockImplementationOnce(
320
+ async () =>
321
+ ({
322
+ headers: new Headers(),
323
+ ok: true,
324
+ json: async () => ({ id: 1, name: "John Doe", email: "john@example.com" }),
325
+ }) as Response,
326
+ );
327
+
328
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
329
+ const clientObj = {
330
+ createUser: client.createMutator("POST", "/users"),
331
+ };
332
+
333
+ const { createUser } = useFragno(clientObj);
334
+ const { mutate, loading, data, error } = createUser();
335
+
336
+ expect(loading.value).toBe(false);
337
+ expect(data.value).toBeUndefined();
338
+ expect(error.value).toBeUndefined();
339
+
340
+ const result = await mutate({
341
+ body: { name: "John Doe", email: "john@example.com" },
342
+ });
343
+
344
+ expect(result).toEqual({ id: 1, name: "John Doe", email: "john@example.com" });
345
+ expect(data.value).toEqual({ id: 1, name: "John Doe", email: "john@example.com" });
346
+ expect(error.value).toBeUndefined();
347
+ });
348
+
349
+ test("Should handle query parameters", async () => {
350
+ const testFragmentDefinition = defineFragment("test-fragment");
351
+ const testRoutes = [
352
+ defineRoute({
353
+ method: "GET",
354
+ path: "/users",
355
+ outputSchema: z.array(z.object({ id: z.number(), name: z.string() })),
356
+ handler: async (_ctx, { json }) => json([{ id: 1, name: "John" }]),
357
+ }),
358
+ ] as const;
359
+
360
+ vi.mocked(global.fetch).mockImplementation(async (input) => {
361
+ assert(typeof input === "string");
362
+ expect(input).toContain("limit=10");
363
+ expect(input).toContain("page=1");
364
+
365
+ return {
366
+ headers: new Headers(),
367
+ ok: true,
368
+ json: async () => [{ id: 1, name: "John" }],
369
+ } as Response;
370
+ });
371
+
372
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
373
+ const clientObj = {
374
+ useUsers: client.createHook("/users"),
375
+ };
376
+
377
+ const limit = ref("10");
378
+ const page = ref("1");
379
+
380
+ const { useUsers } = useFragno(clientObj);
381
+ const { loading, data, error } = useUsers({ query: { limit, page } });
382
+
383
+ await waitFor(() => {
384
+ expect(loading.value).toBe(false);
385
+ });
386
+
387
+ expect(data.value).toEqual([{ id: 1, name: "John" }]);
388
+ expect(error.value).toBeUndefined();
389
+
390
+ // Update query params
391
+ limit.value = "20";
392
+ page.value = "2";
393
+
394
+ await waitFor(() => {
395
+ expect(fetch).toHaveBeenCalledTimes(2);
396
+ });
397
+
398
+ // Verify the second call has updated params
399
+ const lastCall = vi.mocked(global.fetch).mock.calls[1];
400
+ expect(lastCall[0]).toContain("limit=20");
401
+ expect(lastCall[0]).toContain("page=2");
402
+ });
403
+
404
+ test("Should handle multiple hooks together", async () => {
405
+ const testFragmentDefinition = defineFragment("test-fragment");
406
+ const testRoutes = [
407
+ defineRoute({
408
+ method: "GET",
409
+ path: "/users",
410
+ outputSchema: z.array(z.object({ id: z.number(), name: z.string() })),
411
+ handler: async (_ctx, { json }) => json([{ id: 1, name: "John" }]),
412
+ }),
413
+ defineRoute({
414
+ method: "GET",
415
+ path: "/posts",
416
+ outputSchema: z.array(z.object({ id: z.number(), title: z.string() })),
417
+ handler: async (_ctx, { json }) => json([{ id: 1, title: "First Post" }]),
418
+ }),
419
+ defineRoute({
420
+ method: "DELETE",
421
+ path: "/users/:id",
422
+ inputSchema: z.object({}),
423
+ outputSchema: z.object({ success: z.boolean() }),
424
+ handler: async (_ctx, { json }) => json({ success: true }),
425
+ }),
426
+ ] as const;
427
+
428
+ vi.mocked(global.fetch).mockImplementation(async (input) => {
429
+ assert(typeof input === "string");
430
+
431
+ if (input.includes("/users") && !input.includes("/users/")) {
432
+ return {
433
+ headers: new Headers(),
434
+ ok: true,
435
+ json: async () => [{ id: 1, name: "John" }],
436
+ } as Response;
437
+ } else if (input.includes("/posts")) {
438
+ return {
439
+ headers: new Headers(),
440
+ ok: true,
441
+ json: async () => [{ id: 1, title: "First Post" }],
442
+ } as Response;
443
+ } else if (input.includes("/users/")) {
444
+ return {
445
+ headers: new Headers(),
446
+ ok: true,
447
+ json: async () => ({ success: true }),
448
+ } as Response;
449
+ }
450
+
451
+ throw new Error(`Unexpected URL: ${input}`);
452
+ });
453
+
454
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
455
+ const clientObj = {
456
+ useUsers: client.createHook("/users"),
457
+ usePosts: client.createHook("/posts"),
458
+ deleteUser: client.createMutator("DELETE", "/users/:id"),
459
+ };
460
+
461
+ const { useUsers, usePosts, deleteUser } = useFragno(clientObj);
462
+
463
+ // Use multiple hooks
464
+ const users = useUsers();
465
+ const posts = usePosts();
466
+ const deleter = deleteUser();
467
+
468
+ await waitFor(() => {
469
+ expect(users.loading.value).toBe(false);
470
+ expect(posts.loading.value).toBe(false);
471
+ });
472
+
473
+ expect(users.data.value).toEqual([{ id: 1, name: "John" }]);
474
+ expect(posts.data.value).toEqual([{ id: 1, title: "First Post" }]);
475
+
476
+ // Use the mutator
477
+ const result = await deleter.mutate({
478
+ body: {},
479
+ path: { id: "1" },
480
+ });
481
+
482
+ expect(result).toEqual({ success: true });
483
+ });
484
+
485
+ test("Should handle mixed reactive parameters - ref path param, atom and ref query params, with reactive updates", async () => {
486
+ const testFragmentDefinition = defineFragment("test-fragment");
487
+ const testRoutes = [
488
+ defineRoute({
489
+ method: "GET",
490
+ path: "/users/:id/posts",
491
+ inputSchema: z.object({
492
+ limit: z.string().optional(),
493
+ category: z.string().optional(),
494
+ sort: z.string().optional(),
495
+ }),
496
+ outputSchema: z.array(
497
+ z.object({ id: z.number(), title: z.string(), category: z.string() }),
498
+ ),
499
+ handler: async (_ctx, { empty }) => empty(),
500
+ }),
501
+ ] as const;
502
+
503
+ // Mock fetch to verify URL construction and parameter passing
504
+ vi.mocked(global.fetch).mockImplementation(async (input) => {
505
+ assert(typeof input === "string");
506
+
507
+ // Extract user ID from path
508
+ const [, userId] = String(input).match(/\/users\/([^/]+)\/posts/) ?? [];
509
+ expect(userId).toBeDefined();
510
+ expect(+userId).not.toBeNaN();
511
+
512
+ // Parse query parameters
513
+ const url = new URL(input);
514
+ const limit = url.searchParams.get("limit") || "5";
515
+ const category = url.searchParams.get("category") || "general";
516
+ const sort = url.searchParams.get("sort") || "asc";
517
+
518
+ return {
519
+ headers: new Headers(),
520
+ ok: true,
521
+ json: async () => [
522
+ {
523
+ id: Number(userId) * 100,
524
+ title: `Post for user ${userId}`,
525
+ category: `${category}-${limit}-${sort}`,
526
+ },
527
+ ],
528
+ } as Response;
529
+ });
530
+
531
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
532
+ const clientObj = {
533
+ useUserPosts: client.createHook("/users/:id/posts"),
534
+ };
535
+
536
+ // Set up reactive parameters
537
+ const userId = ref("1"); // Vue ref for path parameter
538
+ const limit = atom("10"); // Nanostores atom for query parameter
539
+ const category = ref("tech"); // Vue ref for query parameter
540
+ const sort = "desc"; // Normal string for query parameter
541
+
542
+ const { useUserPosts } = useFragno(clientObj);
543
+ const { loading, data, error } = useUserPosts({
544
+ path: { id: userId },
545
+ query: { limit, category, sort },
546
+ });
547
+
548
+ // Wait for initial load
549
+ await waitFor(() => {
550
+ expect(loading.value).toBe(false);
551
+ });
552
+
553
+ expect(data.value).toEqual([{ id: 100, title: "Post for user 1", category: "tech-10-desc" }]);
554
+ expect(error.value).toBeUndefined();
555
+ expect(fetch).toHaveBeenCalledTimes(1);
556
+
557
+ // Verify initial URL construction
558
+ const firstCall = vi.mocked(global.fetch).mock.calls[0][0] as string;
559
+ expect(firstCall).toContain("/users/1/posts");
560
+ expect(firstCall).toContain("limit=10");
561
+ expect(firstCall).toContain("category=tech");
562
+ expect(firstCall).toContain("sort=desc");
563
+
564
+ // Update the Vue ref path parameter
565
+ userId.value = "2";
566
+
567
+ await waitFor(() => {
568
+ expect(data.value).toEqual([{ id: 200, title: "Post for user 2", category: "tech-10-desc" }]);
569
+ });
570
+
571
+ expect(fetch).toHaveBeenCalledTimes(2);
572
+
573
+ // Verify the second call has updated path param
574
+ const secondCall = vi.mocked(global.fetch).mock.calls[1][0] as string;
575
+ expect(secondCall).toContain("/users/2/posts");
576
+ expect(secondCall).toContain("limit=10");
577
+ expect(secondCall).toContain("category=tech");
578
+ expect(secondCall).toContain("sort=desc");
579
+
580
+ // Update the nanostores atom query parameter
581
+ limit.set("20");
582
+
583
+ await waitFor(() => {
584
+ expect(data.value).toEqual([{ id: 200, title: "Post for user 2", category: "tech-20-desc" }]);
585
+ });
586
+
587
+ expect(fetch).toHaveBeenCalledTimes(3);
588
+
589
+ // Verify the third call has updated atom query param
590
+ const thirdCall = vi.mocked(global.fetch).mock.calls[2][0] as string;
591
+ expect(thirdCall).toContain("/users/2/posts");
592
+ expect(thirdCall).toContain("limit=20");
593
+ expect(thirdCall).toContain("category=tech");
594
+ expect(thirdCall).toContain("sort=desc");
595
+
596
+ // Update the Vue ref query parameter
597
+ category.value = "science";
598
+
599
+ await waitFor(() => {
600
+ expect(data.value).toEqual([
601
+ { id: 200, title: "Post for user 2", category: "science-20-desc" },
602
+ ]);
603
+ });
604
+
605
+ expect(fetch).toHaveBeenCalledTimes(4);
606
+
607
+ // Verify the fourth call has updated ref query param
608
+ const fourthCall = vi.mocked(global.fetch).mock.calls[3][0] as string;
609
+ expect(fourthCall).toContain("/users/2/posts");
610
+ expect(fourthCall).toContain("limit=20");
611
+ expect(fourthCall).toContain("category=science");
612
+ expect(fourthCall).toContain("sort=desc");
613
+
614
+ // Update both reactive parameters simultaneously
615
+ userId.value = "3";
616
+ limit.set("5");
617
+ category.value = "news";
618
+
619
+ await waitFor(() => {
620
+ expect(data.value).toEqual([{ id: 300, title: "Post for user 3", category: "news-5-desc" }]);
621
+ });
622
+
623
+ expect(fetch).toHaveBeenCalledTimes(5);
624
+
625
+ // Verify the final call has all updated parameters
626
+ const finalCall = vi.mocked(global.fetch).mock.calls[4][0] as string;
627
+ expect(finalCall).toContain("/users/3/posts");
628
+ expect(finalCall).toContain("limit=5");
629
+ expect(finalCall).toContain("category=news");
630
+ expect(finalCall).toContain("sort=desc"); // Static parameter should remain unchanged
631
+ });
632
+ });
633
+
634
+ describe("createVueMutator", () => {
635
+ const clientConfig: FragnoPublicClientConfig = {
636
+ baseUrl: "http://localhost:3000",
637
+ };
638
+
639
+ beforeEach(() => {
640
+ vi.clearAllMocks();
641
+ (global.fetch as ReturnType<typeof vi.fn>).mockReset();
642
+ });
643
+
644
+ afterEach(() => {
645
+ vi.restoreAllMocks();
646
+ });
647
+
648
+ test("Should handle mutator with path parameters", async () => {
649
+ const testFragmentDefinition = defineFragment("test-fragment");
650
+ const testRoutes = [
651
+ defineRoute({
652
+ method: "PUT",
653
+ path: "/users/:id",
654
+ inputSchema: z.object({ name: z.string() }),
655
+ outputSchema: z.object({ id: z.number(), name: z.string() }),
656
+ handler: async ({ pathParams, input }, { json }) => {
657
+ const { name } = await input.valid();
658
+ return json({ id: Number(pathParams["id"]), name });
659
+ },
660
+ }),
661
+ ] as const;
662
+
663
+ vi.mocked(global.fetch).mockImplementation(async (input) => {
664
+ assert(typeof input === "string");
665
+ const [, id] = String(input).match(/\/users\/([^/]+)/) ?? [];
666
+
667
+ return {
668
+ headers: new Headers(),
669
+ ok: true,
670
+ json: async () => ({ id: Number(id), name: "Updated Name" }),
671
+ } as Response;
672
+ });
673
+
674
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
675
+ const clientObj = {
676
+ updateUser: client.createMutator("PUT", "/users/:id"),
677
+ };
678
+
679
+ const { updateUser } = useFragno(clientObj);
680
+ const { mutate, data, error } = updateUser();
681
+
682
+ const userId = ref("42");
683
+ const result = await mutate({
684
+ body: { name: "Updated Name" },
685
+ path: { id: userId },
686
+ });
687
+
688
+ expect(result).toEqual({ id: 42, name: "Updated Name" });
689
+ expect(data.value).toEqual({ id: 42, name: "Updated Name" });
690
+ expect(error.value).toBeUndefined();
691
+
692
+ // Update the ref and call again
693
+ userId.value = "100";
694
+ const result2 = await mutate({
695
+ body: { name: "Another Name" },
696
+ path: { id: userId },
697
+ });
698
+
699
+ expect(result2).toEqual({ id: 100, name: "Updated Name" });
700
+ });
701
+ });
702
+
703
+ describe("useFragno", () => {
704
+ const clientConfig: FragnoPublicClientConfig = {
705
+ baseUrl: "http://localhost:3000",
706
+ };
707
+
708
+ const testFragmentDefinition = defineFragment("test-fragment");
709
+ const testRoutes = [
710
+ defineRoute({
711
+ method: "GET",
712
+ path: "/data",
713
+ outputSchema: z.string(),
714
+ handler: async (_ctx, { json }) => json("test data"),
715
+ }),
716
+ defineRoute({
717
+ method: "POST",
718
+ path: "/action",
719
+ inputSchema: z.object({ value: z.string() }),
720
+ outputSchema: z.object({ result: z.string() }),
721
+ handler: async (_ctx, { json }) => json({ result: "test value" }),
722
+ }),
723
+ ] as const;
724
+
725
+ test("should pass through non-hook values unchanged", () => {
726
+ const client = createClientBuilder(testFragmentDefinition, clientConfig, testRoutes);
727
+ const clientObj = {
728
+ useData: client.createHook("/data"),
729
+ usePostAction: client.createMutator("POST", "/action"),
730
+ someString: "hello world",
731
+ someNumber: 42,
732
+ someObject: { foo: "bar", nested: { value: true } },
733
+ someArray: [1, 2, 3],
734
+ someFunction: () => "test",
735
+ someNull: null,
736
+ someUndefined: undefined,
737
+ };
738
+
739
+ const result = useFragno(clientObj);
740
+
741
+ // Check that non-hook values are passed through unchanged
742
+ expect(result.someString).toBe("hello world");
743
+ expect(result.someNumber).toBe(42);
744
+ expect(result.someObject).toEqual({ foo: "bar", nested: { value: true } });
745
+ expect(result.someArray).toEqual([1, 2, 3]);
746
+ expect(result.someFunction()).toBe("test");
747
+ expect(result.someNull).toBeNull();
748
+ expect(result.someUndefined).toBeUndefined();
749
+
750
+ // Verify that hooks are still transformed
751
+ expect(typeof result.useData).toBe("function");
752
+ expect(typeof result.usePostAction).toBe("function");
753
+ });
754
+ });