Skip to content
Open
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
347 changes: 149 additions & 198 deletions src/components/DrugInfo.tsx

Large diffs are not rendered by default.

139 changes: 139 additions & 0 deletions src/components/__tests__/AppealPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<AppealPage />);
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<typeof reply>) => void = () => {};
const pendingBanCheck = new Promise<ReturnType<typeof reply>>((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(<AppealPage />);
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);
Expand All @@ -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(<AppealPage />);
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(<AppealPage />);
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<typeof reply>) => void = () => {};
const pendingRefresh = new Promise<ReturnType<typeof reply>>((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(<AppealPage />);
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(<AppealPage />);
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) => {
Expand Down Expand Up @@ -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 }))
Expand All @@ -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();
});
59 changes: 59 additions & 0 deletions src/components/__tests__/Document.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => <html>{children}</html>,
Head: ({ children }: { children: ReactNode }) => <div>{children}</div>,
Main: () => <main>Page content</main>,
NextScript: () => <output data-script-src="/next-runtime.js" />,
}));
jest.mock(
"next/script",
() =>
function Script({ src }: { src: string }) {
return <output data-script-src={src} />;
},
);

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(
<RootLayout>
<main>Hello</main>
</RootLayout>,
);
expect(html).toContain('lang="en"');
expect(html).toContain("<main>Hello</main>");
expect(metadata.title).toBe("TripSit");
});

test("uses the blog root during server rendering", () => {
expect(
renderToStaticMarkup(
<BlogSuggestion blogOrigin="https://blog.tripsit.me" />,
),
).toContain('href="https://blog.tripsit.me/"');
});

test("renders community support links", () => {
expect(renderToStaticMarkup(<Github />)).toContain(
'href="https://www.github.com/tripsit"',
);
expect(renderToStaticMarkup(<Patreon />)).toContain(
'href="https://www.patreon.com/bePatron?u=899550"',
);
});
61 changes: 52 additions & 9 deletions src/components/__tests__/DrugInfo.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,21 @@ const drug = {
properties: {},
} as Drug;

test("renders older database records without properties", () => {
render(
<DrugInfo
drugData={
{
name: "legacy",
pretty_name: "Legacy",
properties: undefined,
} as unknown as Drug
}
/>,
);
expect(screen.getByText("Legacy")).toBeVisible();
});

test.each(["EXAMPLE", "example compound", "ALIAS"])(
"looks up a factsheet by %s",
(name) => {
Expand Down Expand Up @@ -95,46 +110,46 @@ 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(
<DrugInfo
drugData={{
...drug,
dose_note: "Example dose note",
formatted_dose: {
oral: { common: "1-2mg", light: "unknown" },
insufflated: { common: "3-4mg" },
oral: { common: "100–200 µg", light: "unknown" },
insufflated: { common: "1-2 mg" },
},
}}
/>,
);
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 ?? ""),
).toEqual([
{
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(
<DrugInfo
drugData={{ ...drug, formatted_dose: { oral: { light: "unknown" } } }}
/>,
);
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", () => {
Expand Down Expand Up @@ -227,6 +242,33 @@ test("displays after effects without onset or duration data", () => {
);
});

test("renders fractional hour durations without after effects", () => {
render(
<DrugInfo
drugData={{
...drug,
aliases: undefined,
formatted_duration: { oral: "1.5-2.5", _unit: "hours" as Unit },
}}
/>,
);
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<typeof useDrugs>);
render(<DrugPage />);
expect(
screen.getByRole("heading", { name: /drug not found/i }),
).toBeVisible();
});

test("renders optional references, warnings, and combination notes", () => {
render(
<DrugInfo
Expand All @@ -240,6 +282,7 @@ test("renders optional references, warnings, and combination notes", () => {
experiences: "https://example.test/experiences",
},
combos: {
absent: undefined,
mdma: { status: "Caution", note: "(Sample combination note)" },
alcohol: { status: "Dangerous" },
} as Drug["combos"],
Expand Down
Loading
Loading