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

mdw.test.ts

  1import {
  2  createApi,
  3  createKey,
  4  mdw,
  5  put,
  6  safe,
  7  takeEvery,
  8  takeLatest,
  9  waitFor,
 10} from "../index.js";
 11import type { ApiCtx, Next, ThunkCtx } from "../index.js";
 12import {
 13  createSchema,
 14  createStore,
 15  slice,
 16  updateStore,
 17  waitForLoader,
 18} from "../store/index.js";
 19import { assertLike, expect, test } from "../test.js";
 20
 21interface User {
 22  id: string;
 23  name: string;
 24  email: string;
 25}
 26
 27const emptyUser: User = { id: "", name: "", email: "" };
 28const mockUser: User = { id: "1", name: "test", email: "test@test.com" };
 29const mockUser2: User = { id: "2", name: "two", email: "two@test.com" };
 30
 31// deno-lint-ignore no-explicit-any
 32const jsonBlob = (data: any) => {
 33  return JSON.stringify(data);
 34};
 35
 36const testStore = () => {
 37  const [schema, initialState] = createSchema({
 38    users: slice.table<User>({ empty: emptyUser }),
 39    loaders: slice.loaders(),
 40    cache: slice.table({ empty: {} }),
 41  });
 42  const store = createStore({ initialState });
 43  return { schema, store };
 44};
 45
 46test("basic", () => {
 47  const { store, schema } = testStore();
 48  const query = createApi<ApiCtx>();
 49  query.use(mdw.api({ schema }));
 50  query.use(query.routes());
 51  query.use(function* fetchApi(ctx, next) {
 52    if (`${ctx.req().url}`.startsWith("/users/")) {
 53      ctx.json = { ok: true, value: mockUser2 };
 54      yield* next();
 55      return;
 56    }
 57    const data = {
 58      users: [mockUser],
 59    };
 60    ctx.json = { ok: true, value: data };
 61    yield* next();
 62  });
 63
 64  const fetchUsers = query.create(
 65    "/users",
 66    { supervisor: takeEvery },
 67    function* processUsers(ctx: ApiCtx<unknown, { users: User[] }>, next) {
 68      yield* next();
 69      if (!ctx.json.ok) return;
 70      const { users } = ctx.json.value;
 71
 72      yield* updateStore((state) => {
 73        users.forEach((u) => {
 74          state.users[u.id] = u;
 75        });
 76      });
 77    },
 78  );
 79
 80  const fetchUser = query.create<{ id: string }>(
 81    "/users/:id",
 82    {
 83      supervisor: takeLatest,
 84    },
 85    function* processUser(ctx, next) {
 86      ctx.request = ctx.req({ method: "POST" });
 87      yield* next();
 88      if (!ctx.json.ok) return;
 89      const curUser = ctx.json.value;
 90      yield* updateStore((state) => {
 91        state.users[curUser.id] = curUser;
 92      });
 93    },
 94  );
 95
 96  store.run(query.bootup);
 97
 98  store.dispatch(fetchUsers());
 99  expect(store.getState().users).toEqual({ [mockUser.id]: mockUser });
100  store.dispatch(fetchUser({ id: "2" }));
101  expect(store.getState().users).toEqual({
102    [mockUser.id]: mockUser,
103    [mockUser2.id]: mockUser2,
104  });
105});
106
107test("with loader", () => {
108  const { schema, store } = testStore();
109  const api = createApi<ApiCtx>();
110  api.use(mdw.api({ schema }));
111  api.use(api.routes());
112  api.use(function* fetchApi(ctx, next) {
113    ctx.response = new Response(jsonBlob(mockUser), { status: 200 });
114    const data = { users: [mockUser] };
115    ctx.json = { ok: true, value: data };
116    yield* next();
117  });
118
119  const fetchUsers = api.create(
120    "/users",
121    { supervisor: takeEvery },
122    function* processUsers(ctx: ApiCtx<unknown, { users: User[] }>, next) {
123      yield* next();
124      if (!ctx.json.ok) return;
125
126      const { value } = ctx.json;
127
128      yield* updateStore((state) => {
129        value.users.forEach((u) => {
130          state.users[u.id] = u;
131        });
132      });
133    },
134  );
135
136  store.run(api.bootup);
137
138  store.dispatch(fetchUsers());
139  assertLike(store.getState(), {
140    users: { [mockUser.id]: mockUser },
141    loaders: {
142      "/users": {
143        status: "success",
144      },
145    },
146  });
147});
148
149test("with item loader", () => {
150  const { store, schema } = testStore();
151  const api = createApi<ApiCtx>();
152  api.use(mdw.api({ schema }));
153  api.use(api.routes());
154  api.use(function* fetchApi(ctx, next) {
155    ctx.response = new Response(jsonBlob(mockUser), { status: 200 });
156    const data = { users: [mockUser] };
157    ctx.json = { ok: true, value: data };
158    yield* next();
159  });
160
161  const fetchUser = api.create<{ id: string }>(
162    "/users/:id",
163    { supervisor: takeEvery },
164    function* processUsers(ctx: ApiCtx<unknown, { users: User[] }>, next) {
165      yield* next();
166      if (!ctx.json.ok) return;
167
168      const { value } = ctx.json;
169      yield* updateStore((state) => {
170        value.users.forEach((u) => {
171          state.users[u.id] = u;
172        });
173      });
174    },
175  );
176
177  store.run(api.bootup);
178
179  const action = fetchUser({ id: mockUser.id });
180  store.dispatch(action);
181  assertLike(store.getState(), {
182    users: { [mockUser.id]: mockUser },
183    loaders: {
184      "/users/:id": {
185        status: "success",
186      },
187      [action.payload.key]: {
188        status: "success",
189      },
190    },
191  });
192});
193
194test("with POST", () => {
195  const { store, schema } = testStore();
196  const query = createApi();
197  query.use(mdw.queryCtx);
198  query.use(mdw.api({ schema }));
199  query.use(query.routes());
200  query.use(function* fetchApi(ctx, next) {
201    const request = ctx.req();
202    expect(request).toEqual({
203      url: "/users",
204      headers: {},
205      method: "POST",
206      body: JSON.stringify({ email: "test@test.com" }),
207    });
208
209    const data = {
210      users: [mockUser],
211    };
212    ctx.response = new Response(jsonBlob(data), { status: 200 });
213    yield* next();
214  });
215
216  const createUser = query.create<{ email: string }>(
217    "/users [POST]",
218    { supervisor: takeEvery },
219    function* processUsers(
220      ctx: ApiCtx<{ email: string }, { users: User[] }>,
221      next,
222    ) {
223      ctx.request = ctx.req({
224        method: "POST",
225        body: JSON.stringify({ email: ctx.payload.email }),
226      });
227
228      yield* next();
229
230      if (!ctx.json.ok) return;
231
232      const { users } = ctx.json.value;
233      yield* updateStore((state) => {
234        users.forEach((u) => {
235          state.users[u.id] = u;
236        });
237      });
238    },
239  );
240
241  store.run(query.bootup);
242  store.dispatch(createUser({ email: mockUser.email }));
243});
244
245test("simpleCache", () => {
246  const { store, schema } = testStore();
247  const api = createApi<ApiCtx>();
248  api.use(mdw.api({ schema }));
249  api.use(api.routes());
250  api.use(function* fetchApi(ctx, next) {
251    const data = { users: [mockUser] };
252    ctx.response = new Response(jsonBlob(data));
253    ctx.json = { ok: true, value: data };
254    yield* next();
255  });
256
257  const fetchUsers = api.get("/users", { supervisor: takeEvery }, api.cache());
258  store.run(api.bootup);
259
260  const action = fetchUsers();
261  store.dispatch(action);
262  assertLike(store.getState(), {
263    data: {
264      [action.payload.key]: { users: [mockUser] },
265    },
266    loaders: {
267      [`${fetchUsers}`]: {
268        status: "success",
269      },
270    },
271  });
272});
273
274test("overriding default loader behavior", () => {
275  const { store, schema } = testStore();
276  const api = createApi<ApiCtx>();
277  api.use(mdw.api({ schema }));
278  api.use(api.routes());
279  api.use(function* fetchApi(ctx, next) {
280    const data = { users: [mockUser] };
281    ctx.response = new Response(jsonBlob(data));
282    ctx.json = { ok: true, value: data };
283    yield* next();
284  });
285
286  const fetchUsers = api.create(
287    "/users",
288    { supervisor: takeEvery },
289    function* (ctx: ApiCtx<unknown, { users: User[] }>, next) {
290      yield* next();
291
292      if (!ctx.json.ok) {
293        return;
294      }
295      const { value } = ctx.json;
296      ctx.loader = { message: "yes", meta: { wow: true } };
297      yield* updateStore((state) => {
298        value.users.forEach((u) => {
299          state.users[u.id] = u;
300        });
301      });
302    },
303  );
304
305  store.run(api.bootup);
306
307  store.dispatch(fetchUsers());
308  assertLike(store.getState(), {
309    users: { [mockUser.id]: mockUser },
310    loaders: {
311      [`${fetchUsers}`]: {
312        status: "success",
313        message: "yes",
314        meta: { wow: true },
315      },
316    },
317  });
318});
319
320test("mdw.api() - error handler", () => {
321  let err = false;
322  console.error = (msg: string) => {
323    if (err) return;
324    expect(msg).toBe("Error: something happened.  Check the endpoint [/users]");
325    err = true;
326  };
327
328  const { schema, store } = testStore();
329  const query = createApi<ApiCtx>();
330  query.use(mdw.api({ schema }));
331  query.use(query.routes());
332  query.use(function* () {
333    throw new Error("something happened");
334  });
335
336  const fetchUsers = query.create("/users", { supervisor: takeEvery });
337
338  store.run(query.bootup);
339  store.dispatch(fetchUsers());
340});
341
342test("createApi with own key", async () => {
343  const { schema, store } = testStore();
344  const query = createApi();
345  query.use(mdw.api({ schema }));
346  query.use(query.routes());
347  query.use(mdw.customKey);
348  query.use(function* fetchApi(ctx, next) {
349    const data = {
350      users: [{ ...mockUser, ...ctx.action.payload.options }],
351    };
352    ctx.response = new Response(jsonBlob(data), { status: 200 });
353    yield* next();
354  });
355
356  const theTestKey = `some-custom-key-${Math.ceil(Math.random() * 1000)}`;
357
358  const createUserCustomKey = query.post<{ email: string }>(
359    "/users",
360    { supervisor: takeEvery },
361    function* processUsers(ctx: ApiCtx, next) {
362      ctx.cache = true;
363      ctx.key = theTestKey; // or some calculated key //
364      yield* next();
365      const buff = yield* safe(() => {
366        if (!ctx.response) throw new Error("no response");
367        return ctx.response.arrayBuffer();
368      });
369      if (!buff.ok) {
370        throw buff.error;
371      }
372
373      const result = new TextDecoder("utf-8").decode(buff.value);
374      const { users } = JSON.parse(result);
375      if (!users) return;
376      const curUsers = (users as User[]).reduce<Record<string, User>>(
377        (acc, u) => {
378          acc[u.id] = u;
379          return acc;
380        },
381        {},
382      );
383      ctx.response = new Response();
384      ctx.json = {
385        ok: true,
386        value: curUsers,
387      };
388    },
389  );
390  const newUEmail = `${mockUser.email}.org`;
391
392  store.run(query.bootup);
393
394  store.dispatch(createUserCustomKey({ email: newUEmail }));
395
396  await store.run(waitForLoader(schema.loaders, createUserCustomKey));
397
398  const expectedKey = theTestKey
399    ? `/users [POST]|${theTestKey}`
400    : createKey("/users [POST]", { email: newUEmail });
401
402  const s = store.getState();
403  expect(schema.cache.selectById(s, { id: expectedKey })).toEqual({
404    "1": { id: "1", name: "test", email: newUEmail },
405  });
406
407  expect(expectedKey.split("|")[1]).toEqual(theTestKey);
408});
409
410test("createApi with custom key but no payload", async () => {
411  const { store, schema } = testStore();
412  const query = createApi();
413  query.use(mdw.api({ schema }));
414  query.use(query.routes());
415  query.use(mdw.customKey);
416  query.use(function* fetchApi(ctx, next) {
417    const data = {
418      users: [mockUser],
419    };
420    ctx.response = new Response(jsonBlob(data), { status: 200 });
421    yield* next();
422  });
423
424  const theTestKey = `some-custom-key-${Math.ceil(Math.random() * 1000)}`;
425
426  const getUsers = query.get(
427    "/users",
428    { supervisor: takeEvery },
429    function* processUsers(ctx: ApiCtx, next) {
430      ctx.cache = true;
431      ctx.key = theTestKey; // or some calculated key //
432      yield* next();
433      const buff = yield* safe(() => {
434        if (!ctx.response) throw new Error("no response");
435        return ctx.response?.arrayBuffer();
436      });
437      if (!buff.ok) {
438        throw buff.error;
439      }
440
441      const result = new TextDecoder("utf-8").decode(buff.value);
442      const { users } = JSON.parse(result);
443      if (!users) return;
444      const curUsers = (users as User[]).reduce<Record<string, User>>(
445        (acc, u) => {
446          acc[u.id] = u;
447          return acc;
448        },
449        {},
450      );
451      ctx.response = new Response();
452      ctx.json = {
453        ok: true,
454        value: curUsers,
455      };
456    },
457  );
458
459  store.run(query.bootup);
460  store.dispatch(getUsers());
461
462  await store.run(waitForLoader(schema.loaders, getUsers));
463
464  const expectedKey = theTestKey
465    ? `/users [GET]|${theTestKey}`
466    : createKey("/users [GET]", null);
467
468  const s = store.getState();
469  expect(schema.cache.selectById(s, { id: expectedKey })).toEqual({
470    "1": mockUser,
471  });
472
473  expect(expectedKey.split("|")[1]).toBe(theTestKey);
474});
475
476test("errorHandler", () => {
477  let a = 0;
478  const query = createApi<ApiCtx>();
479  query.use(function* errorHandler<Ctx extends ThunkCtx = ThunkCtx>(
480    ctx: Ctx,
481    next: Next,
482  ) {
483    try {
484      a = 1;
485      yield* next();
486      a = 2;
487    } catch (err) {
488      const errorMessage = err instanceof Error ? err.message : "Unknown error";
489      console.error(
490        `Error: ${errorMessage}.  Check the endpoint [${ctx.name}]`,
491        ctx,
492      );
493    }
494  });
495  query.use(mdw.queryCtx);
496  query.use(query.routes());
497  query.use(function* fetchApi(ctx, next) {
498    if (`${ctx.req().url}`.startsWith("/users/")) {
499      ctx.json = { ok: true, value: mockUser2 };
500      yield* next();
501      return;
502    }
503    const data = {
504      users: [mockUser],
505    };
506    ctx.json = { ok: true, value: data };
507    yield* next();
508  });
509
510  const fetchUsers = query.create(
511    "/users",
512    { supervisor: takeEvery },
513    function* processUsers(_: ApiCtx<unknown, { users: User[] }>, next) {
514      // throw new Error("some error");
515      yield* next();
516    },
517  );
518
519  const store = createStore({
520    initialState: {
521      users: {},
522    },
523  });
524  store.run(query.bootup);
525  store.dispatch(fetchUsers());
526  expect(store.getState()).toEqual({
527    users: {},
528  });
529  expect(a).toEqual(2);
530});
531
532test("stub predicate", async () => {
533  let actual: { ok: boolean } = { ok: false };
534  const { store, schema } = testStore();
535  const api = createApi();
536  api.use(function* (ctx, next) {
537    ctx.stub = true;
538    yield* next();
539  });
540
541  api.use(mdw.api({ schema }));
542  api.use(api.routes());
543  api.use(mdw.fetch({ baseUrl: "http://nowhere.com" }));
544
545  const stub = mdw.predicate((ctx) => ctx.stub === true);
546
547  const fetchUsers = api.get("/users", { supervisor: takeEvery }, [
548    function* (ctx, next) {
549      yield* next();
550      actual = ctx.json;
551      yield* put({ type: "DONE" });
552    },
553    stub(function* (ctx, next) {
554      ctx.response = new Response(JSON.stringify({ frodo: "shire" }));
555      yield* next();
556    }),
557  ]);
558
559  store.run(api.bootup);
560  store.dispatch(fetchUsers());
561
562  await store.run(waitFor(() => actual.ok));
563
564  expect(actual).toEqual({
565    ok: true,
566    value: { frodo: "shire" },
567  });
568});