Skip to content

Commit

Permalink
chore: add tests for service primitives (#1704)
Browse files Browse the repository at this point in the history
Signed-off-by: Ryan Hopper-Lowe <[email protected]>
  • Loading branch information
ryanhopperlowe authored Feb 11, 2025
1 parent 13bef8e commit 4e0b256
Show file tree
Hide file tree
Showing 2 changed files with 227 additions and 10 deletions.
196 changes: 196 additions & 0 deletions ui/admin/app/lib/service/api/__tests__/service-primitives.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import { Mock } from "vitest";
import { z } from "zod";

import {
CreateFetcherReturn,
createFetcher,
} from "~/lib/service/api/service-primitives";
import { revalidateObject } from "~/lib/service/revalidation";

vi.mock("~/lib/service/revalidation.ts", () => ({
revalidateObject: vi.fn(),
}));
const mockRevalidateObject = revalidateObject as Mock;

describe(createFetcher, () => {
const test = new TestFetcher();

beforeEach(() => test.setup());

it("should return fetcher values", () => {
expect(test.fetcher).toEqual({
handler: expect.any(Function),
key: expect.any(Function),
swr: expect.any(Function),
revalidate: expect.any(Function),
});
});

it("should trigger handler when handler is called", async () => {
const params = { test: "test" };
const config = { signal: new AbortController().signal };

await test.fetcher.handler(params, config);
expect(test.spy).toHaveBeenCalledWith(params, config);
});

it.each([
[{ test: "test-value" }, { test: "test-value" }],
[{ test: 1231 }, { test: undefined }],
[{ test: null }, { test: undefined }],
[{}, { test: undefined }],
[undefined, { test: undefined }],
])(
"should trigger revalidation and skip invalid params when revalidate is called",
(params, expected) => {
// @ts-expect-error - we want to test the error case
test.fetcher.revalidate(params);
expect(mockRevalidateObject).toHaveBeenCalledWith({
key: test.keyVal,
params: expected,
});
}
);

describe(test.fetcher.swr, () => {
it("should return a tuple with the key and the fetcher", () => {
const params = { test: "test" };

const [key, fetcher] = test.fetcher.swr(params);

expect(key).toEqual({ key: test.keyVal, params });
expect(fetcher).toEqual(expect.any(Function));
});

it("should return null for the key when disabled", () => {
const [key] = test.fetcher.swr({ test: "test" }, { enabled: false });
expect(key).toBeNull();
});

it.each([
["empty", {}, null],
["invalid-type", { test: 1234123 }, null], // number is not a valid value for `params.test`
])(
"should return null for the key when params are %s",
(_, params, expected) => {
// @ts-expect-error - we want to test the error case
const [key] = test.fetcher.swr(params, { enabled: true });
expect(key).toEqual(expected);
}
);

it("should cancel duplicate requests", async () => {
const params = { test: "test" };

const abortControllerMock = mockAbortController();
// setup spy this way to prevent the promise from resolving without using setTimeout
const [spy, actions] = mockPromise();

const test = new TestFetcher().setup({ spy });

const [_, fetcher] = test.fetcher.swr(params);

expect(spy).not.toHaveBeenCalled();
expect(abortControllerMock.mock.abort).not.toHaveBeenCalled();

// first trigger sets the abortController
fetcher();

expect(spy).toHaveBeenCalledTimes(1);
expect(abortControllerMock.mock.abort).not.toHaveBeenCalled();

// second trigger aborts
fetcher();

expect(spy).toHaveBeenCalledTimes(2);
expect(abortControllerMock.mock.abort).toHaveBeenCalledTimes(1);

// cleanup
actions.resolve!(); // resolve the promise to allow garbage collection
abortControllerMock.cleanup();
});

it("should not cancel duplicate requests when cancellable is false", async () => {
const params = { test: "test" };

const abortControllerMock = mockAbortController();
// setup spy this way to prevent the promise from resolving without using setTimeout
const [spy, actions] = mockPromise();

const test = new TestFetcher().setup({ spy });

const [_, fetcher] = test.fetcher.swr(params, { cancellable: false });

expect(spy).not.toHaveBeenCalled();
expect(abortControllerMock.mock.abort).not.toHaveBeenCalled();

// first trigger sets the abortController
fetcher();

expect(spy).toHaveBeenCalledTimes(1);
expect(abortControllerMock.mock.abort).not.toHaveBeenCalled();

// second trigger aborts
fetcher();

expect(spy).toHaveBeenCalledTimes(2);
expect(abortControllerMock.mock.abort).not.toHaveBeenCalled();

// cleanup
actions.resolve!(); // resolve the promise to allow garbage collection
abortControllerMock.cleanup();
});
});
});

class TestFetcher {
fetcher!: CreateFetcherReturn<{ test: string }, unknown>;
spy!: Mock;
keyVal!: string;

constructor() {
this.setup();
}

setup(config?: { spy?: Mock; keyVal?: string }) {
const { spy = vi.fn(), keyVal = "test-key" } = config ?? {};

this.spy = spy;
this.keyVal = keyVal;

this.fetcher = createFetcher(
z.object({ test: z.string() }),
this.spy,
() => this.keyVal
);

return this;
}
}

function mockAbortController() {
const tempAbortController = AbortController;
const mock = { abort: vi.fn(), signal: vi.fn() };

// @ts-expect-error - the internal abort controller is hidden via a closure
global.AbortController = vi.fn(() => mock);

return {
mock,
cleanup: () => (global.AbortController = tempAbortController),
};
}

function mockPromise() {
const actions: { resolve?: () => void } = { resolve: undefined };

const spy = vi.fn(() => {
const mp = new Promise((res) => {
actions.resolve = () => res(null);
});

return mp;
});

return [spy, actions] as const;
}
41 changes: 31 additions & 10 deletions ui/admin/app/lib/service/api/service-primitives.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,36 @@ import { ZodRawShape, z } from "zod";

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

type FetcherConfig = {
export type FetcherConfig = {
signal?: AbortSignal;
cancellable?: boolean;
};

type FetcherSWR<TParams extends object, TResponse> = (
params: NullishPartial<TParams>,
config?: FetcherConfig & {
enabled?: boolean;
}
) => [Nullish<KeyObj<TParams>>, () => Promise<TResponse>];

type FetcherHandler<TParams extends object, TResponse> = (
params: TParams,
config?: FetcherConfig
) => Promise<TResponse>;

type FetcherRevalidate<TParams extends object> = (
params?: NullishPartial<TParams>
) => void;

type FetcherKey = () => string;

export type CreateFetcherReturn<TParams extends object, TResponse> = {
handler: FetcherHandler<TParams, TResponse>;
key: FetcherKey;
swr: FetcherSWR<TParams, TResponse>;
revalidate: FetcherRevalidate<TParams>;
};

/**
* Creates a fetcher for a given API function
* @param input - The input schema
Expand All @@ -17,8 +42,8 @@ type FetcherConfig = {
export const createFetcher = <TParams extends object, TResponse>(
input: z.ZodSchema<TParams>,
handler: (params: TParams, config: FetcherConfig) => Promise<TResponse>,
key: () => string
) => {
key: FetcherKey
): CreateFetcherReturn<TParams, TResponse> => {
type KeyParams = NullishPartial<TParams>;

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

return handler(params, { signal: abortController.signal, ...config });
return handler(params, { signal: abortController?.signal, ...config });
};

return {
handler: (params: TParams, config: FetcherConfig = {}) =>
handleFetch(params, config),
handler: (params, config = {}) => handleFetch(params, config),
key,
/** Creates a SWR key and fetcher for the given params. This works for both `useSWR` and `prefetch` from SWR */
swr: (
params: KeyParams,
config: FetcherConfig & { enabled?: boolean } = {}
) => {
swr: (params, config = {}) => {
const { enabled = true, ...restConfig } = config;

return [
Expand Down

0 comments on commit 4e0b256

Please sign in to comment.