diff --git a/src/components/DrugInfo.tsx b/src/components/DrugInfo.tsx index 3ab65a0..760a622 100644 --- a/src/components/DrugInfo.tsx +++ b/src/components/DrugInfo.tsx @@ -167,32 +167,30 @@ const addDurations = (drugData: Drug) => { const durationSeries = [] as ApexAxisChartSeries; Object.keys(durationTiming).forEach((roa) => { const roaData = durationTiming[roa as keyof typeof durationTiming]; - if (roaData) { - Object.keys(roaData).forEach((timing) => { - const timingData = roaData[timing as keyof typeof roaData]; - if (timingData) { - const data = { - name: timing, - data: [ + Object.keys(roaData).forEach((timing) => { + const timingData = roaData[timing as keyof typeof roaData] as NonNullable< + (typeof roaData)[keyof typeof roaData] + >; + const data = { + name: timing, + data: [ + { + x: roa, + y: [timingData.startTime, timingData.endTime], + goals: [ + // The black line that signifies the minimum duration { - x: roa, - y: [timingData.startTime, timingData.endTime], - goals: [ - // The black line that signifies the minimum duration - { - name: "Minimum", - value: timingData.min + timingData.startTime, - strokeWidth: 2, - strokeColor: "#000000", - }, - ] as Record[], + name: "Minimum", + value: timingData.min + timingData.startTime, + strokeWidth: 2, + strokeColor: "#000000", }, - ], - } as ApexAxisChartSeries[number]; - durationSeries.push(data); - } - }); - } + ] as Record[], + }, + ], + } as ApexAxisChartSeries[number]; + durationSeries.push(data); + }); }); // Create chart options @@ -239,13 +237,12 @@ const addDurations = (drugData: Drug) => { const roaTimingData = durationTiming[s.x as keyof typeof durationTiming]; - const periodData = - roaTimingData[ - w.config.series[seriesIndex].name as keyof typeof roaTimingData - ]; + const periodData = roaTimingData[ + w.config.series[seriesIndex].name as keyof typeof roaTimingData + ] as NonNullable<(typeof roaTimingData)[keyof typeof roaTimingData]>; - const yStartValue = periodData ? periodData.min : 0; - const yEndValue = periodData ? periodData.max : 0; + const yStartValue = periodData.min; + const yEndValue = periodData.max; let yStart = `${yStartValue}` as string; let yEnd = `${yEndValue}h` as string; @@ -669,39 +666,37 @@ export default function DrugInfoCard({ // This is the array that will hold all the elements that will be displayed in the expanded row const elements = [] as React.JSX.Element[]; + const properties = drugData.properties ?? {}; // If there's a warning, display it first - if (drugData.properties) { - Object.keys(drugData.properties).forEach((property) => { - const importantProperties = [ - "avoid", - "warning", - "risks", - "adverseEffects", - "contraindications", - ]; - if (importantProperties.includes(property)) { - const propertyName = property.replace(/_/g, " "); - const propertyValue = - drugData.properties[property as keyof typeof drugData.properties]; - elements.push( - - - - - {propertyName.charAt(0).toUpperCase() + propertyName.slice(1)}{" "} - {propertyValue} - - - - , - ); - } - }); - } + Object.keys(properties).forEach((property) => { + const importantProperties = [ + "avoid", + "warning", + "risks", + "adverseEffects", + "contraindications", + ]; + if (importantProperties.includes(property)) { + const propertyName = property.replace(/_/g, " "); + const propertyValue = properties[property as keyof typeof properties]; + elements.push( + + + + + {propertyName.charAt(0).toUpperCase() + propertyName.slice(1)}{" "} + {propertyValue} + + + + , + ); + } + }); // Always push the drug name since it's the one required field elements.push( @@ -755,7 +750,7 @@ export default function DrugInfoCard({ ); } - if (drugData.properties.summary) { + if (properties.summary) { // Take the category list, capitalize each word, and join them with commas elements.push( @@ -765,78 +760,73 @@ export default function DrugInfoCard({ Summary - - {addDictionaryDefs(drugData.properties.summary)} - + {addDictionaryDefs(properties.summary)} , ); } - if (drugData.properties) { - Object.keys(drugData.properties).forEach((property) => { - const duplicatedProperties = [ - "avoid", - "name", - "summary", - "categories", - "aliases", - "afterEffects", - "dose", - "duration", - "onset", - "effects", - "after-effects", - "pweffects", - "combos", - "sources", - "links", - "warning", - ]; - if (duplicatedProperties.includes(property)) { - return; - } - const propertyName = property.replace(/_/g, " "); - const propertyValue = - drugData.properties[property as keyof typeof drugData.properties]; - if (propertyName === "experiences") { - elements.push( - - - - - {propertyName.charAt(0).toUpperCase() + propertyName.slice(1)} - - - - Erowid Experience Vault - - - - - , - ); - } else { - elements.push( - - - - - {propertyName.charAt(0).toUpperCase() + propertyName.slice(1)} - - {propertyValue} - - - , - ); - } - }); - } + Object.keys(properties).forEach((property) => { + const duplicatedProperties = [ + "avoid", + "name", + "summary", + "categories", + "aliases", + "afterEffects", + "dose", + "duration", + "onset", + "effects", + "after-effects", + "pweffects", + "combos", + "sources", + "links", + "warning", + ]; + if (duplicatedProperties.includes(property)) { + return; + } + const propertyName = property.replace(/_/g, " "); + const propertyValue = properties[property as keyof typeof properties]; + if (propertyName === "experiences") { + elements.push( + + + + + {propertyName.charAt(0).toUpperCase() + propertyName.slice(1)} + + + + Erowid Experience Vault + + + + + , + ); + } else { + elements.push( + + + + + {propertyName.charAt(0).toUpperCase() + propertyName.slice(1)} + + {propertyValue} + + + , + ); + } + }); // Dose note comes before dosage if (drugData.dose_note) { diff --git a/src/components/__tests__/AppealPage.test.tsx b/src/components/__tests__/AppealPage.test.tsx index 81a4fd7..c3cab60 100644 --- a/src/components/__tests__/AppealPage.test.tsx +++ b/src/components/__tests__/AppealPage.test.tsx @@ -112,6 +112,50 @@ jest.mock("@/utils/keycloak", () => ({ const fetchMock = jest.fn(); const originalFetch = global.fetch; + +test("keeps a still-valid token without repeating the ban check on each interval", async () => { + jest.useFakeTimers(); + const access = validToken(); + sessionStorage.setItem("kc_token", access); + fetchMock + .mockResolvedValueOnce(reply({ banned: false })) + .mockResolvedValueOnce(reply({})); + render(); + expect( + await screen.findByRole("heading", { name: "Not Banned" }), + ).toBeVisible(); + await act(() => jest.advanceTimersByTimeAsync(90000)); + expect(sessionStorage.getItem("kc_token")).toBe(access); + expect(fetchMock).toHaveBeenCalledTimes(2); +}); + +test("avoids overlapping ban checks when the token refreshes during a slow request", async () => { + jest.useFakeTimers(); + sessionStorage.setItem("kc_token", token(Date.now() / 1000 + 75)); + sessionStorage.setItem("kc_refresh_token", "refresh"); + let completeBanCheck: (value: ReturnType) => void = () => {}; + const pendingBanCheck = new Promise>((resolve) => { + completeBanCheck = resolve; + }); + fetchMock.mockImplementation(async (url: string) => { + if (url === "/api/v2/users/banned") return pendingBanCheck; + if (url === "/api/v2/keycloak/refresh") + return reply({ access_token: validToken() }); + return reply({}); + }); + render(); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + await act(() => jest.advanceTimersByTimeAsync(30000)); + expect( + fetchMock.mock.calls.filter(([url]) => url === "/api/v2/users/banned"), + ).toHaveLength(1); + await act(async () => { + completeBanCheck(reply({ banned: false })); + }); + expect( + await screen.findByRole("heading", { name: "Not Banned" }), + ).toBeVisible(); +}); const token = (exp: number) => `header.${btoa(JSON.stringify({ exp }))}.signature`; const validToken = () => token(Date.now() / 1000 + 3600); @@ -136,12 +180,103 @@ beforeEach(() => { }); afterEach(() => { + jest.restoreAllMocks(); jest.useRealTimers(); global.fetch = originalFetch; sessionStorage.clear(); window.history.replaceState({}, "", "/"); }); +test("exchanges a code when the server supplies only an access token", async () => { + window.history.replaceState({}, "", "/appeal?code=minimal"); + fetchMock + .mockResolvedValueOnce(reply({ access_token: validToken() })) + .mockResolvedValueOnce(reply({ banned: false })) + .mockResolvedValueOnce(reply({ id: "history" })); + render(); + expect( + await screen.findByRole("heading", { name: "Appeal History" }), + ).toBeVisible(); + expect(screen.getByText("Unknown")).toBeVisible(); + expect(sessionStorage.getItem("kc_refresh_token")).toBeNull(); + expect(sessionStorage.getItem("kc_id_token")).toBeNull(); +}); + +test("keeps a denied appeal locked when no decision timestamp is available", async () => { + sessionStorage.setItem("kc_token", validToken()); + fetchMock + .mockResolvedValueOnce(reply({ banned: true })) + .mockResolvedValueOnce( + reply({ id: "denied", status: "DENIED", created_at: daysAgo(1) }), + ); + render(); + expect( + await screen.findByRole("heading", { name: "Your Appeal Status" }), + ).toBeVisible(); + expect(screen.queryByRole("button", { name: "Submit Appeal" })).toBeNull(); +}); + +test("shares a background token refresh with an appeal submission", async () => { + jest.useFakeTimers(); + sessionStorage.setItem("kc_token", token(Date.now() / 1000 + 75)); + sessionStorage.setItem("kc_refresh_token", "refresh"); + let completeRefresh: (value: ReturnType) => void = () => {}; + const pendingRefresh = new Promise>((resolve) => { + completeRefresh = resolve; + }); + fetchMock.mockImplementation(async (url: string) => { + if (url === "/api/v2/keycloak/refresh") return pendingRefresh; + if (url === "/api/v2/users/banned") return reply({ banned: true }); + if (url === "/api/v2/appeals/create") + return reply({ message: "Submission success" }); + return reply({}); + }); + render(); + const submit = await screen.findByRole("button", { name: "Submit Appeal" }); + for (const label of [ + /why you were banned/i, + /make amends/i, + /avoid repeating/i, + ]) { + fireEvent.change(screen.getByRole("textbox", { name: label }), { + target: { value: "Answer" }, + }); + } + await act(() => jest.advanceTimersByTimeAsync(30000)); + fireEvent.click(submit); + expect( + fetchMock.mock.calls.filter(([url]) => url === "/api/v2/keycloak/refresh"), + ).toHaveLength(1); + await act(async () => { + completeRefresh(reply({ access_token: validToken() })); + }); + expect(await screen.findByText("Submission success")).toBeVisible(); + expect( + fetchMock.mock.calls.filter(([url]) => url === "/api/v2/appeals/create"), + ).toHaveLength(1); + expect( + fetchMock.mock.calls.filter(([url]) => url === "/api/v2/keycloak/refresh"), + ).toHaveLength(1); +}); + +test("handles credentials removed between initialization and the ban check", async () => { + const getItem = Storage.prototype.getItem; + let tokenReads = 0; + jest.spyOn(Storage.prototype, "getItem").mockImplementation(function ( + this: Storage, + key: string, + ) { + if (key === "kc_token") { + tokenReads += 1; + return tokenReads === 1 ? validToken() : null; + } + return getItem.call(this, key); + }); + render(); + await waitFor(() => expect(tokenReads).toBeGreaterThan(1)); + expect(fetchMock).not.toHaveBeenCalled(); +}); + test.each(["missing token", "network error"])( "returns to login when the code exchange fails with %s", async (failure) => { @@ -403,6 +538,7 @@ test("disables reminders for a newly submitted appeal", async () => { }); test("submits an appeal and refreshes its status", async () => { + jest.useFakeTimers(); sessionStorage.setItem("kc_token", validToken()); fetchMock .mockResolvedValueOnce(reply({ banned: true })) @@ -426,4 +562,7 @@ test("submits an appeal and refreshes its status", async () => { expect(await screen.findByText("Under Review")).toBeVisible(); expect(screen.getByText("Your appeal has been submitted.")).toBeVisible(); expect(fetchMock).toHaveBeenCalledTimes(5); + await act(() => jest.advanceTimersByTimeAsync(3000)); + expect(screen.queryByText("Your appeal has been submitted.")).toBeNull(); + expect(screen.getByText("Under Review")).toBeVisible(); }); diff --git a/src/components/__tests__/Document.test.tsx b/src/components/__tests__/Document.test.tsx new file mode 100644 index 0000000..381f655 --- /dev/null +++ b/src/components/__tests__/Document.test.tsx @@ -0,0 +1,59 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import type { ReactNode } from "react"; +import Document from "@/pages/_document"; +import RootLayout, { metadata } from "@/app/layout"; +import BlogSuggestion from "../BlogSuggestion"; +import Github from "../Github"; +import Patreon from "../Patreon"; + +jest.mock("next/document", () => ({ + __esModule: true, + default: class {}, + Html: ({ children }: { children: ReactNode }) => {children}, + Head: ({ children }: { children: ReactNode }) =>
{children}
, + Main: () =>
Page content
, + NextScript: () => , +})); +jest.mock( + "next/script", + () => + function Script({ src }: { src: string }) { + return ; + }, +); + +test("renders the document shell, favicons and browser scripts", () => { + const html = renderToStaticMarkup(Document.prototype.render()); + expect(html).toContain('rel="icon"'); + expect(html).toContain("Page content"); + expect(html).toContain("/next-runtime.js"); + expect(html).toContain("bootstrap.bundle.min.js"); +}); + +test("renders the app layout with metadata and page content", () => { + const html = renderToStaticMarkup( + +
Hello
+
, + ); + expect(html).toContain('lang="en"'); + expect(html).toContain("
Hello
"); + expect(metadata.title).toBe("TripSit"); +}); + +test("uses the blog root during server rendering", () => { + expect( + renderToStaticMarkup( + , + ), + ).toContain('href="https://blog.tripsit.me/"'); +}); + +test("renders community support links", () => { + expect(renderToStaticMarkup()).toContain( + 'href="https://www.github.com/tripsit"', + ); + expect(renderToStaticMarkup()).toContain( + 'href="https://www.patreon.com/bePatron?u=899550"', + ); +}); diff --git a/src/components/__tests__/DrugInfo.test.tsx b/src/components/__tests__/DrugInfo.test.tsx index c1a962d..4fcf0dd 100644 --- a/src/components/__tests__/DrugInfo.test.tsx +++ b/src/components/__tests__/DrugInfo.test.tsx @@ -57,6 +57,21 @@ const drug = { properties: {}, } as Drug; +test("renders older database records without properties", () => { + render( + , + ); + expect(screen.getByText("Legacy")).toBeVisible(); +}); + test.each(["EXAMPLE", "example compound", "ALIAS"])( "looks up a factsheet by %s", (name) => { @@ -227,6 +242,33 @@ test("displays after effects without onset or duration data", () => { ); }); +test("renders fractional hour durations without after effects", () => { + render( + , + ); + expect(screen.getByText("1h 30m to 2h 30m")).toBeVisible(); + expect(screen.queryByText("Aliases")).toBeNull(); +}); + +test("renders an empty factsheet result while the database is loading", () => { + jest + .mocked(useRouter) + .mockReturnValue({ query: { drug: "example" } } as unknown as ReturnType< + typeof useRouter + >); + jest.mocked(useDrugs).mockReturnValue({} as ReturnType); + render(); + expect( + screen.getByRole("heading", { name: /drug not found/i }), + ).toBeVisible(); +}); + test("renders optional references, warnings, and combination notes", () => { render( { experiences: "https://example.test/experiences", }, combos: { + absent: undefined, mdma: { status: "Caution", note: "(Sample combination note)" }, alcohol: { status: "Dangerous" }, } as Drug["combos"], @@ -268,3 +311,21 @@ test("renders optional references, warnings, and combination notes", () => { "https://psychonautwiki.org/wiki/Nausea", ); }); + +test("keeps open-ended dose text while plotting its existing visual range", () => { + render( + , + ); + expect(screen.getByText("2+")).toBeVisible(); + expect(screen.getByLabelText("Tooltip for common")).toHaveTextContent( + "oral: 2 - 3", + ); + expect( + JSON.parse(screen.getByLabelText("Chart series").textContent ?? ""), + ).toEqual([{ name: "common", data: [{ x: "oral", y: [2, 3] }] }]); +}); diff --git a/src/components/__tests__/DynamicWidgets.test.tsx b/src/components/__tests__/DynamicWidgets.test.tsx new file mode 100644 index 0000000..47980ed --- /dev/null +++ b/src/components/__tests__/DynamicWidgets.test.tsx @@ -0,0 +1,69 @@ +import { render, screen, act } from "@testing-library/react"; +import type { ComponentType } from "react"; +import dynamic from "next/dynamic"; + +jest.mock("next/dynamic", () => jest.fn(() => () => null)); +jest.mock("particles-bg", () => ({ __esModule: true, default: () => null })); +jest.mock("react-apexcharts", () => ({ + __esModule: true, + default: () => null, +})); +jest.mock("@widgetbot/react-embed", () => ({ + __esModule: true, + default: () => null, +})); +jest.mock("@heroui/react", () => ({})); + +test.each([ + "@/components/Particles", + "@/components/DrugInfo", + "@/components/Webchat", +])("loads the browser-only dependency in %s", async (moduleName) => { + jest.mocked(dynamic).mockClear(); + jest.isolateModules(() => { + jest.requireActual(moduleName); + }); + const [loader, options] = jest.mocked(dynamic).mock.calls[0] as unknown as [ + () => Promise<{ default: ComponentType }>, + { ssr: boolean; loading?: ComponentType }, + ]; + expect(options.ssr).toBe(false); + const dependency = await loader(); + expect(dependency.default).toEqual(expect.any(Function)); + if (options.loading) { + const Loading = options.loading; + render(); + expect(screen.getByText("Loading...")).toBeVisible(); + } +}); + +test("selects the production WidgetBot server", async () => { + jest.replaceProperty(process, "env", { + ...process.env, + NODE_ENV: "production", + }); + try { + let Webchat: ComponentType = () => null; + jest.isolateModules(() => { + Webchat = jest.requireActual("@/components/Webchat").default; + }); + const widget = jest.fn(() => null); + jest.mocked(dynamic).mockReturnValue(widget); + jest.isolateModules(() => { + Webchat = jest.requireActual("@/components/Webchat").default; + }); + await act(async () => { + render(); + }); + expect(widget).toHaveBeenCalledWith( + expect.objectContaining({ + server: "179641883222474752", + channel: "1021385869262864464", + shard: "https://emerald.widgetbot.io", + }), + undefined, + ); + } finally { + jest.restoreAllMocks(); + } +}); diff --git a/src/components/__tests__/FactsheetColumns.test.tsx b/src/components/__tests__/FactsheetColumns.test.tsx new file mode 100644 index 0000000..ee509e8 --- /dev/null +++ b/src/components/__tests__/FactsheetColumns.test.tsx @@ -0,0 +1,97 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import type { ReactNode } from "react"; +import type { Drug } from "tripsit_drug_db"; +import { useDrugs } from "@/utils/drugs"; +import Factsheets from "../Factsheets"; + +jest.mock("@/utils/drugs", () => ({ useDrugs: jest.fn() })); +jest.mock("@heroui/react", () => { + const Wrapper = ({ children }: { children: ReactNode }) => ( +
{children}
+ ); + return { + cn: (...classes: string[]) => classes.filter(Boolean).join(" "), + Accordion: Object.assign(Wrapper, { + Item: ({ + children, + }: { + children: (state: { isExpanded: boolean }) => ReactNode; + }) => ( + <> + {children({ isExpanded: false })} + {children({ isExpanded: true })} + + ), + Heading: Wrapper, + Trigger: Wrapper, + Panel: Wrapper, + Body: Wrapper, + }), + }; +}); +jest.mock("material-react-table", () => ({ + useMaterialReactTable: (options: unknown) => options, + MaterialReactTable: ({ + table, + }: { + table: { + data: Drug[]; + columns: { + id?: string; + accessorKey?: keyof Drug; + accessorFn?: (row: Drug) => unknown; + Cell?: (props: { cell: { getValue: () => unknown } }) => ReactNode; + }[]; + renderTopToolbarCustomActions: () => ReactNode; + }; + }) => ( + <> + {table.renderTopToolbarCustomActions()} + {table.data.map((row) => ( +
+ {table.columns.map((column) => { + const value = column.accessorFn + ? column.accessorFn(row) + : row[column.accessorKey as keyof Drug]; + return ( +
+ {column.Cell + ? column.Cell({ cell: { getValue: () => value } }) + : String(value ?? "")} +
+ ); + })} +
+ ))} + + ), +})); + +test("formats all optional factsheet columns and tolerates missing values", () => { + const refetch = jest.fn(); + jest.mocked(useDrugs).mockReturnValue({ + data: [ + { + name: "full", + pretty_name: "Full", + aliases: ["Alias"], + properties: { + "test-kits": "Reagent result", + categories: ["tentative"], + summary: "Summary", + }, + formatted_effects: ["Example effect"], + pweffects: { Nausea: "example" }, + }, + { name: "minimal", pretty_name: "Minimal", properties: {} }, + ], + refetch, + } as unknown as ReturnType); + render(); + expect(screen.getByText("Reagent result")).toBeVisible(); + expect(screen.getByText("Example effect")).toBeVisible(); + expect(screen.getByText("Nausea")).toBeVisible(); + expect(screen.getByText("Minimal")).toBeVisible(); + fireEvent.click(screen.getByRole("button", { name: "Refresh Data" })); + expect(refetch).toHaveBeenCalledTimes(1); +}); diff --git a/src/components/__tests__/Factsheets.test.tsx b/src/components/__tests__/Factsheets.test.tsx index 2612e5f..8e102d8 100644 --- a/src/components/__tests__/Factsheets.test.tsx +++ b/src/components/__tests__/Factsheets.test.tsx @@ -11,9 +11,12 @@ const drugs = { name: "example", pretty_name: "Example compound", aliases: ["Test alias"], + formatted_effects: ["Sample effects"], + pweffects: { Nausea: "example" }, properties: { summary: "Example summary", categories: ["Stimulant", "tentative", "supplement"], + "test-kits": "Sample reagent results", }, }, minimal: { name: "minimal", pretty_name: "Minimal compound", properties: {} }, diff --git a/src/components/__tests__/Header.test.tsx b/src/components/__tests__/Header.test.tsx index c2852b8..d742bce 100644 --- a/src/components/__tests__/Header.test.tsx +++ b/src/components/__tests__/Header.test.tsx @@ -6,6 +6,13 @@ test("opens the desktop menu and dismisses it on outside interaction or Escape", const nav = within(screen.getByRole("navigation", { name: "Main" })); const resources = nav.getByRole("button", { name: "Resources" }); fireEvent.click(resources); + fireEvent.pointerDown(resources); + expect(resources).toHaveAttribute("aria-expanded", "true"); + fireEvent.keyDown(document, { key: "Enter" }); + expect(resources).toHaveAttribute("aria-expanded", "true"); + fireEvent.click(resources); + expect(resources).toHaveAttribute("aria-expanded", "false"); + fireEvent.click(resources); expect(resources).toHaveAttribute("aria-expanded", "true"); expect(nav.getByRole("link", { name: "Factsheets" })).toHaveAttribute( "href", @@ -18,6 +25,18 @@ test("opens the desktop menu and dismisses it on outside interaction or Escape", expect(nav.queryByRole("link", { name: "Factsheets" })).toBeNull(); }); +test("clears a pending hover timer when navigation is unmounted", () => { + jest.useFakeTimers(); + try { + const { unmount } = render(
); + fireEvent.mouseLeave(screen.getByRole("button", { name: "Resources" })); + unmount(); + expect(jest.getTimerCount()).toBe(0); + } finally { + jest.useRealTimers(); + } +}); + test("gives the pointer time to cross from the menu trigger to the panel", () => { jest.useFakeTimers(); try { diff --git a/src/components/__tests__/HomeEffects.test.tsx b/src/components/__tests__/HomeEffects.test.tsx index adf5520..c1d7a83 100644 --- a/src/components/__tests__/HomeEffects.test.tsx +++ b/src/components/__tests__/HomeEffects.test.tsx @@ -25,6 +25,41 @@ test("uses animation defaults when only the ending count is supplied", () => { expect(useCountUp).toHaveBeenLastCalledWith( expect.objectContaining({ start: 0, end: 25, duration: 5, delay: 1 }), ); + const options = jest.mocked(useCountUp).mock.calls.at(-1)?.[0]; + expect(options?.easingFn?.(0, 5, 10, 2)).toBe(5); + expect(options?.easingFn?.(2, 5, 10, 2)).toBe(15); +}); + +test("tolerates missing hero elements during inactivity", () => { + jest.useFakeTimers(); + try { + render(); + act(() => jest.advanceTimersByTime(10)); + fireEvent.mouseMove(document); + act(() => jest.advanceTimersByTime(10000)); + fireEvent.keyDown(document); + expect(document.getElementById("hero")).toBeNull(); + } finally { + jest.useRealTimers(); + } +}); + +test("waits until inactivity exceeds ten seconds before fading", () => { + jest.useFakeTimers(); + try { + render( + <> + +
+ + , + ); + fireEvent.mouseMove(document); + act(() => jest.advanceTimersByTime(10000)); + expect(screen.getByRole("region", { name: "Hero" }).style.opacity).toBe(""); + } finally { + jest.useRealTimers(); + } }); test("loads the Discord member count and starts its counter on the animation event", async () => { diff --git a/src/components/__tests__/ProfileButton.test.tsx b/src/components/__tests__/ProfileButton.test.tsx index f5e7fa1..24b116d 100644 --- a/src/components/__tests__/ProfileButton.test.tsx +++ b/src/components/__tests__/ProfileButton.test.tsx @@ -1,6 +1,11 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import ProfileButton from "../ProfileButton"; +jest.mock("@/utils/keycloak", () => ({ + getLoginUrl: () => "#login", + getPostLogoutUrl: () => "#logout", +})); + const originalFetch = global.fetch; const fetchMock = jest.fn(); @@ -14,6 +19,58 @@ afterEach(() => { global.fetch = originalFetch; sessionStorage.clear(); localStorage.clear(); + window.history.replaceState({}, "", "/"); +}); + +test("starts login when a guest clicks the profile button", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "Login" })); + expect(window.location.hash).toBe("#login"); +}); + +test("falls back to the guest avatar when credentials disappear before the avatar fetch", () => { + const originalGetItem = Storage.prototype.getItem; + let tokenReads = 0; + jest.spyOn(Storage.prototype, "getItem").mockImplementation(function ( + this: Storage, + key: string, + ) { + if (key === "kc_token") { + tokenReads += 1; + return tokenReads === 1 ? "temporary-token" : null; + } + return originalGetItem.call(this, key); + }); + render(); + expect( + screen.getByRole("button", { name: "Account menu" }).style.backgroundImage, + ).toContain("/assets/img/guest.png"); + expect(fetchMock).not.toHaveBeenCalled(); +}); + +test("closes the menu on a second click and clears session tokens on logout", async () => { + sessionStorage.setItem("kc_token", "token"); + sessionStorage.setItem("kc_refresh_token", "refresh"); + sessionStorage.setItem("kc_id_token", "id"); + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ avatarUrl: "/avatar.png" }), + }); + render(); + const account = screen.getByRole("button", { name: "Account menu" }); + await waitFor(() => + expect(account.style.backgroundImage).toContain("/avatar.png"), + ); + fireEvent.click(account); + fireEvent.mouseDown(account); + expect(screen.getByRole("button", { name: "Logout" })).toBeVisible(); + fireEvent.click(account); + expect(screen.queryByRole("button", { name: "Logout" })).toBeNull(); + fireEvent.click(account); + fireEvent.click(screen.getByRole("button", { name: "Logout" })); + expect(sessionStorage).toHaveLength(0); + expect(screen.getByRole("button", { name: "Login" })).toBeVisible(); + expect(window.location.hash).toBe("#logout"); }); test("reacts to session changes and dismisses the account menu outside", async () => { diff --git a/src/components/__tests__/UiAdapters.test.tsx b/src/components/__tests__/UiAdapters.test.tsx new file mode 100644 index 0000000..1013cb1 --- /dev/null +++ b/src/components/__tests__/UiAdapters.test.tsx @@ -0,0 +1,117 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import AccordionSection from "../AccordionSection"; +import Particles from "../Particles"; + +jest.mock("@heroui/react", () => { + const React = jest.requireActual("react"); + const Wrapper = ({ children }: { children: ReactNode }) => ( +
{children}
+ ); + return { + cn: (...classes: string[]) => classes.filter(Boolean).join(" "), + Accordion: { + Item: ({ + children, + }: { + children: (state: { isExpanded: boolean }) => ReactNode; + }) => { + const [isExpanded, setExpanded] = React.useState(false); + return ( +
+ + {children({ isExpanded })} +
+ ); + }, + Heading: Wrapper, + Trigger: ({ children, ...props }: { children: ReactNode }) => ( + + ), + Panel: Wrapper, + Body: Wrapper, + }, + Dropdown: Object.assign(Wrapper, { + Trigger: Wrapper, + Popover: Wrapper, + Menu: ({ + items, + children, + onAction, + }: { + items: { key: string; label: string }[]; + children: (item: { key: string; label: string }) => ReactNode; + onAction: (key: string) => void; + }) => ( +
+ {items.map((item) => ( +
onAction(item.key)}> + {children(item)} +
+ ))} + +
+ ), + Item: ({ children }: { children: ReactNode }) => ( + + ), + }), + }; +}); + +jest.mock( + "next/dynamic", + () => () => + function Background({ type, color }: { type: string; color?: string }) { + return ( + + {type} {color} + + ); + }, +); + +test("provides a default accordion indicator and exposes expansion state", () => { + const { container } = render( + Section content, + ); + expect( + screen.getByRole("button", { name: "Details" }).querySelector("svg"), + ).toBeInTheDocument(); + expect(container.querySelector("[data-open]")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Toggle section" })); + expect(container.querySelector('[data-open="true"]')).toBeInTheDocument(); +}); + +test("passes expanded state to a caller's accordion indicator", () => { + render( + Icon} + indicator={({ isOpen }) => (isOpen ? "Expanded" : "Collapsed")} + > + Content + , + ); + expect(screen.getByText("Icon")).toBeVisible(); + expect(screen.getByText("Collapsed")).toBeVisible(); + fireEvent.click(screen.getByRole("button", { name: "Toggle section" })); + expect(screen.getByText("Expanded")).toBeVisible(); +}); + +test("changes the background effect and ignores unknown selections", () => { + render(); + expect(screen.getByLabelText("Background effect")).toHaveTextContent( + "cobweb #FFFFFF", + ); + fireEvent.click(screen.getByRole("button", { name: "Tiny Tracers" })); + expect(screen.getByLabelText("Background effect")).toHaveTextContent("lines"); + fireEvent.click(screen.getByRole("button", { name: "Unknown effect" })); + expect(screen.getByLabelText("Background effect")).toHaveTextContent("lines"); + fireEvent.click(screen.getByRole("button", { name: "Connections" })); + expect(screen.getByLabelText("Background effect")).toHaveTextContent( + "cobweb #FFFFFF", + ); +}); diff --git a/src/components/comboChecker/__tests__/comboDataFallback.test.ts b/src/components/comboChecker/__tests__/comboDataFallback.test.ts new file mode 100644 index 0000000..808ea7d --- /dev/null +++ b/src/components/comboChecker/__tests__/comboDataFallback.test.ts @@ -0,0 +1,12 @@ +import { getCombo } from "../comboData"; + +jest.mock("tripsit_drug_db/combos.json", () => ({ + example: { + other: { status: "Unrecognized status" }, + }, +})); + +test("uses unknown risk and an empty note for an unrecognized database status", () => { + expect(getCombo("example", "other")).toEqual({ risk: "unknown", note: "" }); + expect(getCombo("other", "example")).toEqual({ risk: "unknown", note: "" }); +}); diff --git a/src/pages/appeal.tsx b/src/pages/appeal.tsx index a6d7c50..15021ef 100644 --- a/src/pages/appeal.tsx +++ b/src/pages/appeal.tsx @@ -29,17 +29,14 @@ const AppealPage: React.FC = () => { const [reminding, setReminding] = useState(false); const [remindMessage, setRemindMessage] = useState(null); - // Use useRef to store the interval ID so we can clear it - const refreshIntervalRef = useRef(null); - // Use ref to prevent concurrent token refreshes - const isRefreshingRef = useRef(false); + // Share a pending refresh so concurrent requests use the same tokens. const refreshPromiseRef = useRef | null>(null); // Use ref to prevent concurrent checkBan calls const isCheckingBanRef = useRef(false); async function refreshTokenIfNeeded() { // If already refreshing, wait for that promise to complete - if (isRefreshingRef.current && refreshPromiseRef.current) { + if (refreshPromiseRef.current) { return refreshPromiseRef.current; } @@ -56,8 +53,7 @@ const AppealPage: React.FC = () => { // If token expires within 60 seconds, refresh it if (expiresAt - now < 60000) { if (refreshToken) { - // Set flag and create promise to prevent concurrent refreshes - isRefreshingRef.current = true; + // Share the promise to prevent concurrent refreshes. refreshPromiseRef.current = (async () => { try { const refreshRes = await fetch("/api/v2/keycloak/refresh", { @@ -89,7 +85,6 @@ const AppealPage: React.FC = () => { sessionStorage.removeItem("kc_refresh_token"); return null; } finally { - isRefreshingRef.current = false; refreshPromiseRef.current = null; } })(); @@ -156,8 +151,7 @@ const AppealPage: React.FC = () => { } function canReappeal(appeal: Appeal | null): boolean { - if (!appeal || appeal.status !== "DENIED" || !appeal.decided_at) - return false; + if (!appeal?.decided_at) return false; const decidedAt = new Date(appeal.decided_at); @@ -174,9 +168,7 @@ const AppealPage: React.FC = () => { return decidedAt <= threeMonthsAgo; } - function canShowReminder(appeal: Appeal | null): boolean { - if (!appeal || appeal.status !== "RECEIVED") return false; - + function canShowReminder(appeal: Appeal): boolean { const createdAt = new Date(appeal.created_at); // 30 seconds for development @@ -266,11 +258,9 @@ const AppealPage: React.FC = () => { // Appeal is still pending (RECEIVED status) setBanStatus("has_appeal"); } - } else if (!hasAppeal) { + } else { // No appeal found - user can create one setBanStatus("can_appeal"); - } else { - setBanStatus("unknown"); } } catch { setBanStatus("unknown"); @@ -406,36 +396,21 @@ const AppealPage: React.FC = () => { // Set up background token refresh when we have a token useEffect(() => { - if (token) { - // Clear any existing interval - if (refreshIntervalRef.current) { - clearInterval(refreshIntervalRef.current); + if (!token) return; + // Set up new interval to refresh every 30 seconds + const refreshInterval = setInterval(async () => { + const refreshedToken = await refreshTokenIfNeeded(); + // Only update token state if it actually changed to prevent unnecessary re-renders + if (refreshedToken && refreshedToken !== token) { + setToken(refreshedToken); + } else if (!refreshedToken) { + // Token is now invalid, clear it + setToken(null); + clearInterval(refreshInterval); } - - // Set up new interval to refresh every 30 seconds - refreshIntervalRef.current = setInterval(async () => { - const refreshedToken = await refreshTokenIfNeeded(); - // Only update token state if it actually changed to prevent unnecessary re-renders - if (refreshedToken && refreshedToken !== token) { - setToken(refreshedToken); - } else if (!refreshedToken && token) { - // Token is now invalid, clear it - setToken(null); - if (refreshIntervalRef.current) { - clearInterval(refreshIntervalRef.current); - } - } - // If refreshedToken === token, do nothing (no state change needed) - }, 30000); // 30 seconds - } - - // Cleanup function - return () => { - if (refreshIntervalRef.current) { - clearInterval(refreshIntervalRef.current); - refreshIntervalRef.current = null; - } - }; + // If refreshedToken === token, do nothing (no state change needed) + }, 30000); // 30 seconds + return () => clearInterval(refreshInterval); }, [token]); // Fetch ban status when we have a token @@ -850,37 +825,6 @@ const AppealPage: React.FC = () => { - {latestAppeal?.status === "ACCEPTED" && ( -
-
-
- Appeal - Accepted! -
-
-
- {latestAppeal?.response_message && ( -

- Moderator Response: -
- - {latestAppeal.response_message} - -

- )} - - - Join Discord - -
-
- )} - {latestAppeal?.status === "DENIED" && (
diff --git a/src/utils/__tests__/discordStats.test.ts b/src/utils/__tests__/discordStats.test.ts index 6ae022d..6718a3d 100644 --- a/src/utils/__tests__/discordStats.test.ts +++ b/src/utils/__tests__/discordStats.test.ts @@ -77,6 +77,28 @@ describe("getDiscordStats", () => { expect(mockedGet).not.toHaveBeenCalled(); }); + it("reads the configured bot token when called without arguments", async () => { + jest.replaceProperty(process, "env", { + ...process.env, + DISCORD_CLIENT_TOKEN: "configured", + }); + mockedGet.mockResolvedValue({ data: {} }); + try { + await expect(getDiscordStats()).resolves.toEqual({ + memberCount: 0, + presenceCount: 0, + }); + expect(mockedGet).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: { Authorization: "Bot configured" }, + }), + ); + } finally { + jest.restoreAllMocks(); + } + }); + it("returns null when Discord fails", async () => { mockedGet.mockRejectedValue(new Error("timeout")); diff --git a/src/utils/__tests__/drugs.test.ts b/src/utils/__tests__/drugs.test.ts index d2a0717..087631b 100644 --- a/src/utils/__tests__/drugs.test.ts +++ b/src/utils/__tests__/drugs.test.ts @@ -1,4 +1,8 @@ -import { DRUGS_URL, fetchDrugs } from "../drugs"; +import { DRUGS_STALE_TIME, DRUGS_URL, fetchDrugs } from "../drugs"; + +test("keeps drug downloads fresh for thirty minutes", () => { + expect(DRUGS_STALE_TIME).toBe(30 * 60 * 1000); +}); describe("fetchDrugs", () => { const originalFetch = global.fetch; diff --git a/src/utils/__tests__/env.config.test.ts b/src/utils/__tests__/env.config.test.ts index df6bce6..d6abf37 100644 --- a/src/utils/__tests__/env.config.test.ts +++ b/src/utils/__tests__/env.config.test.ts @@ -17,6 +17,10 @@ test.each([ expect(env.NODE_ENV).toBe(expected); expect(env.DISCORD_GUILD_ID).toBe("test-guild"); expect(global.env).toBe(env); + expect( + jest.requireActual("../env.config") + .default, + ).toBe(env); }); } finally { global.env = previousEnv; diff --git a/src/utils/__tests__/keycloak.server.test.ts b/src/utils/__tests__/keycloak.server.test.ts new file mode 100644 index 0000000..8dde079 --- /dev/null +++ b/src/utils/__tests__/keycloak.server.test.ts @@ -0,0 +1,23 @@ +/** @jest-environment node */ +import { getLoginUrl, getPostLogoutUrl } from "../keycloak"; + +afterEach(() => jest.restoreAllMocks()); + +test("omits browser redirect origins when building authentication URLs on the server", () => { + jest.replaceProperty(process, "env", { + ...process.env, + NEXT_PUBLIC_KEYCLOAK_CLIENT_ID: "appeals", + }); + Object.defineProperty(globalThis, "sessionStorage", { + configurable: true, + value: { getItem: () => null }, + }); + try { + expect(new URL(getLoginUrl()).searchParams.get("redirect_uri")).toBe(""); + expect( + new URL(getPostLogoutUrl()).searchParams.get("post_logout_redirect_uri"), + ).toBe(""); + } finally { + Reflect.deleteProperty(globalThis, "sessionStorage"); + } +});