diff --git a/tests/enquiry.spec.ts b/tests/enquiry.spec.ts index ea27425..8325418 100644 --- a/tests/enquiry.spec.ts +++ b/tests/enquiry.spec.ts @@ -1,10 +1,49 @@ import { test, expect } from "@playwright/test"; +/** + * Enquiry flow tests. + * + * We intercept POST /api/enquiry and return a mock 200 response so that: + * - No Resend emails are sent during CI + * - Tests are deterministic (no network variability from an email provider) + * - The free Resend allowance is not consumed by automated test runs + * + * The intercepted handler validates that the request body is well-formed, + * so we are still testing that the form sends the right payload. + */ + const TEST_EMAIL = "djibysowrebollo@gmail.com"; const TEST_PHONE = "1234567890"; const TEST_MESSAGE = "test message"; -const CHILD_DOB = "2024-01-10"; // 10th Jan -const START_DATE = "2025-01-20"; // 20th Jan +const CHILD_DOB = "2024-01-10"; +const START_DATE = "2025-01-20"; + +async function mockEnquiryRoute(page: import("@playwright/test").Page) { + await page.route("**/api/enquiry", async (route) => { + const request = route.request(); + + // Still validate the shape the form is sending + const body = request.postDataJSON() as Record; + const required = ["nurseryName", "nurseryArea", "name", "email", "phone", "childDob", "startDate"]; + const missing = required.filter((k) => !body[k]); + + if (missing.length > 0) { + await route.fulfill({ + status: 400, + contentType: "application/json", + body: JSON.stringify({ error: `Missing: ${missing.join(", ")}` }), + }); + return; + } + + // All required fields present — simulate a successful send + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ success: true }), + }); + }); +} async function fillEnquiryForm(page: import("@playwright/test").Page) { // Step 1 — personal details @@ -19,56 +58,75 @@ async function fillEnquiryForm(page: import("@playwright/test").Page) { await page.getByPlaceholder("Any questions or specific requirements...").fill(TEST_MESSAGE); await page.getByRole("button", { name: "Send enquiry" }).click(); - // Confirm success screen + // Success screen await expect(page.getByText("Enquiry sent")).toBeVisible({ timeout: 10000 }); } test.describe("Enquiry flow", () => { test.beforeEach(async ({ page }) => { + // Intercept before navigation so no request slips through + await mockEnquiryRoute(page); + await page.goto("http://localhost:3000"); - // Wait for nurseries to load - await expect(page.locator("text=Meadowside Nursery")).toBeVisible({ timeout: 10000 }); + await expect(page.locator("text=Meadowside Nursery")).toBeVisible({ + timeout: 10000, + }); }); test("enquiry from list view", async ({ page }) => { - // Click Enquire on the first nursery card await page.getByRole("button", { name: "Enquire" }).first().click(); - - // Modal should open await expect(page.getByText("Enquire at")).toBeVisible(); await fillEnquiryForm(page); - // Close modal await page.getByRole("button", { name: "Done" }).click(); - - // Should be back on main page showing nurseries await expect(page.locator("text=Meadowside Nursery")).toBeVisible(); }); test("enquiry from map view", async ({ page }) => { - // Switch to map view await page.getByRole("button", { name: "Map" }).click(); - - // Wait for map to load await page.waitForTimeout(2000); - // Click a marker — Leaflet markers are divs inside the map const marker = page.locator(".leaflet-marker-icon").first(); await marker.click(); - - // Wait for popup await page.waitForTimeout(500); - // Click Enquire in the popup await page.locator(".leaflet-popup button", { hasText: "Enquire" }).click(); - - // Modal should open await expect(page.getByText("Enquire at")).toBeVisible(); await fillEnquiryForm(page); - - // Close modal await page.getByRole("button", { name: "Done" }).click(); }); + + test("validation rejects an empty name", async ({ page }) => { + await page.getByRole("button", { name: "Enquire" }).first().click(); + await expect(page.getByText("Enquire at")).toBeVisible(); + + // Skip name, fill phone and email, try to continue + await page.getByPlaceholder("07700 900000").fill(TEST_PHONE); + await page.getByPlaceholder("you@example.com").fill(TEST_EMAIL); + await page.getByRole("button", { name: "Continue" }).click(); + + // Should show inline error, not advance to step 2 + await expect(page.getByText("Name is required")).toBeVisible(); + await expect(page.getByText("Child date of birth")).not.toBeVisible(); + }); + + test("validation rejects a malformed email", async ({ page }) => { + await page.getByRole("button", { name: "Enquire" }).first().click(); + + await page.getByPlaceholder("Your name").fill("Test User"); + await page.getByPlaceholder("07700 900000").fill(TEST_PHONE); + await page.getByPlaceholder("you@example.com").fill("not-an-email"); + await page.getByRole("button", { name: "Continue" }).click(); + + await expect(page.getByText("Enter a valid email")).toBeVisible(); + }); + + test("ESC closes the modal", async ({ page }) => { + await page.getByRole("button", { name: "Enquire" }).first().click(); + await expect(page.getByText("Enquire at")).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.getByText("Enquire at")).not.toBeVisible(); + }); }); \ No newline at end of file diff --git a/tests/seo.spec.ts b/tests/seo.spec.ts new file mode 100644 index 0000000..f1dda77 --- /dev/null +++ b/tests/seo.spec.ts @@ -0,0 +1,77 @@ +import { test, expect } from "@playwright/test"; + +test.describe("SEO", () => { + test("sitemap.xml is served and lists nursery URLs", async ({ request }) => { + const res = await request.get("http://localhost:3000/sitemap.xml"); + expect(res.status()).toBe(200); + const body = await res.text(); + expect(body).toContain(" { + const res = await request.get("http://localhost:3000/robots.txt"); + expect(res.status()).toBe(200); + const body = await res.text(); + expect(body).toContain("User-Agent: *"); + expect(body).toContain("Sitemap:"); + expect(body).toContain("/sitemap.xml"); + // Admin and api should be disallowed + expect(body).toContain("/admin"); + expect(body).toContain("/api"); + }); + + test("home page has canonical link, OG tags, and JSON-LD", async ({ page }) => { + await page.goto("http://localhost:3000"); + + // Canonical + const canonical = page.locator('link[rel="canonical"]'); + await expect(canonical).toHaveAttribute("href", /\/$/); + + // OG + const ogTitle = page.locator('meta[property="og:title"]'); + await expect(ogTitle).toHaveAttribute("content", /nvvri/); + + // JSON-LD WebSite schema + const jsonLd = await page.locator('script[type="application/ld+json"]').first().textContent(); + expect(jsonLd).toBeTruthy(); + const parsed = JSON.parse(jsonLd!); + expect(parsed["@type"]).toBe("WebSite"); + expect(parsed.potentialAction["@type"]).toBe("SearchAction"); + }); + + test("nursery detail page has Preschool JSON-LD", async ({ page }) => { + await page.goto("http://localhost:3000/nursery/meadowside-nursery"); + + // Check it rendered + await expect( + page.getByRole("heading", { name: "Meadowside Nursery" }) + ).toBeVisible(); + + // Check JSON-LD + const jsonLd = await page + .locator('script[type="application/ld+json"]') + .first() + .textContent(); + expect(jsonLd).toBeTruthy(); + const parsed = JSON.parse(jsonLd!); + expect(parsed["@type"]).toBe("Preschool"); + expect(parsed.name).toBe("Meadowside Nursery"); + expect(parsed.aggregateRating).toBeTruthy(); + expect(parsed.aggregateRating.ratingValue).toBeGreaterThan(0); + expect(parsed.address.addressLocality).toBe("Morningside"); + }); + + test("unknown nursery slug returns 404", async ({ page }) => { + const res = await page.goto("http://localhost:3000/nursery/does-not-exist"); + expect(res?.status()).toBe(404); + await expect(page.getByText("Nursery not found")).toBeVisible(); + }); + + test("admin route is hidden from unauthenticated visitors", async ({ request }) => { + const res = await request.get("http://localhost:3000/admin/searches"); + // Middleware should return 404, not 401, to hide existence + expect(res.status()).toBe(404); + }); +}); diff --git a/tests/shortlist.spec.ts b/tests/shortlist.spec.ts new file mode 100644 index 0000000..2d6ba36 --- /dev/null +++ b/tests/shortlist.spec.ts @@ -0,0 +1,81 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Shortlist", () => { + test.beforeEach(async ({ page }) => { + await page.goto("http://localhost:3000"); + await expect(page.locator("text=Meadowside Nursery")).toBeVisible({ + timeout: 10000, + }); + }); + + test("save and unsave a nursery from a card", async ({ page }) => { + // First nursery's heart should not be pressed initially + const firstHeart = page + .getByRole("button", { name: /Add .* to shortlist/ }) + .first(); + await expect(firstHeart).toBeVisible(); + await expect(firstHeart).toHaveAttribute("aria-pressed", "false"); + + // Save it + await firstHeart.click(); + await expect(firstHeart).toHaveAttribute("aria-pressed", "true"); + + // Nav counter should appear with "1" + const navLink = page.getByRole("link", { + name: /View shortlist \(1 nursery\)/, + }); + await expect(navLink).toBeVisible(); + + // Unsave it + await firstHeart.click(); + await expect(firstHeart).toHaveAttribute("aria-pressed", "false"); + }); + + test("shortlist page shows saved nurseries and compare view", async ({ page }) => { + // Save first two nurseries + const hearts = page.getByRole("button", { name: /Add .* to shortlist/ }); + await hearts.nth(0).click(); + await hearts.nth(1).click(); + + // Navigate to shortlist + await page.getByRole("link", { name: /View shortlist/ }).click(); + await expect(page).toHaveURL(/\/shortlist/); + + // Should see two cards on the shortlist page + await expect(page.getByRole("heading", { name: "Your shortlist" })).toBeVisible(); + + // Switch to compare view + await page.getByRole("tab", { name: "Compare" }).click(); + + // Compare table should show row labels + await expect(page.getByRole("rowheader", { name: "Daily fee" })).toBeVisible(); + await expect(page.getByRole("rowheader", { name: "Ofsted" })).toBeVisible(); + await expect(page.getByRole("rowheader", { name: "Tags" })).toBeVisible(); + }); + + test("shortlist persists across page reloads", async ({ page }) => { + const firstHeart = page + .getByRole("button", { name: /Add .* to shortlist/ }) + .first(); + await firstHeart.click(); + await expect(firstHeart).toHaveAttribute("aria-pressed", "true"); + + await page.reload(); + await expect(page.locator("text=Meadowside Nursery")).toBeVisible({ + timeout: 10000, + }); + + // Heart should still be pressed + const heartAfterReload = page + .getByRole("button", { name: /Remove .* from shortlist/ }) + .first(); + await expect(heartAfterReload).toBeVisible(); + await expect(heartAfterReload).toHaveAttribute("aria-pressed", "true"); + }); + + test("empty shortlist shows guidance", async ({ page }) => { + await page.goto("http://localhost:3000/shortlist"); + await expect(page.getByText("Nothing saved yet")).toBeVisible(); + await expect(page.getByRole("link", { name: "Browse nurseries" })).toBeVisible(); + }); +});