Skip to content

Commit

Permalink
Keep the value of config from becoming null
Browse files Browse the repository at this point in the history
  • Loading branch information
TaiSakuma committed Aug 8, 2024
1 parent bd402db commit c0547cd
Show file tree
Hide file tree
Showing 2 changed files with 162 additions and 56 deletions.
131 changes: 93 additions & 38 deletions src/utils/config/__tests__/load-config.spec.ts
Original file line number Diff line number Diff line change
@@ -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();

Expand All @@ -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;
Expand All @@ -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");
};

Expand All @@ -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<Config>();
vi.mocked(fetch).mockResolvedValueOnce(createResponse(responseData));

const { config, loading, error, execute } = await useLoadConfigT<Config>(
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<Config>();
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<Config>(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<Config>(defaultConfig);
vi.mocked(fetch)
.mockResolvedValueOnce(createResponse(initialData))
.mockResolvedValueOnce(createErrorResponse(500, "Internal Server Error"));

const { config, loading, error, execute } = await useLoadConfigT<Config>(
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<Config>(
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<Config>(
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<Config>(
vi.mocked(fetch).mockResolvedValueOnce(createResponse(responseData));

await expect(useLoadConfigT<Config>(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<Config>(
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");
});
});
87 changes: 69 additions & 18 deletions src/utils/config/load-config.ts
Original file line number Diff line number Diff line change
@@ -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<T extends object>(
defaultConfig: Partial<T> = {},
validate: (config: T) => void = () => true
Expand All @@ -12,37 +44,56 @@ export async function useLoadConfigT<T extends object>(
data,
error: fetchError,
isFinished,
execute,
} = await useFetch<T>(configUrl, { refetch: true }).json<T>();

// 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<T | null>(
() => 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<Error | undefined>(undefined);
const error = ref<Error | undefined>(undefined);
let validConfig!: ShallowRef<T> | 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<T>(rawValidConfig);
} else {
validConfig.value = rawValidConfig;
}
},
{ flush: "sync" }
);

const config = computed<T | null>(() => (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,
};
}

Expand Down

0 comments on commit c0547cd

Please sign in to comment.