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
4 changes: 2 additions & 2 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@ import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
// Ignores build outputs and test coverage reports (#252)
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"coverage/**",
"next-env.d.ts",
]),
]);
Expand Down
11 changes: 5 additions & 6 deletions jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,11 @@ const config: Config = {
{ plugins: ["@babel/plugin-transform-modules-commonjs"] },
],
},
// react-markdown's dependency tree (remark/rehype/unified/hast/mdast/
// micromark, and each of *their* sub-dependencies) is too deep to name
// exhaustively — new transitive packages kept surfacing one at a time.
// Transform all of node_modules rather than maintain a brittle allowlist;
// this only affects Jest's test run, never the production build.
transformIgnorePatterns: [],
// Targeted negative lookahead: only transform ESM packages that actually require
// CommonJS transpilation, leaving standard CJS packages untouched (#251).
transformIgnorePatterns: [
"node_modules/(?!(react-markdown|remark-.*|rehype-.*|unified|hast-.*|mdast-.*|micromark.*|vfile.*|unist-.*|bail|is-plain-obj|trough|zwitch|longest-streak|ccount|escape-string-regexp|markdown-table|trim-lines|decode-named-character-reference|character-entities.*|devlop|comma-separated-tokens|space-separated-tokens|property-information|html-void-elements|html-url-attributes|estree-util-is-identifier-name)/)",
],
};

export default config;
18 changes: 18 additions & 0 deletions jest.setup.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,21 @@
// Extend Jest's built-in matchers with jest-dom's DOM-specific matchers
// (e.g. toBeInTheDocument, toHaveAttribute, toHaveClass)
import "@testing-library/jest-dom";

// Ensure environment variables required by src/lib/config.ts are preset in test env
process.env.NEXT_PUBLIC_STELLAR_NETWORK = "TESTNET";

// Mock window.matchMedia for JSDOM
Object.defineProperty(window, "matchMedia", {
writable: true,
value: jest.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(), // deprecated
removeListener: jest.fn(), // deprecated
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
1 change: 1 addition & 0 deletions src/components/bounty/BountyCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export function BountyCard({ bounty }: { bounty: Bounty }) {
return (
<Link
href={`/issues/${bounty.id}`}
prefetch={false}
className={`relative block overflow-hidden rounded-2xl border border-slate-200 bg-white p-5 shadow-sm transition-all before:absolute before:inset-y-0 before:left-0 before:w-1 hover:-translate-y-0.5 hover:shadow-md dark:border-slate-800 dark:bg-slate-900 ${accentByDifficulty[bounty.difficulty]}`}
>
<div className="flex items-start justify-between gap-4 pl-2">
Expand Down
36 changes: 35 additions & 1 deletion src/components/bounty/BountyDescription.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* Tests for BountyDescription (#89).
* Tests for BountyDescription (#89, #272).
*
* bounty.description is raw, untrusted, third-party GitHub issue Markdown
* with no backend sanitization guaranteed anywhere in this pipeline. These
Expand Down Expand Up @@ -47,6 +47,40 @@ describe("BountyDescription — content fidelity", () => {
expect(screen.getByText("first step").closest("li")).toBeInTheDocument();
expect(screen.getByText("second step").closest("li")).toBeInTheDocument();
});

it("renders an ordered list as real <ol><li> elements (#272)", () => {
render(<BountyDescription description={"1. first numbered item\n2. second numbered item"} />);

const first = screen.getByText("first numbered item");
expect(first.closest("ol")).toBeInTheDocument();
expect(first.closest("li")).toBeInTheDocument();
});

it("renders blockquotes with quote styling (#272)", () => {
render(<BountyDescription description={"> This is a quoted note"} />);

const quote = screen.getByText("This is a quoted note");
expect(quote.closest("blockquote")).toBeInTheDocument();
expect(quote.closest("blockquote")).toHaveClass("border-l-2");
});

it("remaps h1, h2, h3 markdown headers to h2, h3, h4 DOM elements (#272)", () => {
render(
<BountyDescription
description={"# Top Level Section\n## Sub Section\n### Sub-sub Section"}
/>,
);

const h2 = screen.getByRole("heading", { level: 2, name: "Top Level Section" });
const h3 = screen.getByRole("heading", { level: 3, name: "Sub Section" });
const h4 = screen.getByRole("heading", { level: 4, name: "Sub-sub Section" });

expect(h2).toBeInTheDocument();
expect(h3).toBeInTheDocument();
expect(h4).toBeInTheDocument();
// No h1 should exist in the description output
expect(screen.queryByRole("heading", { level: 1 })).not.toBeInTheDocument();
});
});

describe("BountyDescription — untrusted-content hardening", () => {
Expand Down
90 changes: 90 additions & 0 deletions src/components/dashboard/DashboardShell.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { render, screen } from "@testing-library/react";
import { DashboardShell } from "./DashboardShell";
import * as navigation from "next/navigation";

jest.mock("next/navigation", () => ({
usePathname: jest.fn(),
}));

describe("DashboardShell Component (#275)", () => {
beforeEach(() => {
jest.clearAllMocks();
});

it("renders contributor role nav items and highlights active route", () => {
(navigation.usePathname as jest.Mock).mockReturnValue("/dashboard/contributor");

render(
<DashboardShell role="contributor" title="Contributor Dashboard">
<div>Content Area</div>
</DashboardShell>,
);

expect(screen.getByText("Contributor Dashboard")).toBeInTheDocument();
expect(screen.getByText("Content Area")).toBeInTheDocument();

const overviewLink = screen.getByRole("link", { name: /Overview/i });
expect(overviewLink).toBeInTheDocument();
expect(overviewLink).toHaveAttribute("href", "/dashboard/contributor");
// Check active class styling
expect(overviewLink).toHaveClass("bg-indigo-50");

const browseLink = screen.getByRole("link", { name: /Browse bounties/i });
expect(browseLink).toBeInTheDocument();
expect(browseLink).toHaveAttribute("href", "/issues");
expect(browseLink).not.toHaveClass("bg-indigo-50");

expect(screen.getByRole("link", { name: /Reputation/i })).toBeInTheDocument();
});

it("renders maintainer role nav items", () => {
(navigation.usePathname as jest.Mock).mockReturnValue("/milestones");

render(
<DashboardShell role="maintainer" title="Maintainer Dashboard">
<div>Maintainer View</div>
</DashboardShell>,
);

expect(screen.getByText("Maintainer Dashboard")).toBeInTheDocument();
expect(screen.getByRole("link", { name: /Bounty pipeline/i })).toHaveAttribute("href", "/issues");

const milestoneLink = screen.getByRole("link", { name: /Milestones/i });
expect(milestoneLink).toHaveAttribute("href", "/milestones");
expect(milestoneLink).toHaveClass("bg-indigo-50");

expect(screen.getByRole("link", { name: /Team/i })).toHaveAttribute("href", "/dashboard/maintainer");
});

it("renders sponsor role nav items", () => {
(navigation.usePathname as jest.Mock).mockReturnValue("/dashboard/sponsor");

render(
<DashboardShell role="sponsor" title="Sponsor Dashboard">
<div>Sponsor View</div>
</DashboardShell>,
);

expect(screen.getByText("Sponsor Dashboard")).toBeInTheDocument();
expect(screen.getByRole("link", { name: /Bounties funded/i })).toHaveAttribute("href", "/issues");
expect(screen.getByRole("link", { name: /Payments/i })).toHaveAttribute("href", "/dashboard/sponsor");
});

it("renders role switcher links with active role emphasized", () => {
(navigation.usePathname as jest.Mock).mockReturnValue("/dashboard/contributor");

render(
<DashboardShell role="contributor" title="Contributor Dashboard">
<div>Content</div>
</DashboardShell>,
);

const contributorSwitcher = screen.getByRole("link", { name: "Contributor" });
const maintainerSwitcher = screen.getByRole("link", { name: "Maintainer" });
const sponsorSwitcher = screen.getByRole("link", { name: "Sponsor" });

expect(contributorSwitcher).toHaveClass("font-medium");
expect(maintainerSwitcher).toHaveClass("text-slate-500");
expect(sponsorSwitcher).toHaveClass("text-slate-500");
});
});
96 changes: 96 additions & 0 deletions src/components/layout/Navbar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { Navbar } from "./Navbar";
import * as AuthContext from "@/context/AuthContext";
import { ThemeProvider } from "@/context/ThemeContext";

jest.mock("@/context/AuthContext", () => ({
useAuth: jest.fn(),
}));

function renderNavbar() {
return render(
<ThemeProvider>
<Navbar />
</ThemeProvider>,
);
}

describe("Navbar Component (#276)", () => {
const mockLogout = jest.fn();

beforeEach(() => {
jest.clearAllMocks();
});

it("renders loading state (no auth buttons or user info mounted)", () => {
(AuthContext.useAuth as jest.Mock).mockReturnValue({
user: null,
loading: true,
logout: mockLogout,
});

renderNavbar();

expect(screen.getByText("MergeFi")).toBeInTheDocument();
expect(screen.getByText("Bounties")).toBeInTheDocument();
expect(screen.getByText("Milestones")).toBeInTheDocument();
expect(screen.queryByText("Sign in")).not.toBeInTheDocument();
expect(screen.queryByText("Connect GitHub")).not.toBeInTheDocument();
expect(screen.queryByTitle("Sign out")).not.toBeInTheDocument();
expect(screen.queryByText("Reputation")).not.toBeInTheDocument();
});

it("renders signed-out state with Sign in and Connect GitHub buttons", () => {
(AuthContext.useAuth as jest.Mock).mockReturnValue({
user: null,
loading: false,
logout: mockLogout,
});

renderNavbar();

expect(screen.getByText("Sign in")).toBeInTheDocument();
expect(screen.getByText("Connect GitHub")).toBeInTheDocument();
expect(screen.queryByTitle("Sign out")).not.toBeInTheDocument();
expect(screen.queryByText("Reputation")).not.toBeInTheDocument();
});

it("renders signed-in user state with display name, avatar link, reputation and logout button", () => {
(AuthContext.useAuth as jest.Mock).mockReturnValue({
user: {
username: "alice_dev",
displayName: "Alice Developer",
avatarUrl: "https://example.com/alice.png",
},
loading: false,
logout: mockLogout,
});

renderNavbar();

expect(screen.getByText("Alice Developer")).toBeInTheDocument();
expect(screen.getByText("Reputation")).toBeInTheDocument();
expect(screen.queryByText("Sign in")).not.toBeInTheDocument();
expect(screen.queryByText("Connect GitHub")).not.toBeInTheDocument();

const signOutBtn = screen.getByTitle("Sign out");
expect(signOutBtn).toBeInTheDocument();

fireEvent.click(signOutBtn);
expect(mockLogout).toHaveBeenCalledTimes(1);
});

it("renders dashboard links inside dropdown", () => {
(AuthContext.useAuth as jest.Mock).mockReturnValue({
user: null,
loading: false,
logout: mockLogout,
});

renderNavbar();

expect(screen.getByText("Contributor")).toHaveAttribute("href", "/dashboard/contributor");
expect(screen.getByText("Maintainer")).toHaveAttribute("href", "/dashboard/maintainer");
expect(screen.getByText("Sponsor")).toHaveAttribute("href", "/dashboard/sponsor");
});
});