import { test, expect } from '@playwright/test'; /** * Inline Lead Form — Homepage * * Covers: * - The form is mounted on the homepage between FAQ/tech-stack and the * final CTA band. * - Filling all three fields and submitting calls /api/contact and shows * an in-place thank-you state without a full-page navigation. * - The submission is tagged `source: 'homepage-inline'` so leads can be * segmented downstream. * - The backend itself accepts the new inline payload shape * (server-side validation check). */ test.describe('Inline Lead Form (homepage)', () => { test('form is visible on the homepage and has the three required fields', async ({ page }) => { await page.goto('/'); const form = page.getByTestId('inline-lead-form'); await form.scrollIntoViewIfNeeded(); await expect(form).toBeVisible(); await expect(page.locator('#inline-lead-name')).toBeVisible(); await expect(page.locator('#inline-lead-contact')).toBeVisible(); await expect(page.locator('#inline-lead-project-type')).toBeVisible(); // Touch-target sanity: the submit button must be at least 48px tall. const submit = page.locator('#inline-lead-submit'); const submitBox = await submit.boundingBox(); expect(submitBox).not.toBeNull(); expect(submitBox!.height).toBeGreaterThanOrEqual(48); // Correct mobile-friendly attributes on the contact field. await expect(page.locator('#inline-lead-contact')).toHaveAttribute('autocomplete', 'email tel'); await expect(page.locator('#inline-lead-contact')).toHaveAttribute('inputmode', 'email'); }); test('blocks submit when required fields are empty', async ({ page }) => { await page.goto('/'); const form = page.getByTestId('inline-lead-form'); await form.scrollIntoViewIfNeeded(); // Intercept so a real submit can't slip through if the validator fails. let apiCalled = false; await page.route('**/api/contact', async (route) => { apiCalled = true; await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ success: true, message: 'ok' }), }); }); await page.locator('#inline-lead-submit').click(); // The blank-field errors should surface and the network should NOT be hit. await expect(page.locator('#inline-lead-name-error')).toBeVisible(); await expect(page.locator('#inline-lead-contact-error')).toBeVisible(); await expect(page.locator('#inline-lead-project-type-error')).toBeVisible(); expect(apiCalled).toBe(false); }); test('fills and submits the form successfully with an email contact', async ({ page }) => { await page.goto('/'); const form = page.getByTestId('inline-lead-form'); await form.scrollIntoViewIfNeeded(); // Capture the outgoing payload so we can assert source/projectType tagging. let captured: any = null; await page.route('**/api/contact', async (route) => { const req = route.request(); try { captured = req.postDataJSON(); } catch { captured = null; } await route.fulfill({ status: 200, contentType: 'application/json', headers: { 'Access-Control-Allow-Origin': '*' }, body: JSON.stringify({ success: true, message: 'Received.' }), }); }); await page.locator('#inline-lead-name').fill('Inline Lead Tester'); await page.locator('#inline-lead-contact').fill('inline-lead@example.com'); await page.locator('#inline-lead-project-type').selectOption('Web App'); await page.locator('#inline-lead-submit').click(); // Success state should replace the form in-place, no navigation. await expect(page.getByTestId('inline-lead-success')).toBeVisible(); await expect(page.getByTestId('inline-lead-success')).toContainText( "Thanks! We'll reply within 24 hours." ); await expect(page).toHaveURL(/\/$|\/#?$/); // Confirm the payload is tagged correctly. expect(captured).toBeTruthy(); expect(captured.source).toBe('homepage-inline'); expect(captured.projectType).toBe('Web App'); expect(captured.name).toBe('Inline Lead Tester'); expect(captured.email).toBe('inline-lead@example.com'); expect(captured.subject).toBe('web-development'); // Honeypot must be empty for a real user submission. expect(captured.website ?? '').toBe(''); }); test('routes a phone-shaped contact value into the phone field', async ({ page }) => { await page.goto('/'); const form = page.getByTestId('inline-lead-form'); await form.scrollIntoViewIfNeeded(); let captured: any = null; await page.route('**/api/contact', async (route) => { try { captured = route.request().postDataJSON(); } catch { captured = null; } await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ success: true, message: 'Received.' }), }); }); await page.locator('#inline-lead-name').fill('Phone Lead'); await page.locator('#inline-lead-contact').fill('+91 95614 17403'); await page.locator('#inline-lead-project-type').selectOption('Government / GeM'); await page.locator('#inline-lead-submit').click(); await expect(page.getByTestId('inline-lead-success')).toBeVisible(); expect(captured).toBeTruthy(); expect(captured.phone).toBe('+91 95614 17403'); // The client synthesizes a placeholder email so the backend's strict email // validator accepts phone-only contacts. The real phone is what matters. expect(captured.email).toMatch(/^lead-\d+@homepage\.workroot\.in$/); expect(captured.subject).toBe('government-systems'); expect(captured.source).toBe('homepage-inline'); }); }); // ============================================================ // Server-side: /api/contact accepts the inline lead payload // ============================================================ test.describe('API: inline-lead payload (/api/contact)', () => { test('accepts a payload tagged source=homepage-inline', async ({ request }) => { const response = await request.post('/api/contact', { headers: { 'Content-Type': 'application/json' }, data: { name: 'Inline API Test', email: 'inline-api@example.com', subject: 'web-development', message: 'New inline lead from the homepage.\n\nProject type: Web App\nPreferred contact: inline-api@example.com', source: 'homepage-inline', projectType: 'Web App', website: '', }, }); // 200 success or 429 rate-limited (both acceptable in CI re-runs). expect([200, 429]).toContain(response.status()); if (response.status() === 200) { const body = (await response.json()) as { success: boolean }; expect(body.success).toBe(true); } }); test('accepts the new erp-solutions and government-systems subjects', async ({ request }) => { for (const subject of ['erp-solutions', 'government-systems']) { const response = await request.post('/api/contact', { headers: { 'Content-Type': 'application/json' }, data: { name: 'Inline API Subject Test', email: `inline-${subject}@example.com`, subject, message: `Inline lead for ${subject}. Project type test.`, source: 'homepage-inline', projectType: subject === 'erp-solutions' ? 'ERP / Custom Software' : 'Government / GeM', website: '', }, }); // Either accepted or rate-limited — both prove the subject is recognized // (a rejected subject would 422 instead). expect([200, 429]).toContain(response.status()); } }); test('still rejects payloads missing required fields', async ({ request }) => { const response = await request.post('/api/contact', { headers: { 'Content-Type': 'application/json' }, data: { name: '', email: 'inline-missing@example.com', subject: 'web-development', message: 'A message long enough to pass validation.', source: 'homepage-inline', website: '', }, }); expect(response.status()).toBe(422); const body = (await response.json()) as { success: boolean; error: string }; expect(body.success).toBe(false); expect(body.error).toBeTruthy(); }); test('treats unknown source values as the safe default and still accepts the lead', async ({ request }) => { const response = await request.post('/api/contact', { headers: { 'Content-Type': 'application/json' }, data: { name: 'Unknown Source Test', email: 'inline-unknown@example.com', subject: 'other', message: 'Inline lead from an unrecognised source string.', source: 'totally-made-up-source', projectType: 'Other', website: '', }, }); expect([200, 429]).toContain(response.status()); }); });