import { Container, injectable, Store } from '@servicetitan/react-ioc'; import forEach from 'lodash/forEach'; import { reaction } from 'mobx'; import { QueryApiStore } from '../../utils/query-api.store'; import { QueryClientStore } from '../../utils/query-client.store'; import { QueryApi } from '../../utils/query.api'; import type { QueryClientConfig } from '@tanstack/query-core'; /** * Waits for a MobX observable condition to become true. * * Uses a MobX reaction that fires immediately and whenever tracked observables change. * Resolves the promise when the condition function returns `true`. * * @param check - Function that returns true when the condition is met * @returns Promise that resolves when the condition becomes true * * @example * ```tsx * // Wait for a store to be initialized * await waitFor(() => store.initialized); * * // Wait for data to be loaded * await waitFor(() => store.data.length > 0); * * // Wait for multiple conditions * await waitFor(() => store.initialized && !store.isLoading); * * // Wait for a specific value * await waitFor(() => store.status === 'success'); * ``` */ export const waitFor = (check: () => boolean) => new Promise(resolve => { let dispose: (() => void) | undefined; // eslint-disable-next-line prefer-const dispose = reaction( check, (value: boolean) => { if (value) { resolve(value); dispose?.(); } }, { fireImmediately: true, } ); }); function isStorePrototype(value?: any): value is Type { return Boolean(value?.prototype && value.prototype instanceof Store); } function isNewable(value?: any): value is Type { return Boolean( typeof value === 'function' && value.prototype && typeof value.prototype === 'object' ); } export interface Type extends Function { new (...args: any[]): T; } export type SymbolToken = symbol & { typeRef?: T }; export type Token = SymbolToken | Type | Function; type ItemType = Type & { initialize?: () => Promise; initialized?: boolean; }; type InstanceType = undefined | { useClass?: Type; useValue?: T }; /** * Options for `ContainerBuilder.initialize()`. */ export interface InitializeOptions { /** Store tokens to skip initialization for entirely. */ ignoreStores?: Token[]; /** Individual `QueryApi` instances to skip waiting for. */ skipQueries?: QueryApi[]; } /** * Test container builder for dependency injection in tests. * * Simplifies setting up stores and their dependencies with automatic query client * configuration and store initialization. Use with `@servicetitan/react-ioc` containers. * * @example * ```tsx * // Basic usage * const { container, initialize } = new ContainerBuilder() * .add(JobsStore) * .add(JobsApi) * .build(); * * await initialize(); * const store = container.get(JobsStore); * expect(store.initialized).toBe(true); * ``` * * @example * ```tsx * // With mocked dependencies * const mockApi = new MockJobsApi(); * const { container, initialize } = new ContainerBuilder() * .add(JobsStore) * .add(JobsApi, { useValue: mockApi }) * .build(); * * await initialize(); * ``` * * @example * ```tsx * // With custom query client config * const builder = new ContainerBuilder(undefined, { * defaultOptions: { * queries: { staleTime: 0, retry: false }, * }, * }); * ``` */ export class ContainerBuilder { container = new Container(); private instances = new Map(); constructor(container?: Container, options?: QueryClientConfig) { this.addQueryClient(options); if (container) { this.container = container; } } /** * Builds the container and binds all added dependencies. * * Call this after adding all classes and values with `add()`. * Returns `this` to allow chaining with `initialize()`. * * @returns The builder instance with `container` and `initialize` available * * @example * ```tsx * const { container, initialize } = new ContainerBuilder() * .add(JobsStore) * .add(JobsApi) * .build(); * ``` */ build = () => { this.instances.forEach((value, provide) => { if (value) { if (value.useValue) { this.container .bind(provide) .toDynamicValue(() => value.useValue) .inSingletonScope(); } else if (value.useClass) { this.container.bind(provide).to(value.useClass).inSingletonScope(); } } else if (isStorePrototype(provide)) { // Handle Store classes this.container.bind(provide).to(provide).inSingletonScope(); } else if (isNewable(provide)) { // Handle regular newable classes when no custom value is provided this.container.bind(provide).to(provide).inSingletonScope(); } }); return this; }; /** * Runs the `initialize()` lifecycle method on all stores and waits for completion. * * Waits for: * - All store `initialize()` promises to complete * - All `QueryApiStore` instances to reach `initialized: true` * - All queries within stores to reach `initialized: true` * * Queries intended to stay disabled for the duration of a test (e.g. * `enabled: false` literal) must be opted out explicitly via `skipQueries` * or `ignoreStores`; otherwise this method waits for them indefinitely. * * @param options - Array of tokens to skip, or an options object * @returns Promise that resolves when all initialization is complete * * @example * ```tsx * // Initialize all stores * await initialize(); * expect(store.initialized).toBe(true); * ``` * * @example * ```tsx * // Skip specific stores * await initialize([StoreToSkip]); * ``` * * @example * ```tsx * // Skip specific queries * await initialize({ skipQueries: [store.lazyQuery] }); * ``` */ initialize = async (options?: Token[] | InitializeOptions) => { const ignoreInitialize = Array.isArray(options) ? options : (options?.ignoreStores ?? []); const skipQueries = new Set(Array.isArray(options) ? [] : (options?.skipQueries ?? [])); const initializePromises: Promise[] = []; const waitPromises: Promise[] = []; const shouldWaitForQuery = (api: QueryApi) => { if (skipQueries.has(api)) { return false; } return true; }; /** * Wait condition for a query: resolves when initialized. * For queries that should stay disabled for the duration of the test, * use `skipQueries` (individual) or `ignoreStores` (whole store) to opt out. */ const waitForQuery = (api: QueryApi) => { waitPromises.push(waitFor(() => Boolean(api.initialized))); }; this.instances.forEach((value: any, provide) => { /* * Skip useValue entries — matches react-ioc Provider behavior * (react-ioc does not call initialize/dispose on useValue bindings) */ if (value?.useValue) { return; } const instanceValue = value?.useClass ?? provide; const isStore = Boolean(isStorePrototype(instanceValue)); const isQueryStore = Boolean( instanceValue?.prototype && instanceValue.prototype instanceof QueryApiStore ); if (isStore) { const item = this.container.get(provide); const canInitialize = !item?.initialized && !ignoreInitialize.includes(provide); if (isQueryStore && canInitialize) { waitPromises.push(waitFor(() => Boolean(item?.initialized))); forEach( [...((item as any).queries as Map).values()], queryApi => { if (shouldWaitForQuery(queryApi)) { waitForQuery(queryApi); } } ); } if (item?.initialize && canInitialize) { let promise; try { promise = item.initialize?.(); } catch { promise = null; } if (promise && typeof promise.catch === 'function') { initializePromises.push(promise); } } const managedApis = (item as any).queryStoreState?.managedApis; if (managedApis) { for (const api of managedApis) { if (api instanceof QueryApi && shouldWaitForQuery(api)) { waitForQuery(api); } } } } }); await Promise.all(initializePromises); await Promise.all(waitPromises); }; /** * Configures the `QueryClientStore` with custom TanStack Query options. * * Called automatically in the constructor with test-friendly defaults. * Only call manually if you need custom configuration. * * @param config - TanStack Query configuration options * @returns The builder instance for chaining * * @example * ```tsx * builder.addQueryClient({ * defaultOptions: { * queries: { * staleTime: Infinity, * retry: false, * }, * }, * }); * ``` */ addQueryClient = (config?: QueryClientConfig) => { const defaultConfig: QueryClientConfig = { defaultOptions: { queries: { staleTime: Infinity, retry: false, }, }, }; const capturedConfig = config ?? defaultConfig; class TestQueryClientStore extends QueryClientStore { isolateFromWindowClients = true; constructor() { super(undefined, capturedConfig); } } injectable()(TestQueryClientStore); this.instances.set(QueryClientStore, { useClass: TestQueryClientStore }); return this; }; /** * Adds a class or value to the container for dependency injection. * * @param provide - The token (class, symbol, or function) to provide * @param customValue - Optional custom implementation or value * @returns The builder instance for chaining * * @example * ```tsx * // Add a store class * builder.add(JobsStore); * ``` * * @example * ```tsx * // Add with a custom implementation class * builder.add(JobsApi, { useClass: MockJobsApi }); * ``` * * @example * ```tsx * // Add with a concrete value instance * const mockApi = new MockJobsApi(); * builder.add(JobsApi, { useValue: mockApi }); * ``` */ add = (provide: Token, customValue?: InstanceType) => { if (!this.instances.has(provide)) { this.instances.set(provide, customValue); } return this; }; }