diff --git a/packages/app-kit/src/popup/index.ts b/packages/app-kit/src/popup/index.ts index 954d1bf..5f06f46 100644 --- a/packages/app-kit/src/popup/index.ts +++ b/packages/app-kit/src/popup/index.ts @@ -2,4 +2,5 @@ export * from "./PopupContext"; export * from "./PopupTitle"; export * from "./PopupButtons"; export * from "./BasePopup"; +export * from "./useConnectivityErrorPopup"; export { default as TrackUploadPopup } from "./TrackUploadPopup"; diff --git a/packages/app-kit/src/popup/useConnectivityErrorPopup.test.tsx b/packages/app-kit/src/popup/useConnectivityErrorPopup.test.tsx new file mode 100644 index 0000000..a940385 --- /dev/null +++ b/packages/app-kit/src/popup/useConnectivityErrorPopup.test.tsx @@ -0,0 +1,188 @@ +import { act, render } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { ReactNode } from "react"; +import { + AuthRequired, + BackendError, + BadRequestError, + ConnectivityError, + InvalidInputError, + NetworkError, + ServiceError, +} from "../transport/app-errors/app-error"; +import { ErrorCode } from "../transport/app-errors/app-error-codes"; +import { ConnectivityErrorProvider, useConnectivityError } from "../transport/connectivity-error-context"; +import { PopupProvider, usePopup } from "./PopupContext"; +import { + ConnectivityErrorPopupRenderers, + UseConnectivityErrorPopupOptions, + useConnectivityErrorPopup, +} from "./useConnectivityErrorPopup"; + +function makeRenderers(): ConnectivityErrorPopupRenderers & Record> { + return { + renderAuthPopup: vi.fn(() =>
auth-popup
), + renderSpotifyOnlyAuthPopup: vi.fn(() =>
spotify-only-auth-popup
), + renderInternalErrorPopup: vi.fn(() =>
internal-error-popup
), + renderSpotifyAuthErrorPopup: vi.fn(() =>
spotify-auth-error-popup
), + renderGoogleAuthErrorPopup: vi.fn(() =>
google-auth-error-popup
), + renderNetworkErrorPopup: vi.fn(() =>
network-error-popup
), + }; +} + +type ConsumerOptions = Omit; + +const DEFAULT_OPTIONS: ConsumerOptions = { + isAccountPage: false, + routeRequiresAuth: false, + routeRequiresSpotify: false, +}; + +function setupHarness(options: Partial = {}) { + const renderers = makeRenderers(); + const resolvedOptions = { ...DEFAULT_OPTIONS, ...options }; + + let setError!: (error: ConnectivityError | null) => void; + let popupCtx!: ReturnType; + + function Capture({ children }: { children: ReactNode }) { + const { setConnectivityError } = useConnectivityError(); + popupCtx = usePopup(); + setError = setConnectivityError; + return <>{children}; + } + + render( + + + + + + + , + ); + + return { + renderers, + setError: (error: ConnectivityError | null) => act(() => setError(error)), + getPopupCtx: () => popupCtx, + }; +} + +describe("useConnectivityErrorPopup", () => { + it("routes AuthRequired to renderAuthPopup on a route that requires auth", () => { + const { renderers, setError } = setupHarness({ routeRequiresAuth: true }); + + setError(new AuthRequired(ErrorCode.BACKEND_UNAUTHORIZED)); + + expect(renderers.renderAuthPopup).toHaveBeenCalledTimes(1); + expect(renderers.renderInternalErrorPopup).not.toHaveBeenCalled(); + }); + + it("routes AuthRequired to renderInternalErrorPopup instead of dropping it silently on a route that does not require auth", () => { + const { renderers, setError } = setupHarness({ routeRequiresAuth: false }); + + setError(new AuthRequired(ErrorCode.BACKEND_UNAUTHORIZED)); + + expect(renderers.renderInternalErrorPopup).toHaveBeenCalledTimes(1); + expect(renderers.renderInternalErrorPopup).toHaveBeenCalledWith(ErrorCode.BACKEND_UNAUTHORIZED); + expect(renderers.renderAuthPopup).not.toHaveBeenCalled(); + }); + + it("hides the popup once the connectivity error clears", () => { + const { setError, getPopupCtx } = setupHarness({ routeRequiresAuth: false }); + + setError(new AuthRequired(ErrorCode.BACKEND_UNAUTHORIZED)); + expect(getPopupCtx().activePopup).not.toBeNull(); + + setError(null); + expect(getPopupCtx().activePopup).toBeNull(); + }); + + it("routes a Spotify-authorization-required BackendError to renderSpotifyOnlyAuthPopup on a route that requires Spotify", () => { + const { renderers, setError } = setupHarness({ routeRequiresSpotify: true }); + + setError(new BackendError(ErrorCode.BACKEND_SPOTIFY_AUTHORIZATION_REQUIRED)); + + expect(renderers.renderSpotifyOnlyAuthPopup).toHaveBeenCalledTimes(1); + }); + + it("routes InvalidInputError to renderInternalErrorPopup", () => { + const { renderers, setError } = setupHarness(); + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + setError(new InvalidInputError(ErrorCode.BACKEND_INVALID_INPUT, { field: "name" })); + + expect(renderers.renderInternalErrorPopup).toHaveBeenCalledWith(ErrorCode.BACKEND_INVALID_INPUT); + expect(consoleErrorSpy).toHaveBeenCalled(); + consoleErrorSpy.mockRestore(); + }); + + it("routes a Spotify allowlist BackendError to renderSpotifyAuthErrorPopup on a route that requires Spotify", () => { + const { renderers, setError, getPopupCtx } = setupHarness({ routeRequiresSpotify: true }); + + setError(new BackendError(ErrorCode.BACKEND_SPOTIFY_USER_NOT_IN_ALLOWLIST)); + + expect(renderers.renderSpotifyAuthErrorPopup).toHaveBeenCalledTimes(1); + + const { onClose } = vi.mocked(renderers.renderSpotifyAuthErrorPopup).mock.calls[0][0]; + act(() => onClose()); + expect(getPopupCtx().activePopup).toBeNull(); + }); + + it("hides the popup and clears the error for a Spotify allowlist BackendError on a route that does not require Spotify", () => { + const { renderers, setError, getPopupCtx } = setupHarness({ routeRequiresSpotify: false }); + + setError(new BackendError(ErrorCode.BACKEND_SPOTIFY_USER_NOT_IN_ALLOWLIST)); + + expect(renderers.renderSpotifyAuthErrorPopup).not.toHaveBeenCalled(); + expect(getPopupCtx().activePopup).toBeNull(); + }); + + it("routes a Google-authentication BackendError to renderGoogleAuthErrorPopup", () => { + const { renderers, setError, getPopupCtx } = setupHarness(); + + setError(new BackendError(ErrorCode.BACKEND_GOOGLE_AUTHENTICATION_ERROR)); + + expect(renderers.renderGoogleAuthErrorPopup).toHaveBeenCalledTimes(1); + + const { onClose } = vi.mocked(renderers.renderGoogleAuthErrorPopup).mock.calls[0][0]; + act(() => onClose()); + expect(getPopupCtx().activePopup).toBeNull(); + }); + + it("routes a plain BadRequestError to renderInternalErrorPopup", () => { + const { renderers, setError } = setupHarness(); + + setError(new BadRequestError(ErrorCode.BACKEND_BAD_REQUEST)); + + expect(renderers.renderInternalErrorPopup).toHaveBeenCalledWith(ErrorCode.BACKEND_BAD_REQUEST); + }); + + it("routes a plain ServiceError to renderInternalErrorPopup", () => { + const { renderers, setError } = setupHarness(); + + setError(new ServiceError(ErrorCode.SERVICE_INTERNAL_ERROR)); + + expect(renderers.renderInternalErrorPopup).toHaveBeenCalledWith(ErrorCode.SERVICE_INTERNAL_ERROR); + }); + + it("routes NetworkError to renderNetworkErrorPopup", () => { + const { renderers, setError } = setupHarness(); + + setError(new NetworkError(ErrorCode.NETWORK_ERROR)); + + expect(renderers.renderNetworkErrorPopup).toHaveBeenCalledTimes(1); + }); +}); + +function Consumer({ + renderers, + options, +}: { + renderers: ConnectivityErrorPopupRenderers; + options: ConsumerOptions; +}) { + useConnectivityErrorPopup({ ...options, renderers }); + return null; +} diff --git a/packages/app-kit/src/popup/useConnectivityErrorPopup.ts b/packages/app-kit/src/popup/useConnectivityErrorPopup.ts new file mode 100644 index 0000000..e5ff2e2 --- /dev/null +++ b/packages/app-kit/src/popup/useConnectivityErrorPopup.ts @@ -0,0 +1,150 @@ +"use client"; + +import { ReactNode, useEffect, useRef } from "react"; +import { + AuthRequired, + BackendError, + BadRequestError, + ClientError, + ConnectivityError, + InvalidInputError, + NetworkError, + ServiceError, +} from "../transport/app-errors/app-error"; +import { ErrorCode } from "../transport/app-errors/app-error-codes"; +import { useConnectivityError } from "../transport/connectivity-error-context"; +import { AUTH_POPUP_TYPE, usePopup } from "./PopupContext"; + +export interface ConnectivityErrorPopupRenderers { + renderAuthPopup: () => ReactNode; + renderSpotifyOnlyAuthPopup: () => ReactNode; + renderInternalErrorPopup: (errorCode: ErrorCode) => ReactNode; + renderSpotifyAuthErrorPopup: (params: { message: string; errorCode: ErrorCode; onClose: () => void }) => ReactNode; + renderGoogleAuthErrorPopup: (params: { message: string; onClose: () => void }) => ReactNode; + renderNetworkErrorPopup: () => ReactNode; +} + +export interface UseConnectivityErrorPopupOptions { + isAccountPage: boolean; + routeRequiresAuth: boolean; + routeRequiresSpotify: boolean; + renderers: ConnectivityErrorPopupRenderers; +} + +const ALWAYS_REDISPLAY_ON_REPEAT = [NetworkError, BackendError, ClientError, ServiceError, InvalidInputError]; + +/** + * Classifies the shared connectivity-error state into the right popup and shows it. Consuming apps + * supply the actual popup components via `renderers` since those are app-branded; this hook owns + * only the error -> popup-kind decision, so a fix here (e.g. a route not requiring auth still + * surfacing an unexpected AuthRequired) applies to every app instead of needing a per-app patch. + */ +export function useConnectivityErrorPopup({ + isAccountPage, + routeRequiresAuth, + routeRequiresSpotify, + renderers, +}: UseConnectivityErrorPopupOptions): void { + const { showPopup, hidePopup } = usePopup(); + const { connectivityError, clearConnectivityError } = useConnectivityError(); + const currentConnectivityErrorRef = useRef(null); + const renderersRef = useRef(renderers); + renderersRef.current = renderers; + + useEffect(() => { + if (connectivityError === null) { + if (currentConnectivityErrorRef.current !== null) { + currentConnectivityErrorRef.current = null; + hidePopup(); + } + return; + } + + const error = connectivityError; + const isSpotifyAllowlistOrAuthError = + error instanceof BackendError && + [ErrorCode.BACKEND_SPOTIFY_USER_NOT_IN_ALLOWLIST, ErrorCode.BACKEND_SPOTIFY_AUTHENTICATION_ERROR].includes( + error.code, + ); + + if (isSpotifyAllowlistOrAuthError && !routeRequiresSpotify) { + hidePopup(); + clearConnectivityError(); + currentConnectivityErrorRef.current = null; + return; + } + + if ( + currentConnectivityErrorRef.current == null || + (!ALWAYS_REDISPLAY_ON_REPEAT.includes(currentConnectivityErrorRef.current) && + !(connectivityError instanceof currentConnectivityErrorRef.current)) + ) { + const renderer = renderersRef.current; + let popup: ReactNode | null = null; + let popupType: string | null = null; + + if (!isAccountPage && routeRequiresAuth && error instanceof AuthRequired) { + popup = renderer.renderAuthPopup(); + popupType = AUTH_POPUP_TYPE; + } else if ( + !isAccountPage && + routeRequiresSpotify && + error instanceof BackendError && + error.code === ErrorCode.BACKEND_SPOTIFY_AUTHORIZATION_REQUIRED + ) { + popup = renderer.renderSpotifyOnlyAuthPopup(); + popupType = AUTH_POPUP_TYPE; + } else if (error instanceof InvalidInputError) { + console.error("[InvalidInputError]", error.code, error.json); + popup = renderer.renderInternalErrorPopup(error.code); + } else if ( + routeRequiresSpotify && + error instanceof BackendError && + [ErrorCode.BACKEND_SPOTIFY_USER_NOT_IN_ALLOWLIST, ErrorCode.BACKEND_SPOTIFY_AUTHENTICATION_ERROR].includes( + error.code, + ) + ) { + popup = renderer.renderSpotifyAuthErrorPopup({ + message: error.message, + errorCode: error.code, + onClose: () => hidePopup(), + }); + } else if ( + error instanceof BackendError && + [ + ErrorCode.BACKEND_GOOGLE_AUTHENTICATION_ERROR, + ErrorCode.BACKEND_GOOGLE_OAUTH_MISCONFIGURED, + ErrorCode.BACKEND_GOOGLE_OAUTH_CODE_INVALID_OR_EXPIRED, + ].includes(error.code) + ) { + popup = renderer.renderGoogleAuthErrorPopup({ message: error.message, onClose: () => hidePopup() }); + } else if ( + error instanceof BadRequestError || + error instanceof BackendError || + error instanceof ServiceError || + error instanceof AuthRequired + ) { + // AuthRequired reaches here when the route doesn't require a session (e.g. a public + // reference page) or is the account page, so the AuthPopup branch above didn't fire — + // an unexpected 401 on those routes still needs to surface, not be dropped silently. + popup = renderer.renderInternalErrorPopup(error.code); + } else if (error instanceof NetworkError) { + popup = renderer.renderNetworkErrorPopup(); + } + + if (popup) { + showPopup(popup, popupType); + } + + currentConnectivityErrorRef.current = error.constructor as typeof ConnectivityError; + } + }, [ + connectivityError, + showPopup, + hidePopup, + clearConnectivityError, + isAccountPage, + routeRequiresAuth, + routeRequiresSpotify, + ]); +} diff --git a/packages/app-kit/src/transport/app-errors/app-error.test.ts b/packages/app-kit/src/transport/app-errors/app-error.test.ts new file mode 100644 index 0000000..948f736 --- /dev/null +++ b/packages/app-kit/src/transport/app-errors/app-error.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { BackendError, BackendSpotifyUserNotAllowlistedError, InvalidInputError } from "./app-error"; +import { ErrorCode } from "./app-error-codes"; +import { getSpotifyAllowlistMailtoHref } from "./app-error-messages"; + +describe("app-error classes", () => { + it("includes the JSON payload in InvalidInputError.toString()", () => { + const error = new InvalidInputError(ErrorCode.BACKEND_INVALID_INPUT, { field: "name" }); + + expect(error.toString()).toContain('"field": "name"'); + }); + + it("overrides the message when BackendError is given a backendMessage", () => { + const error = new BackendError(ErrorCode.BACKEND_BAD_REQUEST, "custom backend message"); + + expect(error.message).toBe("custom backend message"); + }); + + it("sets the class name for BackendSpotifyUserNotAllowlistedError", () => { + const error = new BackendSpotifyUserNotAllowlistedError( + ErrorCode.BACKEND_SPOTIFY_USER_NOT_IN_ALLOWLIST, + "you're not on the list", + ); + + expect(error.name).toBe("BackendSpotifyUserNotAllowlistedError"); + expect(error.detailsMessage).toBe("you're not on the list"); + }); +}); + +describe("getSpotifyAllowlistMailtoHref", () => { + it("returns null when no contact email is configured", () => { + expect(getSpotifyAllowlistMailtoHref(null)).toBeNull(); + expect(getSpotifyAllowlistMailtoHref(undefined)).toBeNull(); + }); + + it("builds a mailto href with the request subject and body", () => { + const href = getSpotifyAllowlistMailtoHref("contact@example.com"); + + expect(href).toContain("mailto:contact@example.com?"); + expect(href).toContain("subject="); + expect(href).toContain("body="); + }); +});