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
16 changes: 10 additions & 6 deletions src/app/(dashboard)/dashboard/radar/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export default function RadarPage() {
const [meta, setMeta] = useState<RadarMeta | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [featureAvailable, setFeatureAvailable] = useState<boolean | null>(null);
const [optIn, setOptIn] = useState<boolean | null>(null);
const [activating, setActivating] = useState(false);
const [syncing, setSyncing] = useState(false);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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();
}

Expand Down
120 changes: 120 additions & 0 deletions tests/unit/radar-optin-page.test.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLAnchorElement>) => (
<a href={String(href)} {...props}>
{children}
</a>
),
}));

vi.mock("@/shared/components", () => ({
Card: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));

vi.mock("@/lib/radar/autoSync", () => ({
shouldAutoSyncOnOpen: () => false,
}));

vi.mock("@/lib/radar/supporterKey", () => ({
isValidSupporterKeyFormat: () => true,
}));

vi.mock("../../src/app/(dashboard)/dashboard/radar/RadarCatalogTable", () => ({
RadarCatalogTable: () => <div>catalog</div>,
}));

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<void> {
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(<RadarPage />);
});
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(<RadarPage />);
});
await settle();

expect(notFoundMock).toHaveBeenCalled();
expect(container.textContent).not.toContain("activateTitle");
});
});
Loading