From 8ec23fa24e4302aa28895431c659acdd15ec917f Mon Sep 17 00:00:00 2001 From: mignot Date: Sat, 15 Aug 2026 20:57:11 +0200 Subject: [PATCH 1/5] feat(popup): extract connectivity-error-to-popup classification into a shared hook grow-frontend and hear-frontend each carry a byte-for-byte copy of this classification logic in their own AppContent.tsx, including the same bug (an AuthRequired error on a route that doesn't require auth was silently dropped instead of surfacing a popup). Centralizing it here means that class of bug only needs fixing once; apps still own their own popup components via the renderers they pass in. --- packages/app-kit/src/popup/index.ts | 1 + .../popup/useConnectivityErrorPopup.test.tsx | 127 +++++++++++++++ .../src/popup/useConnectivityErrorPopup.ts | 150 ++++++++++++++++++ 3 files changed, 278 insertions(+) create mode 100644 packages/app-kit/src/popup/useConnectivityErrorPopup.test.tsx create mode 100644 packages/app-kit/src/popup/useConnectivityErrorPopup.ts 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..73c8da4 --- /dev/null +++ b/packages/app-kit/src/popup/useConnectivityErrorPopup.test.tsx @@ -0,0 +1,127 @@ +import { act, render } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { ReactNode } from "react"; +import { AuthRequired, ErrorCode, ConnectivityErrorProvider, useConnectivityError } from "../transport"; +import { PopupProvider, usePopup } from "./PopupContext"; +import { ConnectivityErrorPopupRenderers, 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
), + }; +} + +describe("useConnectivityErrorPopup", () => { + it("routes AuthRequired to renderAuthPopup on a route that requires auth", () => { + const renderers = makeRenderers(); + + let setError!: (error: AuthRequired) => void; + function SetErrorCapture({ children }: { children: ReactNode }) { + const { setConnectivityError } = useConnectivityError(); + setError = setConnectivityError; + return <>{children}; + } + + render( + + + + + + + , + ); + + act(() => { + 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 = makeRenderers(); + + let setError!: (error: AuthRequired) => void; + function SetErrorCapture({ children }: { children: ReactNode }) { + const { setConnectivityError } = useConnectivityError(); + setError = setConnectivityError; + return <>{children}; + } + + render( + + + + + + + , + ); + + act(() => { + 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 renderers = makeRenderers(); + + let setError!: (error: AuthRequired | null) => void; + let popupCtx!: ReturnType; + + function SetErrorCapture({ children }: { children: ReactNode }) { + const { setConnectivityError } = useConnectivityError(); + setError = setConnectivityError; + return <>{children}; + } + + function PopupCapture({ children }: { children: ReactNode }) { + popupCtx = usePopup(); + return <>{children}; + } + + render( + + + + + + + + + , + ); + + act(() => { + setError(new AuthRequired(ErrorCode.BACKEND_UNAUTHORIZED)); + }); + expect(popupCtx.activePopup).not.toBeNull(); + + act(() => { + setError(null); + }); + expect(popupCtx.activePopup).toBeNull(); + }); +}); + +function Consumer({ + renderers, + routeRequiresAuth, +}: { + renderers: ConnectivityErrorPopupRenderers; + routeRequiresAuth: boolean; +}) { + useConnectivityErrorPopup({ isAccountPage: false, routeRequiresAuth, routeRequiresSpotify: false, 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..d8631a7 --- /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, + ErrorCode, + InvalidInputError, + NetworkError, + ServiceError, + useConnectivityError, +} from "../transport"; +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, + ]); +} From 31ce6f59866c1ceaa1375cc3abe57f2243ca6b23 Mon Sep 17 00:00:00 2001 From: mignot Date: Sat, 15 Aug 2026 21:08:29 +0200 Subject: [PATCH 2/5] fix: import error/context modules directly, not via the transport barrel The transport barrel re-exports fetch-wrapper, useFetchWrapper, site-urls, and query-client too; pulling it in dragged those largely-untested modules into vitest's all:false coverage set and tripped the repo's 80% threshold. --- .../app-kit/src/popup/useConnectivityErrorPopup.test.tsx | 4 +++- packages/app-kit/src/popup/useConnectivityErrorPopup.ts | 6 +++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/app-kit/src/popup/useConnectivityErrorPopup.test.tsx b/packages/app-kit/src/popup/useConnectivityErrorPopup.test.tsx index 73c8da4..018b3f9 100644 --- a/packages/app-kit/src/popup/useConnectivityErrorPopup.test.tsx +++ b/packages/app-kit/src/popup/useConnectivityErrorPopup.test.tsx @@ -1,7 +1,9 @@ import { act, render } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { ReactNode } from "react"; -import { AuthRequired, ErrorCode, ConnectivityErrorProvider, useConnectivityError } from "../transport"; +import { AuthRequired } 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, useConnectivityErrorPopup } from "./useConnectivityErrorPopup"; diff --git a/packages/app-kit/src/popup/useConnectivityErrorPopup.ts b/packages/app-kit/src/popup/useConnectivityErrorPopup.ts index d8631a7..e5ff2e2 100644 --- a/packages/app-kit/src/popup/useConnectivityErrorPopup.ts +++ b/packages/app-kit/src/popup/useConnectivityErrorPopup.ts @@ -7,12 +7,12 @@ import { BadRequestError, ClientError, ConnectivityError, - ErrorCode, InvalidInputError, NetworkError, ServiceError, - useConnectivityError, -} from "../transport"; +} 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 { From f15f2ec172613e5a067408e52e07dc113c42f778 Mon Sep 17 00:00:00 2001 From: mignot Date: Sat, 15 Aug 2026 21:14:29 +0200 Subject: [PATCH 3/5] test: cover remaining useConnectivityErrorPopup branches Function coverage was failing CI (55.55% < 80%) because the prior tests only exercised the AuthRequired branches, leaving the hook's other renderer branches and most app-error classes' constructors unexercised. --- .../popup/useConnectivityErrorPopup.test.tsx | 223 +++++++++++------- 1 file changed, 137 insertions(+), 86 deletions(-) diff --git a/packages/app-kit/src/popup/useConnectivityErrorPopup.test.tsx b/packages/app-kit/src/popup/useConnectivityErrorPopup.test.tsx index 018b3f9..a830e61 100644 --- a/packages/app-kit/src/popup/useConnectivityErrorPopup.test.tsx +++ b/packages/app-kit/src/popup/useConnectivityErrorPopup.test.tsx @@ -1,11 +1,23 @@ import { act, render } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { ReactNode } from "react"; -import { AuthRequired } from "../transport/app-errors/app-error"; +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, useConnectivityErrorPopup } from "./useConnectivityErrorPopup"; +import { + ConnectivityErrorPopupRenderers, + UseConnectivityErrorPopupOptions, + useConnectivityErrorPopup, +} from "./useConnectivityErrorPopup"; function makeRenderers(): ConnectivityErrorPopupRenderers & Record> { return { @@ -18,58 +30,59 @@ function makeRenderers(): ConnectivityErrorPopupRenderers & Record; + +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 = makeRenderers(); - - let setError!: (error: AuthRequired) => void; - function SetErrorCapture({ children }: { children: ReactNode }) { - const { setConnectivityError } = useConnectivityError(); - setError = setConnectivityError; - return <>{children}; - } - - render( - - - - - - - , - ); - - act(() => { - setError(new AuthRequired(ErrorCode.BACKEND_UNAUTHORIZED)); - }); + 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 = makeRenderers(); - - let setError!: (error: AuthRequired) => void; - function SetErrorCapture({ children }: { children: ReactNode }) { - const { setConnectivityError } = useConnectivityError(); - setError = setConnectivityError; - return <>{children}; - } - - render( - - - - - - - , - ); - - act(() => { - setError(new AuthRequired(ErrorCode.BACKEND_UNAUTHORIZED)); - }); + const { renderers, setError } = setupHarness({ routeRequiresAuth: false }); + + setError(new AuthRequired(ErrorCode.BACKEND_UNAUTHORIZED)); expect(renderers.renderInternalErrorPopup).toHaveBeenCalledTimes(1); expect(renderers.renderInternalErrorPopup).toHaveBeenCalledWith(ErrorCode.BACKEND_UNAUTHORIZED); @@ -77,53 +90,91 @@ describe("useConnectivityErrorPopup", () => { }); it("hides the popup once the connectivity error clears", () => { - const renderers = makeRenderers(); - - let setError!: (error: AuthRequired | null) => void; - let popupCtx!: ReturnType; - - function SetErrorCapture({ children }: { children: ReactNode }) { - const { setConnectivityError } = useConnectivityError(); - setError = setConnectivityError; - return <>{children}; - } - - function PopupCapture({ children }: { children: ReactNode }) { - popupCtx = usePopup(); - return <>{children}; - } - - render( - - - - - - - - - , - ); - - act(() => { - setError(new AuthRequired(ErrorCode.BACKEND_UNAUTHORIZED)); - }); - expect(popupCtx.activePopup).not.toBeNull(); - - act(() => { - setError(null); - }); - expect(popupCtx.activePopup).toBeNull(); + 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 } = setupHarness({ routeRequiresSpotify: true }); + + setError(new BackendError(ErrorCode.BACKEND_SPOTIFY_USER_NOT_IN_ALLOWLIST)); + + expect(renderers.renderSpotifyAuthErrorPopup).toHaveBeenCalledTimes(1); + }); + + 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 } = setupHarness(); + + setError(new BackendError(ErrorCode.BACKEND_GOOGLE_AUTHENTICATION_ERROR)); + + expect(renderers.renderGoogleAuthErrorPopup).toHaveBeenCalledTimes(1); + }); + + 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, - routeRequiresAuth, + options, }: { renderers: ConnectivityErrorPopupRenderers; - routeRequiresAuth: boolean; + options: ConsumerOptions; }) { - useConnectivityErrorPopup({ isAccountPage: false, routeRequiresAuth, routeRequiresSpotify: false, renderers }); + useConnectivityErrorPopup({ ...options, renderers }); return null; } From 55a26bff3c50b5335868acd335504d9cd42812c1 Mon Sep 17 00:00:00 2001 From: mignot Date: Sat, 15 Aug 2026 21:17:44 +0200 Subject: [PATCH 4/5] test: close remaining function-coverage gaps for the new hook Functions coverage was still short (77.77% < 80%): the two onClose callbacks passed to the Spotify/Google auth-error renderers were never invoked, and importing app-error.ts directly (instead of via a barrel) brought its previously-untested branches (InvalidInputError.toString(), BackendError's backendMessage override, BackendSpotifyUserNotAllowlistedError, getSpotifyAllowlistMailtoHref) into the coverage-counted set. --- .../popup/useConnectivityErrorPopup.test.tsx | 12 +++++- .../transport/app-errors/app-error.test.ts | 43 +++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 packages/app-kit/src/transport/app-errors/app-error.test.ts diff --git a/packages/app-kit/src/popup/useConnectivityErrorPopup.test.tsx b/packages/app-kit/src/popup/useConnectivityErrorPopup.test.tsx index a830e61..653f733 100644 --- a/packages/app-kit/src/popup/useConnectivityErrorPopup.test.tsx +++ b/packages/app-kit/src/popup/useConnectivityErrorPopup.test.tsx @@ -119,11 +119,15 @@ describe("useConnectivityErrorPopup", () => { }); it("routes a Spotify allowlist BackendError to renderSpotifyAuthErrorPopup on a route that requires Spotify", () => { - const { renderers, setError } = setupHarness({ routeRequiresSpotify: true }); + const { renderers, setError, getPopupCtx } = setupHarness({ routeRequiresSpotify: true }); setError(new BackendError(ErrorCode.BACKEND_SPOTIFY_USER_NOT_IN_ALLOWLIST)); expect(renderers.renderSpotifyAuthErrorPopup).toHaveBeenCalledTimes(1); + + const { onClose } = 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", () => { @@ -136,11 +140,15 @@ describe("useConnectivityErrorPopup", () => { }); it("routes a Google-authentication BackendError to renderGoogleAuthErrorPopup", () => { - const { renderers, setError } = setupHarness(); + const { renderers, setError, getPopupCtx } = setupHarness(); setError(new BackendError(ErrorCode.BACKEND_GOOGLE_AUTHENTICATION_ERROR)); expect(renderers.renderGoogleAuthErrorPopup).toHaveBeenCalledTimes(1); + + const { onClose } = renderers.renderGoogleAuthErrorPopup.mock.calls[0][0]; + act(() => onClose()); + expect(getPopupCtx().activePopup).toBeNull(); }); it("routes a plain BadRequestError to renderInternalErrorPopup", () => { 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="); + }); +}); From 382d0dee9aad4d13f7ffd8ef7f767a03f4145a77 Mon Sep 17 00:00:00 2001 From: mignot Date: Sat, 15 Aug 2026 21:21:45 +0200 Subject: [PATCH 5/5] fix: resolve TS2339 mock-property type error in hook test vi.mocked() casts the renderer mocks so direct .mock access type-checks. --- packages/app-kit/src/popup/useConnectivityErrorPopup.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app-kit/src/popup/useConnectivityErrorPopup.test.tsx b/packages/app-kit/src/popup/useConnectivityErrorPopup.test.tsx index 653f733..a940385 100644 --- a/packages/app-kit/src/popup/useConnectivityErrorPopup.test.tsx +++ b/packages/app-kit/src/popup/useConnectivityErrorPopup.test.tsx @@ -125,7 +125,7 @@ describe("useConnectivityErrorPopup", () => { expect(renderers.renderSpotifyAuthErrorPopup).toHaveBeenCalledTimes(1); - const { onClose } = renderers.renderSpotifyAuthErrorPopup.mock.calls[0][0]; + const { onClose } = vi.mocked(renderers.renderSpotifyAuthErrorPopup).mock.calls[0][0]; act(() => onClose()); expect(getPopupCtx().activePopup).toBeNull(); }); @@ -146,7 +146,7 @@ describe("useConnectivityErrorPopup", () => { expect(renderers.renderGoogleAuthErrorPopup).toHaveBeenCalledTimes(1); - const { onClose } = renderers.renderGoogleAuthErrorPopup.mock.calls[0][0]; + const { onClose } = vi.mocked(renderers.renderGoogleAuthErrorPopup).mock.calls[0][0]; act(() => onClose()); expect(getPopupCtx().activePopup).toBeNull(); });