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