From 26c92de436cce1405f4623ea3dbfa947ea701b5a Mon Sep 17 00:00:00 2001 From: hoixw Date: Sun, 14 Apr 2024 03:22:14 -0400 Subject: [PATCH] Added more tests Increased coverage to ~87%. I would have done more, but I have been unable to get testing working for any of the supabase files (i.e. those that use supabse=CreateClient()), which are the ones left. --- README.md | 2 +- __tests__/app_page.t.js | 130 +++++++++++++++++++++++++ __tests__/average_audio_features.t.js | 20 ++++ __tests__/error.t.js | 61 +++++++++++- __tests__/layout.t.js | 13 +++ __tests__/middleware_src.t.js | 44 +++++++++ __tests__/unify.t.js | 10 ++ package-lock.json | 15 +++ package.json | 1 + src/app/page.jsx | 4 - src/app/unify/[users]/UnifyContent.jsx | 6 -- 11 files changed, 293 insertions(+), 13 deletions(-) create mode 100644 __tests__/app_page.t.js create mode 100644 __tests__/layout.t.js create mode 100644 __tests__/middleware_src.t.js diff --git a/README.md b/README.md index 63e7b67..bdb3767 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ To test the application: npm test ``` -We have achieved 81% statement coverage. +We have achieved 87% statement coverage. ## Linting diff --git a/__tests__/app_page.t.js b/__tests__/app_page.t.js new file mode 100644 index 0000000..6bfad51 --- /dev/null +++ b/__tests__/app_page.t.js @@ -0,0 +1,130 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { useRouter } from "next/navigation"; +import createClient from "@/utils/supabase/client"; +import IndexPage from "../src/app/page"; +import loginWithSpotify from "@/app/login/actions"; + +// Dependency mocks +jest.mock("next/navigation", () => ({ + useRouter: jest.fn(), +})); + +jest.mock("../src/utils/supabase/client", () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mock("../src/app/login/actions", () => ({ + __esModule: true, + default: jest.fn(), +})); + +describe("IndexPage", () => { + beforeEach(() => { + useRouter.mockReturnValue({ + push: jest.fn(), + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test("renders loading state when loggedIn is undefined", () => { + render(); + expect(screen.getByText("Getting things set up...")).toBeInTheDocument(); + }); + + test("redirects to error page when supabase.auth.getUser fails", async () => { + const mockSupabase = { + auth: { + getUser: jest.fn().mockRejectedValue(new Error("Supabase error")), + }, + }; + createClient.mockReturnValue(mockSupabase); + + render(); + + await waitFor(() => { + expect(useRouter().push).toHaveBeenCalledWith("/error"); + }); + }); + + test("sets loggedIn to true when user is already logged in", async () => { + const mockSupabase = { + auth: { + getUser: jest.fn().mockResolvedValue({ data: { user: { id: 1 } } }), + }, + }; + createClient.mockReturnValue(mockSupabase); + + render(); + + await waitFor(() => { + expect(screen.getByText("Continue to Account")).toBeInTheDocument(); + }); + }); + + test("redirects to error page on sign-out error", async () => { + global.fetch = jest.fn().mockRejectedValue(new Error("Network error")); + + render(); + + fireEvent.click(screen.getByText("Logout")); + + await waitFor(() => { + expect(useRouter().push).toHaveBeenCalledWith( + "/error?message=Network error", + ); + }); + }); + + test("redirects to the home page on successful sign-out", async () => { + global.fetch = jest.fn().mockResolvedValue({ ok: true }); + Object.defineProperty(window, "location", { + value: { + href: "", + }, + writable: true, + }); + + render(); + fireEvent.click(screen.getByText("Logout")); + + await waitFor(() => { + expect(window.location.href).toBe("/"); + }); + }); + + test("tests loginWithSpotify on button click", async () => { + // Ensures user is not logged in initially + const mockSupabase = { + auth: { + getUser: jest.fn().mockResolvedValue({ data: { user: null } }), + }, + }; + createClient.mockReturnValue(mockSupabase); + + render(); + + fireEvent.click(screen.getByText("Log in with Spotify")); + expect(loginWithSpotify).toHaveBeenCalled(); + }); + + test("redirects to error page on sign-out failure", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + statusText: "Forbidden", + }); + + const router = useRouter(); + render(); + + fireEvent.click(screen.getByText("Logout")); + + // Wait for the async actions and effects to complete, then check for error + await waitFor(() => { + expect(router.push).toHaveBeenCalledWith("/error?message=Forbidden"); + }); + }); +}); diff --git a/__tests__/average_audio_features.t.js b/__tests__/average_audio_features.t.js index ef2d857..a1c0a0c 100644 --- a/__tests__/average_audio_features.t.js +++ b/__tests__/average_audio_features.t.js @@ -75,5 +75,25 @@ describe("Spotify API functions", () => { "Error fetching data from Spotify API: Failed to fetch audio features", ); }); + + test("throws an error when audio_features is undefined", async () => { + const token = "testToken"; + const topSongs = { + items: [ + { id: "id1", popularity: 70 }, + { id: "id2", popularity: 80 }, + ], + }; + + const getAudioFeaturesSpy = jest.spyOn(axios, "get"); + + getAudioFeaturesSpy.mockResolvedValueOnce({ + data: { audio_features: undefined }, + }); + + await expect(getAverageAudioFeatures(token, topSongs)).rejects.toThrow( + "Unable to fetch audio features.", + ); + }); }); }); diff --git a/__tests__/error.t.js b/__tests__/error.t.js index 56135e9..12be912 100644 --- a/__tests__/error.t.js +++ b/__tests__/error.t.js @@ -1,6 +1,37 @@ +// Tests both error/error.jsx and error/page.jsx + import React from "react"; -import { render, screen } from "@testing-library/react"; -import ErrorAlert from "@/app/error/error"; +import { render, fireEvent, screen } from "@testing-library/react"; +import * as nextNavigation from "next/navigation"; +import ErrorAlert from "../src/app/error/error"; +import ErrorPage from "../src/app/error/page"; + +// Need to mock window.location as JSDOM does not support doesn't implement it +const assignMock = jest.fn(); +delete window.location; +window.location = { assign: assignMock }; + +jest.mock("next/navigation", () => ({ + useSearchParams: jest.fn(), +})); + +describe("ErrorAlert", () => { + test("redirects to the specified URL when close button is clicked", () => { + const redirectUrl = "/user"; + render( + , + ); + + const closeButton = document.querySelector('svg[role="button"]'); + fireEvent.click(closeButton); + + expect(window.location.href).toBe(redirectUrl); + }); +}); describe("ErrorAlert Component Tests", () => { // Test for proper rendering with props @@ -10,3 +41,29 @@ describe("ErrorAlert Component Tests", () => { expect(screen.getByText("An error has occurred")).toBeInTheDocument(); }); }); + +describe("ErrorPage", () => { + test("displays default error message when no error query parameter is provided", async () => { + nextNavigation.useSearchParams.mockImplementation(() => { + return new URLSearchParams(); + }); + + const { findByText } = render(); + // Need to use await for suspense + const message = await findByText("An error occured."); + expect(message).toBeInTheDocument(); + }); + + test("displays custom error message when error query parameter is provided", async () => { + nextNavigation.useSearchParams.mockImplementation(() => { + const searchParams = new URLSearchParams(); + searchParams.set("error", "Custom error message"); + return searchParams; + }); + + const { findByText } = render(); + // Need to use await for suspense + const message = await findByText("Custom error message"); + expect(message).toBeInTheDocument(); + }); +}); diff --git a/__tests__/layout.t.js b/__tests__/layout.t.js new file mode 100644 index 0000000..75f4fe1 --- /dev/null +++ b/__tests__/layout.t.js @@ -0,0 +1,13 @@ +import React from "react"; +import { render } from "@testing-library/react"; +import RootLayout from "../src/app/layout"; + +describe("RootLayout", () => { + test("RootLayout test", () => { + render( + +
Test
+
, + ); + }); +}); diff --git a/__tests__/middleware_src.t.js b/__tests__/middleware_src.t.js new file mode 100644 index 0000000..f946eb0 --- /dev/null +++ b/__tests__/middleware_src.t.js @@ -0,0 +1,44 @@ +import { middleware, config } from "../src/middleware"; +import updateSession from "@/utils/supabase/middleware"; + +jest.mock("../src/utils/supabase/middleware", () => ({ + __esModule: true, + default: jest.fn(), +})); + +describe("supabase-middleware", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("middleware", () => { + test("calls updateSession with the request object", async () => { + const request = { some: "request" }; + const response = { some: "response" }; + + updateSession.mockResolvedValueOnce(response); + + const result = await middleware(request); + + expect(updateSession).toHaveBeenCalledWith(request); + expect(result).toEqual(response); + }); + + test("throws an error if updateSession throws an error", async () => { + const request = { some: "request" }; + const error = new Error("Something went wrong"); + + updateSession.mockRejectedValueOnce(error); + + await expect(middleware(request)).rejects.toThrow(error); + }); + }); + + describe("config", () => { + test("matches the expected paths", () => { + expect(config.matcher).toEqual([ + "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)", + ]); + }); + }); +}); diff --git a/__tests__/unify.t.js b/__tests__/unify.t.js index c365a36..4837b8b 100644 --- a/__tests__/unify.t.js +++ b/__tests__/unify.t.js @@ -71,6 +71,16 @@ describe("featureDataSimilarity", () => { }); }); +describe("featureDataSimilarity", () => { + test("throws an error when the arrays have different lengths", () => { + const features1 = [{ value: 10 }, { value: 30 }, { value: 50 }]; + const features2 = [{ value: 10 }, { value: 40 }]; + expect(() => featureDataSimilarity(features1, features2)).toThrow( + "Arrays must have the same length", + ); + }); +}); + describe("VinylCircle Component", () => { test("renders correctly with given props", () => { const { getByTestId } = render( diff --git a/package-lock.json b/package-lock.json index 7bec7a9..65c505d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "dotenv": "^16.4.4", "express": "^4.18.2", "html-to-image": "^1.11.11", + "isomorphic-fetch": "^3.0.0", "next": "^14.1.0", "prop-types": "^15.8.1", "react": "^18", @@ -6449,6 +6450,15 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, + "node_modules/isomorphic-fetch": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-3.0.0.tgz", + "integrity": "sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==", + "dependencies": { + "node-fetch": "^2.6.1", + "whatwg-fetch": "^3.4.1" + } + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -11087,6 +11097,11 @@ "node": ">=0.10.0" } }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==" + }, "node_modules/whatwg-mimetype": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", diff --git a/package.json b/package.json index 1f6729a..4fbbf0c 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "dotenv": "^16.4.4", "express": "^4.18.2", "html-to-image": "^1.11.11", + "isomorphic-fetch": "^3.0.0", "next": "^14.1.0", "prop-types": "^15.8.1", "react": "^18", diff --git a/src/app/page.jsx b/src/app/page.jsx index 4a1b743..bc7d22c 100644 --- a/src/app/page.jsx +++ b/src/app/page.jsx @@ -54,10 +54,6 @@ export default function IndexPage() { }); } - if (loggedIn === undefined) { - return
; - } - return (