Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 80 additions & 22 deletions tests/enquiry.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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
Expand All @@ -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();
});
});
77 changes: 77 additions & 0 deletions tests/seo.spec.ts
Original file line number Diff line number Diff line change
@@ -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("<urlset");
expect(body).toContain("/nursery/meadowside-nursery");
expect(body).toContain("/nursery/little-scholars");
});

test("robots.txt is served and references sitemap", async ({ request }) => {
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);
});
});
81 changes: 81 additions & 0 deletions tests/shortlist.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading