Eric Bower
·
2025-06-06
thunk.test.ts
1import { API_ACTION_PREFIX } from "../action.js";
2import {
3 call,
4 createThunks,
5 sleep as delay,
6 put,
7 takeEvery,
8 waitFor,
9} from "../index.js";
10import { createStore, updateStore } from "../store/index.js";
11import { expect, test } from "../test.js";
12
13import type { Next, ThunkCtx } from "../index.js";
14// deno-lint-ignore no-explicit-any
15interface RoboCtx<D = Record<string, unknown>, P = any> extends ThunkCtx<P> {
16 url: string;
17 request: { method: string; body?: Record<string, unknown> };
18 response: D;
19}
20
21interface User {
22 id: string;
23 name: string;
24 email: string;
25}
26
27interface UserResponse {
28 id: string;
29 name: string;
30 email_address: string;
31}
32
33const deserializeUser = (u: UserResponse): User => {
34 return {
35 id: u.id,
36 name: u.name,
37 email: u.email_address,
38 };
39};
40
41interface Ticket {
42 id: string;
43 name: string;
44}
45
46interface TicketResponse {
47 id: string;
48 name: string;
49}
50
51const deserializeTicket = (u: TicketResponse): Ticket => {
52 return {
53 id: u.id,
54 name: u.name,
55 };
56};
57
58interface TestState {
59 users: { [key: string]: User };
60 tickets: { [key: string]: Ticket };
61}
62
63const mockUser = { id: "1", name: "test", email_address: "test@test.com" };
64const mockTicket = { id: "2", name: "test-ticket" };
65
66function* convertNameToUrl(ctx: RoboCtx, next: Next) {
67 if (!ctx.url) {
68 ctx.url = ctx.name;
69 }
70 yield* next();
71}
72
73function* onFetchApi(ctx: RoboCtx, next: Next) {
74 const url = ctx.url;
75 let json = {};
76 if (url === "/users") {
77 json = {
78 users: [mockUser],
79 };
80 }
81
82 if (url === "/tickets") {
83 json = {
84 tickets: [mockTicket],
85 };
86 }
87
88 ctx.response = json;
89 yield* next();
90}
91
92function* processUsers(ctx: RoboCtx<{ users?: UserResponse[] }>, next: Next) {
93 if (!ctx.response.users) {
94 yield* next();
95 return;
96 }
97 yield* updateStore<TestState>((state) => {
98 if (!ctx.response.users) return;
99 ctx.response.users.forEach((u) => {
100 state.users[u.id] = deserializeUser(u);
101 });
102 });
103
104 yield* next();
105}
106
107function* processTickets(
108 ctx: RoboCtx<{ tickets?: UserResponse[] }>,
109 next: Next,
110) {
111 if (!ctx.response.tickets) {
112 yield* next();
113 return;
114 }
115 yield* updateStore<TestState>((state) => {
116 if (!ctx.response.tickets) return;
117 ctx.response.tickets.forEach((u) => {
118 state.tickets[u.id] = deserializeTicket(u);
119 });
120 });
121
122 yield* next();
123}
124
125test("when create a query fetch pipeline - execute all middleware and save to redux", () => {
126 expect.assertions(1);
127 const api = createThunks<RoboCtx>();
128 api.use(api.routes());
129 api.use(convertNameToUrl);
130 api.use(onFetchApi);
131 api.use(processUsers);
132 api.use(processTickets);
133 const fetchUsers = api.create("/users", { supervisor: takeEvery });
134
135 const store = createStore<TestState>({
136 initialState: { users: {}, tickets: {} },
137 });
138 store.run(api.bootup);
139
140 store.dispatch(fetchUsers());
141
142 expect(store.getState()).toEqual({
143 users: { [mockUser.id]: deserializeUser(mockUser) },
144 tickets: {},
145 });
146});
147
148test("when providing a generator the to api.create function - should call that generator before all other middleware", () => {
149 expect.assertions(1);
150 const api = createThunks<RoboCtx>();
151 api.use(api.routes());
152 api.use(convertNameToUrl);
153 api.use(onFetchApi);
154 api.use(processUsers);
155 api.use(processTickets);
156 const fetchUsers = api.create("/users", { supervisor: takeEvery });
157 const fetchTickets = api.create(
158 "/ticket-wrong-url",
159 {
160 supervisor: takeEvery,
161 },
162 function* (ctx, next) {
163 // before middleware has been triggered
164 ctx.url = "/tickets";
165
166 // triggers all middleware
167 yield* next();
168
169 yield* put(fetchUsers());
170 },
171 );
172
173 const store = createStore<TestState>({
174 initialState: { users: {}, tickets: {} },
175 });
176 store.run(api.bootup);
177
178 store.dispatch(fetchTickets());
179 expect(store.getState()).toEqual({
180 users: { [mockUser.id]: deserializeUser(mockUser) },
181 tickets: { [mockTicket.id]: deserializeTicket(mockTicket) },
182 });
183});
184
185test("error handling", () => {
186 expect.assertions(1);
187 let called;
188 const api = createThunks<RoboCtx>();
189 api.use(api.routes());
190 api.use(function* upstream(_, next) {
191 try {
192 yield* next();
193 } catch (_) {
194 called = true;
195 }
196 });
197 api.use(function* fail() {
198 throw new Error("some error");
199 });
200
201 const action = api.create("/error", { supervisor: takeEvery });
202
203 const store = createStore({ initialState: {} });
204 store.run(api.bootup);
205 store.dispatch(action());
206 expect(called).toBe(true);
207});
208
209test("error handling inside create", () => {
210 expect.assertions(1);
211 let called = false;
212 const api = createThunks<RoboCtx>();
213 api.use(api.routes());
214 api.use(function* fail() {
215 throw new Error("some error");
216 });
217
218 const action = api.create(
219 "/error",
220 { supervisor: takeEvery },
221 function* (_, next) {
222 try {
223 yield* next();
224 } catch (_) {
225 called = true;
226 }
227 },
228 );
229 const store = createStore({ initialState: {} });
230 store.run(api.bootup);
231 store.dispatch(action());
232 expect(called).toBe(true);
233});
234
235test("error inside endpoint mdw", () => {
236 expect.assertions(1);
237 let called = false;
238 const query = createThunks();
239 query.use(function* (_, next) {
240 try {
241 yield* next();
242 } catch (_) {
243 called = true;
244 }
245 });
246
247 query.use(query.routes());
248
249 const fetchUsers = query.create(
250 "/users",
251 { supervisor: takeEvery },
252 function* processUsers() {
253 throw new Error("some error");
254 },
255 );
256
257 const store = createStore({
258 initialState: {
259 users: {},
260 },
261 });
262 store.run(query.bootup);
263 store.dispatch(fetchUsers());
264 expect(called).toBe(true);
265});
266
267test("create fn is an array", () => {
268 expect.assertions(1);
269 const api = createThunks<RoboCtx>();
270 api.use(api.routes());
271 api.use(function* (ctx, next) {
272 expect(ctx.request).toEqual({
273 method: "POST",
274 body: {
275 test: "me",
276 },
277 });
278 yield* next();
279 });
280 const action = api.create("/users", { supervisor: takeEvery }, [
281 function* (ctx, next) {
282 ctx.request = {
283 method: "POST",
284 };
285 yield* next();
286 },
287 function* (ctx, next) {
288 ctx.request.body = { test: "me" };
289 yield* next();
290 },
291 ]);
292
293 const store = createStore({ initialState: {} });
294 store.run(api.bootup);
295 store.dispatch(action());
296});
297
298test("run() on endpoint action - should run the effect", () => {
299 expect.assertions(4);
300 const api = createThunks<RoboCtx>();
301 api.use(api.routes());
302
303 let acc = "";
304 let curCtx: RoboCtx = {} as RoboCtx;
305
306 const action1 = api.create(
307 "/users",
308 { supervisor: takeEvery },
309 function* (ctx, next) {
310 yield* next();
311 ctx.request = { method: "expect this" };
312 acc += "a";
313 },
314 );
315 const action2 = api.create(
316 "/users2",
317 { supervisor: takeEvery },
318 function* (_, next) {
319 yield* next();
320 curCtx = yield* call(() => action1.run(action1()));
321 acc += "b";
322 },
323 );
324
325 const store = createStore({ initialState: {} });
326 store.run(api.bootup);
327 store.dispatch(action2());
328 expect(acc).toBe("ab");
329 expect(curCtx.action).toMatchObject({
330 type: `${API_ACTION_PREFIX}${action1}`,
331 payload: {
332 name: "/users",
333 },
334 });
335 expect(curCtx.name).toBe("/users");
336 expect(curCtx.request).toEqual({ method: "expect this" });
337});
338
339test("run() on endpoint action with payload - should run the effect", () => {
340 expect.assertions(4);
341 const api = createThunks<RoboCtx>();
342 api.use(api.routes());
343
344 let acc = "";
345 let curCtx: RoboCtx = {} as RoboCtx;
346
347 const action1 = api.create<{ id: string }>(
348 "/users",
349 { supervisor: takeEvery },
350 function* (ctx, next) {
351 yield* next();
352 ctx.request = { method: "expect this" };
353 acc += "a";
354 },
355 );
356 const action2 = api.create(
357 "/users2",
358 { supervisor: takeEvery },
359 function* (_, next) {
360 yield* next();
361 curCtx = yield* call(() => action1.run({ id: "1" }));
362 acc += "b";
363 },
364 );
365
366 const store = createStore({ initialState: {} });
367 store.run(api.bootup);
368 store.dispatch(action2());
369 expect(acc).toBe("ab");
370 expect(curCtx.action).toMatchObject({
371 type: `${API_ACTION_PREFIX}${action1}`,
372 payload: {
373 name: "/users",
374 },
375 });
376 expect(curCtx.name).toBe("/users");
377 expect(curCtx.request).toEqual({ method: "expect this" });
378});
379
380test("middleware order of execution", async () => {
381 expect.assertions(1);
382 let acc = "";
383 const api = createThunks();
384 api.use(api.routes());
385
386 api.use(function* (_, next) {
387 yield* delay(10);
388 acc += "b";
389 yield* next();
390 yield* delay(10);
391 acc += "f";
392 });
393
394 api.use(function* (_, next) {
395 acc += "c";
396 yield* next();
397 acc += "d";
398 yield* delay(30);
399 acc += "e";
400 });
401
402 const action = api.create(
403 "/api",
404 { supervisor: takeEvery },
405 function* (_, next) {
406 acc += "a";
407 yield* next();
408 acc += "g";
409 yield* put({ type: "DONE" });
410 },
411 );
412
413 const store = createStore({ initialState: {} });
414 store.run(api.bootup);
415 store.dispatch(action());
416
417 await store.run(waitFor(() => acc === "abcdefg"));
418 expect(acc).toBe("abcdefg");
419});
420
421test("retry with actionFn", async () => {
422 expect.assertions(1);
423 let acc = "";
424 let called = false;
425
426 const api = createThunks();
427 api.use(api.routes());
428
429 const action = api.create("/api", function* (ctx, next) {
430 acc += "a";
431 yield* next();
432 acc += "g";
433 if (acc === "agag") {
434 yield* put({ type: "DONE" });
435 }
436
437 if (!called) {
438 called = true;
439 yield* put(ctx.actionFn());
440 }
441 });
442
443 const store = createStore({ initialState: {} });
444 store.run(api.bootup);
445 store.dispatch(action());
446
447 await store.run(waitFor(() => acc === "agag"));
448 expect(acc).toBe("agag");
449});
450
451test("retry with actionFn with payload", async () => {
452 expect.assertions(1);
453 let acc = "";
454 const api = createThunks();
455 api.use(api.routes());
456
457 api.use(function* (ctx: ThunkCtx<{ page: number }>, next) {
458 yield* next();
459 if (ctx.payload.page === 1) {
460 yield* put(ctx.actionFn({ page: 2 }));
461 }
462 });
463
464 const action = api.create<{ page: number }>(
465 "/api",
466 { supervisor: takeEvery },
467 function* (_, next) {
468 acc += "a";
469 yield* next();
470 acc += "g";
471 },
472 );
473
474 const store = createStore({ initialState: {} });
475 store.run(api.bootup);
476 store.dispatch(action({ page: 1 }));
477
478 await store.run(waitFor(() => acc === "agag"));
479 expect(acc).toBe("agag");
480});
481
482test("should only call thunk once", () => {
483 expect.assertions(1);
484 const api = createThunks<RoboCtx>();
485 api.use(api.routes());
486 let acc = "";
487
488 const action1 = api.create<number>(
489 "/users",
490 { supervisor: takeEvery },
491 function* (_, next) {
492 yield* next();
493 acc += "a";
494 },
495 );
496 const action2 = api.create(
497 "/users2",
498 { supervisor: takeEvery },
499 function* (_, next) {
500 yield* next();
501 yield* put(action1(1));
502 },
503 );
504
505 const store = createStore({ initialState: {} });
506 store.run(api.bootup);
507 store.dispatch(action2());
508 expect(acc).toBe("a");
509});
510
511test("should be able to create thunk after `register()`", () => {
512 expect.assertions(1);
513 const api = createThunks<RoboCtx>();
514 api.use(api.routes());
515 const store = createStore({ initialState: {} });
516 store.run(api.register);
517
518 let acc = "";
519 const action = api.create("/users", function* () {
520 acc += "a";
521 });
522 store.dispatch(action());
523 expect(acc).toBe("a");
524});
525
526test("should warn when calling thunk before registered", () => {
527 expect.assertions(1);
528 const err = console.warn;
529 let called = false;
530 console.warn = () => {
531 called = true;
532 };
533 const api = createThunks<RoboCtx>();
534 api.use(api.routes());
535 const store = createStore({ initialState: {} });
536
537 const action = api.create("/users");
538 store.dispatch(action());
539 expect(called).toBe(true);
540 console.warn = err;
541});
542
543test("it should call the api once even if we register it twice", () => {
544 expect.assertions(1);
545 const api = createThunks<RoboCtx>();
546 api.use(api.routes());
547 const store = createStore({ initialState: {} });
548 store.run(api.register);
549 store.run(api.register);
550
551 let acc = "";
552 const action = api.create("/users", function* () {
553 acc += "a";
554 });
555 store.dispatch(action());
556 expect(acc).toBe("a");
557});
558
559test("should call the API only once, even if registered multiple times, with multiple APIs defined.", () => {
560 expect.assertions(2);
561 const api1 = createThunks<RoboCtx>();
562 api1.use(api1.routes());
563
564 const api2 = createThunks<RoboCtx>();
565 api2.use(api2.routes());
566
567 const store = createStore({ initialState: {} });
568
569 store.run(api1.register);
570 store.run(api1.register);
571 store.run(api1.register);
572
573 store.run(api2.register);
574 store.run(api2.register);
575
576 let acc = "";
577 const action = api1.create("/users", function* () {
578 acc += "b";
579 });
580 store.dispatch(action());
581
582 expect(acc).toBe("b");
583
584 let acc2 = "";
585 const action2 = api2.create("/users", function* () {
586 acc2 += "c";
587 });
588 store.dispatch(action2());
589
590 expect(acc2).toBe("c");
591});
592
593test("should unregister the thunk when the registration function exits", async () => {
594 expect.assertions(1);
595 const api1 = createThunks<RoboCtx>();
596 api1.use(api1.routes());
597
598 const store = createStore({ initialState: {} });
599 const task = store.run(api1.register);
600 await task.halt();
601 store.run(api1.register);
602
603 let acc = "";
604 const action = api1.create("/users", function* () {
605 acc += "b";
606 });
607 store.dispatch(action());
608
609 expect(acc).toBe("b");
610});
611
612test("should allow multiple stores to register a thunk", () => {
613 expect.assertions(1);
614 const api1 = createThunks<RoboCtx>();
615 api1.use(api1.routes());
616 const storeA = createStore({ initialState: {} });
617 const storeB = createStore({ initialState: {} });
618 storeA.run(api1.register);
619 storeB.run(api1.register);
620 let acc = "";
621 const action = api1.create("/users", function* () {
622 acc += "b";
623 });
624 storeA.dispatch(action());
625 storeB.dispatch(action());
626
627 expect(acc).toBe("bb");
628});