import type { AxiosError, AxiosPromise, AxiosResponse, AxiosRequestConfig } from 'axios'; /** * Framework-agnostic spy type that works with both Jest and Vitest. * Uses a flexible interface that both frameworks support. */ interface GenericSpyInstance { [key: string]: any; mockImplementation: (fn: any) => any; mockResolvedValue?: (value: any) => any; mockRejectedValue?: (value: any) => any; } /** * Configuration options for mocking an API method response. */ export interface ResponseData { key?: string; spy?: GenericSpyInstance; data?: T; impersonate?: (...args: any[]) => AxiosPromise; options?: Partial; reject?: boolean; } export interface SpyResponses { spy?: GenericSpyInstance; } /** * Creates a mock Axios success response. * * @param options - Partial Axios response options * @returns Mock AxiosResponse object * * @example * ```tsx * const response = createAxiosResponse({ * data: { id: 1, title: 'Job' }, * status: 200, * headers: { 'content-type': 'application/json' }, * }); * * mockApi.getJobs.mockResolvedValue(response); * ``` */ export const createAxiosResponse = ( options: Partial> ): AxiosResponse => { return { data: undefined as unknown as T, status: 200, statusText: 'OK', headers: {}, config: {} as AxiosRequestConfig, ...options, }; }; /** * Creates a mock Axios error response. * * @param options - Partial Axios response options for the error * @returns Mock AxiosError object * * @example * ```tsx * const error = createAxiosError({ * status: 404, * data: { Error: { Message: 'Not Found' } }, * }); * * mockApi.getJob.mockRejectedValue(error); * ``` */ export const createAxiosError = ( options: Partial> ): AxiosError => { return { response: createAxiosResponse(options), config: {} as AxiosRequestConfig, name: 'AxiosError', message: 'Request failed with status code 400', isAxiosError: true, toJSON: () => ({ name: 'AxiosError', message: 'Request failed with status code 400' }), }; }; /** * Abstract base class for creating API service mocks using `spyOn` on container-resolved API instances. * * Extend this class and inject a `Container`. Use `jest.spyOn` (Jest) or `vi.spyOn` (Vitest) * on the resolved API instance inside typed `mock*` methods. Each method calls `add()` to * register a default Axios response and store the spy under a key for later retrieval. * * @example **Jest** * ```tsx * import { ResponseData } from '@servicetitan/tanstack-query-mobx/test'; * import { Container } from '@servicetitan/react-ioc'; * import { JobsApi } from './jobs.api'; * * class MockJobsApi extends SpyBuilder { * static spyKeys = { * getJobs: 'getJobs', * getJob: 'getJob', * }; * * static dataMocks = { * jobs: [] as Job[], * job: {} as Job, * }; * * constructor(private container: Container) { * super(); * } * * mockGetJobs = (response: ResponseData = {}) => * this.add({ * key: MockJobsApi.spyKeys.getJobs, * spy: jest.spyOn(this.container.get(JobsApi), 'getJobs'), * data: MockJobsApi.dataMocks.jobs, * ...response, * }); * * mockGetJob = (response: ResponseData = {}) => * this.add({ * key: MockJobsApi.spyKeys.getJob, * spy: jest.spyOn(this.container.get(JobsApi), 'getJob'), * data: MockJobsApi.dataMocks.job, * ...response, * }); * } * ``` * * Replace `jest.spyOn` with `vi.spyOn` for Vitest. * * @example **Use in tests** * ```tsx * const { container, initialize } = new ContainerBuilder().build(); * const spy = new MockJobsApi(container) * .mockGetJobs() * .mockGetJob(); * * await initialize(); * * // Override for a specific test via getSpy * spy.getSpy(MockJobsApi.spyKeys.getJobs)?.mockResolvedValue( * createAxiosResponse({ data: [] }) * ); * * // Assert * expect(spy.getSpy(MockJobsApi.spyKeys.getJobs)).toHaveBeenCalledTimes(1); * ``` */ export abstract class SpyBuilder { private responses = new Map(); /** * Retrieves a spy by its key. * * Use the static `spyKeys` constants defined on your spy class to avoid magic strings. * Returns `undefined` if no spy was registered for the given key. * * @param key - The key used when registering the spy via `add()` * @returns The spy instance, or `undefined` */ getSpy = (key: string) => this.responses.get(key)?.spy; /** * Registers a spy with a default Axios response implementation. * * Pass a spy created with `jest.spyOn` / `vi.spyOn` on a container-resolved API instance. * The spy's implementation is set to return an Axios response (or rejection) * and is stored under `key` so tests can retrieve it via `getSpy()`. * * If `key` is already registered, the call is a no-op (first registration wins). * * @param options - Configuration for the mock response * @returns The builder instance for chaining * * @example **Error response** * ```tsx * this.add({ * key: MockJobsApi.spyKeys.deleteJob, * spy: jest.spyOn(this.container.get(JobsApi), 'deleteJob'), * data: undefined, * reject: true, * options: { status: 404, statusText: 'Not Found' }, * ...response, * }); * ``` * * @example **Custom implementation** * ```tsx * this.add({ * key: MockJobsApi.spyKeys.updateJob, * spy: jest.spyOn(this.container.get(JobsApi), 'updateJob'), * impersonate: (id, data) => * Promise.resolve(createAxiosResponse({ data })), * ...response, * }); * ``` */ add = ({ key, data, spy, impersonate, reject, options }: ResponseData) => { if (!this.responses.has(key ?? '')) { let implementedFunction = impersonate ?? (() => createAxiosResponse({ data, ...(options ?? {}) })); if (reject) { implementedFunction = () => { const response = { data: { Error: { Message: 'Bad Request', }, }, status: 400, statusText: 'Bad Request', ...(options ?? {}), }; return Promise.reject(createAxiosError(response)); }; } spy?.mockImplementation(implementedFunction); this.responses.set(key ?? '', { spy }); } return this; }; }