Vlad
·
14 Nov 24
thunk.test.ts
1import {
2 call,
3 createThunks,
4 put,
5 sleep as delay,
6 takeEvery,
7 waitFor,
8} from "../mod.ts";
9import { createStore, updateStore } from "../store/mod.ts";
10import { assertLike, asserts, describe, it } from "../test.ts";
11
12import type { Next, ThunkCtx } from "../mod.ts";
13
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
125const tests = describe("createThunks()");
126
127it(
128 tests,
129 "when create a query fetch pipeline - execute all middleware and save to redux",
130 () => {
131 const api = createThunks<RoboCtx>();
132 api.use(api.routes());
133 api.use(convertNameToUrl);
134 api.use(onFetchApi);
135 api.use(processUsers);
136 api.use(processTickets);
137 const fetchUsers = api.create(`/users`, { supervisor: takeEvery });
138
139 const store = createStore<TestState>({
140 initialState: { users: {}, tickets: {} },
141 });
142 store.run(api.bootup);
143
144 store.dispatch(fetchUsers());
145
146 asserts.assertEquals(store.getState(), {
147 users: { [mockUser.id]: deserializeUser(mockUser) },
148 tickets: {},
149 });
150 },
151);
152
153it(
154 tests,
155 "when providing a generator the to api.create function - should call that generator before all other middleware",
156 () => {
157 const api = createThunks<RoboCtx>();
158 api.use(api.routes());
159 api.use(convertNameToUrl);
160 api.use(onFetchApi);
161 api.use(processUsers);
162 api.use(processTickets);
163 const fetchUsers = api.create(`/users`, { supervisor: takeEvery });
164 const fetchTickets = api.create(`/ticket-wrong-url`, {
165 supervisor: takeEvery,
166 }, function* (ctx, next) {
167 // before middleware has been triggered
168 ctx.url = "/tickets";
169
170 // triggers all middleware
171 yield* next();
172
173 yield* put(fetchUsers());
174 });
175
176 const store = createStore<TestState>({
177 initialState: { users: {}, tickets: {} },
178 });
179 store.run(api.bootup);
180
181 store.dispatch(fetchTickets());
182 asserts.assertEquals(store.getState(), {
183 users: { [mockUser.id]: deserializeUser(mockUser) },
184 tickets: { [mockTicket.id]: deserializeTicket(mockTicket) },
185 });
186 },
187);
188
189it(tests, "error handling", () => {
190 let called;
191 const api = createThunks<RoboCtx>();
192 api.use(api.routes());
193 api.use(function* upstream(_, next) {
194 try {
195 yield* next();
196 } catch (_) {
197 called = true;
198 }
199 });
200 api.use(function* fail() {
201 throw new Error("some error");
202 });
203
204 const action = api.create(`/error`, { supervisor: takeEvery });
205
206 const store = createStore({ initialState: {} });
207 store.run(api.bootup);
208 store.dispatch(action());
209 asserts.assertStrictEquals(called, true);
210});
211
212it(tests, "error handling inside create", () => {
213 let called = false;
214 const api = createThunks<RoboCtx>();
215 api.use(api.routes());
216 api.use(function* fail() {
217 throw new Error("some error");
218 });
219
220 const action = api.create(
221 `/error`,
222 { supervisor: takeEvery },
223 function* (_, next) {
224 try {
225 yield* next();
226 } catch (_) {
227 called = true;
228 }
229 },
230 );
231 const store = createStore({ initialState: {} });
232 store.run(api.bootup);
233 store.dispatch(action());
234 asserts.assertStrictEquals(called, true);
235});
236
237it(tests, "error inside endpoint mdw", () => {
238 let called = false;
239 const query = createThunks();
240 query.use(function* (_, next) {
241 try {
242 yield* next();
243 } catch (_) {
244 called = true;
245 }
246 });
247
248 query.use(query.routes());
249
250 const fetchUsers = query.create(
251 `/users`,
252 { supervisor: takeEvery },
253 function* processUsers() {
254 throw new Error("some error");
255 },
256 );
257
258 const store = createStore({
259 initialState: {
260 users: {},
261 },
262 });
263 store.run(query.bootup);
264 store.dispatch(fetchUsers());
265 asserts.assertEquals(called, true);
266});
267
268it(tests, "create fn is an array", () => {
269 const api = createThunks<RoboCtx>();
270 api.use(api.routes());
271 api.use(function* (ctx, next) {
272 asserts.assertEquals(ctx.request, {
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
298it(tests, "run() on endpoint action - should run the effect", () => {
299 const api = createThunks<RoboCtx>();
300 api.use(api.routes());
301 let acc = "";
302 const action1 = api.create(
303 "/users",
304 { supervisor: takeEvery },
305 function* (ctx, next) {
306 yield* next();
307 ctx.request = { method: "expect this" };
308 acc += "a";
309 },
310 );
311 const action2 = api.create(
312 "/users2",
313 { supervisor: takeEvery },
314 function* (_, next) {
315 yield* next();
316 const curCtx = yield* call(() => action1.run(action1()));
317 acc += "b";
318 asserts.assert(acc === "ab");
319 assertLike(curCtx, {
320 action: {
321 type: `@@starfx${action1}`,
322 payload: {
323 name: "/users",
324 },
325 },
326 name: "/users",
327 request: { method: "expect this" },
328 });
329 },
330 );
331
332 const store = createStore({ initialState: {} });
333 store.run(api.bootup);
334 store.dispatch(action2());
335});
336
337it(
338 tests,
339 "run() on endpoint action with payload - should run the effect",
340 () => {
341 const api = createThunks<RoboCtx>();
342 api.use(api.routes());
343 let acc = "";
344 const action1 = api.create<{ id: string }>(
345 "/users",
346 { supervisor: takeEvery },
347 function* (ctx, next) {
348 yield* next();
349 ctx.request = { method: "expect this" };
350 acc += "a";
351 },
352 );
353 const action2 = api.create(
354 "/users2",
355 { supervisor: takeEvery },
356 function* (_, next) {
357 yield* next();
358 const curCtx = yield* action1.run({ id: "1" });
359 acc += "b";
360 asserts.assert(acc === "ab");
361 assertLike(curCtx, {
362 action: {
363 type: `@@starfx${action1}`,
364 payload: {
365 name: "/users",
366 },
367 },
368 name: "/users",
369 request: { method: "expect this" },
370 });
371 },
372 );
373
374 const store = createStore({ initialState: {} });
375 store.run(api.bootup);
376 store.dispatch(action2());
377 },
378);
379
380it(tests, "middleware order of execution", async () => {
381 let acc = "";
382 const api = createThunks();
383 api.use(api.routes());
384
385 api.use(function* (_, next) {
386 yield* delay(10);
387 acc += "b";
388 yield* next();
389 yield* delay(10);
390 acc += "f";
391 });
392
393 api.use(function* (_, next) {
394 acc += "c";
395 yield* next();
396 acc += "d";
397 yield* delay(30);
398 acc += "e";
399 });
400
401 const action = api.create(
402 "/api",
403 { supervisor: takeEvery },
404 function* (_, next) {
405 acc += "a";
406 yield* next();
407 acc += "g";
408 yield* put({ type: "DONE" });
409 },
410 );
411
412 const store = createStore({ initialState: {} });
413 store.run(api.bootup);
414 store.dispatch(action());
415
416 await store.run(waitFor(() => acc === "abcdefg"));
417 asserts.assert(acc === "abcdefg");
418});
419
420it(tests, "retry with actionFn", async () => {
421 let acc = "";
422 let called = false;
423
424 const api = createThunks();
425 api.use(api.routes());
426
427 const action = api.create(
428 "/api",
429 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
444 const store = createStore({ initialState: {} });
445 store.run(api.bootup);
446 store.dispatch(action());
447
448 await store.run(waitFor(() => acc === "agag"));
449 asserts.assertEquals(acc, "agag");
450});
451
452it(tests, "retry with actionFn with payload", async () => {
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 asserts.assertEquals(acc, "agag");
480});
481
482it(tests, "should only call thunk once", () => {
483 const api = createThunks<RoboCtx>();
484 api.use(api.routes());
485 let acc = "";
486
487 const action1 = api.create<number>(
488 "/users",
489 { supervisor: takeEvery },
490 function* (_, next) {
491 yield* next();
492 acc += "a";
493 },
494 );
495 const action2 = api.create(
496 "/users2",
497 { supervisor: takeEvery },
498 function* (_, next) {
499 yield* next();
500 yield* put(action1(1));
501 },
502 );
503
504 const store = createStore({ initialState: {} });
505 store.run(api.bootup);
506 store.dispatch(action2());
507 asserts.assertEquals(acc, "a");
508});
509
510it(tests, "should be able to create thunk after `register()`", () => {
511 const api = createThunks<RoboCtx>();
512 api.use(api.routes());
513 const store = createStore({ initialState: {} });
514 store.run(api.register);
515
516 let acc = "";
517 const action = api.create("/users", function* () {
518 acc += "a";
519 });
520 store.dispatch(action());
521 asserts.assertEquals(acc, "a");
522});
523
524it(tests, "should warn when calling thunk before registered", () => {
525 const err = console.warn;
526 let called = false;
527 console.warn = () => {
528 called = true;
529 };
530 const api = createThunks<RoboCtx>();
531 api.use(api.routes());
532 const store = createStore({ initialState: {} });
533
534 const action = api.create("/users");
535 store.dispatch(action());
536 asserts.assertEquals(called, true);
537 console.warn = err;
538});
539
540it(
541 tests,
542 "it should call the api once even if we register it twice",
543 () => {
544 const api = createThunks<RoboCtx>();
545 api.use(api.routes());
546 const store = createStore({ initialState: {} });
547 store.run(api.register);
548 store.run(api.register);
549
550 let acc = "";
551 const action = api.create("/users", function* () {
552 acc += "a";
553 });
554 store.dispatch(action());
555 asserts.assertEquals(acc, "a");
556 },
557);
558
559it(
560 tests,
561 "Should call the API only once, even if registered multiple times, with multiple APIs defined.",
562 () => {
563 const api1 = createThunks<RoboCtx>();
564 api1.use(api1.routes());
565
566 const api2 = createThunks<RoboCtx>();
567 api2.use(api2.routes());
568
569 const store = createStore({ initialState: {} });
570
571 store.run(api1.register);
572 store.run(api1.register);
573 store.run(api1.register);
574
575 store.run(api2.register);
576 store.run(api2.register);
577
578 let acc = "";
579 const action = api1.create("/users", function* () {
580 acc += "b";
581 });
582 store.dispatch(action());
583
584 asserts.assertEquals(
585 acc,
586 "b",
587 "Expected 'b' after first API call, but got: " + acc,
588 );
589
590 let acc2 = "";
591 const action2 = api2.create("/users", function* () {
592 acc2 += "c";
593 });
594 store.dispatch(action2());
595
596 asserts.assertEquals(
597 acc2,
598 "c",
599 "Expected 'c' after second API call, but got: " + acc2,
600 );
601 },
602);
603
604it(
605 tests,
606 "should unregister the thunk when the registration function exits",
607 async () => {
608 const api1 = createThunks<RoboCtx>();
609 api1.use(api1.routes());
610
611 const store = createStore({ initialState: {} });
612 const task = store.run(api1.register);
613 await task.halt();
614 store.run(api1.register);
615
616 let acc = "";
617 const action = api1.create("/users", function* () {
618 acc += "b";
619 });
620 store.dispatch(action());
621
622 asserts.assertEquals(
623 acc,
624 "b",
625 "Expected 'b' after first API call, but got: " + acc,
626 );
627 },
628);
629
630it(
631 tests,
632 "should allow multiple stores to register a thunk",
633 () => {
634 const api1 = createThunks<RoboCtx>();
635 api1.use(api1.routes());
636 const storeA = createStore({ initialState: {} });
637 const storeB = createStore({ initialState: {} });
638 storeA.run(api1.register);
639 storeB.run(api1.register);
640 let acc = "";
641 const action = api1.create("/users", function* () {
642 acc += "b";
643 });
644 storeA.dispatch(action());
645 storeB.dispatch(action());
646
647 asserts.assertEquals(
648 acc,
649 "bb",
650 "Expected 'bb' after first API call, but got: " + acc,
651 );
652 },
653);