repos / starfx

supercharged async flow control library.
git clone https://github.com/neurosnap/starfx.git

starfx / test
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);