Files
NextChair/e2e/staff-journeys.spec.ts
Stefan Lazic 31c48296d9
CI / ci (push) Canceled after 0s
In App Notifications
Policies section (business-details-form.tsx)

Late Arrival Policy, Booking Terms and Privacy Policy URL are gone. The section now has Cancellation Policy plus the Terms & Privacy name and email dropdowns.
Their database columns stay, so nothing is lost. Saving no longer touches them.

Email and push only

One switch, IN_APP_NOTIFICATIONS_VISIBLE in in-app.ts, is set to false and controls everything below.

Bell icon: gone for customers, staff and owners, including the unread badge.
Notification pages: /notifications, /staff/notifications and /owner/notifications now return 404.

Settings: Settings → Notifications shows only Email and Push toggles.
Testing: I checked all of this in a local test app as the owner.
2026-10-17 11:34:30 +01:00

271 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { expect, test } from "@playwright/test";
import { createPool } from "mariadb";
import { buildMysqlPoolConfig } from "../src/lib/db/mysql-pool-config";
import { IN_APP_NOTIFICATIONS_VISIBLE } from "../src/services/notifications/in-app";
import { SEEDED, completeStaffBookingFlow, signIn } from "./helpers";
/**
* The 9 barber-operations journeys required by the Phase 5 brief. Each
* drives real forms against the real dev server and the seeded MySQL
* database (migrated from Postgres in Phase 7 — see e2e/global-setup.ts /
* prisma/seed.ts). Journeys that only
* read data reuse the seed's fixed fixtures (today's appointments across
* every status, Sam Osei sitting at the loyalty threshold, Leo's time off);
* journeys that mutate state book a fresh appointment first so they don't
* fight each other or depend on run order — the same principle
* customer-journeys.spec.ts documents. The exceptions are the two tests
* that intentionally chain (3 → 4, both driving the *same* fresh
* appointment through check-in/start/complete/stamp) and the time-off
* conflict test, which deliberately reuses the seed's untouched
* "Marcus + Priya, later today" CONFIRMED appointment as its conflict
* fixture.
*/
let chainedAppointmentUrl: string | null = null;
test.describe.serial("barber operations journeys", () => {
// Journeys 8 and 9 create a break/time-off with no deterministic id for
// the seed to upsert against, so a mid-test assertion failure (a real
// possibility while iterating on this suite) could leave them behind and
// break journey 1 on the *next* run — journey 1 asserts today's schedule
// is visible, which a leftover "whole day off" row would hide. This
// safety net removes them directly regardless of how the tests above
// exited, on top of (not instead of) each test's own UI-driven cleanup.
test.afterAll(async () => {
const pool = createPool(buildMysqlPoolConfig());
await pool.query("DELETE FROM `TimeOff` WHERE reason = 'E2E conflict test'");
await pool.query("DELETE FROM `Break` WHERE label = 'Afternoon tea'");
await pool.end();
});
test("1. barber signs in and sees today's schedule", async ({ page }) => {
await signIn(page, SEEDED.owner.email);
await page.goto("/staff");
await expect(page.getByRole("heading", { name: "Calendar" })).toBeVisible();
// Seeded: Marcus has appointments in every status today. (.first() because on
// desktop the "Right Now" side panel repeats the in-progress customer's name.)
await expect(page.getByText("Alex Chen").first()).toBeVisible();
await expect(page.getByText("Sam Osei").first()).toBeVisible();
await expect(page.getByText("IN PROGRESS").first()).toBeVisible();
});
test("2. barber opens the current appointment", async ({ page }) => {
await signIn(page, SEEDED.owner.email);
await page.goto("/staff/current");
// Tier 1 of selectCurrentAppointment: the IN_PROGRESS row wins.
await expect(page.getByText("Sam Osei")).toBeVisible();
await expect(page.getByText("In Progress", { exact: true })).toBeVisible();
await expect(page.getByText(/stamps/)).toBeVisible();
});
test("3. barber books, checks in, and starts an appointment", async ({ page }) => {
await signIn(page, SEEDED.owner.email);
await page.goto("/staff/book?q=Jordan");
await page.getByRole("button", { name: /Jordan Blake/ }).click();
await completeStaffBookingFlow(page, {
serviceName: SEEDED.services.haircut,
staffName: SEEDED.owner.name,
});
chainedAppointmentUrl = page.url();
// A staff-created booking defaults to CONFIRMED (no PENDING approval step).
await expect(page.getByText("Confirmed", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "Check In", exact: true }).click();
await expect(page.getByText("Arrived", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "Start Appointment", exact: true }).click();
await expect(page.getByText("In Progress", { exact: true })).toBeVisible();
});
test("4. barber completes the appointment and adds a loyalty stamp", async ({ page }) => {
if (!chainedAppointmentUrl) throw new Error("journey 3 must run first to create the appointment");
await signIn(page, SEEDED.owner.email);
await page.goto(chainedAppointmentUrl);
await page.getByRole("button", { name: "Complete Appointment", exact: true }).click();
await page.getByRole("button", { name: "Complete", exact: true }).click();
await expect(page.getByText("This appointment is marked complete.")).toBeVisible();
// Jordan Blake has no eligibility restrictions in the seed, so completion auto-awards a stamp.
await expect(page.getByText("A loyalty stamp was added for this visit.")).toBeVisible();
await page.getByRole("button", { name: "Done", exact: true }).click();
await expect(page.getByText("Completed", { exact: true })).toBeVisible();
// The manual "Add Loyalty Stamp" action is independent of the auto-award above.
await page.getByRole("button", { name: "Add Loyalty Stamp", exact: true }).click();
await page.getByRole("button", { name: "Add Stamp", exact: true }).click();
await expect(page.getByText("The stamp has been recorded on this customer's loyalty account.")).toBeVisible();
await page.getByRole("button", { name: "Done", exact: true }).click();
// "Book Follow-Up" only appears once COMPLETED — hand off to journey 6.
await expect(page.getByRole("button", { name: "Book Follow-Up" })).toBeVisible();
});
test("5. barber finds a customer and adds a note", async ({ page }) => {
await signIn(page, SEEDED.owner.email);
await page.goto("/staff/clients");
await page.getByPlaceholder("Search by name, email, or phone").fill("Osei");
await page.keyboard.press("Enter");
await page.getByRole("link", { name: /Sam Osei/ }).click();
await expect(page.getByText("Sam Osei")).toBeVisible();
const noteText = `E2E note ${Date.now()}`;
await page.getByPlaceholder("Add a note about this customer…").fill(noteText);
await page.getByRole("button", { name: "Add Note", exact: true }).click();
await expect(page.getByText(noteText)).toBeVisible();
});
test("6. barber books a follow-up from a completed appointment", async ({ page }) => {
if (!chainedAppointmentUrl) throw new Error("journey 4 must run first to complete the appointment");
await signIn(page, SEEDED.owner.email);
await page.goto(chainedAppointmentUrl);
await page.getByRole("button", { name: "Book Follow-Up" }).click();
// Services (and barber, if still active/bookable) are preselected from the original visit,
// but the barber still confirms this step themselves before moving on.
await expect(page).toHaveURL(/\/staff\/book\/services/);
await page.getByRole("button", { name: "Continue", exact: true }).click();
await expect(page).toHaveURL(/\/staff\/book\/barber-time/);
await expect(page.locator("section", { hasText: "Date" }).locator("button[aria-pressed]").first()).toBeVisible();
await page.locator("section", { hasText: "Date" }).locator("button[aria-pressed]:not([disabled])").first().click();
await page.locator("section", { hasText: "Time" }).locator("button").first().click();
await page.getByRole("button", { name: "Continue", exact: true }).click();
await expect(page).toHaveURL(/\/staff\/book\/review/);
await page.getByRole("button", { name: "Confirm Booking" }).click();
await expect(page).toHaveURL(/\/staff\/appointments\/[^/]+$/);
await expect(page.getByText("Jordan Blake")).toBeVisible();
});
test("7. barber changes their working hours", async ({ page }) => {
await signIn(page, SEEDED.barberHaircutOnly.email);
await page.goto("/staff/settings/working-hours");
const sundaySwitch = page.getByRole("switch", { name: "Sunday working" });
const wasOff = (await sundaySwitch.getAttribute("data-state")) === "unchecked";
await sundaySwitch.click();
await page.getByRole("button", { name: "Save", exact: true }).click();
await expect(page.getByText("Saved.")).toBeVisible();
await page.reload();
const expectedState = wasOff ? "checked" : "unchecked";
await expect(page.getByRole("switch", { name: "Sunday working" })).toHaveAttribute(
"data-state",
expectedState,
);
// Restore the seeded default so this test is repeatable. This save can itself surface an
// unrelated conflict — the seed's own "genuinely future" fixture for Leo (see prisma/seed.ts,
// appt-leo-future) is dated relative to whenever the seed happened to run and can
// coincidentally land on a Sunday, which is off by default — so handle either outcome rather
// than assuming a clean save (same pattern as owner-journeys.spec.ts journey 4's restore step).
await page.getByRole("switch", { name: "Sunday working" }).click();
await page.getByRole("button", { name: "Save", exact: true }).click();
const saveAnywayButton = page.getByRole("button", { name: "Save Anyway", exact: true });
const savedStatus = page.getByText("Saved.");
await expect(saveAnywayButton.or(savedStatus)).toBeVisible();
if (await saveAnywayButton.isVisible()) {
await saveAnywayButton.click();
}
await expect(savedStatus).toBeVisible();
});
test("8. barber adds a recurring break", async ({ page }) => {
await signIn(page, SEEDED.barberSkinfadeBeard.email);
await page.goto("/staff/settings/breaks");
await page.getByRole("button", { name: "Add Break", exact: true }).click();
const sheet = page.getByRole("dialog");
await sheet.getByLabel("Label").fill("Afternoon tea");
await sheet.getByLabel("Start").fill("15:30");
await sheet.getByLabel("End").fill("15:45");
await sheet.getByRole("button", { name: "Tue", exact: true }).click();
await sheet.getByRole("button", { name: "Add Break", exact: true }).click();
await expect(page.getByText("Afternoon tea")).toBeVisible();
await expect(page.getByText(/3:30 PM–3:45 PM/)).toBeVisible();
// Clean up — this test has no unique/deterministic id to upsert against on a re-run,
// so leaving it behind would accumulate a duplicate break every time the suite runs.
await page.getByText("Afternoon tea").click();
await sheet.getByRole("button", { name: "Delete Break", exact: true }).click();
await expect(page.getByText("Afternoon tea")).toHaveCount(0);
});
test("9. barber adds time off and resolves a scheduling conflict", async ({ page }) => {
await signIn(page, SEEDED.owner.email);
await page.goto("/staff/settings/time-off");
const now = new Date();
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
await page.getByRole("button", { name: "Add Time Off", exact: true }).click();
const sheet = page.getByRole("dialog");
await sheet.getByLabel("Start date").fill(today);
await sheet.getByLabel("End date").fill(today);
await sheet.getByLabel("Reason (optional)").fill("E2E conflict test");
await sheet.getByRole("button", { name: "Add Time Off", exact: true }).click();
// Seeded: Marcus has a CONFIRMED appointment with Priya Kapoor later today — a real conflict.
await expect(sheet.getByText(/overlaps \d+ existing appointment/)).toBeVisible();
await expect(sheet.getByText("Priya Kapoor")).toBeVisible();
await sheet.getByRole("button", { name: "Save Anyway", exact: true }).click();
await expect(page.getByText("E2E conflict test")).toBeVisible();
// The conflicting appointment itself is untouched — never silently cancelled. (The
// calendar's own day view now shows "You're off this day" instead of the appointment
// list, since Marcus just marked the whole day as time off — a separate, deliberate
// UI choice, not a sign the appointment was cancelled.)
await page.goto("/staff/clients");
await page.getByPlaceholder("Search by name, email, or phone").fill("Priya");
await page.keyboard.press("Enter");
await page.getByRole("link", { name: /Priya Kapoor/ }).click();
await expect(page.getByRole("tab", { name: "Upcoming" })).toHaveAttribute("data-state", "active");
await expect(page.getByText(SEEDED.services.haircut).first()).toBeVisible();
await expect(page.getByText("Confirmed", { exact: true }).first()).toBeVisible();
// Clean up — this test has no unique/deterministic id to upsert against on a re-run,
// so leaving Marcus marked off today would break journey 1's assertions next time.
await page.goto("/staff/settings/time-off");
await page.getByText("E2E conflict test").click();
const editSheet = page.getByRole("dialog");
await editSheet.getByRole("button", { name: "Remove Time Off", exact: true }).click();
await expect(page.getByText("E2E conflict test")).toHaveCount(0);
});
test("10. barber receives a new-booking notification", async ({ page }) => {
test.skip(!IN_APP_NOTIFICATIONS_VISIBLE, "In-app notifications are hidden — see src/features/notifications/in-app.ts");
// Deliberately books its own fresh appointment rather than relying on journey 3's — every
// other independent journey in this file follows that same "book a fresh appointment first"
// rule (see this file's header comment), and this one was the exception, which made it fail
// in confusing ways whenever it ran outside the full serial file (e.g. `-g` filtering past
// journey 3). See NotificationService/booking-notifications.ts, notifyBookingCreated, which
// queues STAFF_NEW_APPOINTMENT for the assigned barber on every booking.
await signIn(page, SEEDED.owner.email);
await page.goto("/staff/book?q=Jordan");
await page.getByRole("button", { name: /Jordan Blake/ }).click();
await completeStaffBookingFlow(page, {
serviceName: SEEDED.services.haircut,
staffName: SEEDED.owner.name,
});
await page.goto("/staff");
const bell = page.getByRole("link", { name: /Notifications, \d+ unread/ });
await expect(bell).toBeVisible();
await bell.click();
await expect(page).toHaveURL(/\/staff\/notifications/);
await expect(page.getByText("New appointment").first()).toBeVisible();
await expect(page.getByText(/Jordan Blake/).first()).toBeVisible();
});
});