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

api.test.ts

  1import {
  2  API_ACTION_PREFIX,
  3  type AnyState,
  4  type ApiCtx,
  5  type Operation,
  6  call,
  7  createAction,
  8  createApi,
  9  createKey,
 10  keepAlive,
 11  mdw,
 12  safe,
 13  takeEvery,
 14  waitFor,
 15} from "../index.js";
 16import { useCache } from "../react.js";
 17import {
 18  createSchema,
 19  createStore,
 20  select,
 21  slice,
 22  updateStore,
 23  waitForLoader,
 24} from "../store/index.js";
 25import { expect, test } from "../test.js";
 26
 27interface User {
 28  id: string;
 29  name: string;
 30  email: string;
 31}
 32
 33const emptyUser: User = { id: "", name: "", email: "" };
 34const mockUser: User = { id: "1", name: "test", email: "test@test.com" };
 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
 46const jsonBlob = (data: unknown) => {
 47  return JSON.stringify(data);
 48};
 49
 50test("POST", async () => {
 51  expect.assertions(2);
 52  const query = createApi();
 53  query.use(mdw.queryCtx);
 54  query.use(mdw.nameParser);
 55  query.use(query.routes());
 56  query.use(function* fetchApi(ctx, next) {
 57    expect(ctx.req()).toEqual({
 58      url: "/users",
 59      headers: {},
 60      method: "POST",
 61      body: JSON.stringify({ email: mockUser.email }),
 62    });
 63    const data = {
 64      users: [mockUser],
 65    };
 66
 67    ctx.response = new Response(jsonBlob(data), { status: 200 });
 68
 69    yield* next();
 70  });
 71
 72  const createUser = query.post<{ email: string }, { users: User[] }>(
 73    "/users",
 74    { supervisor: takeEvery },
 75    function* processUsers(ctx, next) {
 76      ctx.request = ctx.req({
 77        method: "POST",
 78        body: JSON.stringify({ email: ctx.payload.email }),
 79      });
 80      yield* next();
 81
 82      const buff = yield* safe(() => {
 83        if (!ctx.response) throw new Error("no response");
 84        const res = ctx.response.arrayBuffer();
 85        return res;
 86      });
 87
 88      if (!buff.ok) {
 89        throw buff.error;
 90      }
 91
 92      const result = new TextDecoder("utf-8").decode(buff.value);
 93      const { users } = JSON.parse(result);
 94      if (!users) return;
 95
 96      yield* updateStore<{ users: { [key: string]: User } }>((state) => {
 97        (users as User[]).forEach((u) => {
 98          state.users[u.id] = u;
 99        });
100      });
101    },
102  );
103
104  const store = createStore({ initialState: { users: {} } });
105  store.run(query.bootup);
106
107  store.dispatch(createUser({ email: mockUser.email }));
108
109  await store.run(
110    waitFor(function* (): Operation<boolean> {
111      const res = yield* select((state) => state);
112      return (
113        (res as { users: Record<string, User> }).users["1"]?.email ===
114        mockUser.email
115      );
116    }),
117  );
118
119  expect(store.getState().users).toEqual({
120    "1": { id: "1", name: "test", email: "test@test.com" },
121  });
122});
123
124test("POST with uri", () => {
125  expect.assertions(1);
126  const query = createApi();
127  query.use(mdw.queryCtx);
128  query.use(mdw.nameParser);
129  query.use(query.routes());
130  query.use(function* fetchApi(ctx, next) {
131    expect(ctx.req()).toEqual({
132      url: "/users",
133      headers: {},
134      method: "POST",
135      body: JSON.stringify({ email: mockUser.email }),
136    });
137
138    const data = {
139      users: [mockUser],
140    };
141    ctx.response = new Response(jsonBlob(data), { status: 200 });
142    yield* next();
143  });
144
145  const userApi = query.uri("/users");
146  const createUser = userApi.post<{ email: string }>(
147    { supervisor: takeEvery },
148    function* processUsers(
149      ctx: ApiCtx<{ email: string }, { users: User[] }>,
150      next,
151    ) {
152      ctx.request = ctx.req({
153        body: JSON.stringify({ email: ctx.payload.email }),
154      });
155
156      yield* next();
157      if (!ctx.json.ok) return;
158      const { users } = ctx.json.value;
159      yield* updateStore<{ users: { [key: string]: User } }>((state) => {
160        users.forEach((u) => {
161          state.users[u.id] = u;
162        });
163      });
164    },
165  );
166
167  const store = createStore({ initialState: { users: {} } });
168  store.run(query.bootup);
169  store.dispatch(createUser({ email: mockUser.email }));
170});
171
172test("middleware - with request fn", () => {
173  expect.assertions(2);
174  const query = createApi();
175  query.use(mdw.queryCtx);
176  query.use(mdw.nameParser);
177  query.use(query.routes());
178  query.use(function* (ctx, next) {
179    expect(ctx.req().method).toEqual("POST");
180    expect(ctx.req().url).toEqual("/users");
181    yield* next();
182  });
183  const createUser = query.create(
184    "/users",
185    { supervisor: takeEvery },
186    query.request({ method: "POST" }),
187  );
188  const store = createStore({ initialState: { users: {} } });
189  store.run(query.bootup);
190  store.dispatch(createUser());
191});
192
193test("run() on endpoint action - should run the effect", () => {
194  expect.assertions(1);
195  const api = createApi<TestCtx>();
196  api.use(api.routes());
197  let acc = "";
198  const action1 = api.get<{ id: string }, { result: boolean }>(
199    "/users/:id",
200    { supervisor: takeEvery },
201    function* (_, next) {
202      yield* next();
203      acc += "a";
204    },
205  );
206  const action2 = api.get(
207    "/users2",
208    { supervisor: takeEvery },
209    function* (_, next) {
210      yield* next();
211      yield* call(() => action1.run(action1({ id: "1" })));
212      acc += "b";
213      expect(acc).toEqual("ab");
214    },
215  );
216
217  const store = createStore({ initialState: { users: {} } });
218  store.run(api.bootup);
219  store.dispatch(action2());
220});
221
222test("run() from a normal saga", async () => {
223  expect.assertions(6);
224  const api = createApi();
225  api.use(api.routes());
226  let acc = "";
227  const action1 = api.get<{ id: string }>(
228    "/users/:id",
229    {
230      supervisor: takeEvery,
231    },
232    function* (_, next) {
233      yield* next();
234      acc += "a";
235    },
236  );
237  const extractedResults = {
238    actionType: null,
239    actionPayload: null,
240    name: null,
241    payload: null,
242  };
243  const action2 = () => ({ type: "ACTION" });
244  function* onAction() {
245    const ctx = yield* safe(() => action1.run(action1({ id: "1" })));
246    if (!ctx.ok) {
247      throw new Error("no ctx");
248    }
249    Object.assign(extractedResults, {
250      actionType: ctx.value.action.type,
251      actionPayload: ctx.value.action.payload,
252      name: ctx.value.name,
253      payload: ctx.value.payload,
254    });
255    acc += "b";
256  }
257  function* watchAction() {
258    yield* takeEvery(action2, onAction);
259  }
260
261  const store = createStore({ initialState: { users: {} } });
262  store.run(() => keepAlive([api.bootup, watchAction]));
263  store.dispatch(action2());
264
265  await new Promise((resolve) => setTimeout(resolve, 300));
266  const payload = { name: "/users/:id [GET]", options: { id: "1" } };
267
268  expect(extractedResults.actionType).toEqual(`${API_ACTION_PREFIX}${action1}`);
269  expect((extractedResults.actionPayload as any).name).toEqual(payload.name);
270  expect((extractedResults.actionPayload as any).options).toEqual(
271    payload.options,
272  );
273  expect(extractedResults.name).toEqual("/users/:id [GET]");
274  expect(extractedResults.payload).toEqual({ id: "1" });
275  expect(acc).toEqual("ab");
276});
277
278test("with hash key on a large post", async () => {
279  const { store, schema } = testStore();
280  const query = createApi();
281  query.use(mdw.api({ schema }));
282  query.use(query.routes());
283  query.use(function* fetchApi(ctx, next) {
284    const data = {
285      users: [{ ...mockUser, ...ctx.action.payload.options }],
286    };
287    ctx.response = new Response(jsonBlob(data), { status: 200 });
288    yield* next();
289  });
290  const createUserDefaultKey = query.post<{ email: string; largetext: string }>(
291    "/users",
292    { supervisor: takeEvery },
293    function* processUsers(ctx, next) {
294      ctx.cache = true;
295      yield* next();
296      const buff = yield* safe(() => {
297        if (!ctx.response) {
298          throw new Error("no response");
299        }
300        return ctx.response.arrayBuffer();
301      });
302
303      if (!buff.ok) {
304        throw buff.error;
305      }
306      const result = new TextDecoder("utf-8").decode(buff.value);
307      const { users } = JSON.parse(result);
308      if (!users) return;
309      const curUsers = (users as User[]).reduce<Record<string, User>>(
310        (acc, u) => {
311          acc[u.id] = u;
312          return acc;
313        },
314        {},
315      );
316      ctx.response = new Response();
317      ctx.json = {
318        ok: true,
319        value: curUsers,
320      };
321    },
322  );
323
324  const email = `${mockUser.email}9`;
325  const largetext = "abc-def-ghi-jkl-mno-pqr".repeat(100);
326
327  store.run(query.bootup);
328  const action = createUserDefaultKey({ email, largetext });
329  store.dispatch(action);
330
331  await store.run(waitForLoader(schema.loaders, action));
332
333  const s = store.getState();
334  const expectedKey = createKey(action.payload.name, {
335    email,
336    largetext,
337  });
338
339  expect([8, 9].includes(expectedKey.split("|")[1].length)).toBeTruthy();
340  expect(s.cache[expectedKey]).toEqual({
341    "1": { id: "1", name: "test", email: email, largetext: largetext },
342  });
343});
344
345test("two identical endpoints", () => {
346  const actual: string[] = [];
347  const { store, schema } = testStore();
348  const api = createApi();
349  api.use(mdw.api({ schema }));
350  api.use(api.routes());
351
352  const first = api.get("/health", function* (ctx, next) {
353    actual.push(ctx.req().url);
354    yield* next();
355  });
356
357  const second = api.get(["/health", "poll"], function* (ctx, next) {
358    actual.push(ctx.req().url);
359    yield* next();
360  });
361
362  store.run(api.bootup);
363  store.dispatch(first());
364  store.dispatch(second());
365
366  expect(actual).toEqual(["/health", "/health"]);
367});
368
369interface TestCtx<P = any, S = any> extends ApiCtx<P, S, { message: string }> {
370  something: boolean;
371}
372
373// this is strictly for testing types
374test("ensure types for get() endpoint", () => {
375  const api = createApi<TestCtx>();
376  api.use(api.routes());
377  api.use(function* (ctx, next) {
378    yield* next();
379    const data = { result: "wow" };
380    ctx.json = { ok: true, value: data };
381  });
382
383  const acc: string[] = [];
384  const action1 = api.get<{ id: string }, { result: string }>(
385    "/users/:id",
386    { supervisor: takeEvery },
387    function* (ctx, next) {
388      ctx.something = false;
389      acc.push(ctx.payload.id);
390
391      yield* next();
392
393      if (ctx.json.ok) {
394        acc.push(ctx.json.value.result);
395      }
396    },
397  );
398
399  const store = createStore({ initialState: { users: {} } });
400  store.run(api.bootup);
401
402  store.dispatch(action1({ id: "1" }));
403  expect(acc).toEqual(["1", "wow"]);
404});
405
406interface FetchUserProps {
407  id: string;
408}
409type FetchUserCtx = TestCtx<FetchUserProps>;
410
411// this is strictly for testing types
412test("ensure ability to cast `ctx` in function definition", () => {
413  const api = createApi<TestCtx>();
414  api.use(api.routes());
415  api.use(function* (ctx, next) {
416    yield* next();
417    const data = { result: "wow" };
418    ctx.json = { ok: true, value: data };
419  });
420
421  const acc: string[] = [];
422  const action1 = api.get<FetchUserProps>(
423    "/users/:id",
424    { supervisor: takeEvery },
425    function* (ctx: FetchUserCtx, next) {
426      ctx.something = false;
427      acc.push(ctx.payload.id);
428
429      yield* next();
430
431      if (ctx.json.ok) {
432        acc.push(ctx.json.value.result);
433      }
434    },
435  );
436
437  const store = createStore({ initialState: { users: {} } });
438  store.run(api.bootup);
439  store.dispatch(action1({ id: "1" }));
440  expect(acc).toEqual(["1", "wow"]);
441});
442
443type FetchUserSecondCtx = TestCtx<any, { result: string }>;
444
445// this is strictly for testing types
446test("ensure ability to cast `ctx` in function definition with no props", () => {
447  const api = createApi<TestCtx>();
448  api.use(api.routes());
449  api.use(function* (ctx, next) {
450    yield* next();
451    const data = { result: "wow" };
452    ctx.json = { ok: true, value: data };
453  });
454
455  const acc: string[] = [];
456  const action1 = api.get<never, { result: string }>(
457    "/users",
458    { supervisor: takeEvery },
459    function* (ctx: FetchUserSecondCtx, next) {
460      ctx.something = false;
461
462      yield* next();
463
464      if (ctx.json.ok) {
465        acc.push(ctx.json.value.result);
466      }
467    },
468  );
469
470  const store = createStore({ initialState: { users: {} } });
471  store.run(api.bootup);
472  store.dispatch(action1());
473  expect(acc).toEqual(["wow"]);
474});
475
476test("should bubble up error", () => {
477  let error: any = null;
478  const { store } = testStore();
479  const api = createApi();
480  api.use(function* (_, next) {
481    try {
482      yield* next();
483    } catch (err) {
484      error = err;
485    }
486  });
487  api.use(mdw.queryCtx);
488  api.use(api.routes());
489
490  const fetchUser = api.get(
491    "/users/8",
492    { supervisor: takeEvery },
493    function* (ctx, _) {
494      (ctx.loader as any).meta = { key: ctx.payload.thisKeyDoesNotExist };
495      throw new Error("GENERATING AN ERROR");
496    },
497  );
498
499  store.run(api.bootup);
500  store.dispatch(fetchUser());
501  expect(error.message).toBe(
502    "Cannot read properties of undefined (reading 'thisKeyDoesNotExist')",
503  );
504});
505
506// this is strictly for testing types
507test("useCache - derive api success from endpoint", () => {
508  const api = createApi<TestCtx>();
509  api.use(api.routes());
510  api.use(function* (ctx, next) {
511    yield* next();
512    const data = { result: "wow" };
513    ctx.json = { ok: true, value: data };
514  });
515
516  const acc: string[] = [];
517  const action1 = api.get<never, { result: string }>(
518    "/users",
519    { supervisor: takeEvery },
520    function* (ctx, next) {
521      ctx.something = false;
522
523      yield* next();
524
525      if (ctx.json.ok) {
526        acc.push(ctx.json.value.result);
527      } else {
528        // EXPECT { message: string }
529        ctx.json.error;
530      }
531    },
532  );
533
534  const store = createStore({ initialState: { users: {} } });
535  store.run(api.bootup);
536
537  function _App() {
538    const act = action1();
539    act.payload._result;
540    const users = useCache(act);
541    // EXPECT { result: string } | undefined
542    users.data;
543  }
544});