Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/app-kit/src/popup/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
188 changes: 188 additions & 0 deletions packages/app-kit/src/popup/useConnectivityErrorPopup.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, ReturnType<typeof vi.fn>> {
return {
renderAuthPopup: vi.fn(() => <div>auth-popup</div>),
renderSpotifyOnlyAuthPopup: vi.fn(() => <div>spotify-only-auth-popup</div>),
renderInternalErrorPopup: vi.fn(() => <div>internal-error-popup</div>),
renderSpotifyAuthErrorPopup: vi.fn(() => <div>spotify-auth-error-popup</div>),
renderGoogleAuthErrorPopup: vi.fn(() => <div>google-auth-error-popup</div>),
renderNetworkErrorPopup: vi.fn(() => <div>network-error-popup</div>),
};
}

type ConsumerOptions = Omit<UseConnectivityErrorPopupOptions, "renderers">;

const DEFAULT_OPTIONS: ConsumerOptions = {
isAccountPage: false,
routeRequiresAuth: false,
routeRequiresSpotify: false,
};

function setupHarness(options: Partial<ConsumerOptions> = {}) {
const renderers = makeRenderers();
const resolvedOptions = { ...DEFAULT_OPTIONS, ...options };

let setError!: (error: ConnectivityError | null) => void;
let popupCtx!: ReturnType<typeof usePopup>;

function Capture({ children }: { children: ReactNode }) {
const { setConnectivityError } = useConnectivityError();
popupCtx = usePopup();
setError = setConnectivityError;
return <>{children}</>;
}

render(
<PopupProvider>
<ConnectivityErrorProvider>
<Capture>
<Consumer renderers={renderers} options={resolvedOptions} />
</Capture>
</ConnectivityErrorProvider>
</PopupProvider>,
);

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;
}
150 changes: 150 additions & 0 deletions packages/app-kit/src/popup/useConnectivityErrorPopup.ts
Original file line number Diff line number Diff line change
@@ -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<typeof ConnectivityError | null>(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,
]);
}
43 changes: 43 additions & 0 deletions packages/app-kit/src/transport/app-errors/app-error.test.ts
Original file line number Diff line number Diff line change
@@ -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=");
});
});
Loading