Eric Bower
·
2025-06-06
fetch.test.ts
1import nock from "nock";
2import { type ApiCtx, createApi, mdw, takeEvery } from "../index.js";
3import {
4 createSchema,
5 createStore,
6 slice,
7 waitForLoader,
8 waitForLoaders,
9} from "../store/index.js";
10import { expect, test } from "../test.js";
11
12const baseUrl = "https://starfx.com";
13const mockUser = { id: "1", email: "test@starfx.com" };
14
15const testStore = () => {
16 const [schema, initialState] = createSchema({
17 loaders: slice.loaders(),
18 cache: slice.table({ empty: {} }),
19 });
20 const store = createStore({ initialState });
21 return { schema, store };
22};
23
24const getTestData = (ctx: ApiCtx) => {
25 return { request: { ...ctx.req() }, json: { ...ctx.json } };
26};
27
28test("should be able to fetch a resource and save automatically", async () => {
29 nock(baseUrl).get("/users").reply(200, mockUser);
30
31 const { store, schema } = testStore();
32 const api = createApi();
33 api.use(mdw.api({ schema }));
34 api.use(api.routes());
35 api.use(mdw.headers);
36 api.use(mdw.fetch({ baseUrl }));
37
38 const actual: any[] = [];
39 const fetchUsers = api.get(
40 "/users",
41 { supervisor: takeEvery },
42 function* (ctx, next) {
43 ctx.cache = true;
44 yield* next();
45
46 actual.push(ctx.request);
47 actual.push(ctx.json);
48 },
49 );
50
51 store.run(api.bootup);
52
53 const action = fetchUsers();
54 store.dispatch(action);
55
56 await store.run(waitForLoader(schema.loaders, action));
57
58 const state = store.getState();
59 expect(state.cache[action.payload.key]).toEqual(mockUser);
60 expect(actual).toEqual([
61 {
62 url: `${baseUrl}/users`,
63 method: "GET",
64 headers: {
65 "Content-Type": "application/json",
66 },
67 },
68 { ok: true, value: mockUser },
69 ]);
70});
71
72test("should be able to fetch a resource and parse as text instead of json", async () => {
73 nock(baseUrl).get("/users").reply(200, "this is some text");
74
75 const { store, schema } = testStore();
76 const api = createApi();
77 api.use(mdw.api({ schema }));
78 api.use(api.routes());
79 api.use(mdw.fetch({ baseUrl }));
80
81 let actual = null;
82 const fetchUsers = api.get(
83 "/users",
84 { supervisor: takeEvery },
85 function* (ctx, next) {
86 ctx.cache = true;
87 ctx.bodyType = "text";
88 yield* next();
89 actual = ctx.json;
90 },
91 );
92
93 store.run(api.bootup);
94
95 const action = fetchUsers();
96 store.dispatch(action);
97
98 await store.run(waitForLoader(schema.loaders, action));
99
100 const data = "this is some text";
101 expect(actual).toEqual({ ok: true, value: data });
102});
103
104test("error handling", async () => {
105 const errMsg = { message: "something happened" };
106 nock(baseUrl).get("/users").reply(500, errMsg);
107
108 const { schema, store } = testStore();
109 const api = createApi();
110 api.use(mdw.api({ schema }));
111 api.use(api.routes());
112 api.use(mdw.fetch({ baseUrl }));
113
114 let actual = null;
115 const fetchUsers = api.get(
116 "/users",
117 { supervisor: takeEvery },
118 function* (ctx, next) {
119 ctx.cache = true;
120 yield* next();
121
122 actual = ctx.json;
123 },
124 );
125
126 store.run(api.bootup);
127
128 const action = fetchUsers();
129 store.dispatch(action);
130
131 await store.run(waitForLoader(schema.loaders, action));
132
133 const state = store.getState();
134 expect(state.cache[action.payload.key]).toEqual(errMsg);
135 expect(actual).toEqual({ ok: false, error: errMsg });
136});
137
138test("status 204", async () => {
139 nock(baseUrl).get("/users").reply(204);
140
141 const { schema, store } = testStore();
142 const api = createApi();
143 api.use(mdw.api({ schema }));
144 api.use(api.routes());
145 api.use(function* (ctx, next) {
146 const url = ctx.req().url;
147 ctx.request = ctx.req({ url: `${baseUrl}${url}` });
148 yield* next();
149 });
150 api.use(mdw.fetch());
151
152 let actual = null;
153 const fetchUsers = api.get(
154 "/users",
155 { supervisor: takeEvery },
156 function* (ctx, next) {
157 ctx.cache = true;
158 yield* next();
159 actual = ctx.json;
160 },
161 );
162
163 store.run(api.bootup);
164
165 const action = fetchUsers();
166 store.dispatch(action);
167
168 await store.run(waitForLoader(schema.loaders, action));
169
170 const state = store.getState();
171 expect(state.cache[action.payload.key]).toEqual({});
172 expect(actual).toEqual({ ok: true, value: {} });
173});
174
175test("malformed json", async () => {
176 nock(baseUrl).get("/users").reply(200, "not json");
177
178 const { schema, store } = testStore();
179 const api = createApi();
180 api.use(mdw.api({ schema }));
181 api.use(api.routes());
182 api.use(function* (ctx, next) {
183 const url = ctx.req().url;
184 ctx.request = ctx.req({ url: `${baseUrl}${url}` });
185 yield* next();
186 });
187 api.use(mdw.fetch());
188
189 let actual = null;
190 const fetchUsers = api.get(
191 "/users",
192 { supervisor: takeEvery },
193 function* (ctx, next) {
194 ctx.cache = true;
195 yield* next();
196
197 actual = ctx.json;
198 },
199 );
200
201 store.run(api.bootup);
202 const action = fetchUsers();
203 store.dispatch(action);
204
205 await store.run(waitForLoader(schema.loaders, action));
206
207 const data = {
208 message: "Unexpected token 'o', \"not json\" is not valid JSON",
209 };
210 expect(actual).toEqual({
211 ok: false,
212 error: data,
213 });
214});
215
216test("POST", async () => {
217 nock(baseUrl).post("/users").reply(200, mockUser);
218
219 const { schema, store } = testStore();
220 const api = createApi();
221 api.use(mdw.api({ schema }));
222 api.use(api.routes());
223 api.use(mdw.headers);
224 api.use(mdw.fetch({ baseUrl }));
225
226 const fetchUsers = api.post(
227 "/users",
228 { supervisor: takeEvery },
229 function* (ctx, next) {
230 ctx.cache = true;
231 ctx.request = ctx.req({
232 body: JSON.stringify(mockUser),
233 });
234 yield* next();
235
236 ctx.loader = { meta: getTestData(ctx) };
237 },
238 );
239
240 store.run(api.bootup);
241 const action = fetchUsers();
242 store.dispatch(action);
243
244 const loader = await store.run(waitForLoader(schema.loaders, action));
245 if (!loader.ok) {
246 throw loader.error;
247 }
248
249 expect(loader.value.meta.request).toEqual({
250 url: `${baseUrl}/users`,
251 headers: {
252 "Content-Type": "application/json",
253 },
254 method: "POST",
255 body: JSON.stringify(mockUser),
256 });
257
258 expect(loader.value.meta.json).toEqual({
259 ok: true,
260 value: mockUser,
261 });
262});
263
264test("POST multiple endpoints with same uri", async () => {
265 nock(baseUrl).post("/users/1/something").reply(200, mockUser).persist();
266
267 const { store, schema } = testStore();
268 const api = createApi();
269 api.use(mdw.api({ schema }));
270 api.use(api.routes());
271 api.use(mdw.headers);
272 api.use(mdw.fetch({ baseUrl }));
273
274 const fetchUsers = api.post<{ id: string }>(
275 "/users/:id/something",
276 { supervisor: takeEvery },
277 function* (ctx, next) {
278 ctx.cache = true;
279 ctx.request = ctx.req({ body: JSON.stringify(mockUser) });
280 yield* next();
281
282 ctx.loader = { meta: getTestData(ctx) };
283 },
284 );
285
286 const fetchUsersSecond = api.post<{ id: string }>(
287 ["/users/:id/something", "next"],
288 { supervisor: takeEvery },
289 function* (ctx, next) {
290 ctx.cache = true;
291 ctx.request = ctx.req({ body: JSON.stringify(mockUser) });
292 yield* next();
293 ctx.loader = { meta: getTestData(ctx) };
294 },
295 );
296
297 store.run(api.bootup);
298
299 const action1 = fetchUsers({ id: "1" });
300 const action2 = fetchUsersSecond({ id: "1" });
301 store.dispatch(action1);
302 store.dispatch(action2);
303
304 const results = await store.run(
305 waitForLoaders(schema.loaders, [action1, action2]),
306 );
307 if (!results.ok) {
308 throw results.error;
309 }
310 const result1 = results.value[0];
311 if (!result1.ok) {
312 throw result1.error;
313 }
314 const result2 = results.value[1];
315 if (!result2.ok) {
316 throw result2.error;
317 }
318
319 expect(result1.value.meta.request).toEqual({
320 url: `${baseUrl}/users/1/something`,
321 headers: {
322 "Content-Type": "application/json",
323 },
324 method: "POST",
325 body: JSON.stringify(mockUser),
326 });
327
328 expect(result1.value.meta.json).toEqual({
329 ok: true,
330 value: mockUser,
331 });
332
333 expect(result2.value.meta.request).toEqual({
334 url: `${baseUrl}/users/1/something`,
335 headers: {
336 "Content-Type": "application/json",
337 },
338 method: "POST",
339 body: JSON.stringify(mockUser),
340 });
341
342 expect(result2.value.meta.json).toEqual({
343 ok: true,
344 value: mockUser,
345 });
346});
347
348test("slug in url but payload has empty string for slug value", () => {
349 const { store, schema } = testStore();
350 const api = createApi();
351 api.use(mdw.api({ schema }));
352 api.use(api.routes());
353 api.use(mdw.fetch({ baseUrl }));
354 let actual = "";
355
356 const fetchUsers = api.post<{ id: string }>(
357 "/users/:id",
358 { supervisor: takeEvery },
359 function* (ctx, next) {
360 ctx.cache = true;
361 ctx.request = ctx.req({ body: JSON.stringify(mockUser) });
362
363 yield* next();
364 if (!ctx.json.ok) {
365 actual = ctx.json.error;
366 }
367 },
368 );
369
370 store.run(api.bootup);
371 const action = fetchUsers({ id: "" });
372 store.dispatch(action);
373
374 const data =
375 "found :id in endpoint name (/users/:id [POST]) but payload has falsy value ()";
376 expect(actual).toEqual(data);
377});
378
379test("with success - should keep retrying fetch request", async () => {
380 nock(baseUrl)
381 .get("/users")
382 .reply(400, { message: "error" })
383 .get("/users")
384 .reply(400, { message: "error" })
385 .get("/users")
386 .reply(400, { message: "error" })
387 .get("/users")
388 .reply(200, mockUser);
389
390 const { schema, store } = testStore();
391 const api = createApi();
392 api.use(mdw.api({ schema }));
393 api.use(api.routes());
394 api.use(mdw.fetch({ baseUrl }));
395
396 let actual = null;
397 const fetchUsers = api.get("/users", { supervisor: takeEvery }, [
398 function* (ctx, next) {
399 ctx.cache = true;
400 yield* next();
401
402 if (!ctx.json.ok) {
403 return;
404 }
405
406 actual = ctx.json;
407 },
408 mdw.fetchRetry((n) => (n > 4 ? -1 : 10)),
409 ]);
410
411 store.run(api.bootup);
412
413 const action = fetchUsers();
414 store.dispatch(action);
415
416 const loader = await store.run(waitForLoader(schema.loaders, action));
417 if (!loader.ok) {
418 throw loader.error;
419 }
420
421 const state = store.getState();
422 expect(state.cache[action.payload.key]).toEqual(mockUser);
423 expect(actual).toEqual({ ok: true, value: mockUser });
424});
425
426test("fetch retry - with failure - should keep retrying and then quit", async () => {
427 expect.assertions(1);
428 nock(baseUrl).get("/users").reply(400, { message: "error" }).persist();
429
430 const { schema, store } = testStore();
431 let actual = null;
432 const api = createApi();
433 api.use(mdw.api({ schema }));
434 api.use(api.routes());
435 api.use(mdw.fetch({ baseUrl }));
436
437 const fetchUsers = api.get("/users", { supervisor: takeEvery }, [
438 function* (ctx, next) {
439 ctx.cache = true;
440 yield* next();
441 actual = ctx.json;
442 },
443 mdw.fetchRetry((n) => (n > 2 ? -1 : 10)),
444 ]);
445
446 store.run(api.bootup);
447 const action = fetchUsers();
448 store.dispatch(action);
449
450 const loader = await store.run(waitForLoader(schema.loaders, action));
451 if (!loader.ok) {
452 throw loader.error;
453 }
454 const data = { message: "error" };
455 expect(actual).toEqual({ ok: false, error: data });
456});
457
458test("should *not* make http request and instead simply mock response", async () => {
459 const { schema, store } = testStore();
460 let actual = null;
461 const api = createApi();
462 api.use(mdw.api({ schema }));
463 api.use(api.routes());
464 api.use(mdw.fetch({ baseUrl }));
465
466 const fetchUsers = api.get("/users", { supervisor: takeEvery }, [
467 function* (ctx, next) {
468 yield* next();
469 actual = ctx.json;
470 },
471 mdw.response(new Response(JSON.stringify(mockUser))),
472 ]);
473
474 store.run(api.bootup);
475 store.dispatch(fetchUsers());
476
477 const loader = await store.run(waitForLoader(schema.loaders, fetchUsers));
478 if (!loader.ok) {
479 throw loader.error;
480 }
481 expect(actual).toEqual({ ok: true, value: mockUser });
482});
483
484test("should use dynamic mdw to mock response", async () => {
485 const { schema, store } = testStore();
486 let actual = null;
487 const api = createApi();
488 api.use(mdw.api({ schema }));
489 api.use(api.routes());
490 api.use(mdw.fetch({ baseUrl }));
491
492 const fetchUsers = api.get("/users", { supervisor: takeEvery }, [
493 function* (ctx, next) {
494 yield* next();
495 actual = ctx.json;
496 },
497 mdw.response(new Response(JSON.stringify(mockUser))),
498 ]);
499
500 store.run(api.bootup);
501
502 // override default response with dynamic mdw
503 const dynamicUser = { id: "2", email: "dynamic@starfx.com" };
504 fetchUsers.use(mdw.response(new Response(JSON.stringify(dynamicUser))));
505 store.dispatch(fetchUsers());
506 let loader = await store.run(waitForLoader(schema.loaders, fetchUsers));
507 if (!loader.ok) {
508 throw loader.error;
509 }
510 expect(actual).toEqual({ ok: true, value: dynamicUser });
511
512 // reset dynamic mdw and try again
513 api.reset();
514 store.dispatch(fetchUsers());
515 loader = await store.run(waitForLoader(schema.loaders, fetchUsers));
516 if (!loader.ok) {
517 throw loader.error;
518 }
519 expect(actual).toEqual({ ok: true, value: mockUser });
520});