Bladeren bron

feat: Added LongJobs component tests and socket mock

Owen Diffey 2 jaren geleden
bovenliggende
commit
33ec3424fe

+ 4 - 0
frontend/src/classes/CustomWebSocket.class.ts

@@ -23,6 +23,10 @@ export default class CustomWebSocket extends WebSocket {
 
 	PROGRESS_CB_REFS: object;
 
+	data: any; // Mock only
+
+	triggerEvent: (target: string, data: any) => void; // Mock only
+
 	constructor(url) {
 		super(url);
 

+ 93 - 0
frontend/src/classes/__mocks__/CustomWebSocket.class.ts

@@ -0,0 +1,93 @@
+import ListenerHandler from "@/classes/ListenerHandler.class";
+
+export default class CustomWebSocketMock {
+	dispatcher: ListenerHandler;
+
+	url: string;
+
+	data: {
+		dispatch?: any;
+		onProgress?: any[];
+		progressInterval?: number;
+	};
+
+	onDisconnectCbs: {
+		temp: any[];
+		persist: any[];
+	};
+
+	constructor(url) {
+		this.dispatcher = new ListenerHandler();
+		this.url = url;
+		this.data = {
+			dispatch: {},
+			onProgress: [{}],
+			progressInterval: 10
+		};
+		this.onDisconnectCbs = {
+			temp: [],
+			persist: []
+		};
+	}
+
+	on(target, cb, options?) {
+		this.dispatcher.addEventListener(
+			target,
+			event => cb(event.detail),
+			options
+		);
+	}
+
+	dispatch(target, ...args) {
+		const lastArg = args[args.length - 1];
+
+		if (typeof lastArg === "function") {
+			if (this.data.dispatch && this.data.dispatch[target])
+				lastArg(this.data.dispatch[target]);
+		} else if (typeof lastArg === "object") {
+			if (this.data.onProgress && this.data.onProgress[target])
+				this.data.onProgress[target].forEach(data =>
+					setInterval(
+						() => lastArg.onProgress(data),
+						this.data.progressInterval || 0
+					)
+				);
+			if (this.data.dispatch && this.data.dispatch[target])
+				lastArg.cb(this.data.dispatch[target]);
+		}
+	}
+
+	// eslint-disable-next-line class-methods-use-this
+	onConnect(cb) {
+		cb();
+	}
+
+	onDisconnect(...args) {
+		if (args[0] === true) this.onDisconnectCbs.persist.push(args[1]);
+		else this.onDisconnectCbs.temp.push(args[0]);
+
+		this.dispatcher.addEventListener(
+			"socket.disconnect",
+			() => {
+				this.onDisconnectCbs.temp.forEach(cb => cb());
+				this.onDisconnectCbs.persist.forEach(cb => cb());
+			},
+			false
+		);
+	}
+
+	clearCallbacks() {
+		this.onDisconnectCbs.temp = [];
+	}
+
+	// eslint-disable-next-line class-methods-use-this
+	destroyModalListeners() {}
+
+	triggerEvent(target, data) {
+		this.dispatcher.dispatchEvent(
+			new CustomEvent(target, {
+				detail: data
+			})
+		);
+	}
+}

+ 1 - 3
frontend/src/components/ChristmasLights.spec.ts

@@ -1,10 +1,8 @@
 import { flushPromises } from "@vue/test-utils";
 import ChristmasLights from "@/components/ChristmasLights.vue";
-import { useTestUtils } from "@/composables/useTestUtils";
+import { getWrapper } from "@/tests/utils/utils";
 import { useUserAuthStore } from "@/stores/userAuth";
 
-const { getWrapper } = useTestUtils();
-
 describe("ChristmasLights component", () => {
 	beforeEach(async context => {
 		context.wrapper = await getWrapper(ChristmasLights);

+ 1 - 3
frontend/src/components/InfoIcon.spec.ts

@@ -1,7 +1,5 @@
 import InfoIcon from "@/components/InfoIcon.vue";
-import { useTestUtils } from "@/composables/useTestUtils";
-
-const { getWrapper } = useTestUtils();
+import { getWrapper } from "@/tests/utils/utils";
 
 test("InfoIcon component", async () => {
 	const wrapper = await getWrapper(InfoIcon, {

+ 1 - 3
frontend/src/components/InputHelpBox.spec.ts

@@ -1,7 +1,5 @@
 import InputHelpBox from "@/components/InputHelpBox.vue";
-import { useTestUtils } from "@/composables/useTestUtils";
-
-const { getWrapper } = useTestUtils();
+import { getWrapper } from "@/tests/utils/utils";
 
 describe("InputHelpBox component", () => {
 	beforeEach(async context => {

+ 154 - 0
frontend/src/components/LongJobs.spec.ts

@@ -0,0 +1,154 @@
+import { flushPromises } from "@vue/test-utils";
+import LongJobs from "@/components/LongJobs.vue";
+import FloatingBox from "@/components/FloatingBox.vue";
+import { getWrapper } from "@/tests/utils/utils";
+import { useUserAuthStore } from "@/stores/userAuth";
+import { useLongJobsStore } from "@/stores/longJobs";
+import { useWebsocketsStore } from "@/stores/websockets";
+
+describe("LongJobs component", async () => {
+	beforeEach(async context => {
+		context.socketData = {
+			dispatch: {
+				"users.getLongJobs": {
+					status: "success",
+					data: {
+						longJobs: [
+							{
+								id: "8704d336-660f-4d23-8c18-a7271c6656b5",
+								name: "Bulk verifying songs",
+								status: "success",
+								message:
+									"50 songs have been successfully verified"
+							}
+						]
+					}
+				},
+				"users.getLongJob": {
+					status: "success",
+					data: {
+						longJob: {
+							id: "bf3dc3aa-e7aa-4b69-bfd1-8e979fe7dfa5",
+							name: "Successfully edited tags.",
+							status: "success",
+							message: "Bulk editing tags"
+						}
+					}
+				},
+				"users.removeLongJob": {
+					status: "success"
+				}
+			}
+		};
+	});
+
+	test("component does not render if there are no jobs", async () => {
+		const wrapper = await getWrapper(LongJobs, { mockSocket: {} });
+		expect(wrapper.findComponent(FloatingBox).exists()).toBeFalsy();
+	});
+
+	test("component and jobs render if jobs exists", async ({ socketData }) => {
+		const wrapper = await getWrapper(LongJobs, {
+			mockSocket: socketData,
+			stubs: { FloatingBox },
+			beforeMount: async () => {
+				const userAuthStore = useUserAuthStore();
+				userAuthStore.loggedIn = true;
+				await flushPromises();
+			}
+		});
+		expect(wrapper.findComponent(FloatingBox).exists()).toBeTruthy();
+		const activeJobs = wrapper.findAll(".active-jobs .active-job");
+		const { longJobs } = socketData.dispatch["users.getLongJobs"].data;
+		expect(activeJobs.length).toBe(longJobs.length);
+	});
+
+	describe.each(["started", "update", "success", "error"])(
+		"job with %s status",
+		status => {
+			const isRemoveable = status === "success" || status === "error";
+
+			beforeEach(async context => {
+				context.socketData.dispatch[
+					"users.getLongJobs"
+				].data.longJobs[0].status = status;
+
+				context.wrapper = await getWrapper(LongJobs, {
+					mockSocket: context.socketData,
+					stubs: { FloatingBox },
+					beforeMount: async () => {
+						const userAuthStore = useUserAuthStore();
+						userAuthStore.loggedIn = true;
+						await flushPromises();
+					}
+				});
+			});
+
+			test("status icon and name render correctly", ({
+				wrapper,
+				socketData
+			}) => {
+				const activeJob = wrapper.find(".active-jobs .active-job");
+				const job =
+					socketData.dispatch["users.getLongJobs"].data.longJobs[0];
+				let icon;
+				if (job.status === "success") icon = "Complete";
+				else if (job.status === "error") icon = "Failed";
+				else if (job.status === "started" || job.status === "update")
+					icon = "In Progress";
+				icon = `i[content="${icon}"]`;
+				expect(activeJob.find(icon).exists()).toBeTruthy();
+				expect(activeJob.find(".name").text()).toBe(job.name);
+			});
+			test.todo("Latest update message validation");
+
+			test(`job is ${
+				isRemoveable ? "" : "not "
+			}removed on click of clear icon`, async ({ wrapper }) => {
+				await wrapper
+					.find(".active-job .actions .clear")
+					.trigger("click");
+				await flushPromises();
+				const longJobsStore = useLongJobsStore();
+				expect(longJobsStore.removeJob).toBeCalledTimes(
+					isRemoveable ? 1 : 0
+				);
+				expect(wrapper.findComponent(FloatingBox).exists()).not.toEqual(
+					isRemoveable
+				);
+			});
+
+			test("keep.event:longJob.added", async ({
+				wrapper,
+				socketData
+			}) => {
+				const websocketsStore = useWebsocketsStore();
+				websocketsStore.socket.triggerEvent(
+					"keep.event:longJob.added",
+					{
+						data: { jobId: "bf3dc3aa-e7aa-4b69-bfd1-8e979fe7dfa5" }
+					}
+				);
+				await flushPromises();
+				expect(wrapper.findAll(".active-jobs .active-job").length).toBe(
+					socketData.dispatch["users.getLongJobs"].data.longJobs
+						.length + 1
+				);
+			});
+
+			test("keep.event:longJob.removed", async ({ wrapper }) => {
+				const websocketsStore = useWebsocketsStore();
+				websocketsStore.socket.triggerEvent(
+					"keep.event:longJob.removed",
+					{
+						data: { jobId: "8704d336-660f-4d23-8c18-a7271c6656b5" }
+					}
+				);
+				await flushPromises();
+				const longJobsStore = useLongJobsStore();
+				expect(longJobsStore.removeJob).toBeCalledTimes(1);
+				expect(wrapper.findComponent(FloatingBox).exists()).toBeFalsy();
+			});
+		}
+	);
+});

+ 1 - 3
frontend/src/components/Modal.spec.ts

@@ -1,11 +1,9 @@
 import { flushPromises } from "@vue/test-utils";
 import { h } from "vue";
-import { useTestUtils } from "@/composables/useTestUtils";
+import { getWrapper } from "@/tests/utils/utils";
 import { useModalsStore } from "@/stores/modals";
 import Modal from "@/components/Modal.vue";
 
-const { getWrapper } = useTestUtils();
-
 describe("Modal component", () => {
 	beforeEach(async context => {
 		context.wrapper = await getWrapper(Modal);

+ 0 - 59
frontend/src/composables/useTestUtils.ts

@@ -1,59 +0,0 @@
-import { createTestingPinia } from "@pinia/testing";
-import VueTippy, { Tippy } from "vue-tippy";
-import { flushPromises, mount } from "@vue/test-utils";
-import "lofig";
-
-const config = await import("../../dist/config/template.json");
-
-export const useTestUtils = () => {
-	const getWrapper = async (component, options?) => {
-		const opts = options || {};
-
-		if (!opts.global) opts.global = {};
-
-		const plugins = [
-			createTestingPinia(),
-			[
-				VueTippy,
-				{
-					directive: "tippy", // => v-tippy
-					flipDuration: 0,
-					popperOptions: {
-						modifiers: {
-							preventOverflow: {
-								enabled: true
-							}
-						}
-					},
-					allowHTML: true,
-					defaultProps: { animation: "scale", touch: "hold" }
-				}
-			]
-		];
-		if (opts.global.plugins)
-			opts.global.plugins = [...opts.global.plugins, ...plugins];
-		else opts.global.plugins = plugins;
-
-		const components = { Tippy };
-		if (opts.global.components)
-			opts.global.components = {
-				...opts.global.components,
-				...components
-			};
-		else opts.global.components = components;
-
-		if (opts.lofig) {
-			lofig.config = {
-				...config,
-				...opts.lofig
-			};
-			delete opts.lofig;
-		} else lofig.config = config;
-
-		const wrapper = mount(component, opts);
-		await flushPromises();
-		return wrapper;
-	};
-
-	return { getWrapper };
-};

+ 8 - 0
frontend/src/tests/utils/setup.ts

@@ -0,0 +1,8 @@
+import "lofig";
+import CustomWebSocketMock from "@/classes/__mocks__/CustomWebSocket.class";
+
+vi.clearAllMocks();
+
+vi.spyOn(CustomWebSocketMock.prototype, "on");
+vi.spyOn(CustomWebSocketMock.prototype, "dispatch");
+vi.mock("@/classes/CustomWebSocket.class");

+ 96 - 0
frontend/src/tests/utils/utils.ts

@@ -0,0 +1,96 @@
+import { createTestingPinia } from "@pinia/testing";
+import VueTippy, { Tippy } from "vue-tippy";
+import { flushPromises, mount } from "@vue/test-utils";
+import { useWebsocketsStore } from "@/stores/websockets";
+
+let config;
+const getConfig = async () => {
+	if (!config) config = await import("../../../dist/config/template.json");
+	return config;
+};
+
+export const getWrapper = async (component, options?) => {
+	const opts = options || {};
+
+	if (!opts.global) opts.global = {};
+
+	let pinia;
+	if (opts.usePinia) pinia = opts.usePinia;
+	else if (
+		opts.mockSocket ||
+		(opts.pinia && opts.pinia.stubActions === false)
+	)
+		pinia = createTestingPinia({ stubActions: false });
+	else pinia = createTestingPinia();
+	if (opts.usePinia) delete opts.usePinia;
+	if (opts.pinia) delete opts.pinia;
+
+	const plugins = [
+		pinia,
+		[
+			VueTippy,
+			{
+				directive: "tippy", // => v-tippy
+				flipDuration: 0,
+				popperOptions: {
+					modifiers: {
+						preventOverflow: {
+							enabled: true
+						}
+					}
+				},
+				allowHTML: true,
+				defaultProps: { animation: "scale", touch: "hold" }
+			}
+		]
+	];
+	if (opts.global.plugins)
+		opts.global.plugins = [...opts.global.plugins, ...plugins];
+	else opts.global.plugins = plugins;
+
+	const components = { Tippy };
+	if (opts.global.components)
+		opts.global.components = {
+			...opts.global.components,
+			...components
+		};
+	else opts.global.components = components;
+
+	await getConfig();
+	if (opts.lofig) {
+		lofig.config = {
+			...config,
+			...opts.lofig
+		};
+		delete opts.lofig;
+	} else lofig.config = config;
+
+	if (opts.mockSocket) {
+		const websocketsStore = useWebsocketsStore();
+		await websocketsStore.createSocket();
+		await flushPromises();
+		websocketsStore.socket.data = JSON.parse(
+			JSON.stringify(opts.mockSocket)
+		);
+		delete opts.mockSocket;
+	}
+
+	if (opts.beforeMount) {
+		await opts.beforeMount();
+		delete opts.beforeMount;
+	}
+
+	const wrapper = mount(component, opts);
+	if (opts.onMount) {
+		await opts.onMount();
+		delete opts.onMount;
+	}
+	await flushPromises();
+
+	if (opts.afterMount) {
+		await opts.afterMount();
+		delete opts.afterMount;
+	}
+
+	return wrapper;
+};

+ 5 - 0
frontend/src/types/testContext.d.ts

@@ -4,5 +4,10 @@ declare module "vitest" {
 	export interface TestContext {
 		longJobsStore?: any; // TODO use long job store type
 		wrapper?: VueWrapper;
+		socketData?: {
+			dispatch?: any;
+			onProgress?: any;
+			progressInterval?: number;
+		};
 	}
 }

+ 1 - 1
frontend/tsconfig.json

@@ -20,5 +20,5 @@
       "vitest/globals"
     ]
   },
-  "exclude": ["./src/index.html"]
+  "exclude": ["./src/index.html", "./src/coverage"]
 }

+ 2 - 1
frontend/vite.config.js

@@ -184,6 +184,7 @@ export default {
 		coverage: {
 			all: true,
 			extension: [".ts", ".vue"]
-		}
+		},
+		setupFiles: "tests/utils/setup.ts"
 	}
 };