diff --git a/src/utils/config/__tests__/load-config.spec.ts b/src/utils/config/__tests__/load-config.spec.ts index 4c2e49f..ca8dc6b 100644 --- a/src/utils/config/__tests__/load-config.spec.ts +++ b/src/utils/config/__tests__/load-config.spec.ts @@ -1,5 +1,6 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, it, expect, vi, afterEach } from "vitest"; import { useLoadConfigT } from "../load-config"; +import { nextTick } from "vue"; globalThis.fetch = vi.fn(); @@ -12,13 +13,14 @@ const createResponse = (data: any): Response => statusText: "OK", }) as Response; -const response404 = { - json: () => new Promise((resolve) => resolve({})), - clone: () => response404, - ok: false, - status: 404, - statusText: "Not Found", -} as Response; +const createErrorResponse = (status: number, statusText: string): Response => + ({ + json: () => Promise.reject(new Error(statusText)), + clone: () => createErrorResponse(status, statusText), + ok: false, + status, + statusText, + }) as Response; type Config = { apiUrl: string; @@ -33,7 +35,6 @@ const defaultConfig = { const validateConfig = (config: Config) => { if (typeof config.apiUrl !== "string") throw Error("apiUrl is not string"); if (config?.apiUrl === "") throw Error("apiUrl is empty"); - if (typeof config.apiVersion !== "number") throw Error("apiVersion is not number"); }; @@ -42,65 +43,119 @@ describe("useLoadConfigT", () => { vi.mocked(globalThis.fetch).mockReset(); }); - it("should return data", async () => { + it("should return data on successful initial fetch", async () => { const responseData = { apiUrl: "http://example.com/api", apiVersion: 2.0, }; - const expected = { ...responseData }; - vi.mocked(fetch).mockResolvedValue(createResponse(responseData)); - const { config, loading, error } = await useLoadConfigT(); + vi.mocked(fetch).mockResolvedValueOnce(createResponse(responseData)); + + const { config, loading, error, execute } = await useLoadConfigT( + defaultConfig, + validateConfig + ); expect(loading.value).toBe(false); expect(error.value).toBeUndefined(); - expect(config.value).toEqual(expected); + expect(config.value).toEqual(responseData); + expect(execute).toBeDefined(); }); - it("should return 404 error", async () => { - vi.mocked(fetch).mockResolvedValue(response404); - const { config, loading, error } = await useLoadConfigT(); - expect(loading.value).toBe(false); - expect(error.value).toEqual("Not Found"); - expect(config.value).toBeNull(); + it("should throw error on initial fetch failure", async () => { + vi.mocked(fetch).mockResolvedValueOnce(createErrorResponse(404, "Not Found")); + + await expect(useLoadConfigT(defaultConfig, validateConfig)).rejects.toThrow( + "Failed to load config: undefined" + ); }); - it("should merge with default", async () => { - const responseData = { + it("should not throw error on subsequent fetch failure, update error.value, and keep old config", async () => { + const initialData = { + apiUrl: "http://example.com/api", apiVersion: 2.0, }; - const expected = { ...defaultConfig, ...responseData }; - vi.mocked(fetch).mockResolvedValue(createResponse(responseData)); - const { config, loading, error } = await useLoadConfigT(defaultConfig); + vi.mocked(fetch) + .mockResolvedValueOnce(createResponse(initialData)) + .mockResolvedValueOnce(createErrorResponse(500, "Internal Server Error")); + + const { config, loading, error, execute } = await useLoadConfigT( + defaultConfig, + validateConfig + ); + expect(config.value).toEqual(initialData); + + await execute(); + await nextTick(); + + expect(config.value).toEqual(initialData); // Config should remain unchanged expect(loading.value).toBe(false); - expect(error.value).toBeUndefined(); - expect(config.value).toEqual(expected); + expect(error.value).toBeDefined(); + expect(error.value).toBe("Internal Server Error"); }); - it("should validate", async () => { - const responseData = { + it("should update config on successful subsequent fetch", async () => { + const initialData = { + apiUrl: "http://example.com/api", apiVersion: 2.0, }; - const expected = { ...defaultConfig, ...responseData }; - vi.mocked(fetch).mockResolvedValue(createResponse(responseData)); - const { config, loading, error } = await useLoadConfigT( + const updatedData = { + apiUrl: "http://example.com/api/v2", + apiVersion: 3.0, + }; + vi.mocked(fetch) + .mockResolvedValueOnce(createResponse(initialData)) + .mockResolvedValueOnce(createResponse(updatedData)); + + const { config, loading, error, execute } = await useLoadConfigT( defaultConfig, validateConfig ); + expect(config.value).toEqual(initialData); + + await execute(); + await nextTick(); + + expect(config.value).toEqual(updatedData); expect(loading.value).toBe(false); expect(error.value).toBeUndefined(); - expect(config.value).toEqual(expected); }); - it("should return validation error", async () => { + it("should throw validation error on initial fetch", async () => { const responseData = { apiUrl: "", + apiVersion: 2.0, }; - vi.mocked(fetch).mockResolvedValue(createResponse(responseData)); - const { config, loading, error } = await useLoadConfigT( + vi.mocked(fetch).mockResolvedValueOnce(createResponse(responseData)); + + await expect(useLoadConfigT(defaultConfig, validateConfig)).rejects.toThrow( + "Failed to load config: apiUrl is empty" + ); + }); + + it("should not throw validation error on subsequent fetch, update error.value, and keep old config", async () => { + const initialData = { + apiUrl: "http://example.com/api", + apiVersion: 2.0, + }; + const invalidData = { + apiUrl: "", + apiVersion: 3.0, + }; + vi.mocked(fetch) + .mockResolvedValueOnce(createResponse(initialData)) + .mockResolvedValueOnce(createResponse(invalidData)); + + const { config, loading, error, execute } = await useLoadConfigT( defaultConfig, validateConfig ); + expect(config.value).toEqual(initialData); + + await execute(); + await nextTick(); + + expect(config.value).toEqual(initialData); // Config should remain unchanged expect(loading.value).toBe(false); - expect(error.value?.message).toEqual("apiUrl is empty"); - expect(config.value).toBeNull(); + expect(error.value).toBeDefined(); + expect(error.value?.message).toBe("apiUrl is empty"); }); }); diff --git a/src/utils/config/load-config.ts b/src/utils/config/load-config.ts index a9cb443..be51035 100644 --- a/src/utils/config/load-config.ts +++ b/src/utils/config/load-config.ts @@ -1,7 +1,39 @@ import { useFetch } from "@vueuse/core"; import * as path from "path"; -import { computed, ref } from "vue"; +import type { ShallowRef } from "vue"; +import { computed, ref, shallowRef, watchEffect } from "vue"; +/** + * Asynchronously loads and manages a configuration object with validation and error handling. + * + * @template - The type of the configuration object, must extend object. + * @param - The default configuration object to use as a base. + * @param - A function to validate the configuration object. + * + * @returns + * - loading: A computed ref indicating whether the config is currently loading. + * - error: A ref containing any fetch or validation errors. + * - config: A shallow ref containing the current valid configuration. + * - execute: A function to manually trigger a config refresh. + * + * @throws Throws an error if the initial fetch fails or if validation fails on the initial fetch. + * + * @description + * This function fetches a configuration object from a URL, merges it with a default config, + * validates it, and provides reactive references to the resulting data and state. + * It handles both initial and subsequent fetches, with different error behaviors for each: + * - On initial fetch: Throws an error if fetch fails or validation fails. + * - On subsequent fetches: Updates error state but doesn't throw, keeps old config if validation fails. + * + * @example + * ```typescript + * const { config, loading, error, execute } = await useLoadConfigT(defaultConfig, validateConfig); + * // Use config.value to access the current configuration + * // Use loading.value to check if a fetch is in progress + * // Use error.value to check for any errors + * // Call execute() to manually refresh the configuration + * ``` + */ export async function useLoadConfigT( defaultConfig: Partial = {}, validate: (config: T) => void = () => true @@ -12,37 +44,56 @@ export async function useLoadConfigT( data, error: fetchError, isFinished, + execute, } = await useFetch(configUrl, { refetch: true }).json(); - // Initially false because of await. Can be true if configUrl is changed. + // Initially false because of await. Can become true later. const loading = computed(() => !isFinished.value); - // null until data is loaded const toBeValidated = computed( () => data.value && { ...defaultConfig, ...(data.value ?? {}) } ); - const validationError = computed(() => { - if (loading.value) return; - if (!toBeValidated.value) return; - try { - validate(toBeValidated.value); - } catch (e: unknown) { - // console.error(e); - return e as Error; - } - }); - - const error = computed( - () => (fetchError.value as Error | undefined) || validationError.value + const validationError = ref(undefined); + const error = ref(undefined); + let validConfig!: ShallowRef | undefined; + watchEffect( + () => { + if (loading.value) return; + if (toBeValidated.value) { + try { + validate(toBeValidated.value); + validationError.value = undefined; + } catch (e: unknown) { + validationError.value = e as Error; + } + } + error.value = (fetchError.value as Error | undefined) || validationError.value; + if (error.value) return; + const rawValidConfig = toBeValidated.value; + if (rawValidConfig === null) return; + if (validConfig === undefined) { + validConfig = shallowRef(rawValidConfig); + } else { + validConfig.value = rawValidConfig; + } + }, + { flush: "sync" } ); - const config = computed(() => (error.value ? null : toBeValidated.value)); + if (error.value) { + throw Error(`Failed to load config: ${error.value.message}`); + } + + if (validConfig === undefined) { + throw Error("The config is undefined while no error occurred."); + } return { loading, error, - config, + config: validConfig, + execute, }; }