From 0b23f3af6a1e3c0dba8b0db64b6d19157df23dac Mon Sep 17 00:00:00 2001 From: Michael Nardolillo Date: Thu, 26 Aug 2021 15:30:58 +0200 Subject: [PATCH] test(client): fully cover client module (#2295) Contains: * test(client-provider): fix flaky test * wip * test(client-provider): verify more use-cases * test(client): programmatic session refetch * test(client): further coverage * test(client): `stateTime` + `refetchInterval` * refactor(client): test insights * refactor: unused variable * chore: revert `package-lock.json` to v2 * refactor: pair-review suggestions --- package-lock.json | 104 ++++++++++ package.json | 1 + src/client/__tests__/client-provider.test.js | 182 +++++++++++++----- src/client/__tests__/helpers/utils.js | 6 + src/client/__tests__/use-session-hook.test.js | 142 ++++++++++++++ src/client/react.js | 19 +- 6 files changed, 400 insertions(+), 54 deletions(-) create mode 100644 src/client/__tests__/use-session-hook.test.js diff --git a/package-lock.json b/package-lock.json index 310e311164..fcb4b28083 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "@babel/preset-react": "^7.14.5", "@testing-library/jest-dom": "^5.14.1", "@testing-library/react": "^12.0.0", + "@testing-library/react-hooks": "^7.0.1", "@testing-library/user-event": "^13.1.9", "@types/nodemailer": "^6.4.2", "@types/oauth": "^0.9.1", @@ -3276,6 +3277,35 @@ "react-dom": "*" } }, + "node_modules/@testing-library/react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-7.0.1.tgz", + "integrity": "sha512-bpEQ2SHSBSzBmfJ437NmnP+oArQ7aVmmULiAp6Ag2rtyLBLPNFSMmgltUbFGmQOJdPWo4Ub31kpUC5T46zXNwQ==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/react": ">=16.9.0", + "@types/react-dom": ">=16.9.0", + "@types/react-test-renderer": ">=16.9.0", + "react-error-boundary": "^3.1.0" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0", + "react-test-renderer": ">=16.9.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-test-renderer": { + "optional": true + } + } + }, "node_modules/@testing-library/user-event": { "version": "13.2.1", "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-13.2.1.tgz", @@ -3620,6 +3650,24 @@ "csstype": "^3.0.2" } }, + "node_modules/@types/react-dom": { + "version": "17.0.9", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.9.tgz", + "integrity": "sha512-wIvGxLfgpVDSAMH5utdL9Ngm5Owu0VsGmldro3ORLXV8CShrL8awVj06NuEXFQ5xyaYfdca7Sgbk/50Ri1GdPg==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/react-test-renderer": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-17.0.1.tgz", + "integrity": "sha512-3Fi2O6Zzq/f3QR9dRnlnHso9bMl7weKCviFmfF6B4LS1Uat6Hkm15k0ZAQuDz+UBq6B3+g+NM6IT2nr5QgPzCw==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/responselike": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.0.tgz", @@ -15656,6 +15704,22 @@ "react": "17.0.2" } }, + "node_modules/react-error-boundary": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.3.tgz", + "integrity": "sha512-A+F9HHy9fvt9t8SNDlonq01prnU8AmkjvGKV4kk8seB9kU3xMEO8J/PQlLVmoOIDODl5U2kufSBs4vrWIqhsAA==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + }, + "peerDependencies": { + "react": ">=16.13.1" + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -20970,6 +21034,19 @@ "@testing-library/dom": "^8.0.0" } }, + "@testing-library/react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-7.0.1.tgz", + "integrity": "sha512-bpEQ2SHSBSzBmfJ437NmnP+oArQ7aVmmULiAp6Ag2rtyLBLPNFSMmgltUbFGmQOJdPWo4Ub31kpUC5T46zXNwQ==", + "dev": true, + "requires": { + "@babel/runtime": "^7.12.5", + "@types/react": ">=16.9.0", + "@types/react-dom": ">=16.9.0", + "@types/react-test-renderer": ">=16.9.0", + "react-error-boundary": "^3.1.0" + } + }, "@testing-library/user-event": { "version": "13.2.1", "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-13.2.1.tgz", @@ -21276,6 +21353,24 @@ "csstype": "^3.0.2" } }, + "@types/react-dom": { + "version": "17.0.9", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.9.tgz", + "integrity": "sha512-wIvGxLfgpVDSAMH5utdL9Ngm5Owu0VsGmldro3ORLXV8CShrL8awVj06NuEXFQ5xyaYfdca7Sgbk/50Ri1GdPg==", + "dev": true, + "requires": { + "@types/react": "*" + } + }, + "@types/react-test-renderer": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-17.0.1.tgz", + "integrity": "sha512-3Fi2O6Zzq/f3QR9dRnlnHso9bMl7weKCviFmfF6B4LS1Uat6Hkm15k0ZAQuDz+UBq6B3+g+NM6IT2nr5QgPzCw==", + "dev": true, + "requires": { + "@types/react": "*" + } + }, "@types/responselike": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.0.tgz", @@ -30441,6 +30536,15 @@ "scheduler": "^0.20.2" } }, + "react-error-boundary": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.3.tgz", + "integrity": "sha512-A+F9HHy9fvt9t8SNDlonq01prnU8AmkjvGKV4kk8seB9kU3xMEO8J/PQlLVmoOIDODl5U2kufSBs4vrWIqhsAA==", + "dev": true, + "requires": { + "@babel/runtime": "^7.12.5" + } + }, "react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", diff --git a/package.json b/package.json index 7f55785ba4..b1a7358f43 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,7 @@ "@babel/preset-react": "^7.14.5", "@testing-library/jest-dom": "^5.14.1", "@testing-library/react": "^12.0.0", + "@testing-library/react-hooks": "^7.0.1", "@testing-library/user-event": "^13.1.9", "@types/nodemailer": "^6.4.2", "@types/oauth": "^0.9.1", diff --git a/src/client/__tests__/client-provider.test.js b/src/client/__tests__/client-provider.test.js index 135983cc8c..e28c8798eb 100644 --- a/src/client/__tests__/client-provider.test.js +++ b/src/client/__tests__/client-provider.test.js @@ -1,100 +1,188 @@ import { rest } from "msw" import { render, screen, waitFor } from "@testing-library/react" import { server, mockSession } from "./helpers/mocks" -import { SessionProvider, useSession } from "../react" +import { printFetchCalls } from "./helpers/utils" +import { SessionProvider, useSession, signOut, getSession } from "../react" + +const origDocumentVisibility = document.visibilityState +const fetchSpy = jest.spyOn(global, "fetch") beforeAll(() => { server.listen() }) afterEach(() => { - jest.clearAllMocks() server.resetHandlers() + changeTabVisibility(origDocumentVisibility) + fetchSpy.mockClear() }) afterAll(() => { server.close() }) -test("it won't allow to fetch the session in isolation without a session context", () => { - function App() { - useSession() - return null - } +test("fetches the session once and re-uses it for different consumers", async () => { + render() + + expect(screen.getByTestId("session-1")).toHaveTextContent("loading") + expect(screen.getByTestId("session-2")).toHaveTextContent("loading") - jest.spyOn(console, "error") - console.error.mockImplementation(() => {}) + return waitFor(() => { + expect(fetchSpy).toHaveBeenCalledTimes(1) - expect(() => render()).toThrow( - "useSession must be wrapped in a SessionProvider" - ) + expect(fetchSpy).toHaveBeenCalledWith( + "/api/auth/session", + expect.anything() + ) + + const session1 = screen.getByTestId("session-1").textContent + const session2 = screen.getByTestId("session-2").textContent - console.error.mockRestore() + expect(session1).toEqual(session2) + }) }) -test("fetches the session once and re-uses it for different consumers", async () => { - const sessionRouteCall = jest.fn() +test("when there's an existing session, it won't try to fetch a new one straightaway", async () => { + render() - server.use( - rest.get("/api/auth/session", (req, res, ctx) => { - sessionRouteCall() - res(ctx.status(200), ctx.json(mockSession)) - }) - ) + expect(fetchSpy).not.toHaveBeenCalled() +}) - render() +test("will refetch the session when the browser tab becomes active again", async () => { + render() - expect(screen.getByTestId("session-consumer-1")).toHaveTextContent("loading") - expect(screen.getByTestId("session-consumer-2")).toHaveTextContent("loading") + expect(fetchSpy).not.toHaveBeenCalled() - await waitFor(() => { - expect(sessionRouteCall).toHaveBeenCalledTimes(1) + // Hide the current tab + changeTabVisibility("hidden") - const session1 = screen.getByTestId("session-consumer-1").textContent - const session2 = screen.getByTestId("session-consumer-2").textContent + // Given the current tab got hidden, it should not attempt to re-fetch the session + expect(fetchSpy).not.toHaveBeenCalled() - expect(session1).toEqual(session2) + // Make the tab again visible + changeTabVisibility("visible") + + // Given the user made the tab visible again, now attempts to sync and re-fetch the session + return waitFor(() => { + expect(fetchSpy).toHaveBeenCalledTimes(1) + expect(fetchSpy).toHaveBeenCalledWith( + "/api/auth/session", + expect.anything() + ) + }) +}) + +test("will refetch the session if told to do so programmatically from another window", async () => { + render() + + expect(fetchSpy).not.toHaveBeenCalled() + + // Hide the current tab + changeTabVisibility("hidden") + + // Given the current tab got hidden, it should not attempt to re-fetch the session + expect(fetchSpy).not.toHaveBeenCalled() + + // simulate sign-out triggered by another tab + signOut({ redirect: false }) + + // Given signed out in another tab, it attempts to sync and re-fetch the session + return waitFor(() => { + expect(fetchSpy).toHaveBeenCalledWith( + "/api/auth/session", + expect.anything() + ) + + // We should have a call to sign-out and a call to refetch the session accordingly + expect(printFetchCalls(fetchSpy.mock.calls)).toMatchInlineSnapshot(` + Array [ + "GET /api/auth/csrf", + "POST /api/auth/signout", + "GET /api/auth/session", + ] + `) }) }) -test("when there's an existing session, it won't initialize as loading", async () => { - const sessionRouteCall = jest.fn() +test("allows to customize how often the session will be re-fetched through polling", () => { + jest.useFakeTimers() + + render() + + // we provided a mock session so it shouldn't try to fetch a new one + expect(fetchSpy).not.toHaveBeenCalled() + + jest.advanceTimersByTime(1000) + + expect(fetchSpy).toHaveBeenCalledTimes(1) + expect(fetchSpy).toHaveBeenCalledWith("/api/auth/session", expect.anything()) + + jest.advanceTimersByTime(1000) + + // it should have tried to refetch the session, hence counting 2 calls to the session endpoint + expect(fetchSpy).toHaveBeenCalledTimes(2) + expect(printFetchCalls(fetchSpy.mock.calls)).toMatchInlineSnapshot(` + Array [ + "GET /api/auth/session", + "GET /api/auth/session", + ] + `) +}) + +test("allows to customize the URL for session fetching", async () => { + const myPath = "/api/v1/auth" server.use( - rest.get("/api/auth/session", (req, res, ctx) => { - sessionRouteCall() + rest.get(`${myPath}/session`, (req, res, ctx) => res(ctx.status(200), ctx.json(mockSession)) - }) + ) ) - render() + render() - expect(await screen.findByTestId("session-consumer-1")).not.toHaveTextContent( - "loading" - ) + // there's an existing session so it should not try to fetch a new one + expect(fetchSpy).not.toHaveBeenCalled() - expect(screen.getByTestId("session-consumer-2")).not.toHaveTextContent( - "loading" - ) + // force a session refetch across all clients... + getSession() - expect(sessionRouteCall).not.toHaveBeenCalled() + return waitFor(() => { + expect(fetchSpy).toHaveBeenCalledTimes(1) + expect(fetchSpy).toHaveBeenCalledWith( + `${myPath}/session`, + expect.anything() + ) + }) }) -function ProviderFlow({ options = {} }) { +function ProviderFlow(props) { return ( - + ) } -function SessionConsumer({ testId = 1 }) { - const { data: session, status } = useSession() +function SessionConsumer({ testId = 1, ...rest }) { + const { data: session, status } = useSession(rest) return ( -
+
{status === "loading" ? "loading" : JSON.stringify(session)}
) } + +function changeTabVisibility(status) { + const visibleStates = ["visible", "hidden"] + + if (!visibleStates.includes(status)) return + + Object.defineProperty(document, "visibilityState", { + configurable: true, + value: status, + }) + + document.dispatchEvent(new Event("visibilitychange")) +} diff --git a/src/client/__tests__/helpers/utils.js b/src/client/__tests__/helpers/utils.js index bdc430a44d..df2844a183 100644 --- a/src/client/__tests__/helpers/utils.js +++ b/src/client/__tests__/helpers/utils.js @@ -6,3 +6,9 @@ export function getBroadcastEvents() { return { eventName, value: rest } }) } + +export function printFetchCalls(mockCalls) { + return mockCalls.map(([path, { method = "GET" }]) => { + return `${method.toUpperCase()} ${path}` + }) +} diff --git a/src/client/__tests__/use-session-hook.test.js b/src/client/__tests__/use-session-hook.test.js new file mode 100644 index 0000000000..7b00ff652c --- /dev/null +++ b/src/client/__tests__/use-session-hook.test.js @@ -0,0 +1,142 @@ +import { rest } from "msw" +import { renderHook } from "@testing-library/react-hooks" +import { render, waitFor } from "@testing-library/react" +import { SessionProvider, useSession, signOut } from "../react" +import { server, mockSession } from "./helpers/mocks" + +const origConsoleError = console.error +const origLocation = window.location +const locationReplace = jest.fn() + +beforeAll(() => { + // Prevent noise on the terminal... `next-auth` will log to `console.error` + // every time a request fails, which makes the tests output very noisy... + console.error = jest.fn() + + // Allows to spy on `window.location.replace`... + delete window.location + window.location = { ...origLocation, replace: locationReplace } + + server.listen() +}) + +afterEach(() => { + server.resetHandlers() + locationReplace.mockClear() + + // clear the internal session cache... + signOut({ redirect: false }) +}) + +afterAll(() => { + console.error = origConsoleError + window.location = origLocation + server.close() +}) + +test("it won't allow to fetch the session in isolation without a session context", () => { + function App() { + useSession() + return null + } + + expect(() => render()).toThrow( + "[next-auth]: `useSession` must be wrapped in a " + ) +}) + +test("when fetching the session, there won't be `data` and `status` will be 'loading'", () => { + const { result } = renderHook(() => useSession(), { + wrapper: SessionProvider, + }) + + expect(result.current.data).toBe(undefined) + expect(result.current.status).toBe("loading") +}) + +test("when session is fetched, `data` will contain the session data and `status` will be 'authenticated'", async () => { + const { result } = renderHook(() => useSession(), { + wrapper: SessionProvider, + }) + + await waitFor(() => { + expect(result.current.data).toEqual(mockSession) + expect(result.current.status).toBe("authenticated") + }) +}) + +test("when it fails to fetch the session, `data` will be null and `status` will be 'unauthenticated'", async () => { + server.use( + rest.get(`/api/auth/session`, (req, res, ctx) => + res(ctx.status(401), ctx.json({})) + ) + ) + + const { result } = renderHook(() => useSession(), { + wrapper: SessionProvider, + }) + + return waitFor(() => { + expect(result.current.data).toEqual(null) + expect(result.current.status).toBe("unauthenticated") + }) +}) + +test("it'll redirect to sign-in page if the session is required and the user is not authenticated", async () => { + server.use( + rest.get(`/api/auth/session`, (req, res, ctx) => + res(ctx.status(401), ctx.json({})) + ) + ) + + const { result } = renderHook(() => useSession({ required: true }), { + wrapper: SessionProvider, + }) + + await waitFor(() => { + expect(result.current.data).toEqual(null) + expect(result.current.status).toBe("loading") + }) + + expect(locationReplace).toHaveBeenCalledTimes(1) + + expect(locationReplace).toHaveBeenCalledWith( + expect.stringContaining("/api/auth/signin") + ) + + expect(locationReplace).toHaveBeenCalledWith( + expect.stringContaining( + new URLSearchParams({ + error: "SessionRequired", + callbackUrl: window.location.href, + }).toString() + ) + ) +}) + +test("will call custom redirect logic if supplied when the user could not authenticate", async () => { + server.use( + rest.get(`/api/auth/session`, (req, res, ctx) => + res(ctx.status(401), ctx.json({})) + ) + ) + + const customRedirect = jest.fn() + + const { result } = renderHook( + () => useSession({ required: true, onUnauthenticated: customRedirect }), + { + wrapper: SessionProvider, + } + ) + + await waitFor(() => { + expect(result.current.data).toEqual(null) + expect(result.current.status).toBe("loading") + }) + + // it shouldn't have tried to re-direct to sign-in page (default behavior) + expect(locationReplace).not.toHaveBeenCalled() + + expect(customRedirect).toHaveBeenCalledTimes(1) +}) diff --git a/src/client/react.js b/src/client/react.js index 2fe6bec422..9b1312709f 100644 --- a/src/client/react.js +++ b/src/client/react.js @@ -47,7 +47,9 @@ export function useSession(options = {}) { const value = React.useContext(SessionContext) if (process.env.NODE_ENV !== "production" && !value) { - throw new Error("useSession must be wrapped in a SessionProvider") + throw new Error( + "[next-auth]: `useSession` must be wrapped in a " + ) } const { required, onUnauthenticated } = options @@ -74,6 +76,7 @@ export function useSession(options = {}) { export async function getSession(ctx) { const session = await _fetchData("session", ctx) + if (ctx?.broadcast ?? true) { broadcast.post({ event: "session", data: { trigger: "getSession" } }) } @@ -166,8 +169,10 @@ export async function signOut(options = {}) { json: true, }), } + const res = await fetch(`${baseUrl}/signout`, fetchOptions) const data = await res.json() + broadcast.post({ event: "session", data: { trigger: "signout" } }) if (redirect) { @@ -185,9 +190,8 @@ export async function signOut(options = {}) { /** @param {import("types/react-client").SessionProviderProps} props */ export function SessionProvider(props) { - const { children, baseUrl, basePath, staleTime = 0 } = props + const { children, basePath, staleTime = 0 } = props - if (baseUrl) __NEXTAUTH.baseUrl = baseUrl if (basePath) __NEXTAUTH.basePath = basePath /** @@ -270,12 +274,13 @@ export function SessionProvider(props) { }, []) React.useEffect(() => { - // Set up visibility change - // Listen for document visibility change events and - // if visibility of the document changes, re-fetch the session. + // Listen for when the page is visible, if the user switches tabs + // and makes our tab visible again, re-fetch the session. const visibilityHandler = () => { - !document.hidden && __NEXTAUTH._getSession({ event: "visibilitychange" }) + if (document.visibilityState === "visible") + __NEXTAUTH._getSession({ event: "visibilitychange" }) } + document.addEventListener("visibilitychange", visibilityHandler, false) return () => document.removeEventListener("visibilitychange", visibilityHandler, false)