repos / starfx

a micro-mvc framework for react apps
git clone https://github.com/neurosnap/starfx.git

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