Skip to content

Commit 403cc5a

Browse files
committed
Add component tests for the send email modal
1 parent cbc307d commit 403cc5a

1 file changed

Lines changed: 212 additions & 0 deletions

File tree

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
// @vitest-environment happy-dom
2+
import {
3+
render,
4+
screen,
5+
fireEvent,
6+
cleanup,
7+
waitFor,
8+
} from "@testing-library/react";
9+
import { afterEach, describe, expect, it, vi } from "vitest";
10+
import SendEmailModal from "@/components/SendEmailModal";
11+
12+
vi.mock("@/components/bog/BogIcon/BogIcon", () => ({
13+
default: ({ name }: { name: string }) => (
14+
<span data-testid={`icon-${name}`} />
15+
),
16+
}));
17+
18+
vi.mock("@/components/bog/BogModal/BogModal", () => ({
19+
default: ({
20+
title,
21+
description,
22+
}: {
23+
title: React.ReactNode;
24+
description: React.ReactNode;
25+
}) => (
26+
<div>
27+
{title}
28+
{description}
29+
</div>
30+
),
31+
}));
32+
33+
vi.mock("@/components/bog/BogButton/BogButton", () => ({
34+
default: ({
35+
children,
36+
onClick,
37+
disabled,
38+
}: {
39+
children: React.ReactNode;
40+
onClick?: () => void;
41+
disabled?: boolean;
42+
}) => (
43+
<button onClick={onClick} disabled={disabled}>
44+
{children}
45+
</button>
46+
),
47+
}));
48+
49+
vi.mock("@/components/bog/BogTextInput/BogTextInput", () => ({
50+
default: ({
51+
name,
52+
label,
53+
value,
54+
onChange,
55+
multiline,
56+
}: {
57+
name: string;
58+
label: string;
59+
value: string;
60+
onChange: (e: { target: { value: string } }) => void;
61+
multiline?: boolean;
62+
}) =>
63+
multiline ? (
64+
<textarea
65+
aria-label={label}
66+
name={name}
67+
value={value}
68+
onChange={onChange}
69+
/>
70+
) : (
71+
<input aria-label={label} name={name} value={value} onChange={onChange} />
72+
),
73+
}));
74+
75+
// The recipient picker is irrelevant here; recipients come from props.
76+
vi.mock("react-select", () => ({ default: () => null }));
77+
78+
afterEach(cleanup);
79+
80+
function renderModal(
81+
overrides: Partial<{ onSend: unknown; onClose: unknown }>,
82+
) {
83+
const onSend = (overrides.onSend ?? vi.fn()) as never;
84+
const onClose = (overrides.onClose ?? vi.fn()) as never;
85+
86+
render(
87+
<SendEmailModal
88+
isOpen
89+
onClose={onClose as () => void}
90+
recipients={[{ id: "user-1", name: "Member One" }]}
91+
initialRecipientIds={["user-1"]}
92+
onSend={onSend as never}
93+
/>,
94+
);
95+
96+
return { onSend, onClose };
97+
}
98+
99+
function fill(label: string, value: string) {
100+
fireEvent.change(screen.getByLabelText(label), { target: { value } });
101+
}
102+
103+
function clickSend() {
104+
fireEvent.click(screen.getByRole("button", { name: "Send Email" }));
105+
}
106+
107+
describe("SendEmailModal", () => {
108+
it("forwards every filled field to onSend", async () => {
109+
const onSend = vi.fn().mockResolvedValue(undefined);
110+
renderModal({ onSend });
111+
112+
fill("Headline", "Spring Newsletter");
113+
fill("Subtitle", "A note from our director");
114+
fill("Text", "Thanks for volunteering.");
115+
fill("Footer", "Unsubscribe by replying.");
116+
clickSend();
117+
118+
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
119+
expect(onSend).toHaveBeenCalledWith({
120+
subject: "Spring Newsletter",
121+
subtitle: "A note from our director",
122+
body: "Thanks for volunteering.",
123+
footer: "Unsubscribe by replying.",
124+
recipientIds: ["user-1"],
125+
});
126+
});
127+
128+
it("omits blank optional fields instead of sending empty strings", async () => {
129+
const onSend = vi.fn().mockResolvedValue(undefined);
130+
renderModal({ onSend });
131+
132+
fill("Headline", "Headline");
133+
fill("Text", "Body");
134+
clickSend();
135+
136+
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
137+
138+
const payload = onSend.mock.calls[0][0];
139+
expect(payload).toEqual({
140+
subject: "Headline",
141+
body: "Body",
142+
recipientIds: ["user-1"],
143+
});
144+
expect(payload).not.toHaveProperty("subtitle");
145+
expect(payload).not.toHaveProperty("footer");
146+
});
147+
148+
it("trims whitespace from every field", async () => {
149+
const onSend = vi.fn().mockResolvedValue(undefined);
150+
renderModal({ onSend });
151+
152+
fill("Headline", " Headline ");
153+
fill("Subtitle", " Subtitle ");
154+
fill("Text", " Body ");
155+
fill("Footer", " ");
156+
clickSend();
157+
158+
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
159+
expect(onSend).toHaveBeenCalledWith({
160+
subject: "Headline",
161+
subtitle: "Subtitle",
162+
body: "Body",
163+
recipientIds: ["user-1"],
164+
});
165+
});
166+
167+
it("does not send when a required field is empty", () => {
168+
const onSend = vi.fn();
169+
renderModal({ onSend });
170+
171+
fill("Headline", "Headline only");
172+
clickSend();
173+
174+
expect(onSend).not.toHaveBeenCalled();
175+
});
176+
177+
it("keeps the modal open and shows the error when the send fails", async () => {
178+
const onSend = vi.fn().mockRejectedValue(new Error("Failed to send email"));
179+
const onClose = vi.fn();
180+
renderModal({ onSend, onClose });
181+
182+
fill("Headline", "Headline");
183+
fill("Text", "Body");
184+
clickSend();
185+
186+
await waitFor(() =>
187+
expect(screen.getByRole("alert").textContent).toBe(
188+
"Failed to send email",
189+
),
190+
);
191+
expect(onClose).not.toHaveBeenCalled();
192+
});
193+
194+
it("closes the modal after a successful send", async () => {
195+
const onSend = vi.fn().mockResolvedValue(undefined);
196+
const onClose = vi.fn();
197+
renderModal({ onSend, onClose });
198+
199+
fill("Headline", "Headline");
200+
fill("Text", "Body");
201+
clickSend();
202+
203+
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
204+
expect(screen.queryByRole("alert")).toBeNull();
205+
});
206+
207+
it("does not render the unwired send-message-to-recipient checkbox", () => {
208+
renderModal({});
209+
210+
expect(screen.queryByRole("checkbox")).toBeNull();
211+
});
212+
});

0 commit comments

Comments
 (0)