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});