import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderToStaticMarkup } from "react-dom/server";
import Contact from "./Contact";
/**
* Sem atraso entre as teclas: com o atraso padrão, digitar o contexto longo (cerca de 130 caracteres) levava
* mais de 5 s em máquina lenta e o teste estourava o tempo. O texto longo ainda entra por colagem.
*/
const setup = () => userEvent.setup({ delay: null });
/** Aba aberta de verdade; o Contact corta o vínculo com a página (opener = null) logo depois. */
let openedWindow: { opener: unknown };
beforeEach(() => {
jest.replaceProperty(process.env, "NODE_ENV", "production");
localStorage.setItem("evandro.analytics-consent.v1", "granted");
delete window["ga-disable-G-QTT9QT0T3S"];
window.gtag = jest.fn();
openedWindow = { opener: {} };
jest
.spyOn(window, "open")
.mockImplementation(() => openedWindow as unknown as Window);
});
afterEach(() => {
jest.restoreAllMocks();
localStorage.clear();
delete window.gtag;
});
it("prepares an isolated WhatsApp draft with the entered project details only after submission", async () => {
const user = setup();
render();
const name = "Ana & João";
const email = "ana+projeto@example.com";
const context =
"Integrar pedidos & estoque? Objetivo: evitar retrabalho.\nPrazo: outubro #2026";
await user.type(screen.getByRole("textbox", { name: /nome/i }), name);
await user.type(screen.getByRole("textbox", { name: /e-mail/i }), email);
const subject = screen.getByRole("combobox", { name: /tipo de projeto/i });
await user.selectOptions(subject, "Integrações e automações");
await user.click(screen.getByRole("textbox", { name: /contexto/i }));
await user.paste(context);
expect(window.open).not.toHaveBeenCalled();
expect(window.gtag).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: /whatsapp/i }));
expect(window.open).toHaveBeenCalledTimes(1);
const [destination, target, ...rest] = jest.mocked(window.open).mock.calls[0];
const url = new URL(String(destination));
expect(url.origin).toBe("https://wa.me");
expect(url.pathname).toMatch(/^\/\d+$/);
expect([...url.searchParams.keys()]).toEqual(["text"]);
const message = url.searchParams.get("text");
expect(message).toContain(name);
expect(message).toContain(email);
expect(message).toContain("Integrações e automações");
expect(message).toContain(context);
expect(target).toBe("_blank");
// sem "noopener" na chamada (ele faria o window.open devolver sempre null); o vínculo é cortado depois
expect(rest).toEqual([]);
expect(openedWindow.opener).toBeNull();
expect(window.gtag).toHaveBeenCalledTimes(1);
expect(window.gtag).toHaveBeenCalledWith("event", "whatsapp_open", {
send_to: "G-QTT9QT0T3S",
service: "Contato",
placement: "contact_form",
page_path: "/",
page_location: "https://evandro.dev.br/",
page_referrer: "",
});
});
it("does not open WhatsApp while required fields are empty or the email is invalid", async () => {
const user = setup();
render();
const submit = screen.getByRole("button", { name: /whatsapp/i });
await user.click(submit);
expect(window.open).not.toHaveBeenCalled();
await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana");
await user.type(
screen.getByRole("textbox", { name: /e-mail/i }),
"email-invalido",
);
await user.type(
screen.getByRole("textbox", { name: /contexto/i }),
"Organizar pedidos",
);
await user.click(submit);
expect(window.open).not.toHaveBeenCalled();
expect(window.gtag).not.toHaveBeenCalled();
});
it("opens the project draft with analytics unavailable and consent denied", async () => {
localStorage.setItem("evandro.analytics-consent.v1", "denied");
delete window.gtag;
const user = setup();
render();
await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana");
await user.type(
screen.getByRole("textbox", { name: /e-mail/i }),
"ana@example.com",
);
await user.type(
screen.getByRole("textbox", { name: /contexto/i }),
"Organizar pedidos",
);
await user.click(screen.getByRole("button", { name: /whatsapp/i }));
expect(window.open).toHaveBeenCalledTimes(1);
const destination = new URL(
String(jest.mocked(window.open).mock.calls[0][0]),
);
expect(destination.origin).toBe("https://wa.me");
expect(destination.searchParams.get("text")).toContain("Organizar pedidos");
});
it("lets the visitor skip the e-mail and leaves that line out of the message", async () => {
const user = setup();
render();
expect(screen.getByRole("textbox", { name: /e-mail/i })).not.toBeRequired();
await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana");
await user.type(
screen.getByRole("textbox", { name: /contexto/i }),
"Organizar pedidos",
);
await user.click(screen.getByRole("button", { name: /whatsapp/i }));
expect(window.open).toHaveBeenCalledTimes(1);
const url = new URL(String(jest.mocked(window.open).mock.calls[0][0]));
const message = url.searchParams.get("text") ?? "";
expect(message).toContain("Nome: Ana");
expect(message).toContain("Tipo de projeto: Ainda preciso definir");
expect(message).not.toContain("E-mail:");
});
it("offers 'Ainda preciso definir' as the first and default project type", () => {
render();
const subject = screen.getByRole("combobox", { name: /tipo de projeto/i });
const options = Array.from(subject.querySelectorAll("option")).map(
(option) => option.value,
);
expect(options[0]).toBe("Ainda preciso definir");
expect(options).toContain("Site que traz pedidos pelo WhatsApp");
expect(subject).toHaveValue("Ainda preciso definir");
});
it("labels the first field 'Seu nome'", () => {
render();
expect(screen.getByLabelText("Seu nome")).toHaveAttribute("name", "name");
expect(screen.queryByLabelText("Nome completo")).toBeNull();
});
it("explains each invalid field in Portuguese and marks it for assistive technology", async () => {
const user = setup();
render();
await user.type(
screen.getByRole("textbox", { name: /e-mail/i }),
"email-invalido",
);
await user.click(screen.getByRole("button", { name: /whatsapp/i }));
const name = screen.getByRole("textbox", { name: /nome/i });
const email = screen.getByRole("textbox", { name: /e-mail/i });
const message = screen.getByRole("textbox", { name: /contexto/i });
for (const field of [name, email, message]) {
expect(field).toHaveAttribute("aria-invalid", "true");
}
expect(name).toHaveAccessibleDescription(
"Informe seu nome para eu saber com quem falo.",
);
expect(email).toHaveAccessibleDescription(
"Confira o e-mail: ele precisa ter o formato nome@empresa.com.br.",
);
expect(message).toHaveAccessibleDescription(
/Conte em poucas palavras o que você precisa resolver\./,
);
expect(name).toHaveFocus();
expect(window.open).not.toHaveBeenCalled();
await user.type(name, "Ana");
expect(name).not.toHaveAttribute("aria-invalid");
expect(
screen.queryByText("Informe seu nome para eu saber com quem falo."),
).toBeNull();
});
it("offers the same text by e-mail, below the WhatsApp button", async () => {
const user = setup();
render();
await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana");
await user.type(
screen.getByRole("textbox", { name: /contexto/i }),
"Organizar pedidos & estoque",
);
const submit = screen.getByRole("button", { name: /whatsapp/i });
const mail = screen.getByRole("link", {
name: "Prefere e-mail? Enviar por e-mail",
});
expect(
submit.compareDocumentPosition(mail) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
const href = mail.getAttribute("href") ?? "";
expect(href.startsWith("mailto:evandrofonsecajunior@gmail.com?")).toBe(true);
const url = new URL(href);
expect(url.searchParams.get("subject")).toBe("Projeto pelo site");
const body = url.searchParams.get("body") ?? "";
expect(body).toContain("Nome: Ana");
expect(body).toContain("Organizar pedidos & estoque");
expect(body).toContain("Tipo de projeto: Ainda preciso definir");
await user.click(mail);
expect(window.gtag).toHaveBeenCalledWith(
"event",
"email_click",
expect.objectContaining({ placement: "contact_form" }),
);
});
it("does not call the direct WhatsApp row 'Contato'", () => {
render();
const direct = screen.getByRole("link", { name: /Prefere conversar direto/ });
const text = new URL(direct.getAttribute("href") ?? "").searchParams.get(
"text",
);
expect(text).toContain("Quero conversar sobre um projeto.");
expect(text).not.toMatch(/sobre Contato/);
});
describe("e-mail draft validation", () => {
it("does not open the e-mail client while the form is empty, and explains what is missing", () => {
render();
const mail = screen.getByRole("link", {
name: "Prefere e-mail? Enviar por e-mail",
});
// fireEvent.click devolve false quando o clique foi cancelado (preventDefault)
expect(fireEvent.click(mail)).toBe(false);
expect(
screen.getByText("Informe seu nome para eu saber com quem falo."),
).toBeVisible();
expect(
screen.getByText("Conte em poucas palavras o que você precisa resolver."),
).toBeVisible();
expect(screen.getByRole("textbox", { name: /nome/i })).toHaveFocus();
expect(window.gtag).not.toHaveBeenCalled();
});
it("blocks a malformed e-mail address the same way", async () => {
const user = setup();
render();
await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana");
await user.type(
screen.getByRole("textbox", { name: /e-mail/i }),
"email-invalido",
);
await user.type(
screen.getByRole("textbox", { name: /contexto/i }),
"Organizar pedidos",
);
const mail = screen.getByRole("link", {
name: "Prefere e-mail? Enviar por e-mail",
});
expect(fireEvent.click(mail)).toBe(false);
expect(screen.getByRole("textbox", { name: /e-mail/i })).toHaveFocus();
});
it("lets the click through, and measures it, once the form is valid", async () => {
const user = setup();
render();
await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana");
await user.type(
screen.getByRole("textbox", { name: /contexto/i }),
"Organizar pedidos",
);
const mail = screen.getByRole("link", {
name: "Prefere e-mail? Enviar por e-mail",
});
// o clique não é cancelado pela validação: o navegador segue para o mailto:
expect(fireEvent.click(mail)).toBe(true);
expect(window.gtag).toHaveBeenCalledWith(
"event",
"email_click",
expect.objectContaining({ placement: "contact_form" }),
);
});
});
describe("WhatsApp fallback", () => {
it("goes to WhatsApp in the same page when the browser blocks the new tab", async () => {
// o jsdom não navega: ele avisa "Not implemented: navigation" ao atribuir location.href
const errors = jest.spyOn(console, "error").mockImplementation(() => {});
jest.mocked(window.open).mockImplementation(() => null);
const user = setup();
render();
await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana");
await user.type(
screen.getByRole("textbox", { name: /contexto/i }),
"Organizar pedidos",
);
await user.click(screen.getByRole("button", { name: /whatsapp/i }));
expect(window.open).toHaveBeenCalledTimes(1);
expect(
errors.mock.calls.some(([error]) =>
String(error).includes("Not implemented: navigation"),
),
).toBe(true);
expect(window.gtag).toHaveBeenCalledWith(
"event",
"whatsapp_open",
expect.objectContaining({ placement: "contact_form" }),
);
});
it("does not navigate the page when the new tab opened", async () => {
const errors = jest.spyOn(console, "error").mockImplementation(() => {});
const user = setup();
render();
await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana");
await user.type(
screen.getByRole("textbox", { name: /contexto/i }),
"Organizar pedidos",
);
await user.click(screen.getByRole("button", { name: /whatsapp/i }));
expect(errors).not.toHaveBeenCalled();
});
/** Preenche o mínimo e envia; devolve os erros que o jsdom registrou ao "navegar" para location.href. */
async function submitWithMinimumFields() {
const errors = jest.spyOn(console, "error").mockImplementation(() => {});
const user = setup();
render();
await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana");
await user.type(
screen.getByRole("textbox", { name: /contexto/i }),
"Organizar pedidos",
);
await user.click(screen.getByRole("button", { name: /whatsapp/i }));
return errors;
}
const navigated = (errors: jest.SpyInstance) =>
errors.mock.calls.some(([error]) =>
String(error).includes("Not implemented: navigation"),
);
it("goes to WhatsApp in the same page when there is no window.open", async () => {
const original = window.open;
Object.defineProperty(window, "open", {
configurable: true,
value: undefined,
});
try {
const errors = await submitWithMinimumFields();
expect(navigated(errors)).toBe(true);
expect(window.gtag).toHaveBeenCalledWith(
"event",
"whatsapp_open",
expect.objectContaining({ placement: "contact_form" }),
);
} finally {
Object.defineProperty(window, "open", {
configurable: true,
value: original,
});
}
});
it("goes to WhatsApp in the same page when window.open throws", async () => {
jest.mocked(window.open).mockImplementation(() => {
throw new DOMException("bloqueado", "SecurityError");
});
const errors = await submitWithMinimumFields();
expect(navigated(errors)).toBe(true);
});
it("closes the new tab and stays on the same page when its link to this page cannot be cut", async () => {
const close = jest.fn();
const tab = {
close,
get opener() {
return {};
},
set opener(_value: unknown) {
throw new DOMException("sem acesso", "SecurityError");
},
};
jest.mocked(window.open).mockImplementation(() => tab as unknown as Window);
const errors = await submitWithMinimumFields();
expect(close).toHaveBeenCalledTimes(1);
expect(navigated(errors)).toBe(true);
expect(window.gtag).toHaveBeenCalledWith(
"event",
"whatsapp_open",
expect.objectContaining({ placement: "contact_form" }),
);
});
});
describe("without JavaScript", () => {
it("keeps the form body out of the way and offers direct WhatsApp and e-mail links in a