Ask organizer modal implemented

This commit is contained in:
adilallo
2026-05-11 18:03:52 -06:00
parent b5930331c0
commit 625a8c3161
29 changed files with 724 additions and 56 deletions
+12 -2
View File
@@ -1,4 +1,5 @@
import React from "react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders as render, screen } from "../utils/test-utils";
import { describe, it, expect } from "vitest";
import AskOrganizer from "../../app/components/sections/AskOrganizer";
@@ -52,15 +53,24 @@ describe("AskOrganizer (behavioral tests)", () => {
).toBeInTheDocument();
});
it("renders button with default text", () => {
it("renders CTA button with default label", () => {
render(<AskOrganizer title="Test" />);
expect(
screen.getByRole("link", {
screen.getByRole("button", {
name: /ask an organizer/i,
}),
).toBeInTheDocument();
});
it("opens inquiry modal when CTA is clicked", async () => {
const user = userEvent.setup();
render(<AskOrganizer title="Test" />);
await user.click(screen.getByTestId("ask-organizer-cta"));
expect(
await screen.findByRole("dialog", { name: /ask an organizer/i }),
).toBeInTheDocument();
});
it("renders button with custom text", () => {
render(
<AskOrganizer title="Test" buttonText="Contact" buttonHref="/contact" />,
+7 -9
View File
@@ -69,15 +69,13 @@ test.describe("Critical User Journeys", () => {
// 8. User reads testimonial
await expect(page.locator("text=Jo Freeman")).toBeVisible();
// 9. User decides to contact organizer
const askButton = page.locator(
'a:has-text("Ask an organizer"), button:has-text("Ask an organizer")',
);
if (
(await askButton.count()) > 0 &&
(await askButton.first().isVisible())
) {
await askButton.first().click();
// 9. User decides to contact organizer (opens modal)
const askButton = page.getByTestId("ask-organizer-cta").first();
if ((await askButton.count()) > 0 && (await askButton.isVisible())) {
await askButton.click();
await expect(
page.getByRole("dialog", { name: /ask an organizer/i }),
).toBeVisible();
}
});
+13 -7
View File
@@ -130,7 +130,7 @@ describe("Page Flow Integration", () => {
screen.getByText("Get answers from an experienced organizer"),
).toBeInTheDocument();
expect(
screen.getByRole("link", { name: /Ask an organizer/i }),
screen.getByRole("button", { name: /ask an organizer/i }),
).toBeInTheDocument();
});
@@ -198,9 +198,9 @@ describe("Page Flow Integration", () => {
test("ask organizer section has proper call-to-action", () => {
render(<Page />);
const askLink = screen.getByRole("link", { name: /Ask an organizer/i });
expect(askLink).toBeInTheDocument();
expect(askLink).toHaveAttribute("href", "#contact");
const askCta = screen.getByRole("button", { name: /ask an organizer/i });
expect(askCta).toBeInTheDocument();
expect(askCta).not.toHaveAttribute("href");
});
test("page maintains proper semantic structure", async () => {
@@ -223,16 +223,22 @@ describe("Page Flow Integration", () => {
expect(mainContent).toBeInTheDocument();
});
test("all interactive elements are accessible", () => {
test("all interactive elements are accessible", async () => {
render(<Page />);
// Check all buttons have proper roles
await waitFor(() => {
expect(screen.getAllByRole("button").length).toBeGreaterThan(0);
});
const buttons = screen.getAllByRole("button");
buttons.forEach((button) => {
expect(button).toBeInTheDocument();
});
// Check all links have proper roles
await waitFor(() => {
expect(screen.getAllByRole("link").length).toBeGreaterThan(0);
});
const links = screen.getAllByRole("link");
links.forEach((link) => {
expect(link).toBeInTheDocument();
+5 -4
View File
@@ -121,10 +121,11 @@ describe("User Journey Integration", () => {
screen.getByText("Get answers from an experienced organizer"),
).toBeInTheDocument();
// User clicks the ask organizer button (it's actually a link, not a button)
const askLink = screen.getByRole("link", { name: /Ask an organizer/i });
await user.click(askLink);
expect(askLink).toHaveAttribute("href", "#contact");
const askCta = screen.getByTestId("ask-organizer-cta");
await user.click(askCta);
expect(
await screen.findByRole("dialog", { name: /ask an organizer/i }),
).toBeInTheDocument();
});
test("user explores the process through CardSteps", async () => {
@@ -0,0 +1,130 @@
import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const sendOrganizerInquiryNotificationMock = vi.fn();
vi.mock("../../lib/server/mail", () => ({
sendOrganizerInquiryNotification: (...args: unknown[]) =>
sendOrganizerInquiryNotificationMock(...args),
}));
const rateLimitKeyMock = vi.hoisted(() =>
vi.fn(() => ({ ok: true as const })),
);
vi.mock("../../lib/server/rateLimit", () => ({
rateLimitKey: (...args: unknown[]) => rateLimitKeyMock(...args),
}));
import { POST } from "../../app/api/organizer-inquiry/route";
describe("POST /api/organizer-inquiry", () => {
beforeEach(() => {
sendOrganizerInquiryNotificationMock.mockReset();
sendOrganizerInquiryNotificationMock.mockResolvedValue(undefined);
rateLimitKeyMock.mockReset();
rateLimitKeyMock.mockImplementation(() => ({ ok: true as const }));
process.env.ORGANIZER_INQUIRY_TO = "organizers@example.com";
});
afterEach(() => {
delete process.env.ORGANIZER_INQUIRY_TO;
});
it("returns 200 and sends mail for a valid payload", async () => {
const res = await POST(
new NextRequest("https://x.test/api/organizer-inquiry", {
method: "POST",
body: JSON.stringify({
email: "Visitor@Example.com",
message: "How do we run consensus meetings?",
company: "",
}),
}),
undefined,
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toEqual({ ok: true });
expect(sendOrganizerInquiryNotificationMock).toHaveBeenCalledTimes(1);
const arg = sendOrganizerInquiryNotificationMock.mock.calls[0][0];
expect(arg.visitorEmail).toBe("visitor@example.com");
expect(arg.message).toBe("How do we run consensus meetings?");
});
it("returns 200 without sending mail when honeypot is filled", async () => {
const res = await POST(
new NextRequest("https://x.test/api/organizer-inquiry", {
method: "POST",
body: JSON.stringify({
email: "spam@example.com",
message: "How do we run consensus meetings?",
company: "Evil Corp",
}),
}),
undefined,
);
expect(res.status).toBe(200);
expect(sendOrganizerInquiryNotificationMock).not.toHaveBeenCalled();
});
it("returns 400 for invalid email", async () => {
const res = await POST(
new NextRequest("https://x.test/api/organizer-inquiry", {
method: "POST",
body: JSON.stringify({
email: "not-an-email",
message: "How do we run consensus meetings?",
company: "",
}),
}),
undefined,
);
expect(res.status).toBe(400);
expect(sendOrganizerInquiryNotificationMock).not.toHaveBeenCalled();
});
it("returns 429 when rate limited", async () => {
rateLimitKeyMock.mockReturnValue({
ok: false as const,
retryAfterMs: 1000,
});
const res = await POST(
new NextRequest("https://x.test/api/organizer-inquiry", {
method: "POST",
body: JSON.stringify({
email: "a@b.co",
message: "How do we run consensus meetings?",
company: "",
}),
}),
undefined,
);
expect(res.status).toBe(429);
expect(sendOrganizerInquiryNotificationMock).not.toHaveBeenCalled();
});
it("returns 500 when ORGANIZER_INQUIRY_TO is unset", async () => {
delete process.env.ORGANIZER_INQUIRY_TO;
const res = await POST(
new NextRequest("https://x.test/api/organizer-inquiry", {
method: "POST",
body: JSON.stringify({
email: "a@b.co",
message: "How do we run consensus meetings?",
company: "",
}),
}),
undefined,
);
expect(res.status).toBe(500);
expect(sendOrganizerInquiryNotificationMock).not.toHaveBeenCalled();
});
});