Skip to content

Commit 4e0b256

Browse files
chore: add tests for service primitives (#1704)
Signed-off-by: Ryan Hopper-Lowe <[email protected]>
1 parent 13bef8e commit 4e0b256

File tree

2 files changed

+227
-10
lines changed

2 files changed

+227
-10
lines changed
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import { Mock } from "vitest";
2+
import { z } from "zod";
3+
4+
import {
5+
CreateFetcherReturn,
6+
createFetcher,
7+
} from "~/lib/service/api/service-primitives";
8+
import { revalidateObject } from "~/lib/service/revalidation";
9+
10+
vi.mock("~/lib/service/revalidation.ts", () => ({
11+
revalidateObject: vi.fn(),
12+
}));
13+
const mockRevalidateObject = revalidateObject as Mock;
14+
15+
describe(createFetcher, () => {
16+
const test = new TestFetcher();
17+
18+
beforeEach(() => test.setup());
19+
20+
it("should return fetcher values", () => {
21+
expect(test.fetcher).toEqual({
22+
handler: expect.any(Function),
23+
key: expect.any(Function),
24+
swr: expect.any(Function),
25+
revalidate: expect.any(Function),
26+
});
27+
});
28+
29+
it("should trigger handler when handler is called", async () => {
30+
const params = { test: "test" };
31+
const config = { signal: new AbortController().signal };
32+
33+
await test.fetcher.handler(params, config);
34+
expect(test.spy).toHaveBeenCalledWith(params, config);
35+
});
36+
37+
it.each([
38+
[{ test: "test-value" }, { test: "test-value" }],
39+
[{ test: 1231 }, { test: undefined }],
40+
[{ test: null }, { test: undefined }],
41+
[{}, { test: undefined }],
42+
[undefined, { test: undefined }],
43+
])(
44+
"should trigger revalidation and skip invalid params when revalidate is called",
45+
(params, expected) => {
46+
// @ts-expect-error - we want to test the error case
47+
test.fetcher.revalidate(params);
48+
expect(mockRevalidateObject).toHaveBeenCalledWith({
49+
key: test.keyVal,
50+
params: expected,
51+
});
52+
}
53+
);
54+
55+
describe(test.fetcher.swr, () => {
56+
it("should return a tuple with the key and the fetcher", () => {
57+
const params = { test: "test" };
58+
59+
const [key, fetcher] = test.fetcher.swr(params);
60+
61+
expect(key).toEqual({ key: test.keyVal, params });
62+
expect(fetcher).toEqual(expect.any(Function));
63+
});
64+
65+
it("should return null for the key when disabled", () => {
66+
const [key] = test.fetcher.swr({ test: "test" }, { enabled: false });
67+
expect(key).toBeNull();
68+
});
69+
70+
it.each([
71+
["empty", {}, null],
72+
["invalid-type", { test: 1234123 }, null], // number is not a valid value for `params.test`
73+
])(
74+
"should return null for the key when params are %s",
75+
(_, params, expected) => {
76+
// @ts-expect-error - we want to test the error case
77+
const [key] = test.fetcher.swr(params, { enabled: true });
78+
expect(key).toEqual(expected);
79+
}
80+
);
81+
82+
it("should cancel duplicate requests", async () => {
83+
const params = { test: "test" };
84+
85+
const abortControllerMock = mockAbortController();
86+
// setup spy this way to prevent the promise from resolving without using setTimeout
87+
const [spy, actions] = mockPromise();
88+
89+
const test = new TestFetcher().setup({ spy });
90+
91+
const [_, fetcher] = test.fetcher.swr(params);
92+
93+
expect(spy).not.toHaveBeenCalled();
94+
expect(abortControllerMock.mock.abort).not.toHaveBeenCalled();
95+
96+
// first trigger sets the abortController
97+
fetcher();
98+
99+
expect(spy).toHaveBeenCalledTimes(1);
100+
expect(abortControllerMock.mock.abort).not.toHaveBeenCalled();
101+
102+
// second trigger aborts
103+
fetcher();
104+
105+
expect(spy).toHaveBeenCalledTimes(2);
106+
expect(abortControllerMock.mock.abort).toHaveBeenCalledTimes(1);
107+
108+
// cleanup
109+
actions.resolve!(); // resolve the promise to allow garbage collection
110+
abortControllerMock.cleanup();
111+
});
112+
113+
it("should not cancel duplicate requests when cancellable is false", async () => {
114+
const params = { test: "test" };
115+
116+
const abortControllerMock = mockAbortController();
117+
// setup spy this way to prevent the promise from resolving without using setTimeout
118+
const [spy, actions] = mockPromise();
119+
120+
const test = new TestFetcher().setup({ spy });
121+
122+
const [_, fetcher] = test.fetcher.swr(params, { cancellable: false });
123+
124+
expect(spy).not.toHaveBeenCalled();
125+
expect(abortControllerMock.mock.abort).not.toHaveBeenCalled();
126+
127+
// first trigger sets the abortController
128+
fetcher();
129+
130+
expect(spy).toHaveBeenCalledTimes(1);
131+
expect(abortControllerMock.mock.abort).not.toHaveBeenCalled();
132+
133+
// second trigger aborts
134+
fetcher();
135+
136+
expect(spy).toHaveBeenCalledTimes(2);
137+
expect(abortControllerMock.mock.abort).not.toHaveBeenCalled();
138+
139+
// cleanup
140+
actions.resolve!(); // resolve the promise to allow garbage collection
141+
abortControllerMock.cleanup();
142+
});
143+
});
144+
});
145+
146+
class TestFetcher {
147+
fetcher!: CreateFetcherReturn<{ test: string }, unknown>;
148+
spy!: Mock;
149+
keyVal!: string;
150+
151+
constructor() {
152+
this.setup();
153+
}
154+
155+
setup(config?: { spy?: Mock; keyVal?: string }) {
156+
const { spy = vi.fn(), keyVal = "test-key" } = config ?? {};
157+
158+
this.spy = spy;
159+
this.keyVal = keyVal;
160+
161+
this.fetcher = createFetcher(
162+
z.object({ test: z.string() }),
163+
this.spy,
164+
() => this.keyVal
165+
);
166+
167+
return this;
168+
}
169+
}
170+
171+
function mockAbortController() {
172+
const tempAbortController = AbortController;
173+
const mock = { abort: vi.fn(), signal: vi.fn() };
174+
175+
// @ts-expect-error - the internal abort controller is hidden via a closure
176+
global.AbortController = vi.fn(() => mock);
177+
178+
return {
179+
mock,
180+
cleanup: () => (global.AbortController = tempAbortController),
181+
};
182+
}
183+
184+
function mockPromise() {
185+
const actions: { resolve?: () => void } = { resolve: undefined };
186+
187+
const spy = vi.fn(() => {
188+
const mp = new Promise((res) => {
189+
actions.resolve = () => res(null);
190+
});
191+
192+
return mp;
193+
});
194+
195+
return [spy, actions] as const;
196+
}

ui/admin/app/lib/service/api/service-primitives.ts

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,36 @@ import { ZodRawShape, z } from "zod";
22

33
import { type KeyObj, revalidateObject } from "~/lib/service/revalidation";
44

5-
type FetcherConfig = {
5+
export type FetcherConfig = {
66
signal?: AbortSignal;
77
cancellable?: boolean;
88
};
99

10+
type FetcherSWR<TParams extends object, TResponse> = (
11+
params: NullishPartial<TParams>,
12+
config?: FetcherConfig & {
13+
enabled?: boolean;
14+
}
15+
) => [Nullish<KeyObj<TParams>>, () => Promise<TResponse>];
16+
17+
type FetcherHandler<TParams extends object, TResponse> = (
18+
params: TParams,
19+
config?: FetcherConfig
20+
) => Promise<TResponse>;
21+
22+
type FetcherRevalidate<TParams extends object> = (
23+
params?: NullishPartial<TParams>
24+
) => void;
25+
26+
type FetcherKey = () => string;
27+
28+
export type CreateFetcherReturn<TParams extends object, TResponse> = {
29+
handler: FetcherHandler<TParams, TResponse>;
30+
key: FetcherKey;
31+
swr: FetcherSWR<TParams, TResponse>;
32+
revalidate: FetcherRevalidate<TParams>;
33+
};
34+
1035
/**
1136
* Creates a fetcher for a given API function
1237
* @param input - The input schema
@@ -17,8 +42,8 @@ type FetcherConfig = {
1742
export const createFetcher = <TParams extends object, TResponse>(
1843
input: z.ZodSchema<TParams>,
1944
handler: (params: TParams, config: FetcherConfig) => Promise<TResponse>,
20-
key: () => string
21-
) => {
45+
key: FetcherKey
46+
): CreateFetcherReturn<TParams, TResponse> => {
2247
type KeyParams = NullishPartial<TParams>;
2348

2449
/** Creates a closure to trigger abort controller on consecutive requests */
@@ -67,18 +92,14 @@ export const createFetcher = <TParams extends object, TResponse>(
6792
abortController = new AbortController();
6893
}
6994

70-
return handler(params, { signal: abortController.signal, ...config });
95+
return handler(params, { signal: abortController?.signal, ...config });
7196
};
7297

7398
return {
74-
handler: (params: TParams, config: FetcherConfig = {}) =>
75-
handleFetch(params, config),
99+
handler: (params, config = {}) => handleFetch(params, config),
76100
key,
77101
/** Creates a SWR key and fetcher for the given params. This works for both `useSWR` and `prefetch` from SWR */
78-
swr: (
79-
params: KeyParams,
80-
config: FetcherConfig & { enabled?: boolean } = {}
81-
) => {
102+
swr: (params, config = {}) => {
82103
const { enabled = true, ...restConfig } = config;
83104

84105
return [

0 commit comments

Comments
 (0)