repos / starfx

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

starfx / test
Eric Bower · 05 Mar 24

api.test.ts

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