diff --git a/src/components/DrugInfo.tsx b/src/components/DrugInfo.tsx index 3ab65a0..a213bc7 100644 --- a/src/components/DrugInfo.tsx +++ b/src/components/DrugInfo.tsx @@ -12,6 +12,7 @@ import type { ApexAxisChartSeries } from "apexcharts"; import dynamic from "next/dynamic"; import comboDefinitions from "../assets/comboDefinitions.json"; import addDictionaryDefs from "./addDictionaryDefs"; +import { parseDoseRangeMg } from "../utils/parseDoseRangeMg"; // We need to dynamically load this module const ReactApexChart = dynamic(() => import("react-apexcharts"), { @@ -167,32 +168,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 +238,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; @@ -454,71 +452,23 @@ const addDosages = (drugData: Drug) => { [strength]: strengthData, }; - // This essentially looks for "(any digit) (optional dash) (any digit) (optional unit)" - const regex = /(\d+(?:\.\d+)?)(?:-(\d+(?:\.\d+)?))?([a-zA-Z]+)?/; - const match = RegExp(regex).exec(strengthData); - - // This is mostly for type-safety - if (match) { - // The minimum number is the first number to appear - const strengthMinNumber = parseFloat(match[1]); - - // The maximum number is either the second number, or if there is no second number, the first number * 1.5 - // Later, we have code that says "if there is no maximum, then do a fade out", the "1.5" is purely cosmetic - // The doseText above displays the "+" so it's clear to the user, they never see the (dosage * 1.5) value - const strengthMaxNumber = match[2] - ? parseFloat(match[2]) - : strengthMinNumber * 1.5; - - // Get the color associated with the strength - const strengthColor = - doseColorData[strength as keyof typeof doseColorData]; - // If it's not already in the doseColors table, add it - if (!doseColors.includes(strengthColor)) { - doseColors.push(strengthColor); - } - - // Get the gradient color associated with the strength - // If it's not already in the doseGradientToColors table, add it - const strengthGradientColor = match[2] - ? doseGradientData[strength as keyof typeof doseGradientData] - : "#ffffff"; - if (!doseGradientToColors.includes(strengthGradientColor)) { - doseGradientToColors.push(strengthGradientColor); - } - - // Check if the doseSeries already has a series with the same name - // If it does, push the new data to that series - // If it doesn't, create a new series - const existingSeries = doseSeries.find( - (series) => series.name === strength, - ); - - const roaUnit = match[3] ? ` (${match[3]})` : ``; - if (existingSeries) { - const existingSeriesData = existingSeries.data as { - x: string; - y: number[]; - }[]; - - existingSeriesData.push({ - x: `${roa}${roaUnit}`, - y: [strengthMinNumber, strengthMaxNumber], - }); - return; - } + const range = parseDoseRangeMg(strengthData); + if (!range) return; - const dosePropertySeries = { - name: strength, - data: [ - { - x: `${roa}${roaUnit}`, - y: [strengthMinNumber, strengthMaxNumber], - }, - ], - }; - doseSeries.push(dosePropertySeries); + const point = { x: `${roa} (mg)`, y: range }; + const existingSeries = doseSeries.find( + (series) => series.name === strength, + ); + if (existingSeries) { + (existingSeries.data as (typeof point)[]).push(point); + return; } + + doseColors.push(doseColorData[strength as keyof typeof doseColorData]); + doseGradientToColors.push( + doseGradientData[strength as keyof typeof doseGradientData], + ); + doseSeries.push({ name: strength, data: [point] }); }); }); @@ -594,13 +544,21 @@ const addDosages = (drugData: Drug) => { Dosages - + {doseSeries.length > 0 && ( + <> + + Chart shows bounded mass ranges in mg. See the table for all + doses. + + + + )} {/* Dosage Text */} @@ -669,39 +627,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 +711,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 +721,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..b0a308d 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) => { @@ -95,24 +110,24 @@ test.each([ ).toBeVisible(); }); -test("keeps dose text and charts the ranges for each route", () => { +test("keeps all doses in the table and charts only bounded mass ranges", () => { render( , ); expect(screen.getByText("Example dose note")).toBeVisible(); expect(screen.getByText("unknown")).toBeVisible(); - expect(screen.getByText("1-2mg")).toBeVisible(); + expect(screen.getByText("100–200 µg")).toBeVisible(); expect(screen.getByLabelText("Tooltip for common")).toHaveTextContent( - "oral (mg): 1 - 2", + "oral (mg): 0.1 - 0.2", ); expect( JSON.parse(screen.getByLabelText("Chart series").textContent ?? ""), @@ -120,21 +135,21 @@ test("keeps dose text and charts the ranges for each route", () => { { name: "common", data: [ - { x: "oral (mg)", y: [1, 2] }, - { x: "insufflated (mg)", y: [3, 4] }, + { x: "oral (mg)", y: [0.1, 0.2] }, + { x: "insufflated (mg)", y: [1, 2] }, ], }, ]); }); -test("keeps unplottable doses in the table", () => { +test("omits the dose chart when no ranges can be plotted", () => { render( , ); expect(screen.getByText("unknown")).toBeVisible(); - expect(screen.getByLabelText("Chart series")).toHaveTextContent("[]"); + expect(screen.queryByLabelText("Chart series")).toBeNull(); }); test("converts onset to hours and offsets duration and after effects", () => { @@ -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"], 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"); + } +}); diff --git a/src/utils/__tests__/parseDoseRangeMg.test.ts b/src/utils/__tests__/parseDoseRangeMg.test.ts new file mode 100644 index 0000000..bba5f6f --- /dev/null +++ b/src/utils/__tests__/parseDoseRangeMg.test.ts @@ -0,0 +1,32 @@ +import { parseDoseRangeMg } from "../parseDoseRangeMg"; + +test("inherits an omitted upper unit from the lower endpoint", () => { + expect(parseDoseRangeMg("1 mg - 2")).toEqual([1, 2]); +}); + +test("rejects a range whose upper endpoint overflows during conversion", () => { + expect(parseDoseRangeMg(`1-${"9".repeat(308)} g`)).toBeUndefined(); +}); + +describe("parseDoseRangeMg", () => { + it.each([ + ["800-1500ug", [0.8, 1.5]], + ["1.5-3mg", [1.5, 3]], + ["325mg-650mg", [325, 650]], + ["800 µg – 1.5 mg", [0.8, 1.5]], + ])("normalizes %s to milligrams", (source, expected) => { + expect(parseDoseRangeMg(source)).toEqual(expected); + }); + + it.each([ + "25-50ug/hr", + "25ug/hr-50ug/hr", + "3mg+", + "3mg", + "1-3", + "3mg-1mg", + "1-3mg/kg", + ])("leaves %s in the text table only", (source) => + expect(parseDoseRangeMg(source)).toBeUndefined(), + ); +}); diff --git a/src/utils/parseDoseRangeMg.ts b/src/utils/parseDoseRangeMg.ts new file mode 100644 index 0000000..53f0e25 --- /dev/null +++ b/src/utils/parseDoseRangeMg.ts @@ -0,0 +1,28 @@ +const massInMg = { ng: 0.000001, ug: 0.001, mg: 1, g: 1000 }; +type MassUnit = keyof typeof massInMg; + +export function parseDoseRangeMg(text: string): [number, number] | undefined { + // Require two explicit endpoints and at least one mass unit. + const endpoints = text + .toLowerCase() + .replace(/[\u00b5\u03bc]/gu, "u") + .split(/[-\u2013\u2014]/u); + if (endpoints.length !== 2) return; + + const quantity = /^(\d+(?:\.\d+)?|\.\d+)\s*(ng|ug|mg|g)?$/u; + const lower = quantity.exec(endpoints[0].trim()); + const upper = quantity.exec(endpoints[1].trim()); + if (!lower || !upper) return; + + const [, low, lowUnit] = lower; + const [, high, highUnit] = upper; + if (!lowUnit && !highUnit) return; + + const startUnit = (lowUnit || highUnit) as MassUnit; + const endUnit = (highUnit || lowUnit) as MassUnit; + const start = Number(low) * massInMg[startUnit]; + const end = Number(high) * massInMg[endUnit]; + if (Number.isFinite(start) && Number.isFinite(end) && start < end) { + return [start, end]; + } +}