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