From 282c087c271aa6fa43012bba349ad5644e22214b Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sat, 15 Aug 2026 14:13:26 -0300 Subject: [PATCH] fix(radar): separate feature availability from opt-in (#10487) Co-authored-by: Xiangzhe --- src/app/(dashboard)/dashboard/radar/page.tsx | 16 ++- tests/unit/radar-optin-page.test.tsx | 120 +++++++++++++++++++ 2 files changed, 130 insertions(+), 6 deletions(-) create mode 100644 tests/unit/radar-optin-page.test.tsx diff --git a/src/app/(dashboard)/dashboard/radar/page.tsx b/src/app/(dashboard)/dashboard/radar/page.tsx index 86111a803c..4ab1c2d449 100644 --- a/src/app/(dashboard)/dashboard/radar/page.tsx +++ b/src/app/(dashboard)/dashboard/radar/page.tsx @@ -80,6 +80,7 @@ export default function RadarPage() { const [meta, setMeta] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); + const [featureAvailable, setFeatureAvailable] = useState(null); const [optIn, setOptIn] = useState(null); const [activating, setActivating] = useState(false); const [syncing, setSyncing] = useState(false); @@ -116,13 +117,14 @@ export default function RadarPage() { const res = await fetch("/api/radar/catalog", { cache: "no-store" }); if (res.status === 404) { // Flag off — treat as not found - setOptIn(false); + setFeatureAvailable(false); setEntries([]); setMeta(null); if (showLoading) setLoading(false); return; } if (!res.ok) throw new Error(`HTTP ${res.status}`); + setFeatureAvailable(true); const data = await res.json(); setEntries(data.entries || []); setMeta(data.meta || null); @@ -162,11 +164,13 @@ export default function RadarPage() { const settingsRes = await fetch("/api/radar/settings", { cache: "no-store" }); if (settingsRes.status === 404) { // Flag off - setOptIn(false); + setFeatureAvailable(false); + setOptIn(null); return; } if (!settingsRes.ok) throw new Error(`HTTP ${settingsRes.status}`); const settingsData = await settingsRes.json(); + setFeatureAvailable(true); setOptIn(settingsData.optIn === true); setHasSupporterKey(settingsData.hasSupporterKey === true); setSupporterKeyMasked( @@ -295,16 +299,16 @@ export default function RadarPage() { } }, [keyInput, t, handleSync]); - // Determine effective state - const flagOn = optIn !== false || entries.length > 0 || meta !== null; + // Feature availability and privacy opt-in are independent states. A successful + // settings response with `optIn: false` means "show activation", not "flag off". const pageState = resolveRadarPageState( - optIn !== false, // if we got a 404, optIn=false => flag off + featureAvailable !== false, optIn === true, meta !== null ); // Flag off — render not-found - if (pageState === "flag_off" && !loading) { + if (featureAvailable === false && !loading) { notFound(); } diff --git a/tests/unit/radar-optin-page.test.tsx b/tests/unit/radar-optin-page.test.tsx new file mode 100644 index 0000000000..c8b7a6b0e3 --- /dev/null +++ b/tests/unit/radar-optin-page.test.tsx @@ -0,0 +1,120 @@ +// @vitest-environment jsdom + +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { notFoundMock, translationMock } = vi.hoisted(() => ({ + notFoundMock: vi.fn(), + translationMock: (key: string) => key, +})); + +vi.mock("next/navigation", () => ({ + notFound: notFoundMock, +})); + +vi.mock("next-intl", () => ({ + useTranslations: () => translationMock, +})); + +vi.mock("next/link", () => ({ + default: ({ href, children, ...props }: React.AnchorHTMLAttributes) => ( + + {children} + + ), +})); + +vi.mock("@/shared/components", () => ({ + Card: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock("@/lib/radar/autoSync", () => ({ + shouldAutoSyncOnOpen: () => false, +})); + +vi.mock("@/lib/radar/supporterKey", () => ({ + isValidSupporterKeyFormat: () => true, +})); + +vi.mock("../../src/app/(dashboard)/dashboard/radar/RadarCatalogTable", () => ({ + RadarCatalogTable: () =>
catalog
, +})); + +import RadarPage from "../../src/app/(dashboard)/dashboard/radar/page"; + +function response(body: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + } as Response; +} + +async function settle(): Promise { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + await Promise.resolve(); + }); +} + +describe("Radar opt-in page", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + notFoundMock.mockReset(); + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input.toString(); + if (url === "/api/radar/settings") { + return response({ + optIn: false, + hasSupporterKey: false, + supporterKeyMasked: null, + contributorClaimUrl: "https://radar.example.test/auth/github", + supporterPlansUrl: "https://radar.example.test/planos", + }); + } + throw new Error(`Unexpected request: ${url}`); + }) + ); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + }); + + afterEach(() => { + act(() => root.unmount()); + document.body.innerHTML = ""; + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it("renders activation when the feature exists but the owner has not opted in", async () => { + await act(async () => { + root.render(); + }); + await settle(); + + expect(notFoundMock).not.toHaveBeenCalled(); + expect(container.textContent).toContain("activateTitle"); + expect(container.textContent).toContain("activateButton"); + }); + + it("keeps the page hidden when the feature endpoint returns 404", async () => { + vi.mocked(fetch).mockResolvedValueOnce(response({ error: "Not found" }, 404)); + + await act(async () => { + root.render(); + }); + await settle(); + + expect(notFoundMock).toHaveBeenCalled(); + expect(container.textContent).not.toContain("activateTitle"); + }); +});