repos / starfx

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

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