test: add comprehensive automated test coverage
- Vitest multi-project setup (unit / integration / component) - Real-Postgres integration harness via in-process PGlite (TEST_DATABASE_URL override), migrations applied per worker; production code untouched - Unit: routing, locale, validation/Zod schemas, metadata, mail, site-theme, marquee, media, portfolio helpers, plus architecture-rule tests - Integration: Prisma data layer, API routes, and all server actions - Component: UI primitives and form components (jsdom + Testing Library) - 371 tests passing
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
describe("component harness smoke test", () => {
|
||||
it("renders a component into jsdom", () => {
|
||||
render(<Badge>Hello</Badge>);
|
||||
expect(screen.getByText("Hello")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { AdminFlash } from "@/components/admin/admin-flash";
|
||||
|
||||
describe("AdminFlash", () => {
|
||||
it("renders nothing when there are no messages", () => {
|
||||
const { container } = render(<AdminFlash />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders a success message with a status role", () => {
|
||||
render(<AdminFlash success="Saved." />);
|
||||
const status = screen.getByRole("status");
|
||||
expect(status).toHaveTextContent("Saved.");
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders an error message with an alert role", () => {
|
||||
render(<AdminFlash error="Failed." />);
|
||||
const alert = screen.getByRole("alert");
|
||||
expect(alert).toHaveTextContent("Failed.");
|
||||
expect(screen.queryByRole("status")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders both a success and error message together", () => {
|
||||
render(<AdminFlash success="Yes" error="No" />);
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Yes");
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("No");
|
||||
});
|
||||
|
||||
it("applies a custom className to the wrapper", () => {
|
||||
const { container } = render(<AdminFlash success="Yes" className="mb-4" />);
|
||||
expect(container.firstChild).toHaveClass("mb-4");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Activity } from "lucide-react";
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { DashboardCard } from "@/components/dashboard/dashboard-card";
|
||||
import { StatsCard } from "@/components/dashboard/stats-card";
|
||||
|
||||
describe("StatsCard", () => {
|
||||
it("renders the title and value", () => {
|
||||
render(<StatsCard title="Visitors" value="1,234" icon={Activity} />);
|
||||
expect(screen.getByText("Visitors")).toBeInTheDocument();
|
||||
expect(screen.getByText("1,234")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders an optional description and footer", () => {
|
||||
render(
|
||||
<StatsCard title="T" value="V" description="Up 5%" footer={<span>footer</span>} />,
|
||||
);
|
||||
expect(screen.getByText("Up 5%")).toBeInTheDocument();
|
||||
expect(screen.getByText("footer")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits the icon container when no icon is provided", () => {
|
||||
const { container } = render(<StatsCard title="T" value="V" />);
|
||||
expect(container.querySelector("svg")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DashboardCard", () => {
|
||||
it("passes its props through to a StatsCard", () => {
|
||||
render(<DashboardCard title="Sales" value="42" description="today" icon={Activity} />);
|
||||
expect(screen.getByText("Sales")).toBeInTheDocument();
|
||||
expect(screen.getByText("42")).toBeInTheDocument();
|
||||
expect(screen.getByText("today")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { MarqueeSettingsForm } from "@/components/admin/marquee-settings-form";
|
||||
import { buildDefaultMarqueeSettings } from "@/lib/marquee-settings";
|
||||
|
||||
describe("MarqueeSettingsForm", () => {
|
||||
it("renders a textarea per row with german default values", () => {
|
||||
const settings = buildDefaultMarqueeSettings();
|
||||
settings.locales.de.row1 = "First row value";
|
||||
render(<MarqueeSettingsForm action={vi.fn()} initialSettings={settings} />);
|
||||
|
||||
for (const name of ["row1-de", "row2-de", "row3-de", "row4-de"]) {
|
||||
const field = document.querySelector(`textarea[name="${name}"]`);
|
||||
expect(field).not.toBeNull();
|
||||
}
|
||||
expect((document.querySelector('textarea[name="row1-de"]') as HTMLTextAreaElement).value).toBe(
|
||||
"First row value",
|
||||
);
|
||||
});
|
||||
|
||||
it("wires the server action onto the form and renders a submit button", () => {
|
||||
const action = vi.fn();
|
||||
render(<MarqueeSettingsForm action={action} initialSettings={buildDefaultMarqueeSettings()} />);
|
||||
expect(document.querySelector("form#marquee-settings-form")).not.toBeNull();
|
||||
expect(screen.getByRole("button", { name: /save marquee/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { PortfolioCategoryFilter } from "@/components/site/portfolio-category-filter";
|
||||
import type { PortfolioCategoryView } from "@/lib/portfolio";
|
||||
|
||||
function category(overrides: Partial<PortfolioCategoryView> = {}): PortfolioCategoryView {
|
||||
return {
|
||||
id: overrides.id ?? "c1",
|
||||
slug: overrides.slug ?? "branding",
|
||||
name: overrides.name ?? { ar: "الهوية", en: "Branding", de: "Branding" },
|
||||
description: overrides.description ?? { ar: "", en: "", de: "" },
|
||||
sortOrder: overrides.sortOrder ?? 0,
|
||||
isActive: overrides.isActive ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
describe("PortfolioCategoryFilter", () => {
|
||||
const categories = [
|
||||
category({ id: "c1", slug: "branding", name: { ar: "الهوية", en: "Branding", de: "Marke" } }),
|
||||
category({ id: "c2", slug: "web", name: { ar: "ويب", en: "Web", de: "Web" } }),
|
||||
];
|
||||
|
||||
it("renders the all-projects link and one link per category", () => {
|
||||
render(
|
||||
<PortfolioCategoryFilter
|
||||
locale="en"
|
||||
defaultLocale="de"
|
||||
categories={categories}
|
||||
allLabel="All"
|
||||
/>,
|
||||
);
|
||||
const links = screen.getAllByRole("link");
|
||||
expect(links).toHaveLength(3);
|
||||
expect(screen.getByRole("link", { name: "All" })).toHaveAttribute("href", "/en/portfolio");
|
||||
expect(screen.getByRole("link", { name: "Branding" })).toHaveAttribute(
|
||||
"href",
|
||||
"/en/portfolio/category/branding",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses localized labels for the active locale", () => {
|
||||
render(
|
||||
<PortfolioCategoryFilter
|
||||
locale="de"
|
||||
defaultLocale="de"
|
||||
categories={categories}
|
||||
allLabel="Alle"
|
||||
/>,
|
||||
);
|
||||
// German locale on the default locale -> unprefixed paths and German labels
|
||||
expect(screen.getByRole("link", { name: "Marke" })).toHaveAttribute(
|
||||
"href",
|
||||
"/portfolio/category/branding",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "Alle" })).toHaveAttribute("href", "/portfolio");
|
||||
});
|
||||
|
||||
it("highlights the active category", () => {
|
||||
render(
|
||||
<PortfolioCategoryFilter
|
||||
locale="en"
|
||||
defaultLocale="de"
|
||||
categories={categories}
|
||||
allLabel="All"
|
||||
activeCategorySlug="web"
|
||||
/>,
|
||||
);
|
||||
const active = screen.getByRole("link", { name: "Web" });
|
||||
expect(active).toHaveClass("bg-primary");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { createRef } from "react";
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { AppCard } from "@/components/ui/app-card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Container } from "@/components/layout/container";
|
||||
import { HeroBadge } from "@/components/layout/hero-badge";
|
||||
import { SectionHeading } from "@/components/home/section-heading";
|
||||
|
||||
describe("Badge", () => {
|
||||
it("renders children and default variant classes", () => {
|
||||
render(<Badge>New</Badge>);
|
||||
const badge = screen.getByText("New");
|
||||
expect(badge).toHaveClass("bg-primary");
|
||||
});
|
||||
|
||||
it("applies a chosen variant and merges custom classNames", () => {
|
||||
render(<Badge variant="success" className="custom-class">OK</Badge>);
|
||||
const badge = screen.getByText("OK");
|
||||
expect(badge).toHaveClass("bg-status-success-soft");
|
||||
expect(badge).toHaveClass("custom-class");
|
||||
});
|
||||
|
||||
it("passes through arbitrary props", () => {
|
||||
render(<Badge data-testid="b" title="tip">X</Badge>);
|
||||
expect(screen.getByTestId("b")).toHaveAttribute("title", "tip");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Input", () => {
|
||||
it("forwards the ref and renders attributes", () => {
|
||||
const ref = createRef<HTMLInputElement>();
|
||||
render(<Input ref={ref} type="email" placeholder="you@example.com" disabled />);
|
||||
const input = screen.getByPlaceholderText("you@example.com");
|
||||
expect(ref.current).toBe(input);
|
||||
expect(input).toHaveAttribute("type", "email");
|
||||
expect(input).toBeDisabled();
|
||||
});
|
||||
|
||||
it("merges custom classes", () => {
|
||||
render(<Input className="w-20" aria-label="field" />);
|
||||
expect(screen.getByLabelText("field")).toHaveClass("w-20");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Textarea", () => {
|
||||
it("forwards the ref and renders a default value", () => {
|
||||
const ref = createRef<HTMLTextAreaElement>();
|
||||
render(<Textarea ref={ref} defaultValue="hello" aria-label="msg" />);
|
||||
const textarea = screen.getByLabelText("msg");
|
||||
expect(ref.current).toBe(textarea);
|
||||
expect(textarea).toHaveValue("hello");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Label", () => {
|
||||
it("associates with a control via htmlFor", () => {
|
||||
render(<Label htmlFor="name">Name</Label>);
|
||||
expect(screen.getByText("Name")).toHaveAttribute("for", "name");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Separator", () => {
|
||||
it("defaults to a horizontal separator", () => {
|
||||
const { container } = render(<Separator />);
|
||||
expect(container.firstChild).toHaveClass("h-px", "w-full");
|
||||
});
|
||||
|
||||
it("supports a vertical orientation", () => {
|
||||
const { container } = render(<Separator orientation="vertical" />);
|
||||
expect(container.firstChild).toHaveClass("h-full", "w-px");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Card family", () => {
|
||||
it("composes header, title, description and content", () => {
|
||||
render(
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Title</CardTitle>
|
||||
<CardDescription>Desc</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>Body</CardContent>
|
||||
</Card>,
|
||||
);
|
||||
expect(screen.getByRole("heading", { name: "Title" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Desc")).toBeInTheDocument();
|
||||
expect(screen.getByText("Body")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AppCard", () => {
|
||||
it("renders a single-layer card with its children", () => {
|
||||
render(<AppCard layer="single">Single</AppCard>);
|
||||
expect(screen.getByText("Single")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a double-layer card (default) with its children", () => {
|
||||
render(<AppCard>Double</AppCard>);
|
||||
expect(screen.getByText("Double")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Container", () => {
|
||||
it("applies the size variant classes", () => {
|
||||
const { container } = render(<Container size="narrow">Body</Container>);
|
||||
expect(container.firstChild).toHaveClass("max-w-narrow");
|
||||
});
|
||||
|
||||
it("renders as a child element when asChild is set", () => {
|
||||
render(
|
||||
<Container asChild>
|
||||
<section data-testid="as-child">X</section>
|
||||
</Container>,
|
||||
);
|
||||
expect(screen.getByTestId("as-child").tagName).toBe("SECTION");
|
||||
});
|
||||
});
|
||||
|
||||
describe("HeroBadge", () => {
|
||||
it("renders children and optional trailing content", () => {
|
||||
render(<HeroBadge trailing={<span>→</span>}>Available</HeroBadge>);
|
||||
expect(screen.getByText("Available")).toBeInTheDocument();
|
||||
expect(screen.getByText("→")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SectionHeading", () => {
|
||||
it("renders eyebrow, title and description", () => {
|
||||
render(<SectionHeading eyebrow="Work" title="Projects" description="What I build" />);
|
||||
expect(screen.getByText("Work")).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Projects" })).toBeInTheDocument();
|
||||
expect(screen.getByText("What I build")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("centers content when align is center", () => {
|
||||
const { container } = render(
|
||||
<SectionHeading eyebrow="E" title="T" description="D" align="center" />,
|
||||
);
|
||||
expect(container.firstChild).toHaveClass("text-center");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user