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