Full cleanup pass
This commit is contained in:
@@ -26,7 +26,9 @@ vi.mock("../../lib/assetUtils", async (importOriginal) => {
|
||||
(await importOriginal()) as typeof import("../../lib/assetUtils");
|
||||
return {
|
||||
...actual,
|
||||
getAssetPath: vi.fn((asset: string) => `/assets/${asset}`),
|
||||
getAssetPath: vi.fn((asset: string) =>
|
||||
asset.startsWith("/") ? asset : `/${asset}`,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { screen } from "@testing-library/react";
|
||||
import { renderWithProviders as render } from "../utils/test-utils";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import CreateFlowFooter from "../../app/components/navigation/CreateFlowFooter";
|
||||
import Button from "../../app/components/buttons/Button";
|
||||
@@ -35,7 +36,6 @@ const config: ComponentTestSuiteConfig<CreateFlowFooterProps> = {
|
||||
|
||||
componentTestSuite<CreateFlowFooterProps>(config);
|
||||
|
||||
// Pure presentational; no provider context needed (no useMessages/useAuthModal/useCreateFlow consumers).
|
||||
describe("CreateFlowFooter (behavioral tests)", () => {
|
||||
it("renders Back button", () => {
|
||||
render(<CreateFlowFooter />);
|
||||
|
||||
@@ -57,10 +57,10 @@ describe("HeroBanner (behavioral tests)", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders CTA button when provided", () => {
|
||||
it("renders CTA link when provided", () => {
|
||||
render(<HeroBanner title="Test" ctaText="Get Started" ctaHref="/start" />);
|
||||
expect(
|
||||
screen.getAllByRole("button", { name: "Get Started" }).length,
|
||||
screen.getAllByRole("link", { name: "Get Started" }).length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { screen } from "@testing-library/react";
|
||||
import { renderWithProviders as render } from "../utils/test-utils";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import Logo from "../../app/components/asset/Logo";
|
||||
import {
|
||||
@@ -27,7 +28,6 @@ const config: ComponentTestSuiteConfig<LogoProps> = {
|
||||
|
||||
componentTestSuite<LogoProps>(config);
|
||||
|
||||
// Pure presentational; no provider context needed.
|
||||
describe("Logo (behavioral tests)", () => {
|
||||
it("renders as a link to home", () => {
|
||||
render(<Logo />);
|
||||
@@ -38,7 +38,7 @@ describe("Logo (behavioral tests)", () => {
|
||||
|
||||
it("renders logo icon", () => {
|
||||
render(<Logo />);
|
||||
expect(screen.getByAltText("CommunityRule Logo Icon")).toBeInTheDocument();
|
||||
expect(screen.getByAltText("CommunityRule Logo")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders text by default", () => {
|
||||
@@ -50,7 +50,7 @@ describe("Logo (behavioral tests)", () => {
|
||||
const { container } = render(<Logo wordmark={false} />);
|
||||
const textElement = container.querySelector(".hidden");
|
||||
expect(textElement).toBeInTheDocument();
|
||||
expect(screen.getByAltText("CommunityRule Logo Icon")).toBeInTheDocument();
|
||||
expect(screen.getByAltText("CommunityRule Logo")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applies inverse palette styling when palette is inverse", () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { renderWithProviders as render } from "../utils/test-utils";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import TextInput from "../../app/components/controls/TextInput";
|
||||
import { componentTestSuite } from "../utils/componentTestSuite";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { screen } from "@testing-library/react";
|
||||
import { renderWithProviders as render } from "../utils/test-utils";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import Upload from "../../app/components/controls/Upload";
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import Mini from "../../../app/components/cards/Mini";
|
||||
import { getAssetPath, featurePanelPath } from "../../../lib/assetUtils";
|
||||
import { renderWithProviders, screen } from "../../utils/test-utils";
|
||||
|
||||
describe("Mini", () => {
|
||||
it("renders label lines", () => {
|
||||
renderWithProviders(
|
||||
<Mini
|
||||
labelLine1="Decision-making"
|
||||
labelLine2="support"
|
||||
panelContent={getAssetPath(featurePanelPath("support"))}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Decision-making")).toBeInTheDocument();
|
||||
expect(screen.getByText("support")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import SelectOption from "../../../app/components/controls/SelectOption";
|
||||
import { renderWithProviders, screen } from "../../utils/test-utils";
|
||||
|
||||
describe("SelectOption", () => {
|
||||
it("renders option label", () => {
|
||||
renderWithProviders(
|
||||
<SelectOption selected={false}>Option one</SelectOption>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("option", { name: "Option one" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import ContentLockup from "../../../app/components/type/ContentLockup";
|
||||
import { renderWithProviders, screen } from "../../utils/test-utils";
|
||||
|
||||
describe("ContentLockup", () => {
|
||||
it("renders hero title and description", () => {
|
||||
renderWithProviders(
|
||||
<ContentLockup
|
||||
variant="hero"
|
||||
title="Collaborate"
|
||||
subtitle="with clarity"
|
||||
description="Help your community make important decisions."
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Collaborate" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "with clarity" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Help your community make important decisions."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -144,6 +144,7 @@ describe("AuthModalProvider (header overlay)", () => {
|
||||
expect(requestMagicLink).toHaveBeenCalledWith(
|
||||
"guest@example.com",
|
||||
"/create/community-structure?syncDraft=1",
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
expect(setTransferPendingFlag).toHaveBeenCalled();
|
||||
|
||||
@@ -45,6 +45,7 @@ vi.mock("next/dynamic", () => {
|
||||
vi.mock("../../lib/content", () => ({
|
||||
getBlogPostBySlug: vi.fn(),
|
||||
getAllBlogPosts: vi.fn(),
|
||||
getRelatedBlogPosts: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
// Mock components
|
||||
@@ -137,10 +138,11 @@ describe("BlogPostPage", () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Mock the content functions
|
||||
const { getBlogPostBySlug, getAllBlogPosts } =
|
||||
const { getBlogPostBySlug, getAllBlogPosts, getRelatedBlogPosts } =
|
||||
await import("../../lib/content");
|
||||
vi.mocked(getBlogPostBySlug).mockReturnValue(mockPost);
|
||||
vi.mocked(getAllBlogPosts).mockReturnValue([mockPost, ...mockRelatedPosts]);
|
||||
vi.mocked(getRelatedBlogPosts).mockReturnValue(mockRelatedPosts);
|
||||
});
|
||||
|
||||
it("renders the blog post page with correct structure", async () => {
|
||||
@@ -155,7 +157,7 @@ describe("BlogPostPage", () => {
|
||||
expect(mainContainer).toHaveClass(
|
||||
"min-h-screen",
|
||||
"relative",
|
||||
"overflow-hidden",
|
||||
"overflow-x-clip",
|
||||
);
|
||||
// Background color is applied via inline style from frontmatter hex
|
||||
expect(mainContainer).toHaveStyle({ backgroundColor: expect.any(String) });
|
||||
|
||||
@@ -9,37 +9,22 @@ import { vi, describe, test, expect, afterEach } from "vitest";
|
||||
import React from "react";
|
||||
import Page from "../../app/(marketing)/page";
|
||||
|
||||
// Mock next/dynamic to return components synchronously in tests
|
||||
vi.mock("next/dynamic", () => {
|
||||
return {
|
||||
default: (importFn) => {
|
||||
// In tests, return the component directly by importing it synchronously
|
||||
// This bypasses the async loading behavior for testing
|
||||
return (props) => {
|
||||
const [Component, setComponent] = React.useState(null);
|
||||
React.useEffect(() => {
|
||||
importFn().then((mod) => {
|
||||
setComponent(() => mod.default || mod);
|
||||
});
|
||||
}, []);
|
||||
if (!Component) {
|
||||
return null;
|
||||
}
|
||||
return <Component {...props} />;
|
||||
};
|
||||
},
|
||||
};
|
||||
vi.mock("next/dynamic", async () => {
|
||||
const { default: syncDynamic } = await import("../utils/mockNextDynamicSync.js");
|
||||
return { default: syncDynamic };
|
||||
});
|
||||
|
||||
function renderPage(ui = <Page />) {
|
||||
return render(ui);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("Page Flow Integration", () => {
|
||||
// TODO: Fix next/dynamic mock to properly handle async component loading
|
||||
// The mock currently doesn't resolve components synchronously, causing this test to fail
|
||||
test.skip("renders complete page with all sections in correct order", async () => {
|
||||
render(<Page />);
|
||||
test("renders complete page with all sections in correct order", async () => {
|
||||
renderPage();
|
||||
|
||||
// Hero Banner section
|
||||
expect(
|
||||
@@ -53,24 +38,20 @@ describe("Page Flow Integration", () => {
|
||||
"Help your community make important decisions in a way that reflects its unique values.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
// Check that CTA button exists (multiple sizes for responsive design)
|
||||
const ctaButtons = screen.getAllByRole("button", {
|
||||
const ctaButtons = screen.getAllByRole("link", {
|
||||
name: "Learn how CommunityRule works",
|
||||
});
|
||||
expect(ctaButtons.length).toBeGreaterThan(0);
|
||||
|
||||
// Wait for dynamically imported LogoWall component to load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByAltText("Food Not Bombs")).toBeInTheDocument();
|
||||
});
|
||||
// Once LogoWall is loaded, other logos should be available
|
||||
expect(screen.getByAltText("Start COOP")).toBeInTheDocument();
|
||||
expect(screen.getByAltText("Metagov")).toBeInTheDocument();
|
||||
expect(screen.getByAltText("Open Civics")).toBeInTheDocument();
|
||||
expect(screen.getByAltText("Mutual Aid CO")).toBeInTheDocument();
|
||||
expect(screen.getByAltText("CU Boulder")).toBeInTheDocument();
|
||||
|
||||
// CardSteps section — wait for dynamically imported component
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("heading", { name: /How CommunityRule works/ }),
|
||||
@@ -93,8 +74,9 @@ describe("Page Flow Integration", () => {
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Rule Stack section
|
||||
expect(screen.getByRole("heading", { name: "Circles" })).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("heading", { name: "Circles" })).toBeInTheDocument();
|
||||
});
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Elected Board" }),
|
||||
).toBeInTheDocument();
|
||||
@@ -105,24 +87,23 @@ describe("Page Flow Integration", () => {
|
||||
screen.getByRole("heading", { name: "Petition" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Feature Grid section
|
||||
expect(
|
||||
screen.getByRole("heading", {
|
||||
name: "We've got your back, every step of the way",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("heading", {
|
||||
name: "We've got your back, every step of the way",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Use our toolkit to improve, document, and evolve your organization.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Quote Block section
|
||||
expect(
|
||||
screen.getByText(/The rules of decision-making must be open/),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Ask Organizer section
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Still have questions?" }),
|
||||
).toBeInTheDocument();
|
||||
@@ -136,27 +117,22 @@ describe("Page Flow Integration", () => {
|
||||
|
||||
test("hero banner CTA button is interactive", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Page />);
|
||||
renderPage();
|
||||
|
||||
// Get the first CTA button (multiple sizes for responsive design)
|
||||
const ctaButtons = screen.getAllByRole("button", {
|
||||
const ctaLinks = screen.getAllByRole("link", {
|
||||
name: "Learn how CommunityRule works",
|
||||
});
|
||||
const ctaButton = ctaButtons[0];
|
||||
expect(ctaButton).toBeInTheDocument();
|
||||
// Button should be clickable (no href needed for button elements)
|
||||
expect(ctaButton).toBeEnabled();
|
||||
const ctaLink = ctaLinks[0];
|
||||
expect(ctaLink).toBeInTheDocument();
|
||||
expect(ctaLink).toHaveAttribute("href", "/how-it-works");
|
||||
|
||||
// Test button interaction
|
||||
await user.click(ctaButton);
|
||||
// Button should remain visible after click
|
||||
expect(ctaButton).toBeInTheDocument();
|
||||
await user.click(ctaLink);
|
||||
expect(ctaLink).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("CardSteps section shows step tiles with expected icon/color props", async () => {
|
||||
render(<Page />);
|
||||
renderPage();
|
||||
|
||||
// Wait for dynamically imported CardSteps component
|
||||
await waitFor(() => {
|
||||
const cards = screen.getAllByText(
|
||||
/Document how your community|Build an operating manual|Get a link to your manual/,
|
||||
@@ -164,19 +140,17 @@ describe("Page Flow Integration", () => {
|
||||
expect(cards.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Check that all three cards are rendered
|
||||
const cards = screen.getAllByText(
|
||||
/Document how your community|Build an operating manual|Get a link to your manual/,
|
||||
);
|
||||
expect(cards.length).toBeGreaterThan(0);
|
||||
|
||||
// Check that section numbers are present
|
||||
const sectionNumbers = screen.getAllByText(/1|2|3/);
|
||||
expect(sectionNumbers.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("rule stack shows four featured templates and link to full catalog", async () => {
|
||||
render(<Page />);
|
||||
renderPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Circles")).toBeInTheDocument();
|
||||
@@ -189,14 +163,14 @@ describe("Page Flow Integration", () => {
|
||||
const seeAll = screen.getByRole("link", { name: "See all templates" });
|
||||
expect(seeAll).toHaveAttribute("href", "/templates");
|
||||
|
||||
const seeHowButton = screen.getByRole("button", {
|
||||
const seeHowLink = screen.getByRole("link", {
|
||||
name: "See how it works",
|
||||
});
|
||||
expect(seeHowButton).toBeInTheDocument();
|
||||
expect(seeHowLink).toHaveAttribute("href", "/how-it-works");
|
||||
});
|
||||
|
||||
test("ask organizer section has proper call-to-action", () => {
|
||||
render(<Page />);
|
||||
renderPage();
|
||||
|
||||
const askCta = screen.getByRole("button", { name: /ask an organizer/i });
|
||||
expect(askCta).toBeInTheDocument();
|
||||
@@ -204,19 +178,16 @@ describe("Page Flow Integration", () => {
|
||||
});
|
||||
|
||||
test("page maintains proper semantic structure", async () => {
|
||||
render(<Page />);
|
||||
renderPage();
|
||||
|
||||
// Wait for dynamically imported components to load
|
||||
await waitFor(() => {
|
||||
const headings = screen.getAllByRole("heading");
|
||||
expect(headings.length).toBeGreaterThan(4); // Should have multiple headings
|
||||
expect(headings.length).toBeGreaterThan(4);
|
||||
});
|
||||
|
||||
// Check for proper heading hierarchy
|
||||
const headings = screen.getAllByRole("heading");
|
||||
expect(headings.length).toBeGreaterThan(4); // Should have multiple headings
|
||||
expect(headings.length).toBeGreaterThan(4);
|
||||
|
||||
// Check that main content is properly structured
|
||||
const mainContent = screen.getByText(
|
||||
/Help your community make important decisions/,
|
||||
);
|
||||
@@ -224,7 +195,7 @@ describe("Page Flow Integration", () => {
|
||||
});
|
||||
|
||||
test("all interactive elements are accessible", async () => {
|
||||
render(<Page />);
|
||||
renderPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByRole("button").length).toBeGreaterThan(0);
|
||||
@@ -245,33 +216,31 @@ describe("Page Flow Integration", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// TODO: Fix next/dynamic mock to properly handle async component loading
|
||||
test.skip("page content flows logically from top to bottom", async () => {
|
||||
render(<Page />);
|
||||
test("page content flows logically from top to bottom", async () => {
|
||||
renderPage();
|
||||
|
||||
// Verify the logical flow of information
|
||||
// 1. Hero introduces the concept
|
||||
expect(
|
||||
screen.getByText(/Help your community make important decisions/),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// 2. How it works section explains the process
|
||||
expect(screen.getByText("How CommunityRule works")).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("How CommunityRule works")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 3. Rule types show different governance options
|
||||
expect(screen.getByText("Circles")).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Circles")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 4. Features highlight benefits
|
||||
expect(
|
||||
screen.getByText("We've got your back, every step of the way"),
|
||||
).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("We've got your back, every step of the way"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 5. Quote provides social proof
|
||||
expect(
|
||||
screen.getByText(/The rules of decision-making must be open/),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// 6. Call to action for help
|
||||
expect(screen.getByText("Still have questions?")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
renderWithProviders as render,
|
||||
screen,
|
||||
cleanup,
|
||||
waitFor,
|
||||
} from "../utils/test-utils";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, test, expect, afterEach, beforeEach, vi } from "vitest";
|
||||
@@ -59,18 +60,24 @@ describe("Templates page (/templates)", () => {
|
||||
<TemplatesPageClient initialGridEntries={GOVERNANCE_TEMPLATE_CATALOG} />,
|
||||
);
|
||||
|
||||
const consensusCard = screen.getByText("Consensus").closest("div");
|
||||
await user.click(consensusCard);
|
||||
expect(testRouter.push).toHaveBeenCalledWith(
|
||||
"/create/review-template/consensus",
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /Consensus/i }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(testRouter.push).toHaveBeenCalledWith(
|
||||
"/create/review-template/consensus",
|
||||
);
|
||||
});
|
||||
|
||||
testRouter.push.mockClear();
|
||||
const solidarity = screen.getByText("Solidarity Network").closest("div");
|
||||
await user.click(solidarity);
|
||||
expect(testRouter.push).toHaveBeenCalledWith(
|
||||
"/create/review-template/solidarity-network",
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /Solidarity Network/i }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(testRouter.push).toHaveBeenCalledWith(
|
||||
"/create/review-template/solidarity-network",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("direct entry (no ?fromFlow=1): wipes anonymous draft before navigating", async () => {
|
||||
@@ -80,16 +87,19 @@ describe("Templates page (/templates)", () => {
|
||||
<TemplatesPageClient initialGridEntries={GOVERNANCE_TEMPLATE_CATALOG} />,
|
||||
);
|
||||
|
||||
const consensusCard = screen.getByText("Consensus").closest("div");
|
||||
await user.click(consensusCard);
|
||||
|
||||
expect(window.localStorage.getItem(CREATE_FLOW_ANONYMOUS_KEY)).toBeNull();
|
||||
expect(
|
||||
window.localStorage.getItem(CORE_VALUE_DETAILS_STORAGE_KEY),
|
||||
).toBeNull();
|
||||
expect(testRouter.push).toHaveBeenCalledWith(
|
||||
"/create/review-template/consensus",
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /Consensus/i }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.localStorage.getItem(CREATE_FLOW_ANONYMOUS_KEY)).toBeNull();
|
||||
expect(
|
||||
window.localStorage.getItem(CORE_VALUE_DETAILS_STORAGE_KEY),
|
||||
).toBeNull();
|
||||
expect(testRouter.push).toHaveBeenCalledWith(
|
||||
"/create/review-template/consensus",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("in-flow entry (?fromFlow=1): preserves the anonymous draft", async () => {
|
||||
@@ -102,8 +112,9 @@ describe("Templates page (/templates)", () => {
|
||||
<TemplatesPageClient initialGridEntries={GOVERNANCE_TEMPLATE_CATALOG} />,
|
||||
);
|
||||
|
||||
const consensusCard = screen.getByText("Consensus").closest("div");
|
||||
await user.click(consensusCard);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /Consensus/i }),
|
||||
);
|
||||
|
||||
expect(window.localStorage.getItem(CREATE_FLOW_ANONYMOUS_KEY)).toBe(
|
||||
JSON.stringify({ title: "Stale Community" }),
|
||||
@@ -115,8 +126,10 @@ describe("Templates page (/templates)", () => {
|
||||
);
|
||||
// In-flow picks also pass `?fromFlow=1` on the template review URL so
|
||||
// footer Back on `/create/review-template/…` returns to `/create/review`.
|
||||
expect(testRouter.push).toHaveBeenCalledWith(
|
||||
"/create/review-template/consensus?fromFlow=1",
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(testRouter.push).toHaveBeenCalledWith(
|
||||
"/create/review-template/consensus?fromFlow=1",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,67 +8,53 @@ import userEvent from "@testing-library/user-event";
|
||||
import { vi, describe, test, expect, afterEach } from "vitest";
|
||||
import React from "react";
|
||||
import Page from "../../app/(marketing)/page";
|
||||
|
||||
// Mock next/dynamic so dynamically loaded components render after the import resolves
|
||||
vi.mock("next/dynamic", () => {
|
||||
const React = require("react");
|
||||
return {
|
||||
default: (importFn, options) => {
|
||||
function DynamicWrapper(props) {
|
||||
const [Component, setComponent] = React.useState(null);
|
||||
React.useEffect(() => {
|
||||
importFn().then((mod) => setComponent(() => mod.default || mod));
|
||||
}, []);
|
||||
if (!Component) {
|
||||
return options?.loading ? options.loading() : null;
|
||||
}
|
||||
return <Component {...props} />;
|
||||
}
|
||||
return DynamicWrapper;
|
||||
},
|
||||
};
|
||||
});
|
||||
import Footer from "../../app/components/navigation/Footer";
|
||||
|
||||
vi.mock("next/dynamic", async () => {
|
||||
const { default: syncDynamic } = await import("../utils/mockNextDynamicSync.js");
|
||||
return { default: syncDynamic };
|
||||
});
|
||||
|
||||
function renderPageWithFooter() {
|
||||
return render(
|
||||
<>
|
||||
<Page />
|
||||
<Footer />
|
||||
</>,
|
||||
);
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
return render(<Page />);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("User Journey Integration", () => {
|
||||
// TODO: Fix next/dynamic mock to properly handle async component loading
|
||||
test.skip("new user discovers the application through hero section", async () => {
|
||||
test("new user discovers the application through hero section", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<div>
|
||||
<Page />
|
||||
<Footer />
|
||||
</div>,
|
||||
);
|
||||
renderPageWithFooter();
|
||||
|
||||
// User sees the main value proposition
|
||||
expect(
|
||||
screen.getByText(/Help your community make important decisions/),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// User clicks the main CTA to learn more
|
||||
const learnButtons = screen.getAllByRole("button", {
|
||||
const learnLinks = screen.getAllByRole("link", {
|
||||
name: "Learn how CommunityRule works",
|
||||
});
|
||||
const learnButton = learnButtons[0];
|
||||
await user.click(learnButton);
|
||||
await user.click(learnLinks[0]);
|
||||
|
||||
// Wait for dynamically imported CardSteps component
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("How CommunityRule works")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// TODO: Fix next/dynamic mock to properly handle async component loading
|
||||
test.skip("user explores different governance types", async () => {
|
||||
test("user explores different governance types", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Page />);
|
||||
renderPage();
|
||||
|
||||
// Wait for dynamically imported RuleStack component
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Circles")).toBeInTheDocument();
|
||||
});
|
||||
@@ -76,26 +62,18 @@ describe("User Journey Integration", () => {
|
||||
expect(screen.getByText("Consensus")).toBeInTheDocument();
|
||||
expect(screen.getByText("Petition")).toBeInTheDocument();
|
||||
|
||||
// User clicks on a governance type to create a rule
|
||||
const seeHowButtons = screen.getAllByRole("button", {
|
||||
const seeHowLinks = screen.getAllByRole("link", {
|
||||
name: "See how it works",
|
||||
});
|
||||
expect(seeHowButtons.length).toBeGreaterThan(0);
|
||||
expect(seeHowLinks.length).toBeGreaterThan(0);
|
||||
|
||||
await user.click(seeHowButtons[0]);
|
||||
// Button should remain interactive
|
||||
expect(seeHowButtons[0]).toBeInTheDocument();
|
||||
await user.click(seeHowLinks[0]);
|
||||
expect(seeHowLinks[0]).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("user navigates through the application using header navigation", async () => {
|
||||
render(
|
||||
<div>
|
||||
<Page />
|
||||
<Footer />
|
||||
</div>,
|
||||
);
|
||||
renderPageWithFooter();
|
||||
|
||||
// User clicks on navigation links in header (check that they exist and are clickable)
|
||||
const navigationLinks = screen.getAllByRole("link");
|
||||
const headerNavLinks = navigationLinks.filter(
|
||||
(link) =>
|
||||
@@ -104,7 +82,6 @@ describe("User Journey Integration", () => {
|
||||
link.textContent?.includes("About"),
|
||||
);
|
||||
|
||||
// Test that navigation links are present and clickable
|
||||
for (const link of headerNavLinks) {
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).toHaveAttribute("href");
|
||||
@@ -113,9 +90,8 @@ describe("User Journey Integration", () => {
|
||||
|
||||
test("user seeks help through ask organizer section", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Page />);
|
||||
renderPage();
|
||||
|
||||
// User scrolls to the bottom and sees the help section
|
||||
expect(screen.getByText("Still have questions?")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Get answers from an experienced organizer"),
|
||||
@@ -129,9 +105,8 @@ describe("User Journey Integration", () => {
|
||||
});
|
||||
|
||||
test("user explores the process through CardSteps", async () => {
|
||||
render(<Page />);
|
||||
renderPage();
|
||||
|
||||
// Wait for dynamically imported CardSteps component
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("Document how your community makes decisions"),
|
||||
@@ -146,24 +121,16 @@ describe("User Journey Integration", () => {
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// User sees the step numbers
|
||||
const stepNumbers = screen.getAllByText(/1|2|3/);
|
||||
expect(stepNumbers.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("user accesses contact information through footer", async () => {
|
||||
render(
|
||||
<div>
|
||||
<Page />
|
||||
<Footer />
|
||||
</div>,
|
||||
);
|
||||
renderPageWithFooter();
|
||||
|
||||
// User finds contact email in footer
|
||||
const emailLink = screen.getByRole("link", { name: "medlab@colorado.edu" });
|
||||
expect(emailLink).toHaveAttribute("href", "mailto:medlab@colorado.edu");
|
||||
|
||||
// User finds social media links
|
||||
const blueskyLink = screen.getByRole("link", {
|
||||
name: "Follow us on Bluesky",
|
||||
});
|
||||
@@ -176,9 +143,8 @@ describe("User Journey Integration", () => {
|
||||
});
|
||||
|
||||
test("user explores features and benefits", async () => {
|
||||
render(<Page />);
|
||||
renderPage();
|
||||
|
||||
// Wait for dynamically imported FeatureGrid component
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("We've got your back, every step of the way"),
|
||||
@@ -190,21 +156,14 @@ describe("User Journey Integration", () => {
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// User sees the testimonial/quote (check for the actual quote content)
|
||||
expect(
|
||||
screen.getByText(/The rules of decision-making must be open/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("user interacts with logo wall and social proof", async () => {
|
||||
render(
|
||||
<div>
|
||||
<Page />
|
||||
<Footer />
|
||||
</div>,
|
||||
);
|
||||
renderPageWithFooter();
|
||||
|
||||
// Wait for dynamically imported LogoWall component
|
||||
await waitFor(() => {
|
||||
const logoImages = screen.getAllByRole("img");
|
||||
const partnerLogos = logoImages.filter(
|
||||
@@ -219,7 +178,6 @@ describe("User Journey Integration", () => {
|
||||
expect(partnerLogos.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Social links should be present in footer
|
||||
const blueskyLink = screen.getByRole("link", { name: /Bluesky/i });
|
||||
const gitlabLink = screen.getByRole("link", { name: /GitLab/i });
|
||||
expect(blueskyLink).toBeInTheDocument();
|
||||
@@ -227,76 +185,40 @@ describe("User Journey Integration", () => {
|
||||
});
|
||||
|
||||
test("user completes the full journey from discovery to action", async () => {
|
||||
render(
|
||||
<div>
|
||||
<Page />
|
||||
<Footer />
|
||||
</div>,
|
||||
);
|
||||
renderPageWithFooter();
|
||||
|
||||
// 1. User discovers the application
|
||||
expect(screen.getByText("Collaborate")).toBeInTheDocument();
|
||||
expect(screen.getByText("with clarity")).toBeInTheDocument();
|
||||
|
||||
// 2. User learns how it works - wait for dynamically imported component
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("How CommunityRule works")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 3. User sees governance options - wait for dynamically imported component
|
||||
// Note: Dynamic imports may not resolve reliably in test environment
|
||||
// Try to find governance content, but don't fail if dynamic import hasn't resolved
|
||||
try {
|
||||
await waitFor(
|
||||
() => {
|
||||
// Check for any of the governance card titles
|
||||
const hasGovernanceContent =
|
||||
screen.queryByText(/Circles/i) ||
|
||||
screen.queryByText(/Elected Board/i) ||
|
||||
screen.queryByText(/Petition/i);
|
||||
expect(hasGovernanceContent).toBeTruthy();
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
} catch {
|
||||
// Dynamic import may not resolve in test environment - this is a known limitation
|
||||
// The component functionality is tested in RuleStack.test.jsx
|
||||
console.warn(
|
||||
"Dynamic import for RuleStack did not resolve in test environment",
|
||||
);
|
||||
}
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText(/Circles/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// 4. User sees features and benefits - wait for dynamically imported component
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("We've got your back, every step of the way"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 5. User sees social proof
|
||||
expect(
|
||||
screen.getByText(/The rules of decision-making must be open/),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// 6. User can take action
|
||||
const seeHowButtons = screen.getAllByRole("button", {
|
||||
const seeHowLinks = screen.getAllByRole("link", {
|
||||
name: "See how it works",
|
||||
});
|
||||
expect(seeHowButtons.length).toBeGreaterThan(0);
|
||||
expect(seeHowLinks.length).toBeGreaterThan(0);
|
||||
|
||||
// 7. User can get help if needed
|
||||
expect(screen.getByText("Still have questions?")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("user can access all navigation options consistently", async () => {
|
||||
render(
|
||||
<div>
|
||||
<Page />
|
||||
<Footer />
|
||||
</div>,
|
||||
);
|
||||
renderPageWithFooter();
|
||||
|
||||
// Footer navigation (header navigation is handled by layout, not in page component)
|
||||
const footerLinks = screen.getAllByRole("link");
|
||||
const navigationLinks = footerLinks.filter(
|
||||
(link) =>
|
||||
@@ -306,7 +228,6 @@ describe("User Journey Integration", () => {
|
||||
);
|
||||
expect(navigationLinks.length).toBeGreaterThan(0);
|
||||
|
||||
// All navigation links should be accessible
|
||||
navigationLinks.forEach((link) => {
|
||||
expect(link).not.toHaveAttribute("tabindex", "-1");
|
||||
});
|
||||
@@ -314,24 +235,16 @@ describe("User Journey Integration", () => {
|
||||
|
||||
test("user can complete actions without errors", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<div>
|
||||
<Page />
|
||||
<Footer />
|
||||
</div>,
|
||||
);
|
||||
renderPageWithFooter();
|
||||
|
||||
// Test all interactive elements
|
||||
const buttons = screen.getAllByRole("button");
|
||||
const links = screen.getAllByRole("link");
|
||||
|
||||
// All buttons should be clickable
|
||||
for (const button of buttons) {
|
||||
await user.click(button);
|
||||
expect(button).toBeInTheDocument();
|
||||
}
|
||||
|
||||
// All links should be accessible
|
||||
for (const link of links) {
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).not.toHaveAttribute("tabindex", "-1");
|
||||
|
||||
@@ -102,14 +102,14 @@ describe("ContentContainer", () => {
|
||||
});
|
||||
|
||||
it("applies correct width when specified", () => {
|
||||
render(<ContentContainer post={mockPost} width="300px" size="xs" />);
|
||||
render(<ContentContainer post={mockPost} width="300px" size="responsive" />);
|
||||
|
||||
const container = document.querySelector("div[class*='relative z-20']");
|
||||
expect(container).toHaveStyle("width: 300px");
|
||||
});
|
||||
|
||||
it("applies default width when not specified", () => {
|
||||
render(<ContentContainer post={mockPost} size="xs" />);
|
||||
render(<ContentContainer post={mockPost} size="responsive" />);
|
||||
|
||||
const container = document.querySelector("div[class*='relative z-20']");
|
||||
expect(container).toHaveStyle("width: 200px");
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import { screen, cleanup } from "@testing-library/react";
|
||||
import { describe, test, expect, afterEach } from "vitest";
|
||||
import { renderWithProviders as render } from "../utils/test-utils";
|
||||
import LogoWall from "../../app/components/sections/LogoWall";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// Pure presentational; no provider context needed.
|
||||
describe("LogoWall Component", () => {
|
||||
test("renders with default logos", () => {
|
||||
render(<LogoWall />);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const isDatabaseConfiguredMock = vi.fn();
|
||||
const queryRawMock = vi.fn();
|
||||
|
||||
vi.mock("../../../lib/server/env", () => ({
|
||||
isDatabaseConfigured: () => isDatabaseConfiguredMock(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../lib/server/db", () => ({
|
||||
prisma: {
|
||||
$queryRaw: (...args: unknown[]) => queryRawMock(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
import { GET } from "../../../app/api/health/route";
|
||||
|
||||
beforeEach(() => {
|
||||
isDatabaseConfiguredMock.mockReset();
|
||||
queryRawMock.mockReset();
|
||||
});
|
||||
|
||||
describe("GET /api/health", () => {
|
||||
it("returns not_configured when DATABASE_URL is unset", async () => {
|
||||
isDatabaseConfiguredMock.mockReturnValue(false);
|
||||
const res = await GET(
|
||||
new NextRequest("https://x.test/api/health"),
|
||||
undefined,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({
|
||||
ok: true,
|
||||
database: "not_configured",
|
||||
});
|
||||
expect(res.headers.get("x-request-id")).toBeTruthy();
|
||||
expect(queryRawMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns connected when the database probe succeeds", async () => {
|
||||
isDatabaseConfiguredMock.mockReturnValue(true);
|
||||
queryRawMock.mockResolvedValueOnce([{ "?column?": 1 }]);
|
||||
const res = await GET(
|
||||
new NextRequest("https://x.test/api/health"),
|
||||
undefined,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({
|
||||
ok: true,
|
||||
database: "connected",
|
||||
});
|
||||
expect(queryRawMock).toHaveBeenCalled();
|
||||
expect(res.headers.get("x-request-id")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("returns 503 with database error when the probe fails", async () => {
|
||||
isDatabaseConfiguredMock.mockReturnValue(true);
|
||||
queryRawMock.mockRejectedValueOnce(new Error("connection refused"));
|
||||
const res = await GET(
|
||||
new NextRequest("https://x.test/api/health"),
|
||||
undefined,
|
||||
);
|
||||
expect(res.status).toBe(503);
|
||||
expect(await res.json()).toEqual({
|
||||
ok: false,
|
||||
database: "error",
|
||||
});
|
||||
expect(res.headers.get("x-request-id")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("forwards an incoming x-request-id on the response", async () => {
|
||||
isDatabaseConfiguredMock.mockReturnValue(false);
|
||||
const res = await GET(
|
||||
new NextRequest("https://x.test/api/health", {
|
||||
headers: { "x-request-id": "req_health-1" },
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
expect(res.headers.get("x-request-id")).toBe("req_health-1");
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const findManyMock = vi.fn();
|
||||
@@ -17,7 +18,7 @@ vi.mock("../../lib/server/db", () => ({
|
||||
import { GET } from "../../app/api/create-flow/methods/route";
|
||||
|
||||
function makeReq(url: string) {
|
||||
return { nextUrl: new URL(url) } as unknown as Parameters<typeof GET>[0];
|
||||
return new NextRequest(url);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -25,13 +26,28 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("GET /api/create-flow/methods", () => {
|
||||
it("400s on missing or unknown section", async () => {
|
||||
const r1 = await GET(makeReq("https://x.test/api/create-flow/methods"));
|
||||
it("400s on missing or unknown section with the canonical error shape", async () => {
|
||||
const r1 = await GET(
|
||||
makeReq("https://x.test/api/create-flow/methods"),
|
||||
undefined,
|
||||
);
|
||||
expect(r1.status).toBe(400);
|
||||
const body1 = (await r1.json()) as {
|
||||
error: { code: string; message: string };
|
||||
};
|
||||
expect(body1.error.code).toBe("validation_error");
|
||||
expect(body1.error.message).toMatch(/Unknown section/);
|
||||
expect(r1.headers.get("x-request-id")).toBeTruthy();
|
||||
|
||||
const r2 = await GET(
|
||||
makeReq("https://x.test/api/create-flow/methods?section=foo"),
|
||||
undefined,
|
||||
);
|
||||
expect(r2.status).toBe(400);
|
||||
const body2 = (await r2.json()) as {
|
||||
error: { code: string; message: string };
|
||||
};
|
||||
expect(body2.error.code).toBe("validation_error");
|
||||
});
|
||||
|
||||
it("returns ranked methods from the facet query", async () => {
|
||||
@@ -44,6 +60,7 @@ describe("GET /api/create-flow/methods", () => {
|
||||
makeReq(
|
||||
"https://x.test/api/create-flow/methods?section=communication&facet.size=twoToFive&facet.orgType=workersCoop",
|
||||
),
|
||||
undefined,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as {
|
||||
@@ -61,6 +78,7 @@ describe("GET /api/create-flow/methods", () => {
|
||||
makeReq(
|
||||
"https://x.test/api/create-flow/methods?section=communication&facet.size=oneMember",
|
||||
),
|
||||
undefined,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as { methods: unknown[] };
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from "react";
|
||||
|
||||
const importCache = new Map();
|
||||
|
||||
/**
|
||||
* Vitest mock for `next/dynamic` — resolves imports on layout effect without
|
||||
* suspending sibling sections (matches production loading behavior).
|
||||
*/
|
||||
export default function syncDynamic(importFn, options) {
|
||||
if (!importCache.has(importFn)) {
|
||||
const entry = {
|
||||
Component: null,
|
||||
promise: importFn().then((mod) => {
|
||||
entry.Component = mod.default ?? mod;
|
||||
return entry.Component;
|
||||
}),
|
||||
};
|
||||
importCache.set(importFn, entry);
|
||||
}
|
||||
|
||||
const entry = importCache.get(importFn);
|
||||
|
||||
function DynamicComponent(props) {
|
||||
const [Component, setComponent] = React.useState(entry.Component);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (entry.Component) {
|
||||
setComponent(() => entry.Component);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
entry.promise.then((Resolved) => {
|
||||
if (!cancelled) {
|
||||
setComponent(() => Resolved);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!Component) {
|
||||
return options?.loading ? options.loading() : null;
|
||||
}
|
||||
|
||||
return React.createElement(Component, props);
|
||||
}
|
||||
|
||||
DynamicComponent.displayName = "DynamicMock";
|
||||
return DynamicComponent;
|
||||
}
|
||||
Reference in New Issue
Block a user