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