diff --git a/E2E_USER_STORIES.md b/E2E_USER_STORIES.md index 5d503904d4..62b732b5ed 100644 --- a/E2E_USER_STORIES.md +++ b/E2E_USER_STORIES.md @@ -118,11 +118,11 @@ the PR gate. | WP-E2E-08 | P1 | A booking made by one user is **not** visible in another user's listing, and cannot be deleted by them. | **done** — `local/booking-scoping.spec.ts`. Red-checked: the other user's listing really is empty while the booking exists. | | WP-E2E-09 | P1 | Booking a **locker** end to end. | todo — **more setup than desks**, not the same pattern. Lockers come from locker *banks* then lockers within them (`loadLockerResources`), so seeding is two-level. Budget accordingly. | | WP-E2E-10 | P1 | Booking a **parking** space end to end. | todo — **more setup than desks**. Needs a level zone tagged `parking` plus spaces created through the parking API (`queryParkingSpacesForZones`), not Zone metadata. | -| WP-E2E-11 | P2 | Inviting a **visitor** end to end. | **done** — expanded into its own section, [§1a](#1a-workplace--visitor-invites) (VIS-01 … VIS-14) | +| WP-E2E-11 | P2 | Inviting a **visitor** end to end. | **done** — expanded into its own section, [§1a](#1a-workplace--visitor-invites) (VIS-01 … VIS-23) | | WP-E2E-12 | P2 | Directory / colleagues search returns seeded users. | todo | | WP-E2E-13 | P2 | The explore/map view renders for a seeded level and reflects availability. | todo — needs map metadata seeded | | WP-E2E-14 | P2 | Search validation and empty states: no blank page, no console error. | todo | -| WP-E2E-15 | P1 | **Room/meeting** booking end to end. | **out of scope (external)** — the only surface needing a real Microsoft/Google tenant. Opt-in project, never in the PR gate. | +| WP-E2E-15 | P1 | **Room/meeting** booking end to end. | **partial** — split in two once it turned out only half of it is external. The **PlaceOS-native** path (`app.events.use_bookings = true`) is local and covered: its own section, [§1b](#1b-workplace--room-bookings) (ROOM-01 … ROOM-23). The **calendar** path still needs a real Microsoft/Google tenant — `/events` and `/calendars` 500 here — and stays opt-in, never in the PR gate. | | WP-E2E-04 | P2 | Mock mode still renders the landing page with no backend at all. | **done** — `landing.spec.ts` (project `mock`) | ## 1a. Workplace — visitor invites @@ -183,6 +183,90 @@ defects. Listed here so the coverage record is honest about what the suite knows | **Deep link** | `?booking=` never opens the details modal: `booking-card.component.ts` checks `params.has('booking')` and then compares the id against `params.get('event')`, a parameter that is not there. | | **Dead config** | `bookings.allowed_daily_visitor_count` (default 100) is never read anywhere in this repo. Confirmed with the dev that the limit is not wanted. | +## 1b. Workplace — room bookings + +Twenty-two tests in ten files under `apps/workplace/e2e/local/room-*.spec.ts`, with their own +support code in `e2e/support/room/` — separate from both the desk support files and the +visitor ones, so nothing room-shaped can break either. Working notes: +[`e2e/ROOM_E2E_HANDOVER.md`](e2e/ROOM_E2E_HANDOVER.md). + +**These rows exist because WP-E2E-15 was only half right.** The calendar path really is +external and stays out (see that row). But with **`app.events.use_bookings = true`** the same +meeting form saves an ordinary PlaceOS booking of type `room` and reads availability from the +bookings list — no outbound call, fully local. Every row here runs in that mode, which has a +consequence that must be said out loud in any review: **a green run proves the PlaceOS-native +room path works and says nothing about the calendar path.** Whether real customers run one, the +other or both is [open question 3](#notes--blockers) and it decides what this coverage is worth. + +**Rooms are the first resource that must genuinely be created.** A desk is a row in zone +metadata and a visitor is just an email; a room is an engine **System**. It is seeded by +`e2e/support/room/room.seed.ts` — **three** rooms per worker, on demand, idempotent — rather +than by the shared `seed.ts`, so a room mistake cannot break the desk specs. That needs ADMIN, +and is cached per process. The three are a normal room, an `alt` room to move a booking into +(ROOM-16) and a capacity-**1** `small` room (ROOM-17/18); capacity belongs to the System, so it +has to be test data rather than a setting. `catering.seed.ts` does the same for a catering +menu, which is made of **assets**, not settings — see ROOM-22. + +**Not covered, and why.** Real Outlook/Google invites, free/busy and attendee availability need +a real tenant (WP-E2E-15). Email of any kind has no mail server in the stack. Room panels, +signage and recurring meetings are other apps or the calendar path. As with §1a, a second +building cannot be exercised — the stack seeds one org, one building, one level. **Checking in +to a room booking (ROOM-23) is blocked by the stack, not by effort** — it is the one room row +that needs something the local deployment does not have; the reason is in its row. + +| ID | P | Story | Status | +|----|---|-------|--------| +| ROOM-01 | P1 | A **non-admin** books a room through the full UI — form, confirmation, success — and the backend stores a `room` booking with the right system, meeting name and window. | **done** — `local/room-booking.spec.ts`. Nothing is sent until the second screen. The real meeting name is in `extension_data.title`; the booking's own `title` is always the literal "Room Booking", so asserting on it fails against a correct booking. | +| ROOM-02 | P1 | A deleted room booking leaves the listing (teardown really tears down). | **done** — `local/room-booking.spec.ts` | +| ROOM-03 | P1 | A room is **exclusive**: a second user is refused `409` for the same window *and* for a partial overlap, with a control that a clear window is still accepted `201`. | **done** — `local/room-clash.spec.ts`. The desk equivalent is REG-02. Created through the API on purpose: the picker hides busy rooms, so through the form "refused" and "never offered" are indistinguishable. Attempted as a *second* user, so a per-user-only check would fail. `409` specifically, not `>= 400` — a REG-09 `500` must not pass as clash detection. | +| ROOM-04 | P1 | The room frees up once the booking is deleted, so a cancelled meeting does not hold a room forever. | **done** — `local/room-clash.spec.ts` | +| ROOM-05 | P1 | One user's room booking is **not** visible to another user, and cannot be deleted by them. | **done** — `local/room-scoping.spec.ts`. Does not contradict ROOM-03: a room's *availability* is shared, which is why someone else gets a 409; the *booking* — who booked it, what the meeting is called, who is coming — is private. | +| ROOM-06 | P1 | Control for ROOM-05: you *can* see your own room booking, so "nobody sees anything" cannot pass as success. | **done** — `local/room-scoping.spec.ts` | +| ROOM-07 | P1 | The day and start time chosen on the form are the ones stored. | **done** — `local/room-times.spec.ts`. Every other room row takes whatever the form offers, so a form that ignored the pickers would have passed the lot. The **length** is a different story — ROOM-14. | +| ROOM-08 | P2 | A maximum meeting length and bookable hours limit what the form offers. | **done** — `local/room-times.spec.ts`. Asserts on the options *offered*, because an out-of-range choice is absent rather than refused. The keys are `app.events.*`, not `app.bookings.*`: set the bookings keys and the form happily offers an 8-hour meeting. | +| ROOM-09 | P2 | An attendee added on the form is stored with the booking. | **done** — `local/room-attendees.spec.ts`. A room booking is the only one of the three surfaces with a real attendee list, so it is the only place the list can go wrong. Stored in `extension_data.attendees`, which also carries the room itself as a resource. | +| ROOM-10 | P2 | An attendee removed before sending is not invited, and the rest still are. | **done** — `local/room-attendees.spec.ts` | +| ROOM-11 | P1 | Cancelling a room booking **from the app** — the booking menu and its confirmation — really removes it on the backend. | **blocked** — `local/room-cancel.spec.ts`, `test.fixme`. ROOM-B4 below. Every other room spec tears down through the API, so this button was never once pressed. | +| ROOM-12 | P1 | Declining that confirmation leaves the booking alone. | **done** — `local/room-cancel.spec.ts`. A dialog whose decline button also deletes is worse than one that fails to delete. It passing while ROOM-11 does not is what shows ROOM-B4 is the delete itself, not the menu or the dialog. | +| ROOM-13 | P1 | A room booking carries its **zone hierarchy** (org, building, level), as desk and visitor bookings do. | **blocked** — `local/room-booking.spec.ts`, `test.fixme`. ROOM-B2 below. Fold it into ROOM-01 once the app populates zones. | +| ROOM-14 | P1 | The meeting **length** chosen on the form is the length the room is held for. | **blocked** — `local/room-times.spec.ts`, `test.fixme`. ROOM-B3 below. Fold it into ROOM-07 once the app carries the choice through. | +| ROOM-15 | P1 | Re-opening a booking and choosing a new **start time** stores the new time, keeps the room, and updates the same booking rather than replacing it. | **done** — `local/room-edit.spec.ts`. The first row here to exercise a `PATCH` rather than a `POST`: `saveBooking` branches on the id, so an edit that lost it would create a second booking and hold the room twice. | +| ROOM-16 | P1 | Moving a booking to **another room** stores it against that room, and does not move it in time. | **done** — `local/room-edit.spec.ts`. Needs the second seeded room — with one room, "the room changed" and "the field was ignored" are the same observation. | +| ROOM-17 | P1 | With `app.events.strict_capacity_check`, a meeting with more people than the room holds is **refused by the form and nothing reaches the backend**. | **done** — `local/room-capacity.spec.ts`. Asserts on the network, not on a message. Carries its own control — one attendee lighter, the same form reaches the confirm screen. Red-checked: without the setting the over-capacity meeting gets straight through. | +| ROOM-18 | P2 | **By default** the same meeting is only **warned** about, and still books. | **done** — `local/room-capacity.spec.ts`. The shipped behaviour, so the one real users get. Only the room changes between the control and the assertion, which is what makes it about capacity. | +| ROOM-19 | P2 | A room marked as a **favourite** is saved to the user's own settings and the "Favorites Only" filter then narrows the picker to it. | **done** — `local/room-favourites.spec.ts`. The one room feature that outlives the booking flow: it is stored in the user's `settings` metadata, debounced ~2.4s, so the spec polls the backend rather than trusting the star. | +| ROOM-20 | P1 | A room booked with the default settings is stored **unapproved** (`tentative`), and still holds the room. | **done** — `local/room-approval.spec.ts`. Also the control for ROOM-21. | +| ROOM-21 | P2 | With `app.bookings.no_approval`, the booking is stored **approved**. | **blocked** — `local/room-approval.spec.ts`, `test.fixme`. ROOM-B1 below, **re-measured 2026-09-16**: the booking POST is still a `500`. | +| ROOM-22 | P1 | **Catering** ordered on the form reaches the backend as its own `catering-order` booking, linked to the meeting. | **blocked** — `local/room-catering.spec.ts`, `test.fixme`. ROOM-B5 below. The menu seeding works and the form offers catering; it is the order that cannot be saved in this mode. | +| ROOM-23 | P1 | **Checking in** to a room booking. | **blocked — by the stack, not by a bug.** No spec, deliberately. The check-in button only renders when a websocket `binding` to a **`Bookings` driver module** on the room's System reports a status (`event-details-modal.component.ts`: `mod="Bookings" bind="status"`, and the button also needs `room_status() !== 'free'`). This stack has one driver (`spec_helper`) and one module (`PrivateHelper`) — measured — so no room can ever have that module, and the control can never appear. Unblocking it means building and running a real driver in the e2e stack, which is a stack change, not a spec. Contrast VIS-11, where visitor check-in is a plain API call and is covered. | + +### Findings from building this coverage + +Five, all reproduced and minimised, **none filed**. Each has a `fixme` row above waiting on it. + +| ID | Finding | +|----|---------| +| **ROOM-B5** | **Catering cannot be ordered with a PlaceOS-native room booking.** The meeting is created (`201`) and the catering order that follows is refused: `POST /bookings` with `booking_type: catering-order` → **422 `{"error":"error linking booking to event","failures":[{"field":"event_id","reason":"Could not find metadata for event ARRAY['1138']"}]}`**. The order is linked to a calendar **event** by `event_id`, and in `use_bookings` mode there is no event — the id handed over is a *booking* id, so the lookup finds nothing. Measured what survives, because that is what decides the severity: **the room booking is left behind undeleted and no catering order exists**, while the user is shown an error on the confirm screen and has every reason to think nothing was booked. `postForm` does call `_removeBookingAfterError` for a catering failure and it did not roll the room back. Blocks ROOM-22. | +| **ROOM-B4** | **Cancelling a room booking from the schedule does nothing.** Confirming the cancel fires `DELETE /api/staff/v1/events/` → **500**, and the booking is still live afterwards. In `use_bookings` mode a room booking *is* a staff-api booking, but `schedule.component.ts` deletes whatever it is displaying as an event (`item instanceof CalendarEvent ? removeEvent : removeBooking`) and a room booking is rebuilt into a `CalendarEvent` for display — so it takes the calendar path and fails. Worse than cosmetic: the room stays held by a booking the user believes they cancelled, refusing everyone else while looking free on their own screen. Blocks ROOM-11. | +| **ROOM-B3** | **The meeting length picked on the form is not the length booked.** Ask for 90 minutes: the field reads "1 hour 30 minutes" and the confirmation shows 6:00–7:30 PM, but the request sends `booking_end` at 7:00 PM while `extension_data.event_end` says 7:30. `newBookingFromCalendarEvent` reads `event.duration`, which is still the default. The user sees one range and the room is held for another, so the last half hour looks free to everybody else. Not timing and not ordering — both orders were tried, and the field still reads 90 four seconds later, immediately before the confirmation is sent. Blocks ROOM-14. | +| **ROOM-B2** | A room booked through the app is stored with **`zones: []`**, where desk and visitor bookings carry org, building and level. Anything scoping by zone cannot see it — and it is what walks the request into ROOM-B1. Blocks ROOM-13. | +| **ROOM-B1** | A non-admin sending `approved: true` **without** zones gets **500 `syntax error at or near ")" (PQ::PQError)`**. With zones it is correctly refused `403`; an admin gets `201`; desks do it too, so this is not room-specific — the approval permission check dies instead of refusing when it has no zones to check against. Reachable from the app via `app.bookings.no_approval = true`, which is why that preset (`NO_APPROVAL` in `room.settings.ts`) is **not** in the base settings. Compounds with ROOM-B2: a room booking made through the form carries no zones at all, which is what walks it into this. Blocks ROOM-21, and **re-measured through the app on 2026-09-16** — still a `500`, with an empty response body. | + +### Still to write + +The seven scenarios this section was opened with are done or accounted for (ROOM-15 … ROOM-23). +What is left, in rough order of value: + +- **Equipment / asset requests** on a meeting — the other half of the "catering and equipment" + row. The form's asset section is a separate flow from catering (`AssetRequest`, + `validateAssetRequestsForResource`) and would need its own seeding, like the menu did. +- **Recurring** room bookings in `use_bookings` mode. The calendar path is out of scope, but + `toBookingRecurrence` suggests the native path takes a pattern, and nothing tests it. +- **Multi-room** meetings. `multipleSpacesEnabled` changes the picker's confirm button and the + form's whole shape, and every row here books exactly one room. +- **Room features / facilities** filtering in the picker, which has the same shape as ROOM-19's + favourites filter. + ## 2. Auth & session Grounded in the auth.cr work (PPT-2536), where every production failure was an @@ -233,13 +317,31 @@ Config gaps caused several production incidents, and they are invisible to UI sp ## Notes & blockers -- **Room/calendar events are the only genuinely external surface.** A placeholder tenant - unblocks every PlaceOS-native booking type (desks, lockers, parking, visitors) with no - outbound call. `/calendars` and `/events` do call Microsoft and fail `AADSTS900023`, so - WP-E2E-15 stays opt-in and out of the gate. -- **Four rows are blocked on product fixes, not on test effort** (REG-08, REG-09, REG-10, - VIS-15). All were found by this suite. Leaving them visible here is the point — a blocked row is coverage +- **The room CALENDAR path is the only genuinely external surface — the room itself is not.** + A placeholder tenant unblocks every PlaceOS-native booking type (desks, lockers, parking, + visitors) with no outbound call, and `app.events.use_bookings = true` puts **rooms** in that + same group: the meeting form then saves an ordinary `room` booking locally, which is what §1b + covers. `/calendars` and `/events` do call Microsoft and fail `AADSTS900023`, so the calendar + half of WP-E2E-15 stays opt-in and out of the gate. +- **Open, and it decides what §1b is worth: do real customers book rooms through the calendar, + or through `use_bookings`?** Nobody has answered it. If the answer is "the calendar", the + green rows in §1b guard a path those customers never take. This needs a product answer, not more + specs. +- **Nine rows are blocked on product fixes, not on test effort** (REG-08, REG-09, REG-10, + VIS-15, ROOM-11, ROOM-13, ROOM-14, ROOM-21, ROOM-22). All were found by this suite. Leaving them visible here is the point — a blocked row is coverage information, a deleted row is not. +- **Two rows are blocked by the environment rather than by a bug** (AUTH-E2E-08, ROOM-23), and + both say what would unblock them. ROOM-23 needs a real `Bookings` driver running in the e2e + stack; there is no amount of spec work that substitutes for it. +- **Run hygiene: cancelled bookings accumulate, and the schedule counts them.** Every run leaves + soft-deleted rows behind; `GET /bookings` defaults to `limit=100` and the schedule sends + `include_deleted=true`, so once a user passes 100 their *new* bookings stop appearing and + every card-based spec fails for a reason that has nothing to do with the app. Measured: a + serial visitor run hit **106 cards** and failed 5 tests; one worker had **61 cancelled + bookings on a single day** by mid-afternoon; 428 stale rows had to be cleared by hand once. + The desk specs also fail more often as the suite grows, because more specs means more + parallel load. **Nothing should go near CI until this is settled**, and settling it is a + backend change or a purge step — not a spec change, and the user's decision either way. - **REG-09 is fixed, and worth reading about.** One burst of concurrent booking POSTs used to poison staff-api's connection pool, so booking creation returned 500 for everyone until the service restarted — while reads kept working, because they ran inside the orphaned transaction. diff --git a/apps/workplace/e2e/local/bookings-cancel.spec.ts b/apps/workplace/e2e/local/bookings-cancel.spec.ts new file mode 100644 index 0000000000..8bf2dd2006 --- /dev/null +++ b/apps/workplace/e2e/local/bookings-cancel.spec.ts @@ -0,0 +1,198 @@ +/** + * YB-06 / YB-07 — cancelling a booking from Your Bookings. + * + * Every desk spec in this suite tears its booking down through the API, which is + * right for a teardown and useless as coverage: the Cancel item in a desk + * booking's menu, and the confirmation in front of it, have never been pressed + * by anything. The equivalent gap for rooms and visitors was worth two bugs + * between them, one of which (ROOM-B4) still holds a room after the user thinks + * they cancelled it. + * + * The pair is deliberate, and the second test is the important one: a dialog + * whose DECLINE button also deletes is worse than one that fails to delete, and + * only "I said no and it is still there" can catch it. + * + * Both read the backend afterwards. The card leaves the screen either way — + * the page removes it optimistically — so the screen is not evidence. + * + * ## Why this lives with the page and not with the desk form + * + * The cancel control belongs to the schedule, not to the booking form. Keeping + * it here means the desk specs stay about the desk form, and this file owns + * every route into the schedule's cancel dialog regardless of booking type. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { deleteBooking, uniqueTitle } from '../../../../e2e/support/api'; +import { deskFor } from '../../../../e2e/support/env'; +import { + SCHEDULE_DAYS, + dayBoundsOn, + slotOn, +} from '../../../../e2e/support/bookings/bookings.env'; +import { + createBookingViaApi, + readBooking, + isLive, + releaseFor, +} from '../../../../e2e/support/bookings/bookings.api'; +import { SchedulePage } from '../../../../e2e/support/bookings/schedule.page'; + +const DAY = 86_400; + +/** + * The window a spec sweeps and lists over: EXACTLY its own day. + * + * Not the day plus or minus one. A wider window reaches into the day another + * spec file owns, and the sweep that lets a spec recover from its own past + * failures then clears somebody else's booking instead. + */ +function dayWindow(dayOffset: number) { + return dayBoundsOn(dayOffset); +} + +test.describe('your bookings — cancelling', () => { + test('cancelling a desk booking from the list removes it for real', async ({ + staffPage, + staffApi, + }, testInfo) => { + const desk = deskFor(testInfo.parallelIndex); + const day = SCHEDULE_DAYS.cancel; + const slot = slotOn(day, 9); + const { from, to } = dayWindow(day); + let booking_id: number | undefined; + + await releaseFor(staffApi, 'desk', desk.id, from, to); + + try { + const booking = await createBookingViaApi(staffApi, { + type: 'desk', + asset_id: desk.id, + asset_name: desk.name, + title: uniqueTitle('E2E YB Cancel'), + start: slot.start, + end: slot.end, + }); + booking_id = booking.id; + // POLLED, not read once. Under a full parallel run a booking that + // was just created has been seen missing from the very next listing + // call and present a moment later — read-after-write lag on a + // hammered stack. Asserting immediately turned that into "the + // booking was not live before anyone pressed Cancel", which is + // alarming and wrong: the booking existed, the list was behind. + await expect(async () => { + const live = await isLive(staffApi, 'desk', booking_id!, from, to); + if (!live) { + const row = await readBooking(staffApi, booking_id!); + expect( + live, + `the new booking is not in the listing yet. By id it reads: ` + + `deleted=${row.deleted} start=${row.booking_start} ` + + `window=${from}-${to}`, + ).toBe(true); + } + }).toPass({ timeout: 20_000 }); + + const schedule = new SchedulePage(staffPage); + await schedule.open(); + // `showBooking` rather than day-then-filters, because the page's list + // has been seen going stale under a full parallel run — see the note + // on the helper. + await schedule.showBooking(booking_id, slot.date_ms, ['desk'], async () => { + const now = await readBooking(staffApi, booking_id!); + return `id ${now.id} deleted=${now.deleted} start=${new Date( + now.booking_start * 1000, + ).toString()}`; + }); + + // Menu -> Cancel -> confirm. `startCancel` opens the details modal, + // the overflow menu, and picks the item by its ICON, because the + // labels are translated. + await schedule.startCancel(booking_id); + await schedule.acceptConfirm(); + + await expect(async () => { + expect( + await isLive(staffApi, 'desk', booking_id!, from, to), + 'after confirming, the booking must be gone on the BACKEND, not ' + + 'just off the screen', + ).toBe(false); + }).toPass({ timeout: 30_000 }); + + // And the desk is free again — which is the thing a user actually + // cares about after cancelling. + const still_held = await isLive(staffApi, 'desk', booking_id, from, to); + expect(still_held, 'the desk must not stay held by a cancelled booking').toBe( + false, + ); + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + await releaseFor(staffApi, 'desk', desk.id, from, to); + } + }); + + test('declining the confirmation leaves the booking alone', async ({ + staffPage, + staffApi, + }, testInfo) => { + const desk = deskFor(testInfo.parallelIndex); + // Its own day, not another hour on test 1's day — see SCHEDULE_DAYS. + const day = SCHEDULE_DAYS.cancel_declined; + const slot = slotOn(day, 15); + const { from, to } = dayWindow(day); + let booking_id: number | undefined; + + await releaseFor(staffApi, 'desk', desk.id, from, to); + + try { + const booking = await createBookingViaApi(staffApi, { + type: 'desk', + asset_id: desk.id, + asset_name: desk.name, + title: uniqueTitle('E2E YB Keep'), + start: slot.start, + end: slot.end, + }); + booking_id = booking.id; + + const schedule = new SchedulePage(staffPage); + await schedule.open(); + await schedule.showBooking(booking_id, slot.date_ms, ['desk'], async () => { + const now = await readBooking(staffApi, booking_id!); + return `id ${now.id} deleted=${now.deleted}`; + }); + + await schedule.startCancel(booking_id); + await schedule.dismissConfirm(); + + // Give the app the chance to do the wrong thing before believing it + // did the right one. Asserting immediately would pass against a + // delete that is merely slow. + await staffPage.waitForTimeout(3_000); + expect( + await isLive(staffApi, 'desk', booking_id, from, to), + 'declining the confirmation must NOT delete the booking', + ).toBe(true); + + // It is also still on the page, which is the user-visible half. + // + // A reload puts the schedule back on TODAY with every filter on — + // nothing about the view is persisted — so the day and the filter + // have to be set again before looking for the card. Forgetting that + // reads as "the booking was deleted after all", which is the + // opposite of what happened. + await staffPage.reload(); + await schedule.waitForLoaded(); + await schedule.showBooking(booking_id, slot.date_ms, ['desk'], async () => { + const now = await readBooking(staffApi, booking_id!); + return `id ${now.id} deleted=${now.deleted}`; + }); + await expect( + schedule.card(booking_id), + 'and it is still listed after a reload', + ).toBeVisible({ timeout: 30_000 }); + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + await releaseFor(staffApi, 'desk', desk.id, from, to); + } + }); +}); diff --git a/apps/workplace/e2e/local/bookings-checkin.spec.ts b/apps/workplace/e2e/local/bookings-checkin.spec.ts new file mode 100644 index 0000000000..e1988fe90f --- /dev/null +++ b/apps/workplace/e2e/local/bookings-checkin.spec.ts @@ -0,0 +1,129 @@ +/** + * YB-10 — checking in to a desk booking, and back out again. + * + * Desk check-in is the one check-in this stack can actually exercise end to end. + * The three surfaces differ, and it is worth being explicit about why only this + * one is here: + * + * desk ....... `POST /bookings/:id/check_in`, a plain staff-api call. Covered + * by this test. + * visitor .... the same call. Covered by `visitor-checkin.spec.ts`. + * room ....... a websocket binding to a `Bookings` DRIVER module on the room's + * System. This stack has no such driver, so the control can never + * render (ROOM-23 in the coverage doc). Not testable here. + * + * ## Why this booking is minutes away and not days + * + * The control only exists while a booking is about to start or is running + * (`booking-details-modal.component.ts` gates it on + * `state === 'upcoming' | 'started' | 'in_progress'`), so unlike every other + * spec on this page the booking cannot sit safely days out. It is booked ten + * minutes from now, which also means this is the one file here that shares TODAY + * with the desk specs — hence the sweep of today's window before booking. + * + * Both halves matter. A check-in that cannot be reversed leaves a desk marked + * occupied after the person has left, which is worse for a colleague looking for + * a seat than never checking in at all. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { deleteBooking, uniqueTitle } from '../../../../e2e/support/api'; +import { deskFor } from '../../../../e2e/support/env'; +import { + createBookingViaApi, + readBooking, + releaseFor, +} from '../../../../e2e/support/bookings/bookings.api'; +import { SchedulePage } from '../../../../e2e/support/bookings/schedule.page'; + +const MINUTE = 60; +const DAY = 86_400; +const window_from = () => Math.floor(Date.now() / 1000) - DAY; +const window_to = () => Math.floor(Date.now() / 1000) + DAY; + +test.describe('your bookings — checking in', () => { + test('a desk booking can be checked in from the list, and checked back out', async ({ + staffPage, + staffApi, + }, testInfo) => { + const desk = deskFor(testInfo.parallelIndex); + const start = Math.floor(Date.now() / 1000) + 10 * MINUTE; + const title = uniqueTitle('E2E YB CheckIn'); + let booking_id: number | undefined; + + // TODAY is shared with the desk specs, which book this same desk all day. + // Sweeping first is what lets this test run after them, and after a run + // that died holding the desk. + await releaseFor(staffApi, 'desk', desk.id, window_from(), window_to()); + + try { + const booking = await createBookingViaApi(staffApi, { + type: 'desk', + asset_id: desk.id, + asset_name: desk.name, + title, + start, + end: start + 60 * MINUTE, + }); + booking_id = booking.id; + expect( + (await readBooking(staffApi, booking_id)).checked_in, + 'precondition: a new booking is not checked in', + ).toBeFalsy(); + + const schedule = new SchedulePage(staffPage); + await schedule.open(); + // The day is set explicitly rather than trusting the page's default — + // a run that crosses midnight would otherwise look at the wrong day — + // and `showBooking` also re-checks that the card really arrived. + await schedule.showBooking(booking_id, start * 1000, ['desk']); + + const modal = await schedule.openDetails(booking_id); + const control = schedule.checkInButton(modal); + await expect( + control, + 'a booking ten minutes from now should offer check-in. If this is ' + + 'missing, check `bookings.hide_checkin` is not set and that the ' + + 'booking really is within its window', + ).toBeVisible({ timeout: 20_000 }); + await control.click(); + + // The backend is the assertion. The button turns green either way. + await expect(async () => { + expect( + (await readBooking(staffApi, booking_id!)).checked_in, + 'checking in must be recorded on the backend', + ).toBe(true); + }).toPass({ timeout: 30_000 }); + + // Now back out. Same control — the app flips it rather than + // rendering a second button. + await expect(control, 'the control should still be there to check out').toBeVisible( + { timeout: 10_000 }, + ); + await control.click(); + // Some types confirm the check-out; accept it if a dialog appears, + // and carry on if it does not. + const confirmed = await staffPage + .locator('.cdk-overlay-container footer button[name="accept"]') + .first() + .click({ timeout: 5_000 }) + .then(() => true) + .catch(() => false); + if (!confirmed) { + // Nothing to accept — the click above was the check-out itself. + } + + await expect(async () => { + const after = await readBooking(staffApi, booking_id!); + expect( + after.checked_in, + 'checking out must be recorded on the backend too, or a desk stays ' + + 'marked occupied after the person has left', + ).toBeFalsy(); + }).toPass({ timeout: 30_000 }); + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + await releaseFor(staffApi, 'desk', desk.id, window_from(), window_to()); + } + }); +}); diff --git a/apps/workplace/e2e/local/bookings-edit.spec.ts b/apps/workplace/e2e/local/bookings-edit.spec.ts new file mode 100644 index 0000000000..c7b2933301 --- /dev/null +++ b/apps/workplace/e2e/local/bookings-edit.spec.ts @@ -0,0 +1,160 @@ +/** + * YB-08 — Edit from Your Bookings opens the right form for the booking. + * + * The schedule lists every booking type together and has to route each one to a + * different place (`schedule.component.ts`): desks, parking, lockers and + * visitors go to `/book/` through `editBooking`, while room bookings are + * rebuilt into calendar events and go to the meeting form through `edit`. + * + * One routing table, four destinations, and the failure is silent: press Edit on + * a desk booking, land on an EMPTY desk form, and you have quietly made a second + * booking instead of changing the first. Nothing else in the suite covers the + * routing itself — the room specs use the room branch and the visitor specs the + * visitor branch, each in isolation. + * + * So these two tests are about arriving in the right place with the booking + * loaded, not about saving: what a saved edit stores is the business of + * `desk-edit`, `visitor-edit` and `room-edit`. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { deleteBooking, uniqueTitle } from '../../../../e2e/support/api'; +import { deskFor } from '../../../../e2e/support/env'; +import { + SCHEDULE_DAYS, + dayBoundsOn, + scheduleVisitorFor, + slotOn, +} from '../../../../e2e/support/bookings/bookings.env'; +import { + createBookingViaApi, + readBooking, + deleteGuestByEmail, + releaseFor, +} from '../../../../e2e/support/bookings/bookings.api'; +import { SchedulePage } from '../../../../e2e/support/bookings/schedule.page'; +import { + ALLOW_VISITOR_EDITING, + useSettings, +} from '../../../../e2e/support/bookings/bookings.settings'; + +const DAY = 86_400; + +/** + * The window a spec sweeps and lists over: EXACTLY its own day. + * + * Not the day plus or minus one. A wider window reaches into the day another + * spec file owns, and the sweep that lets a spec recover from its own past + * failures then clears somebody else's booking instead. + */ +function dayWindow(dayOffset: number) { + return dayBoundsOn(dayOffset); +} + +test.describe('your bookings — editing', () => { + test('editing a desk booking lands on the desk form, with the booking loaded', async ({ + staffPage, + staffApi, + }, testInfo) => { + const desk = deskFor(testInfo.parallelIndex); + const day = SCHEDULE_DAYS.edit; + const slot = slotOn(day, 9); + const { from, to } = dayWindow(day); + const title = uniqueTitle('E2E YB Edit Desk'); + let booking_id: number | undefined; + + await releaseFor(staffApi, 'desk', desk.id, from, to); + + try { + const booking = await createBookingViaApi(staffApi, { + type: 'desk', + asset_id: desk.id, + asset_name: desk.name, + title, + start: slot.start, + end: slot.end, + }); + booking_id = booking.id; + + const schedule = new SchedulePage(staffPage); + schedule.expectEditForm('desk-flow-form'); + await schedule.open(); + await schedule.showBooking(booking_id, slot.date_ms, ['desk'], async () => { + const now = await readBooking(staffApi, booking_id!); + return `id ${now.id} deleted=${now.deleted} start=${new Date( + now.booking_start * 1000, + ).toString()}`; + }); + await schedule.startEdit(booking_id); + + // The URL is half the assertion: landing on the desk form by + // accident from somewhere else would satisfy the element check. + await expect( + staffPage, + 'editing a desk booking should route to the desk flow', + ).toHaveURL(/#\/book\/desk/, { timeout: 30_000 }); + + // And the form must have the booking IN it. An empty form is the + // dangerous outcome: saving it makes a second booking rather than + // changing this one. + await expect( + staffPage.locator('input[name$=".title"]').first(), + 'the desk form should be pre-filled with the booking being edited', + ).toHaveValue(title, { timeout: 30_000 }); + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + await releaseFor(staffApi, 'desk', desk.id, from, to); + } + }); + + test('editing a visitor invite lands on the visitor form', async ({ + staffPage, + staffApi, + }, testInfo) => { + const visitor = scheduleVisitorFor(testInfo.parallelIndex, 'edit'); + const day = SCHEDULE_DAYS.edit; + const slot = slotOn(day, 15); + const { from, to } = dayWindow(day); + const title = uniqueTitle('E2E YB Edit Visit'); + let booking_id: number | undefined; + + await releaseFor(staffApi, 'visitor', visitor.email, from, to); + // Without this the menu has no Edit item at all — `can_edit` is false for + // a visitor booking unless editing is switched on. + await useSettings(staffPage, ALLOW_VISITOR_EDITING); + + try { + const booking = await createBookingViaApi(staffApi, { + type: 'visitor', + asset_id: visitor.email, + asset_name: visitor.name, + title, + start: slot.start, + end: slot.end, + attendees: [{ name: visitor.name, email: visitor.email }], + }); + booking_id = booking.id; + + const schedule = new SchedulePage(staffPage); + schedule.expectEditForm('invite-visitor-form'); + await schedule.open(); + await schedule.showBooking(booking_id, slot.date_ms, ['visitor']); + await schedule.startEdit(booking_id); + + await expect( + staffPage, + 'editing a visitor invite should route to the visitor flow', + ).toHaveURL(/#\/book\/visitor/, { timeout: 30_000 }); + + // The visitor is the invite's identity, so the form arriving without + // them is the same failure as an empty desk form. + await expect( + staffPage.locator('invite-visitor-form'), + 'the visitor form should carry the invited visitor', + ).toContainText(visitor.email.split('@')[0], { timeout: 30_000 }); + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + await deleteGuestByEmail(staffApi, visitor.email); + await releaseFor(staffApi, 'visitor', visitor.email, from, to); + } + }); +}); diff --git a/apps/workplace/e2e/local/bookings-empty.spec.ts b/apps/workplace/e2e/local/bookings-empty.spec.ts new file mode 100644 index 0000000000..1cdf88ebda --- /dev/null +++ b/apps/workplace/e2e/local/bookings-empty.spec.ts @@ -0,0 +1,91 @@ +/** + * YB-11 — a day with nothing on it. + * + * The cheapest test on this page and the one with the worst failure mode: an + * empty list that renders as a blank panel looks identical to a page that + * failed to load, and a user cannot tell whether they have no bookings or the + * app is broken. Nothing else in this suite ever looks at a day with no + * bookings, because every other spec seeds one first. + * + * It also guards the console. A day with no data is where null handling shows + * up, so the page is asserted to render its empty state with no page error. + * + * ## The day + * + * `SCHEDULE_DAYS.empty` is a day no spec ever seeds. That matters more here than + * anywhere else on the page: the schedule requests bookings with + * `include_deleted: true` and renders cancelled ones too, so a day this suite + * has ever used keeps showing cards long after the bookings are gone. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { SCHEDULE_DAYS, slotOn } from '../../../../e2e/support/bookings/bookings.env'; +import { SchedulePage } from '../../../../e2e/support/bookings/schedule.page'; + +test.describe('your bookings — an empty day', () => { + test('a day with no bookings shows the empty state, not a blank panel', async ({ + staffPage, + }) => { + const day = slotOn(SCHEDULE_DAYS.empty, 12); + + // Anything the page logs as an error, kept for the assertion below. + // + // Server errors are collected WITH THEIR URL rather than from the + // console. A console line for a failed request says only "the server + // responded with 500", which cannot be told apart from a genuine page + // fault — and this stack has a known one (see the filter below). + // + // Only UNCAUGHT EXCEPTIONS count as the page's own failure. Console + // errors are not used: on a stack this suite hammers they arrive from + // everywhere — failed requests, third-party noise, another test's load — + // and a spec that fails on any of them fails for reasons that have + // nothing to do with this page. Measured: one run in three tripped on a + // console line about a request that had nothing to do with the schedule. + // + // An uncaught exception is the thing that actually produces a blank + // panel, so that is what is asserted, plus any 5xx below. + const problems: string[] = []; + const server_errors: string[] = []; + staffPage.on('pageerror', (error) => problems.push(`pageerror: ${error.message}`)); + staffPage.on('response', (r) => { + if (r.status() < 500) return; + server_errors.push(`${r.status()} ${new URL(r.url()).pathname}`); + }); + + const schedule = new SchedulePage(staffPage); + await schedule.open(); + await schedule.showDayOf(day.date_ms); + await schedule.waitForLoaded(); + + await expect( + schedule.emptyState, + 'a day with no bookings must show the empty state. A blank panel is ' + + 'indistinguishable from a page that failed to load', + ).toBeVisible({ timeout: 30_000 }); + + expect( + await schedule.cardCount(), + 'and there should be no cards on a day nothing was ever booked on', + ).toBe(0); + + expect( + problems, + `the empty day must not throw an uncaught exception, got:\n${problems.join('\n')}`, + ).toEqual([]); + + // The calendar endpoints are EXPECTED to fail here and are excluded by + // name, not by silence: `/events` and `/calendars` call Microsoft or + // Google, and this stack has placeholder tenant credentials, so they + // return 500 on every page that asks for calendar events. That is the + // documented out-of-scope surface (WP-E2E-15), not a fault in this page. + // + // Every OTHER 5xx is this page's problem and fails the test. + const unexpected = server_errors.filter( + (e) => !/\/(events|calendars)(\/|$)/.test(e), + ); + expect( + unexpected, + `the empty day should not provoke a server error other than the known ` + + `calendar ones. All 5xx seen: ${JSON.stringify(server_errors)}`, + ).toEqual([]); + }); +}); diff --git a/apps/workplace/e2e/local/bookings-filters.spec.ts b/apps/workplace/e2e/local/bookings-filters.spec.ts new file mode 100644 index 0000000000..10219be484 --- /dev/null +++ b/apps/workplace/e2e/local/bookings-filters.spec.ts @@ -0,0 +1,211 @@ +/** + * YB-02 / YB-03 — the type filters on Your Bookings. + * + * The filters are the only way a user narrows this page, and they fail in a + * particular way that no other spec would notice: a filter that hides a card on + * screen while the booking is still fetched looks correct, and a filter that is + * remembered across a reload when it should not be (or forgotten when it + * should) looks correct too. Both are one-click bugs for a user and invisible in + * a screenshot. + * + * Test 1 turns a type off and on again, with a card of a DIFFERENT type on the + * same day as a control. Without that control, "the card disappeared" is + * satisfied by a page that simply broke. + * + * Test 2 uses the chips above the list — a second, separate control for the same + * state, which could drift from the toggle and leave a user unable to put back a + * filter they removed. + * + * It then reloads, and asserts the filters go back to ALL TYPES ON. That is the + * app's actual behaviour and it is deliberate: `shown_types` is a plain signal in + * `schedule-state.service.ts` with no storage behind it, so nothing survives a + * reload. Measured, after a first draft of this test assumed the opposite. + * Asserting it on purpose means a future change that starts persisting filters + * fails here and gets a decision, instead of quietly changing what users see. + * + * Both seed through the API: the subject is the page, not the booking forms. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { deleteBooking, uniqueTitle } from '../../../../e2e/support/api'; +import { deskFor } from '../../../../e2e/support/env'; +import { + SCHEDULE_DAYS, + dayBoundsOn, + scheduleVisitorFor, + slotOn, +} from '../../../../e2e/support/bookings/bookings.env'; +import { + createBookingViaApi, + deleteGuestByEmail, + releaseFor, +} from '../../../../e2e/support/bookings/bookings.api'; +import { SchedulePage } from '../../../../e2e/support/bookings/schedule.page'; + +const DAY = 86_400; + +/** + * The window a spec sweeps and lists over: EXACTLY its own day. + * + * Not the day plus or minus one. A wider window reaches into the day another + * spec file owns, and the sweep that lets a spec recover from its own past + * failures then clears somebody else's booking instead. + */ +function dayWindow(dayOffset: number) { + return dayBoundsOn(dayOffset); +} + +test.describe('your bookings — type filters', () => { + test('turning a type off hides only that type, and turning it on brings it back', async ({ + staffPage, + staffApi, + }, testInfo) => { + const desk = deskFor(testInfo.parallelIndex); + const visitor = scheduleVisitorFor(testInfo.parallelIndex, 'filters'); + const day = SCHEDULE_DAYS.filters; + const desk_slot = slotOn(day, 9); + const visit_slot = slotOn(day, 14); + const { from, to } = dayWindow(day); + const ids: number[] = []; + + await releaseFor(staffApi, 'desk', desk.id, from, to); + await releaseFor(staffApi, 'visitor', visitor.email, from, to); + + try { + const desk_booking = await createBookingViaApi(staffApi, { + type: 'desk', + asset_id: desk.id, + asset_name: desk.name, + title: uniqueTitle('E2E YB Filter Desk'), + start: desk_slot.start, + end: desk_slot.end, + }); + ids.push(desk_booking.id); + const visit = await createBookingViaApi(staffApi, { + type: 'visitor', + asset_id: visitor.email, + asset_name: visitor.name, + title: uniqueTitle('E2E YB Filter Visit'), + start: visit_slot.start, + end: visit_slot.end, + attendees: [{ name: visitor.name, email: visitor.email }], + }); + ids.push(visit.id); + + const schedule = new SchedulePage(staffPage); + await schedule.open(); + await schedule.showBooking(desk_booking.id, desk_slot.date_ms, [ + 'desk', + 'visitor', + ]); + + await expect( + schedule.card(desk_booking.id), + 'precondition: the desk booking is listed before any filtering', + ).toBeVisible({ timeout: 30_000 }); + await expect( + schedule.card(visit.id), + 'precondition: so is the visitor booking', + ).toBeVisible({ timeout: 30_000 }); + + // Desks off. The desk card goes, the visitor card stays — the second + // half is what makes this about the filter rather than about the + // page falling over. + await schedule.setShown('desk', false); + await expect( + schedule.card(desk_booking.id), + 'with desks filtered out, the desk booking must not be listed', + ).toBeHidden({ timeout: 20_000 }); + await expect( + schedule.card(visit.id), + 'filtering desks out must not hide a visitor booking', + ).toBeVisible({ timeout: 20_000 }); + + // And back on again. A filter that only works one way is worse than + // one that does not work at all, because the booking looks deleted. + await schedule.setShown('desk', true); + await expect( + schedule.card(desk_booking.id), + 'turning desks back on must bring the booking back', + ).toBeVisible({ timeout: 20_000 }); + } finally { + for (const id of ids) await deleteBooking(staffApi, id); + await deleteGuestByEmail(staffApi, visitor.email); + await releaseFor(staffApi, 'desk', desk.id, from, to); + await releaseFor(staffApi, 'visitor', visitor.email, from, to); + } + }); + + test('the filter chip removes a type, and the choice survives a reload', async ({ + staffPage, + staffApi, + }, testInfo) => { + const desk = deskFor(testInfo.parallelIndex); + const day = SCHEDULE_DAYS.filters; + const slot = slotOn(day, 16); + const { from, to } = dayWindow(day); + let booking_id: number | undefined; + + await releaseFor(staffApi, 'desk', desk.id, from, to); + + try { + const booking = await createBookingViaApi(staffApi, { + type: 'desk', + asset_id: desk.id, + asset_name: desk.name, + title: uniqueTitle('E2E YB Chip'), + start: slot.start, + end: slot.end, + }); + booking_id = booking.id; + + const schedule = new SchedulePage(staffPage); + await schedule.open(); + await schedule.showBooking(booking_id, slot.date_ms, ['desk']); + await expect( + schedule.card(booking_id), + 'precondition: the booking is listed with the desk filter on', + ).toBeVisible({ timeout: 30_000 }); + + // The chip is a second control for the same state. If it drifted + // from the toggle, a user could remove a filter they cannot put back. + const chip = schedule.filterChip('desk'); + if (await chip.count()) { + await chip.click({ timeout: 10_000 }); + await expect( + schedule.card(booking_id), + 'removing the desk filter with its chip must hide the booking', + ).toBeHidden({ timeout: 20_000 }); + expect( + await schedule.isShown('desk'), + 'and the toggle must agree with the chip — two controls, one state', + ).toBe(false); + } else { + // Say so rather than skipping silently: a missing chip is a + // layout difference, and the reload half below still applies. + console.warn(' ! no desk filter chip on screen; testing the reload only'); + await schedule.setShown('desk', false); + } + + // A reload resets the filters, because they are in-memory only. + await staffPage.reload(); + await schedule.waitForLoaded(); + expect( + await schedule.isShown('desk'), + 'the filters are not persisted anywhere, so a reload must put every ' + + 'type back on. If this fails, the app started remembering them — ' + + 'which is a product decision, not a test fix', + ).toBe(true); + + // And the booking is listed again, on its own day. + await schedule.showDayOf(slot.date_ms); + await schedule.waitForLoaded(); + await expect( + schedule.card(booking_id), + 'with the filters reset, the booking is listed again', + ).toBeVisible({ timeout: 30_000 }); + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + await releaseFor(staffApi, 'desk', desk.id, from, to); + } + }); +}); diff --git a/apps/workplace/e2e/local/bookings-list.spec.ts b/apps/workplace/e2e/local/bookings-list.spec.ts new file mode 100644 index 0000000000..b0ac87d4b0 --- /dev/null +++ b/apps/workplace/e2e/local/bookings-list.spec.ts @@ -0,0 +1,279 @@ +/** + * YB-01 / YB-04 / YB-13 — the Your Bookings page lists what you booked. + * + * The page every booking flow hands off to, and until now the only page in the + * app that was USED by other specs without being TESTED by any. The visitor and + * room specs drive it as a means to an end — open it, find a card, press a menu + * item — so a page that listed the wrong bookings, or listed them on the wrong + * day, would still let all of them pass. + * + * Three questions, in the order they matter: + * + * 1. does it list the user's own bookings, of more than one type, on the day + * they fall on? + * 2. does the sidebar calendar really change which day is listed? + * 3. does it ASK the backend for the right thing — the right window and the + * right types? + * + * The third is on the wire on purpose. A page that fetched a wide window and + * filtered in the browser would look identical while being a listing-limit bug + * waiting to happen; that is precisely how the 100-row problem in the notes + * hides. + * + * ## The bookings here are seeded through the API, not booked through a form + * + * The subject is the page. Driving the desk form and the visitor form first + * would add two unrelated ways to fail before this spec has begun — and both + * are already covered by their own files. + * + * ## Do not assert an exact card count on this page + * + * The schedule asks for bookings with **`include_deleted: true`** + * (`schedule-state.service.ts`) and renders the cancelled ones too, marked + * "Cancelled". It only hides what the user deleted in the CURRENT session, from + * `sessionStorage`. So a user's cancelled bookings keep appearing for ever, and + * the number of cards on any day grows with every run this suite makes. + * + * Measured while writing this file: with the backend reporting **zero** live + * bookings for the day, the page rendered **eight** cards, all of them from + * earlier runs. That is the same mechanism as the 100-row listing problem in the + * project notes, seen from the other end. + * + * So these tests assert on WHICH bookings are listed, against what the backend + * says is live, and never on how many cards are on screen. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { deleteBooking, listBookings, uniqueTitle } from '../../../../e2e/support/api'; +import { deskFor } from '../../../../e2e/support/env'; +import { + SCHEDULE_DAYS, + dayBoundsOn, + scheduleVisitorFor, + slotOn, +} from '../../../../e2e/support/bookings/bookings.env'; +import { + createBookingViaApi, + deleteGuestByEmail, + releaseFor, +} from '../../../../e2e/support/bookings/bookings.api'; +import { SchedulePage } from '../../../../e2e/support/bookings/schedule.page'; + +const DAY = 86_400; + +/** A window around one seeded day, for sweeping and for listing. */ +/** + * The window a spec sweeps and lists over: EXACTLY its own day. + * + * Not the day plus or minus one. A wider window reaches into the day another + * spec file owns, and the sweep that lets a spec recover from its own past + * failures then clears somebody else's booking instead. + */ +function dayWindow(dayOffset: number) { + return dayBoundsOn(dayOffset); +} + +test.describe('your bookings — the listing', () => { + test('the page lists your own bookings of more than one type on their day', async ({ + staffPage, + staffApi, + }, testInfo) => { + const desk = deskFor(testInfo.parallelIndex); + const visitor = scheduleVisitorFor(testInfo.parallelIndex, 'list'); + const day = SCHEDULE_DAYS.list; + const desk_slot = slotOn(day, 9); + const visit_slot = slotOn(day, 14); + const { from, to } = dayWindow(day); + const desk_title = uniqueTitle('E2E YB Desk'); + const visit_title = uniqueTitle('E2E YB Visit'); + const ids: { type: string; id: number }[] = []; + + // Sweep this spec's own day first, for both assets. A run that died + // between seeding and cleanup leaves cards behind, and this test counts + // cards — so a leftover does not just linger, it fails the next run for + // a reason that looks nothing like the cause. + await releaseFor(staffApi, 'desk', desk.id, from, to); + await releaseFor(staffApi, 'visitor', visitor.email, from, to); + + try { + const desk_booking = await createBookingViaApi(staffApi, { + type: 'desk', + asset_id: desk.id, + asset_name: desk.name, + title: desk_title, + start: desk_slot.start, + end: desk_slot.end, + }); + ids.push({ type: 'desk', id: desk_booking.id }); + + const visit = await createBookingViaApi(staffApi, { + type: 'visitor', + asset_id: visitor.email, + asset_name: visitor.name, + title: visit_title, + start: visit_slot.start, + end: visit_slot.end, + attendees: [{ name: visitor.name, email: visitor.email }], + }); + ids.push({ type: 'visitor', id: visit.id }); + + const schedule = new SchedulePage(staffPage); + await schedule.open(); + // Both types on, and only those two, so the assertions below are + // about these two bookings rather than whatever else the worker owns. + await schedule.showBooking(desk_booking.id, desk_slot.date_ms, [ + 'desk', + 'visitor', + ]); + + // Assert on the ids, not on a count. "Two cards are on screen" is + // true of the wrong two cards. + await expect( + schedule.card(desk_booking.id), + `the desk booking ${desk_booking.id} should be listed on its own day`, + ).toBeVisible({ timeout: 30_000 }); + await expect( + schedule.card(visit.id), + `the visitor booking ${visit.id} should be listed on the same day`, + ).toBeVisible({ timeout: 30_000 }); + + // Nothing LIVE on this day may be missing from the page. Compared + // against the backend rather than against a number, because the page + // also carries cancelled cards from previous runs — see the note at + // the top of this file. + const rendered = await schedule.renderedBookingIds(); + const day_start = desk_slot.start - 12 * 3600; + const day_end = desk_slot.start + 12 * 3600; + const live: number[] = []; + for (const type of ['desk', 'visitor']) { + const list = await listBookings(staffApi, type, from, to); + for (const booking of list) { + if (booking.deleted) continue; + if (booking.booking_start < day_start) continue; + if (booking.booking_start > day_end) continue; + live.push(Number(booking.id)); + } + } + expect( + live.length, + 'precondition: the backend has exactly the two bookings this test made', + ).toBe(2); + for (const id of live) { + expect( + rendered, + `booking ${id} is live on this day but is not on the page. ` + + `Rendered: [${rendered.join(', ')}]`, + ).toContain(id); + } + } finally { + for (const { id } of ids) await deleteBooking(staffApi, id); + await deleteGuestByEmail(staffApi, visitor.email); + await releaseFor(staffApi, 'desk', desk.id, from, to); + await releaseFor(staffApi, 'visitor', visitor.email, from, to); + } + }); + + test('the sidebar calendar changes which day is listed', async ({ + staffPage, + staffApi, + }, testInfo) => { + const desk = deskFor(testInfo.parallelIndex); + const day = SCHEDULE_DAYS.list; + const on_day = slotOn(day, 10); + const next_day = slotOn(day + 1, 10); + const title = uniqueTitle('E2E YB Day'); + const { from, to } = dayWindow(day); + let booking_id: number | undefined; + + await releaseFor(staffApi, 'desk', desk.id, from, to + DAY); + + try { + const booking = await createBookingViaApi(staffApi, { + type: 'desk', + asset_id: desk.id, + asset_name: desk.name, + title, + start: on_day.start, + end: on_day.end, + }); + booking_id = booking.id; + + const schedule = new SchedulePage(staffPage); + await schedule.open(); + await schedule.showOnly(['desk']); + + // The day it is on: the card is there. + await schedule.showBooking(booking_id, on_day.date_ms, ['desk']); + await expect( + schedule.card(booking_id), + 'the booking should be listed on the day it falls on', + ).toBeVisible({ timeout: 30_000 }); + + // The next day: it is not. This is the half that matters — a page + // that ignored the calendar and always listed a wide window would + // pass the first assertion and fail this one. + await schedule.showDayOf(next_day.date_ms); + await schedule.waitForLoaded(); + await expect( + schedule.card(booking_id), + 'the booking must NOT be listed on a day it does not fall on', + ).toBeHidden({ timeout: 30_000 }); + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + await releaseFor(staffApi, 'desk', desk.id, from, to + DAY); + } + }); + + test('the page asks the backend for one day at a time, per type', async ({ + staffPage, + }) => { + const day = SCHEDULE_DAYS.list; + const target = slotOn(day, 10); + + // Every bookings request the page makes, with its window. + const asked: { type: string; from: number; to: number }[] = []; + staffPage.on('request', (r) => { + const url = new URL(r.url()); + if (!url.pathname.endsWith('/api/staff/v1/bookings')) return; + if (r.method() !== 'GET') return; + asked.push({ + type: url.searchParams.get('type') ?? '', + from: Number(url.searchParams.get('period_start')), + to: Number(url.searchParams.get('period_end')), + }); + }); + + const schedule = new SchedulePage(staffPage); + await schedule.open(); + await schedule.showOnly(['desk', 'visitor']); + await schedule.showDayOf(target.date_ms); + await schedule.waitForLoaded(); + + const for_day = asked.filter( + (a) => a.from >= target.start - DAY && a.to <= target.start + DAY, + ); + expect( + for_day.length, + `the page should request the day being shown; it asked for ` + + `${JSON.stringify(asked.slice(0, 8))}`, + ).toBeGreaterThan(0); + + // A day, not a fortnight. The 100-row listing limit in the notes is + // reachable precisely because a wide window returns more rows than the + // default limit, so the width of this request is the thing to guard. + for (const ask of for_day) { + expect( + ask.to - ask.from, + `a listing request should cover about a day, not ${(ask.to - ask.from) / DAY} days`, + ).toBeLessThanOrEqual(2 * DAY); + } + + // And it asks per type rather than for everything at once — which is + // what makes the type filters meaningful on the backend as well as on + // screen. + const types = new Set(for_day.map((a) => a.type).filter(Boolean)); + expect( + [...types].length, + `the page should ask per booking type, got ${JSON.stringify([...types])}`, + ).toBeGreaterThan(1); + }); +}); diff --git a/apps/workplace/e2e/local/bookings-past.spec.ts b/apps/workplace/e2e/local/bookings-past.spec.ts new file mode 100644 index 0000000000..11a24f7e39 --- /dev/null +++ b/apps/workplace/e2e/local/bookings-past.spec.ts @@ -0,0 +1,93 @@ +/** + * YB-09 — a booking that has already finished. + * + * The schedule can be walked backwards, so a finished booking is not a + * hypothetical state — it is what most of a user's history looks like. The thing + * that must not happen is a finished booking still offering to be used: + * checking in to yesterday marks a desk occupied for a day nobody can attend, + * and it is recorded on the backend exactly like a real check-in. + * + * `booking-details-modal.component.ts` gates the control on + * `state === 'upcoming' | 'started' | 'in_progress'`, so a booking whose window + * has passed should offer nothing. This test is what stops that gate being + * loosened by accident. + * + * Deliberately NOT asserted here: whether a past booking can still be cancelled. + * The app does still offer it, and whether that is right is a product question + * rather than a defect — a test either way would be writing down an opinion. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { deleteBooking, uniqueTitle } from '../../../../e2e/support/api'; +import { deskFor } from '../../../../e2e/support/env'; +import { + SCHEDULE_DAYS, + dayBoundsOn, + slotOn, +} from '../../../../e2e/support/bookings/bookings.env'; +import { + createBookingViaApi, + readBooking, + releaseFor, +} from '../../../../e2e/support/bookings/bookings.api'; +import { SchedulePage } from '../../../../e2e/support/bookings/schedule.page'; + +const DAY = 86_400; + +test.describe('your bookings — a finished booking', () => { + test('a booking whose day has passed is listed, and offers no check-in', async ({ + staffPage, + staffApi, + }, testInfo) => { + const desk = deskFor(testInfo.parallelIndex); + const slot = slotOn(SCHEDULE_DAYS.past, 9); + const from = slot.start - DAY; + const to = slot.start + DAY; + const title = uniqueTitle('E2E YB Past'); + let booking_id: number | undefined; + + await releaseFor(staffApi, 'desk', desk.id, from, to); + + try { + const booking = await createBookingViaApi(staffApi, { + type: 'desk', + asset_id: desk.id, + asset_name: desk.name, + title, + start: slot.start, + end: slot.end, + }); + booking_id = booking.id; + expect( + slot.end, + 'precondition: this booking really is in the past', + ).toBeLessThan(Math.floor(Date.now() / 1000)); + + const schedule = new SchedulePage(staffPage); + await schedule.open(); + await schedule.showBooking(booking_id, slot.date_ms, ['desk']); + + // It is still listed — history is not hidden. + await expect( + schedule.card(booking_id), + 'a past booking should still be listed on its own day', + ).toBeVisible({ timeout: 30_000 }); + + const modal = await schedule.openDetails(booking_id); + await expect( + schedule.checkInButton(modal), + 'a booking that has already finished must NOT offer check-in — ' + + 'checking in to yesterday marks a desk occupied for a day nobody ' + + 'can attend, and the backend records it like any other check-in', + ).toBeHidden({ timeout: 10_000 }); + + // And nothing about opening it changed the booking. + expect( + (await readBooking(staffApi, booking_id)).checked_in, + 'and it is still not checked in', + ).toBeFalsy(); + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + await releaseFor(staffApi, 'desk', desk.id, from, to); + } + }); +}); diff --git a/apps/workplace/e2e/local/bookings-scoping.spec.ts b/apps/workplace/e2e/local/bookings-scoping.spec.ts new file mode 100644 index 0000000000..892f3ced70 --- /dev/null +++ b/apps/workplace/e2e/local/bookings-scoping.spec.ts @@ -0,0 +1,152 @@ +/** + * YB-05 — one person's bookings are not another person's business, on the page. + * + * `booking-scoping.spec.ts` already proves the API side: `GET /bookings` is + * caller-scoped and a delete attempt by somebody else is rejected. This is the + * other half, and it is a different failure: the page asks for several booking + * types at once and merges them into one list, and a merge that dropped the + * caller scope — an admin-ish query, a shared cache, an `include_booked_by` that + * is too generous — would leak a colleague's day into yours while every API test + * stayed green. + * + * The pair here is the usual one, and the control is not optional: a page that + * listed nothing at all would pass the first test on its own. + * + * The second user is real. A token is minted for another seeded identity rather + * than reusing this worker's bearer, so "another user" means another user rather + * than the same one twice. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { request } from '@playwright/test'; +import { deleteBooking, uniqueTitle } from '../../../../e2e/support/api'; +import { APP_URL, BACKEND_URL, WORKERS, deskFor, roleFor } from '../../../../e2e/support/env'; +import { mintToken } from '../../../../e2e/support/auth'; +import { + SCHEDULE_DAYS, + dayBoundsOn, + slotOn, +} from '../../../../e2e/support/bookings/bookings.env'; +import { + createBookingViaApi, + releaseFor, +} from '../../../../e2e/support/bookings/bookings.api'; +import { SchedulePage } from '../../../../e2e/support/bookings/schedule.page'; + +const DAY = 86_400; + +/** + * The window a spec sweeps and lists over: EXACTLY its own day. + * + * Not the day plus or minus one. A wider window reaches into the day another + * spec file owns, and the sweep that lets a spec recover from its own past + * failures then clears somebody else's booking instead. + */ +function dayWindow(dayOffset: number) { + return dayBoundsOn(dayOffset); +} + +/** An API context signed in as a DIFFERENT seeded user. */ +async function apiAsOtherUser(workerIndex: number) { + const role = roleFor('staff', workerIndex); + const mint = await mintToken(BACKEND_URL, APP_URL, role.email, role.password); + return request.newContext({ + baseURL: BACKEND_URL, + ignoreHTTPSErrors: true, + extraHTTPHeaders: { Authorization: `Bearer ${mint.accessToken}` }, + }); +} + +test.describe('your bookings — visibility between users', () => { + test('another user\'s booking is not listed on your page', async ({ + staffPage, + staffApi, + }, testInfo) => { + const mine = testInfo.parallelIndex; + const theirs = (mine + 1) % WORKERS; + test.skip(theirs === mine, 'needs at least two workers to have two distinct users'); + + const their_desk = deskFor(theirs); + const day = SCHEDULE_DAYS.scoping; + const slot = slotOn(day, 9); + const { from, to } = dayWindow(day); + const other = await apiAsOtherUser(theirs); + let their_booking: number | undefined; + + // Their desk, their booking, cleared by THEM: `GET /bookings` is + // caller-scoped, so this worker's sweep cannot see it and the desk would + // stay held by something invisible. + await releaseFor(other, 'desk', their_desk.id, from, to); + + try { + const booking = await createBookingViaApi(other, { + type: 'desk', + asset_id: their_desk.id, + asset_name: their_desk.name, + title: uniqueTitle('E2E YB Theirs'), + start: slot.start, + end: slot.end, + }); + their_booking = booking.id; + + const schedule = new SchedulePage(staffPage); + await schedule.open(); + await schedule.showOnly(['desk', 'visitor']); + await schedule.showDayOf(slot.date_ms); + await schedule.waitForLoaded(); + + await expect( + schedule.card(their_booking), + `booking ${their_booking} belongs to another user and must not appear ` + + `on this user's page`, + ).toBeHidden({ timeout: 20_000 }); + + const rendered = await schedule.renderedBookingIds(); + expect( + rendered, + `nothing of theirs should be listed. Rendered: [${rendered.join(', ')}]`, + ).not.toContain(their_booking); + } finally { + if (their_booking != null) await deleteBooking(other, their_booking); + await releaseFor(other, 'desk', their_desk.id, from, to); + await other.dispose(); + } + }); + + test('control: your own booking on the same day IS listed', async ({ + staffPage, + staffApi, + }, testInfo) => { + const desk = deskFor(testInfo.parallelIndex); + const day = SCHEDULE_DAYS.scoping; + const slot = slotOn(day, 14); + const { from, to } = dayWindow(day); + let booking_id: number | undefined; + + await releaseFor(staffApi, 'desk', desk.id, from, to); + + try { + const booking = await createBookingViaApi(staffApi, { + type: 'desk', + asset_id: desk.id, + asset_name: desk.name, + title: uniqueTitle('E2E YB Mine'), + start: slot.start, + end: slot.end, + }); + booking_id = booking.id; + + const schedule = new SchedulePage(staffPage); + await schedule.open(); + await schedule.showBooking(booking_id, slot.date_ms, ['desk']); + + await expect( + schedule.card(booking_id), + 'your own booking must be listed — without this, "nobody sees ' + + 'anything" would pass as success', + ).toBeVisible({ timeout: 30_000 }); + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + await releaseFor(staffApi, 'desk', desk.id, from, to); + } + }); +}); diff --git a/apps/workplace/e2e/local/desk-api.spec.ts b/apps/workplace/e2e/local/desk-api.spec.ts new file mode 100644 index 0000000000..4f12877628 --- /dev/null +++ b/apps/workplace/e2e/local/desk-api.spec.ts @@ -0,0 +1,229 @@ +/** + * DESK-18 / DESK-19 — the booking API's own behaviour for desks. + * + * Two gaps the UI specs cannot cover: + * + * 1. **Negative cases.** Every desk spec so far asks the backend to do + * something reasonable. Nothing asks it to do something wrong, so nothing + * would notice a bad request turning into a 500 — and a 500 is not just + * untidy here, it is how REG-09 poisons a connection for everybody else. + * 2. **REG-03**, a named past regression with no test: *"a clash check uses the + * current `booking_end`, not a stale one"*. The shape of the bug is a + * booking that is EXTENDED and then not respected — the check compares + * against the old end time, so the desk is double-booked for the extension. + * `desk-clash.spec.ts` only covers a booking that never changed. + * + * Both are API-only on purpose: the subject is the backend's rules, and driving + * a form would only add ways to fail. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { request } from '@playwright/test'; +import { + STAFF_API, + currentUser, + deleteBooking, + getBooking, + releaseAsset, + uniqueTitle, + zonesWithTag, +} from '../../../../e2e/support/api'; +import { + APP_URL, + BACKEND_URL, + WORKERS, + deskFor, + roleFor, +} from '../../../../e2e/support/env'; +import { mintToken } from '../../../../e2e/support/auth'; +import { DESK_SLOTS, dayBoundsOn, slotOn } from '../../../../e2e/support/desk/desk.env'; +import { createDeskBookingViaApi } from '../../../../e2e/support/desk/desk.api'; + +/** An API context signed in as a DIFFERENT seeded user. */ +async function apiAsOtherUser(workerIndex: number) { + const role = roleFor('staff', workerIndex); + const mint = await mintToken(BACKEND_URL, APP_URL, role.email, role.password); + return request.newContext({ + baseURL: BACKEND_URL, + ignoreHTTPSErrors: true, + extraHTTPHeaders: { Authorization: `Bearer ${mint.accessToken}` }, + }); +} + +test.describe('desk booking API rules', () => { + test('a malformed or impossible desk booking is refused with a 4xx, never a 5xx', async ({ + staffApi, + }, testInfo) => { + const desk = deskFor(testInfo.parallelIndex); + const slot = slotOn(DESK_SLOTS.api.day, DESK_SLOTS.api.hour); + const me = await currentUser(staffApi); + const zones = ( + await Promise.all( + ['org', 'building', 'level'].map((t) => zonesWithTag(staffApi, t)), + ) + ) + .flat() + .map((z) => z.id); + + const base = { + booking_type: 'desk', + asset_id: desk.id, + asset_name: desk.name, + booking_start: slot.start, + booking_end: slot.end, + timezone: 'Etc/UTC', + user_email: me.email, + user_id: me.id, + user_name: me.name, + title: uniqueTitle('E2E Desk Bad'), + zones, + }; + + // Each case is a request a client could plausibly send by accident. + const cases: { name: string; data: Record }[] = [ + { + name: 'a desk that does not exist', + data: { ...base, asset_id: 'e2e-desk-does-not-exist' }, + }, + { + name: 'an end time before the start time', + data: { ...base, booking_end: slot.start - 3600 }, + }, + { + name: 'no booking type at all', + data: { ...base, booking_type: undefined }, + }, + { name: 'no asset', data: { ...base, asset_id: undefined } }, + ]; + + const created: number[] = []; + try { + for (const { name, data } of cases) { + const res = await staffApi.post(`${STAFF_API}/bookings`, { data }); + const body = await res.text(); + if (res.ok()) { + // Accepting it is a finding rather than a crash, and this + // test is about 5xx, so record it and clean it up. + const id = JSON.parse(body).id; + if (id) created.push(id); + console.warn( + ` ! the backend ACCEPTED "${name}" (HTTP ${res.status()}). ` + + `That may be worth a bug of its own.`, + ); + continue; + } + expect( + res.status(), + `"${name}" must be refused with a 4xx, not a server error. ` + + `Got ${res.status()}: ${body.slice(0, 200)}. A 5xx here is ` + + `worse than untidy — a booking write that 500s is how REG-09 ` + + `poisons a connection for every other request`, + ).toBeLessThan(500); + } + } finally { + for (const id of created) await deleteBooking(staffApi, id); + } + }); + + test('a clash check uses the current booking_end, not the one it was created with', async ({ + staffApi, + }, testInfo) => { + const mine = testInfo.parallelIndex; + const theirs = (mine + 1) % WORKERS; + test.skip(theirs === mine, 'needs at least two workers to have two distinct users'); + + const desk = deskFor(mine); + // 9-10, then extended to 9-12. The extension is the point. + const first = slotOn(DESK_SLOTS.api.day, 9); + const { from, to } = dayBoundsOn(DESK_SLOTS.api.day); + const other = await apiAsOtherUser(theirs); + let booking_id: number | undefined; + const other_ids: number[] = []; + + await releaseAsset(staffApi, 'desk', desk.id, from, to); + await releaseAsset(other, 'desk', desk.id, from, to); + + try { + const booking = await createDeskBookingViaApi(staffApi, { + desk, + title: uniqueTitle('E2E Desk Extend'), + start: first.start, + end: first.start + 3600, + }); + booking_id = booking.id; + + // Extend it by two hours. + const extended_end = first.start + 3 * 3600; + const patch = await staffApi.patch(`${STAFF_API}/bookings/${booking_id}`, { + data: { booking_start: first.start, booking_end: extended_end }, + }); + expect( + patch.status(), + `extending the booking failed: ${await patch.text()}`, + ).toBeLessThan(300); + expect( + (await getBooking(staffApi, booking_id)).booking_end, + 'precondition: the booking really was extended', + ).toBe(extended_end); + + // Now somebody else asks for an hour INSIDE the extension. Against + // the original end time this looks free; against the current one it + // is taken. + const inside = { + booking_type: 'desk', + asset_id: desk.id, + asset_name: desk.name, + booking_start: first.start + 2 * 3600, + booking_end: first.start + 3 * 3600, + timezone: 'Etc/UTC', + title: uniqueTitle('E2E Desk Stale'), + }; + const them = await currentUser(other); + const res = await other.post(`${STAFF_API}/bookings`, { + data: { + ...inside, + user_email: them.email, + user_id: them.id, + user_name: them.name, + }, + }); + const body = await res.text(); + if (res.ok()) other_ids.push(JSON.parse(body).id); + expect( + res.status(), + `an hour inside the EXTENDED window must be refused with 409. Got ` + + `${res.status()}: ${body.slice(0, 200)}. Accepting it means the ` + + `clash check compared against the booking_end the row was created ` + + `with — that is REG-03, and the desk is now double-booked`, + ).toBe(409); + + // Control: an hour AFTER the extension is still free, so the refusal + // above was about the overlap and not about refusing everything. + const after = await other.post(`${STAFF_API}/bookings`, { + data: { + ...inside, + booking_start: extended_end + 3600, + booking_end: extended_end + 7200, + user_email: them.email, + user_id: them.id, + user_name: them.name, + title: uniqueTitle('E2E Desk Clear'), + }, + }); + const after_body = await after.text(); + if (after.ok()) other_ids.push(JSON.parse(after_body).id); + expect( + after.status(), + `an hour clear of the booking must still be accepted, got ` + + `${after.status()}: ${after_body.slice(0, 200)}`, + ).toBe(201); + } finally { + // Their bookings have to go BY THEM: `GET /bookings` is + // caller-scoped, so this worker's sweep cannot see them and the desk + // would stay held by something invisible. + for (const id of other_ids) await deleteBooking(other, id); + if (booking_id != null) await deleteBooking(staffApi, booking_id); + await releaseAsset(staffApi, 'desk', desk.id, from, to); + await other.dispose(); + } + }); +}); diff --git a/apps/workplace/e2e/local/desk-delegate.spec.ts b/apps/workplace/e2e/local/desk-delegate.spec.ts new file mode 100644 index 0000000000..12959a480b --- /dev/null +++ b/apps/workplace/e2e/local/desk-delegate.spec.ts @@ -0,0 +1,126 @@ +/** + * DESK-14 — booking a desk on somebody else's behalf. + * + * The visitor equivalent (VIS-09) found a real defect: the host picker rendered + * from one setting while the choice was only KEPT when a second one was also + * set, so a booking silently came back owned by whoever filled the form. The + * desk form has the same shape — a host field rendered from + * `app.bookings.can_book_for_others` — and nothing has ever checked which user + * the booking ends up against. + * + * It matters beyond bookkeeping: a desk booked "for" a colleague but stored + * against the booker does not appear on the colleague's schedule, so they have + * no seat as far as the app is concerned, and the person who booked it holds two. + * + * Both halves are asserted, because they are different fields: + * user_* the person the desk is FOR + * booked_by_* the person who made the booking + * + * ## One setting, not two + * + * Unlike visitors, the desk form reads `can_book_for_others` only + * (`desk-form-details.component.ts`). `can_book_for_anyone` is a visitor-form + * key; setting it here would look thorough and do nothing. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { + currentUser, + deleteBooking, + getBooking, + releaseAsset, + uniqueTitle, +} from '../../../../e2e/support/api'; +import { WORKERS, deskFor, staffEmail } from '../../../../e2e/support/env'; +import { + DESK_SLOTS, + dayBoundsOn, + hhmm, + slotOn, +} from '../../../../e2e/support/desk/desk.env'; +import { BOOK_FOR_OTHERS, useSettings } from '../../../../e2e/support/desk/desk.settings'; +import { DeskForm } from '../../../../e2e/support/desk/desk-form.page'; + +test.describe('booking a desk for a colleague', () => { + test('the chosen colleague is stored as the user, and you as the booker', async ({ + staffPage, + staffApi, + }, testInfo) => { + const mine = testInfo.parallelIndex; + const theirs = (mine + 1) % WORKERS; + test.skip(theirs === mine, 'needs at least two workers to have two distinct users'); + + const desk = deskFor(mine); + const colleague = staffEmail(theirs); + const slot = slotOn(DESK_SLOTS.delegate.day, DESK_SLOTS.delegate.hour); + const { from, to } = dayBoundsOn(DESK_SLOTS.delegate.day); + const title = uniqueTitle('E2E Desk ForColleague'); + let booking_id: number | undefined; + + await releaseAsset(staffApi, 'desk', desk.id, from, to); + await useSettings(staffPage, BOOK_FOR_OTHERS); + + try { + const me = await currentUser(staffApi); + const form = new DeskForm(staffPage); + await form.open(); + + // The host field only exists with the setting on. Saying so here + // turns a missing-selector timeout into a sentence about + // configuration. + await expect( + form.userField, + 'the host field is missing — is `app.bookings.can_book_for_others` set?', + ).toBeVisible({ timeout: 30_000 }); + + await form.pickDate(slot.date_ms); + + await expect(async () => { + await form.setChecked(form.requireLocker, false); + await form.setChecked(form.allDay, false); + await form.title.fill(title); + await form.chooseUser(colleague); + if ((await form.chosenDesks.count()) === 0) { + await form.chooseDesk(desk.name); + } + expect(await form.title.inputValue()).toBe(title); + }).toPass({ timeout: 60_000 }); + + await form.setStartTime(hhmm(slot.start)); + await staffPage.waitForTimeout(3_000); + + const [response] = await Promise.all([ + staffPage.waitForResponse( + (r) => + r.url().includes('/api/staff/v1/bookings') && + r.request().method() === 'POST', + { timeout: 30_000 }, + ), + form.confirmAndSend(), + ]); + expect( + response.status(), + `the booking POST failed: ${await response.text()}`, + ).toBeLessThan(300); + booking_id = (await response.json()).id; + + const stored: any = await getBooking(staffApi, booking_id!); + expect( + `${stored.user_email}`.toLowerCase(), + 'the desk must be booked FOR the colleague. If this comes back as the ' + + 'person who filled the form, the host choice was discarded — which ' + + 'is exactly what the visitor form used to do (VIS-09)', + ).toBe(colleague.toLowerCase()); + expect( + `${stored.booked_by_email}`.toLowerCase(), + 'and BY the person who filled the form', + ).toBe(me.email.toLowerCase()); + expect( + `${stored.user_email}`.toLowerCase(), + 'the two must differ, or this proves nothing', + ).not.toBe(me.email.toLowerCase()); + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + await releaseAsset(staffApi, 'desk', desk.id, from, to); + } + }); +}); diff --git a/apps/workplace/e2e/local/desk-edit.spec.ts b/apps/workplace/e2e/local/desk-edit.spec.ts new file mode 100644 index 0000000000..8e9028f395 --- /dev/null +++ b/apps/workplace/e2e/local/desk-edit.spec.ts @@ -0,0 +1,192 @@ +/** + * DESK-09 / DESK-10 — changing a desk booking that already exists. + * + * Nothing in the desk suite has ever changed a booking, and an edit is a + * different code path in two ways that matter: + * + * - it PATCHes rather than POSTs (`saveBooking` branches on the id), so an edit + * that lost the id silently creates a SECOND booking and holds two desks; + * - moving to another desk has to free the first one, or a desk stays held by a + * booking that no longer claims it. + * + * ## Where the edit starts, and why that is not this page + * + * A desk booking cannot be opened for editing from the desk form directly: the + * schedule loads the form with the booking (`editBooking`) and then routes to + * `/book/desk`. So these tests begin on the schedule, through + * `DeskSchedulePage` — which inherits it rather than copying it. + * + * What is tested where: + * bookings-edit.spec.ts ... that Edit ROUTES here with the booking loaded. + * this file .............. that a change SAVES. + * + * ## The second desk + * + * `seed.ts` creates WORKERS + 1 desks, so there is always one desk no worker + * owns; `altDesk()` returns it. Because it is shared, every worker books it on + * its OWN day (`dayFor`) — otherwise two workers would clash on it and the + * failure would read as a backend refusal. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { + deleteBooking, + getBooking, + releaseAsset, + uniqueTitle, +} from '../../../../e2e/support/api'; +import { deskFor } from '../../../../e2e/support/env'; +import { + DESK_SLOTS, + altDesk, + dayBoundsOn, + hhmm, + hourFor, + slotOn, +} from '../../../../e2e/support/desk/desk.env'; +import { DeskForm } from '../../../../e2e/support/desk/desk-form.page'; +import { DeskSchedulePage } from '../../../../e2e/support/desk/desk-schedule.page'; +import { createDeskBookingViaApi } from '../../../../e2e/support/desk/desk.api'; + +test.describe('editing a desk booking', () => { + test('a new start time chosen on the form is the one stored', async ({ + staffPage, + staffApi, + }, testInfo) => { + const desk = deskFor(testInfo.parallelIndex); + const { day, hour, moved_to } = DESK_SLOTS.edit_time; + const from_slot = slotOn(day, hour); + const to_slot = slotOn(day, moved_to); + const { from, to } = dayBoundsOn(day); + const title = uniqueTitle('E2E Desk Edit Time'); + let booking_id: number | undefined; + + // The whole day, because the hour being moved INTO has to be free too — + // otherwise the app refuses the edit for a perfectly good reason and the + // test reads as a bug. + await releaseAsset(staffApi, 'desk', desk.id, from, to); + + try { + const booking = await createDeskBookingViaApi(staffApi, { + desk, + title, + start: from_slot.start, + end: from_slot.end, + }); + booking_id = booking.id; + + const schedule = new DeskSchedulePage(staffPage); + await schedule.open(); + await schedule.showDesks(); + await schedule.showDayOf(from_slot.date_ms); + await schedule.startEdit(booking_id); + + const form = new DeskForm(staffPage); + await expect( + form.chosenDesks, + 'the edit form should open with the booked desk already on it', + ).toHaveCount(1, { timeout: 30_000 }); + + // Only the time changes. The fields reach the model asynchronously + // and the confirm dialog snapshots the model when it opens, so set + // and settle before confirming. + await form.setStartTime(hhmm(to_slot.start)); + await staffPage.waitForTimeout(3_000); + await form.confirmAndSend(); + + await expect(async () => { + const updated = await getBooking(staffApi, booking_id!); + expect( + hhmm(updated.booking_start), + `the booking should now start at ${hhmm(to_slot.start)}`, + ).toBe(hhmm(to_slot.start)); + }).toPass({ timeout: 45_000 }); + + const updated = await getBooking(staffApi, booking_id); + expect(updated.asset_id, 'moving the time must not change the desk').toBe( + desk.id, + ); + expect( + updated.deleted, + 'an edit must UPDATE the booking, not delete and replace it — a new row ' + + 'would mean the id the user holds is dead', + ).toBeFalsy(); + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + await releaseAsset(staffApi, 'desk', desk.id, from, to); + } + }); + + test('a booking moved to another desk is stored against that desk', async ({ + staffPage, + staffApi, + }, testInfo) => { + const desk = deskFor(testInfo.parallelIndex); + const other = altDesk(); + // The spare desk is shared between workers, so the HOUR is staggered by + // worker index — the day cannot be, because the schedule's sidebar + // calendar only reaches the displayed month. + const { day, hour } = DESK_SLOTS.edit_desk; + const slot = slotOn(day, hourFor(hour, testInfo.parallelIndex)); + const { from, to } = dayBoundsOn(day); + const title = uniqueTitle('E2E Desk Edit Desk'); + let booking_id: number | undefined; + + await releaseAsset(staffApi, 'desk', desk.id, from, to); + await releaseAsset(staffApi, 'desk', other.id, from, to); + + try { + const booking = await createDeskBookingViaApi(staffApi, { + desk, + title, + start: slot.start, + end: slot.end, + }); + booking_id = booking.id; + + const schedule = new DeskSchedulePage(staffPage); + await schedule.open(); + await schedule.showDesks(); + await schedule.showDayOf(slot.date_ms); + await schedule.startEdit(booking_id); + + const form = new DeskForm(staffPage); + await expect( + form.chosenDesks, + 'the edit form should open with the booked desk already on it', + ).toHaveCount(1, { timeout: 30_000 }); + + // The form holds one desk, so the old one comes off before the new + // one goes on. Converged on the NAME rather than the count: the + // count is 1 before and 1 after, so it cannot see this swap, and the + // choice reaches the model asynchronously while the confirm dialog + // snapshots the model when it opens. + await expect(async () => { + if (!(await form.chosenDeskNames()).includes(other.name)) { + await form.removeDesk(); + await form.chooseDesk(other.name); + } + expect(await form.chosenDeskNames()).toEqual([other.name]); + }).toPass({ timeout: 45_000 }); + await staffPage.waitForTimeout(3_000); + await form.confirmAndSend(); + + await expect(async () => { + const updated = await getBooking(staffApi, booking_id!); + expect( + updated.asset_id, + `the booking should now be held against ${other.name}`, + ).toBe(other.id); + }).toPass({ timeout: 45_000 }); + + const updated = await getBooking(staffApi, booking_id); + expect( + hhmm(updated.booking_start), + 'moving desk must not move the booking in time', + ).toBe(hhmm(slot.start)); + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + await releaseAsset(staffApi, 'desk', desk.id, from, to); + await releaseAsset(staffApi, 'desk', other.id, from, to); + } + }); +}); diff --git a/apps/workplace/e2e/local/desk-favourites.spec.ts b/apps/workplace/e2e/local/desk-favourites.spec.ts new file mode 100644 index 0000000000..7484da25ce --- /dev/null +++ b/apps/workplace/e2e/local/desk-favourites.spec.ts @@ -0,0 +1,101 @@ +/** + * DESK-16 — marking a desk as a favourite. + * + * Favourites are a USER setting, written to the signed-in user's `settings` + * metadata as `favourite_desks` and read back on every later visit. The room + * equivalent (ROOM-19) is green; desks have the same feature and no coverage, and + * the failure mode is the one that makes a feature pointless rather than broken: + * a star that lights up and is forgotten on reload looks perfect in a screenshot. + * + * Asserted on the BACKEND, not on the star, and polled — the write is debounced + * (~2.4 seconds in `SettingsService.saveUserSetting`) and sends the whole + * settings blob as one `PUT /metadata/{user_id}`. + * + * ## Cleanup matters more here than in a booking spec + * + * This changes a user's saved settings, which persist across runs and share one + * blob with the visitor specs' invitee list and the room specs' favourite rooms. + * It is cleared either side, through a read-modify-write that cannot wipe the + * neighbours. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { deskFor } from '../../../../e2e/support/env'; +import { + readUserSettings, + setFavouriteDesks, +} from '../../../../e2e/support/desk/desk.api'; +import { useSettings } from '../../../../e2e/support/desk/desk.settings'; +import { DeskForm } from '../../../../e2e/support/desk/desk-form.page'; + +test.describe('favourite desks', () => { + test('a desk marked as a favourite is saved against the user', async ({ + staffPage, + staffApi, + }, testInfo) => { + const desk = deskFor(testInfo.parallelIndex); + + // Start from none. A favourite left behind by an earlier run would let + // the assertion pass without this test having done anything. + await setFavouriteDesks(staffApi, []); + await useSettings(staffPage, {}); + + try { + const form = new DeskForm(staffPage); + await form.open(); + await form.setChecked(form.requireLocker, false); + await form.addDeskButton.click(); + + const row = staffPage + .locator('li[desk], [desk]') + .filter({ hasText: desk.name }) + .first(); + const star = row.locator('button[name="toggle-desk-favourite"]').first(); + const found = await star + .waitFor({ state: 'visible', timeout: 20_000 }) + .then(() => true) + .catch(() => false); + if (!found) { + const offered = await staffPage + .locator('button[name="select-desk"]') + .allInnerTexts(); + throw new Error( + `no favourite control on the row for ${desk.name}. The picker offers ` + + `${offered.length} desk(s): ` + + `${JSON.stringify(offered.map((t) => t.split('\n')[0].trim()))}.`, + ); + } + await star.click(); + + await expect(async () => { + const settings = await readUserSettings(staffApi); + expect( + settings.favourite_desks ?? [], + `${desk.name} (${desk.id}) should be saved as a favourite desk`, + ).toContain(desk.id); + }).toPass({ timeout: 30_000 }); + + // And it is remembered: a fresh page load reads the saved setting + // back, which is the whole point of a favourite. + await staffPage.reload(); + await form.open(); + await form.setChecked(form.requireLocker, false); + await form.addDeskButton.click(); + await expect( + staffPage + .locator('[desk]') + .filter({ hasText: desk.name }) + .first() + .locator('button[name="toggle-desk-favourite"] icon'), + 'the favourite should still be marked after a reload', + ).toBeVisible({ timeout: 20_000 }); + const after = await readUserSettings(staffApi); + expect( + after.favourite_desks ?? [], + 'and still saved against the user', + ).toContain(desk.id); + } finally { + // Not optional: this is a saved user setting, not a booking. + await setFavouriteDesks(staffApi, []); + } + }); +}); diff --git a/apps/workplace/e2e/local/desk-settings.spec.ts b/apps/workplace/e2e/local/desk-settings.spec.ts new file mode 100644 index 0000000000..c915bd0532 --- /dev/null +++ b/apps/workplace/e2e/local/desk-settings.spec.ts @@ -0,0 +1,110 @@ +/** + * DESK-13 — an all-day desk booking is stored as all-day. + * + * The dev's desk specs tick All Day to make their booking stable, and then never + * check what it produced. So the flag is exercised constantly and asserted + * nowhere: a form that quietly stored a long timed booking instead of an all-day + * one would pass every desk test in the suite. + * + * It matters because the two are not interchangeable. An all-day booking holds + * the desk for the building's whole day whatever the user's timezone, while a + * timed one holds a window — so a "9 to 5" stand-in frees the desk at 5 and + * leaves it bookable by somebody else for the evening. + * + * ## The setting + * + * The checkbox only renders when all-day bookings are allowed + * (`desk-form-details.component.ts`: `allow_all_day`, which also requires + * `allow_time_changes`). It is `app.bookings.allow_all_day` — the BOOKING + * family, not the event family the meeting form reads. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { + deleteBooking, + getBooking, + releaseAsset, + uniqueTitle, +} from '../../../../e2e/support/api'; +import { deskFor } from '../../../../e2e/support/env'; +import { + DESK_SLOTS, + dayBoundsOn, + slotOn, +} from '../../../../e2e/support/desk/desk.env'; +import { ALLOW_ALL_DAY, useSettings } from '../../../../e2e/support/desk/desk.settings'; +import { DeskForm } from '../../../../e2e/support/desk/desk-form.page'; + +const HOUR = 3600; + +test.describe('desk booking settings', () => { + test('an all-day booking is stored as all-day, not as a long timed booking', async ({ + staffPage, + staffApi, + }, testInfo) => { + const desk = deskFor(testInfo.parallelIndex); + const slot = slotOn(DESK_SLOTS.settings.day, DESK_SLOTS.settings.hour); + const { from, to } = dayBoundsOn(DESK_SLOTS.settings.day); + const title = uniqueTitle('E2E Desk AllDay'); + let booking_id: number | undefined; + + await releaseAsset(staffApi, 'desk', desk.id, from, to); + await useSettings(staffPage, ALLOW_ALL_DAY); + + try { + const form = new DeskForm(staffPage); + await form.open(); + await form.pickDate(slot.date_ms); + + await expect(async () => { + await form.setChecked(form.requireLocker, false); + await form.setChecked(form.allDay, true); + await form.title.fill(title); + if ((await form.chosenDesks.count()) === 0) { + await form.chooseDesk(desk.name); + } + expect(await form.title.inputValue()).toBe(title); + expect( + await form.allDay.isChecked(), + 'the All Day checkbox should be on — is `app.bookings.allow_all_day` set?', + ).toBe(true); + }).toPass({ timeout: 45_000 }); + + await staffPage.waitForTimeout(2_000); + const [response] = await Promise.all([ + staffPage.waitForResponse( + (r) => + r.url().includes('/api/staff/v1/bookings') && + r.request().method() === 'POST', + { timeout: 30_000 }, + ), + form.confirmAndSend(), + ]); + expect( + response.status(), + `the booking POST failed: ${await response.text()}`, + ).toBeLessThan(300); + booking_id = (await response.json()).id; + + const stored: any = await getBooking(staffApi, booking_id!); + expect( + stored.all_day, + 'the booking must be stored with the all-day flag set, not merely as a ' + + 'booking that happens to be long — a timed stand-in frees the desk ' + + 'at its end time', + ).toBeTruthy(); + expect( + new Date(stored.booking_start * 1000).toDateString(), + 'and on the day that was picked', + ).toBe(new Date(slot.date_ms).toDateString()); + // Long, as well as flagged: whatever the backend does with the flag, + // the desk has to be held for the working day rather than an hour. + expect( + (stored.booking_end - stored.booking_start) / HOUR, + 'an all-day booking should span most of the day', + ).toBeGreaterThanOrEqual(8); + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + await releaseAsset(staffApi, 'desk', desk.id, from, to); + } + }); +}); diff --git a/apps/workplace/e2e/local/desk-status.spec.ts b/apps/workplace/e2e/local/desk-status.spec.ts new file mode 100644 index 0000000000..398bee4caf --- /dev/null +++ b/apps/workplace/e2e/local/desk-status.spec.ts @@ -0,0 +1,113 @@ +/** + * DESK-20 (REG-04) — a desk booking's status shows on its card. + * + * A named past regression — *"status display for desk bookings"* — with no test + * behind it. The card is where a user answers "am I checked in?", and it is the + * only place they can: the state lives on the booking, not on the screen, so a + * card that draws the wrong badge is indistinguishable from a check-in that + * never happened. + * + * The badge is matched on the ATTRIBUTE the template puts on it + * (`div[checked-in-badge]`), not on its colour or its text, both of which are + * styling and translation. + * + * ## The check-in here is done through the API on purpose + * + * `bookings-checkin.spec.ts` covers checking in through the UI. This test is + * about what the CARD draws for a given state, so the state is set directly and + * the page is only ever read. + * + * ## Why this booking is minutes away, on the spare desk + * + * The backend refuses a check-in more than an HOUR before the booking starts + * (measured: `{"error":"Can only check in an 1.0 hour before the booking + * start"}`), so this cannot sit days out like the other desk specs. Booking + * today brings it into contention with two other things, and both are avoided + * by construction: + * + * - the dev's desk specs book the WORKER'S desk all day today, and + * `bookings-checkin.spec.ts` books it for an hour from now — so this uses the + * SPARE desk instead; + * - the spare desk is shared between workers, so the START is staggered by + * worker index, ten minutes apart, with a window of eight minutes. Four + * workers therefore fit inside the one-hour check-in gate without overlapping + * each other. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { STAFF_API, deleteBooking, releaseAsset, uniqueTitle } from '../../../../e2e/support/api'; +import { altDesk } from '../../../../e2e/support/desk/desk.env'; +import { createDeskBookingViaApi } from '../../../../e2e/support/desk/desk.api'; +import { DeskSchedulePage } from '../../../../e2e/support/desk/desk-schedule.page'; + +test.describe('desk booking status on the card', () => { + test('the checked-in badge appears only once the booking is checked in', async ({ + staffPage, + staffApi, + }, testInfo) => { + const desk = altDesk(); + const now = Math.floor(Date.now() / 1000); + // Five minutes out, ten minutes apart per worker, eight minutes long. + const start = now + (5 + 10 * testInfo.parallelIndex) * 60; + const end = start + 8 * 60; + const from = now - 3600; + const to = now + 6 * 3600; + const title = uniqueTitle('E2E Desk Status'); + let booking_id: number | undefined; + + // Only this worker's own window, not the whole day: the spare desk is + // shared, and a day-wide sweep here would delete another worker's + // booking mid-test. + await releaseAsset(staffApi, 'desk', desk.id, start - 60, end + 60); + + try { + const booking = await createDeskBookingViaApi(staffApi, { + desk, + title, + start, + end, + }); + booking_id = booking.id; + + const schedule = new DeskSchedulePage(staffPage); + await schedule.open(); + await schedule.showDesks(); + await schedule.showDayOf(start * 1000); + await expect( + schedule.card(booking_id), + 'the booking should be listed on its own day', + ).toBeVisible({ timeout: 30_000 }); + + // Before: no badge. This half is what stops the test passing against + // a card that always draws one. + await expect( + schedule.checkedInBadge(booking_id), + 'a booking nobody has checked in to must not be badged as checked in', + ).toBeHidden({ timeout: 10_000 }); + + // Check in on the backend, then reload and look again. + const res = await staffApi.post( + `${STAFF_API}/bookings/${booking_id}/check_in?state=true`, + ); + expect( + res.status(), + `checking in through the API failed: ${await res.text()}`, + ).toBeLessThan(300); + + await staffPage.reload(); + await schedule.showDesks(); + await schedule.showDayOf(start * 1000); + await expect( + schedule.card(booking_id), + 'the booking should still be listed after checking in', + ).toBeVisible({ timeout: 30_000 }); + await expect( + schedule.checkedInBadge(booking_id), + 'once checked in, the card must say so — this is the only place a user ' + + 'can tell', + ).toBeVisible({ timeout: 20_000 }); + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + await releaseAsset(staffApi, 'desk', desk.id, start - 60, end + 60); + } + }); +}); diff --git a/apps/workplace/e2e/local/desk-times.spec.ts b/apps/workplace/e2e/local/desk-times.spec.ts new file mode 100644 index 0000000000..103335e56b --- /dev/null +++ b/apps/workplace/e2e/local/desk-times.spec.ts @@ -0,0 +1,167 @@ +/** + * DESK-11 / DESK-12 — when a desk is booked for, and the limits on what may be + * chosen. + * + * The dev's desk specs book ALL DAY, deliberately: an all-day booking cannot + * drift past the next five-minute boundary mid-run. The side effect is that + * nothing in this suite has ever picked a desk booking's start time or its + * length — so a form that ignored both pickers and always booked all day would + * have passed every desk test ever written here. + * + * Test 1 asks the plain question: pick a day, a start time and a length, and are + * THOSE the times stored? + * + * Test 2 covers the two settings that fence the pickers in — a maximum length + * and the hours a booking may start. Both are unset by default, so nothing else + * sees them, and both fail quietly in the same way: an out-of-range option is + * simply ABSENT rather than refused. That is why this asserts on the options + * OFFERED; "a nine-hour booking is rejected" would be testing something the form + * never lets you attempt. + * + * Settings are `app.bookings.*` for desks, not `app.events.*` — the meeting form + * reads the other family. Getting that wrong changes nothing and looks right. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { + deleteBooking, + getBooking, + releaseAsset, + uniqueTitle, +} from '../../../../e2e/support/api'; +import { deskFor } from '../../../../e2e/support/env'; +import { + DESK_SLOTS, + dayBoundsOn, + hhmm, + slotOn, +} from '../../../../e2e/support/desk/desk.env'; +import { LIMITED_HOURS, useSettings } from '../../../../e2e/support/desk/desk.settings'; +import { DeskForm } from '../../../../e2e/support/desk/desk-form.page'; + +const MINUTES = 60; + +test.describe('desk booking times', () => { + test('the day, start time and length chosen on the form are what get stored', async ({ + staffPage, + staffApi, + }, testInfo) => { + const desk = deskFor(testInfo.parallelIndex); + const slot = slotOn(DESK_SLOTS.times.day, DESK_SLOTS.times.hour); + const { from, to } = dayBoundsOn(DESK_SLOTS.times.day); + const title = uniqueTitle('E2E Desk Times'); + let booking_id: number | undefined; + + await releaseAsset(staffApi, 'desk', desk.id, from, to); + + try { + const form = new DeskForm(staffPage); + await form.open(); + + // The date goes first and ONCE. Changing the day resets the + // duration asynchronously, so a date click repeated inside the + // converging block below lands after the length is picked and + // quietly reverts it — the same trap the meeting form has. + await form.pickDate(slot.date_ms); + + // Converge on the rest: the form is rebuilt when async init + // finishes and restores its defaults (REG-10). + await expect(async () => { + await form.setChecked(form.requireLocker, false); + await form.setChecked(form.allDay, false); + await form.title.fill(title); + if ((await form.chosenDesks.count()) === 0) { + await form.chooseDesk(desk.name); + } + expect(await form.title.inputValue()).toBe(title); + expect(await form.allDay.isChecked()).toBe(false); + }).toPass({ timeout: 45_000 }); + + // Times LAST, after the rebuild can no longer undo them, and with a + // settle before confirming: the fields reach the model + // asynchronously and the confirm dialog snapshots the model when it + // opens. + await form.setDuration(60); + await form.setStartTime(hhmm(slot.start)); + await staffPage.waitForTimeout(3_000); + + const [response] = await Promise.all([ + staffPage.waitForResponse( + (r) => + r.url().includes('/api/staff/v1/bookings') && + r.request().method() === 'POST', + { timeout: 30_000 }, + ), + form.confirmAndSend(), + ]); + expect( + response.status(), + `the booking POST failed: ${await response.text()}`, + ).toBeLessThan(300); + booking_id = (await response.json()).id; + + const stored = await getBooking(staffApi, booking_id!); + expect( + new Date(stored.booking_start * 1000).toDateString(), + 'the booking must land on the day that was picked', + ).toBe(new Date(slot.date_ms).toDateString()); + expect( + hhmm(stored.booking_start), + 'and start at the time that was picked', + ).toBe(hhmm(slot.start)); + expect( + (stored.booking_end - stored.booking_start) / MINUTES, + 'and last as long as was picked', + ).toBe(60); + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + await releaseAsset(staffApi, 'desk', desk.id, from, to); + } + }); + + test('a maximum length and bookable hours limit what can be chosen', async ({ + staffPage, + }) => { + const max = LIMITED_HOURS['app.bookings.max_duration']; + const hours = LIMITED_HOURS['app.bookings.bookable_hours']; + + await useSettings(staffPage, LIMITED_HOURS); + + const form = new DeskForm(staffPage); + await form.open(); + await expect(async () => { + await form.setChecked(form.requireLocker, false); + await form.setChecked(form.allDay, false); + expect(await form.allDay.isChecked()).toBe(false); + }).toPass({ timeout: 45_000 }); + + // Lengths: nothing beyond the cap may be on offer. Without the setting + // the form offers up to eight hours, which is what makes this + // meaningful. + const lengths = await form.durationOptions(); + expect(lengths.length, 'the form should offer some lengths').toBeGreaterThan(0); + expect( + Math.max(...lengths), + `no length beyond the ${max} minute cap may be offered, got ` + + `${JSON.stringify(lengths)}`, + ).toBeLessThanOrEqual(max); + + // Start times: nothing outside the bookable hours. Compared as minutes + // past midnight rather than as strings, so 9:00 and 09:00 cannot differ. + const times = await form.startTimeOptions(); + expect(times.length, 'the form should offer some start times').toBeGreaterThan(0); + const minutes = times.map((t) => { + const [h, m] = t.split(':').map(Number); + return h * 60 + m; + }); + expect( + Math.min(...minutes), + `no start time before ${hours.start}:00 may be offered, got ` + + `${JSON.stringify(times)}`, + ).toBeGreaterThanOrEqual(hours.start * 60); + expect( + Math.max(...minutes), + `no start time after ${hours.end}:00 may be offered, got ` + + `${JSON.stringify(times)}`, + ).toBeLessThanOrEqual(hours.end * 60); + }); +}); diff --git a/apps/workplace/e2e/local/home-availability.spec.ts b/apps/workplace/e2e/local/home-availability.spec.ts new file mode 100644 index 0000000000..ad26f009af --- /dev/null +++ b/apps/workplace/e2e/local/home-availability.spec.ts @@ -0,0 +1,81 @@ +/** + * HOME-10 — the availability panel, and booking a room from it. + * + * The panel lists the building's bookable rooms with their live status, and its + * button is a second route into booking a room — one that skips the meeting form + * entirely: `explore-spaces.service.ts::bookSpace` loads the room into the event + * form and opens a booking modal. + * + * What is asserted is that the panel offers the seeded rooms and that the button + * opens that modal carrying the room that was clicked. Deliberately NOT + * asserted: completing the booking. The modal's confirm goes through the same + * path as the meeting form, which is covered properly by the room specs, and in + * the default mode it reaches the calendar and 500s on this stack. + * + * ## `fixme` — blocked by the stack, not by a bug + * + * The panel does not list every bookable room. It lists the ones a LIVE STATUS + * BINDING reports as free (`landing-state.service.ts`): + * + * free_space_list = space_list().filter((_, i) => space_statuses()[i] === 'free') + * + * and those statuses come from websocket bindings to a driver module on each + * room's System. This stack has one driver (`spec_helper`) and one module + * (`PrivateHelper`), so no room ever reports a status, `free_space_list` is + * always empty, and the panel renders with nothing in it. Measured: the panel + * was present and offered zero rooms while four bookable rooms existed on the + * building. + * + * Same blocker as ROOM-23 (room check-in), and the same fix: a real driver + * running in the e2e stack. The assertions below are right; nothing about them + * needs changing when that happens. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { roomForWorker } from '../../../../e2e/support/room/room.seed'; +import { LandingPage } from '../../../../e2e/support/home/landing.page'; + +test.describe('home page — room availability', () => { + test.fixme('the panel lists a seeded room, and its button opens the booking modal', async ({ + staffPage, + }, testInfo) => { + // The rooms the room specs seed are the only ones this org has. + const room = await roomForWorker(testInfo.parallelIndex); + + const home = new LandingPage(staffPage); + await home.open(); + + const button = home.bookRoom(room.name); + const listed = await button + .waitFor({ state: 'visible', timeout: 30_000 }) + .then(() => true) + .catch(() => false); + if (!listed) { + // Say what IS offered: "not listed" cannot tell apart a room that + // was never seeded, one on the wrong zone, and a panel that is + // hidden for this deployment. + const offered = await home.offeredRooms(); + const panel = await home.availability.count(); + throw new Error( + `${room.name} is not on the availability panel. The panel ` + + `${panel ? 'is present and offers' : 'is not on the page at all;'} ` + + `${offered.length} room(s): ${JSON.stringify(offered)}. Rooms come ` + + `from the building's systems, so check room.seed.ts ran and the ` + + `room sits on the building zone.`, + ); + } + + await button.click(); + + // The modal is the assertion: it carries the room that was clicked, so a + // panel that opened the wrong room's booking would fail here. + const modal = staffPage.locator('.cdk-overlay-container mat-dialog-container'); + await expect( + modal, + 'clicking Book should open the room booking modal', + ).toBeVisible({ timeout: 20_000 }); + await expect( + modal, + `the modal should be for ${room.name} — the room that was clicked`, + ).toContainText(room.name, { timeout: 20_000 }); + }); +}); diff --git a/apps/workplace/e2e/local/home-colleagues.spec.ts b/apps/workplace/e2e/local/home-colleagues.spec.ts new file mode 100644 index 0000000000..3fd22f63a8 --- /dev/null +++ b/apps/workplace/e2e/local/home-colleagues.spec.ts @@ -0,0 +1,124 @@ +/** + * HOME-11 — the colleagues list in the home page sidebar. + * + * A colleague list is stored per user, in its own `contacts` metadata document + * (`landing-state.service.ts`: `updateMetadata(user.id, { name: 'contacts' })`). + * That makes the failure mode the same one every favourites feature has: a name + * that appears in the list and is forgotten on reload looks perfect in a + * screenshot and is useless to the person who added it. + * + * Asserted on the BACKEND. Note the document is `contacts` and NOT the + * `favourite_team_members` key that `libs/common/constants.ts` advertises — a + * first draft of this file assumed the constant and failed twice. + * + * ## The search is local, unlike the desk form's host picker + * + * `landing-state.service.ts::search_fn` uses `queryUsers` — the PlaceOS user + * list — whenever `basic_user_search` is set OR `colleagues_require_auth` is + * anything other than `false`. The second is undefined by default, so the local + * list is the default path and this needs no settings at all. Contrast DESK-14, + * where the host picker goes to the calendar directory and 500s here. + * + * Cleared either side through a read-modify-write: the settings blob is shared + * with the visitor specs' invitee list and every area's favourites. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { WORKERS, staffEmail } from '../../../../e2e/support/env'; +import { + CONTACTS_DOC, + readContacts, + setContacts, +} from '../../../../e2e/support/home/home.api'; +import { LandingPage } from '../../../../e2e/support/home/landing.page'; + +test.describe('home page — colleagues', () => { + test('a colleague added from the sidebar is saved against the user', async ({ + staffPage, + staffApi, + }, testInfo) => { + const mine = testInfo.parallelIndex; + const theirs = (mine + 1) % WORKERS; + test.skip(theirs === mine, 'needs at least two workers to have somebody to add'); + const colleague = staffEmail(theirs); + + // Start from none: a colleague left by an earlier run would let the + // assertion pass without this test having done anything. + await setContacts(staffApi, []); + + try { + const home = new LandingPage(staffPage); + await home.open(); + await home.showTab('people'); + await home.addColleague(colleague); + + await expect(async () => { + const saved = JSON.stringify(await readContacts(staffApi)); + expect( + saved.toLowerCase(), + `${colleague} should be saved in the "${CONTACTS_DOC}" document, ` + + `got ${saved}`, + ).toContain(colleague.toLowerCase()); + }).toPass({ timeout: 30_000 }); + } finally { + await setContacts(staffApi, []); + } + }); + + test('removing a colleague clears them from the saved list', async ({ + staffPage, + staffApi, + }, testInfo) => { + const mine = testInfo.parallelIndex; + const theirs = (mine + 1) % WORKERS; + test.skip(theirs === mine, 'needs at least two workers to have somebody to remove'); + const colleague = staffEmail(theirs); + + // Seeded through the API rather than through the UI: this test is about + // REMOVING, and adding through the form is the other test's subject. + await setContacts(staffApi, [{ email: colleague, name: colleague.split('@')[0] }]); + + try { + const home = new LandingPage(staffPage); + await home.open(); + await home.showTab('people'); + + // The row shows the person's NAME, not their address, so the lookup + // normalises both sides — see `colleagueIndex`. + await expect( + home.colleagueRows, + 'the seeded colleague should be listed in the sidebar', + ).toHaveCount(1, { timeout: 20_000 }); + const index = await home.colleagueIndex(colleague); + if (index < 0) { + const shown = await home.colleagues.innerText().catch(() => ''); + throw new Error( + `${colleague} was saved but cannot be found in the sidebar. The ` + + `panel shows: "${shown.replace(/\s+/g, ' ').trim().slice(0, 200)}". ` + + `Rows are labelled with the display name, so check the user ` + + `exists in the directory.`, + ); + } + + // The remove control is behind the row's overflow menu. + await home.colleagueRows.nth(index).click({ timeout: 10_000 }); + const remove = staffPage + .locator('.cdk-overlay-container button[name="remove-colleague"]') + .first(); + await expect(remove, 'the colleague menu has no remove action').toBeVisible({ + timeout: 10_000, + }); + await remove.click(); + + await expect(async () => { + const saved = JSON.stringify(await readContacts(staffApi)); + expect( + saved.toLowerCase(), + `${colleague} should be gone from the "${CONTACTS_DOC}" document, ` + + `got ${saved}`, + ).not.toContain(colleague.toLowerCase()); + }).toPass({ timeout: 30_000 }); + } finally { + await setContacts(staffApi, []); + } + }); +}); diff --git a/apps/workplace/e2e/local/home-favourites.spec.ts b/apps/workplace/e2e/local/home-favourites.spec.ts new file mode 100644 index 0000000000..72da7df08d --- /dev/null +++ b/apps/workplace/e2e/local/home-favourites.spec.ts @@ -0,0 +1,104 @@ +/** + * HOME-13 / HOME-14 — the Favourites tab in the home page sidebar. + * + * Favourites are set in the pickers (covered for rooms, desks and parking in + * their own specs) and READ here. That split is the point: the home page is + * where a favourite is supposed to pay off, so a favourite that saves correctly + * and never appears on the home page is a feature nobody benefits from — and + * nothing would notice, because the picker specs stop at the write. + * + * Test 1 seeds a favourite desk through the API and asserts the panel lists it. + * Test 2 removes it from the home page and asserts the saved setting is cleared, + * which is the other half of the same contract. + * + * ## The key + * + * `landing-favourites.component.ts` reads `FAV_DESK_KEY` from `@placeos/bookings` + * — `favourite_desks`, inside the user's `settings` blob. The same key the desk + * picker writes, which is why DESK-16 and this test agree. + * + * Cleared either side through a read-modify-write: that blob is shared with the + * visitor specs' invitee list and every other area's favourites. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { deskFor } from '../../../../e2e/support/env'; +import { + FAVOURITE_DESKS_KEY, + readUserSettings, + setUserSetting, +} from '../../../../e2e/support/home/home.api'; +import { LandingPage } from '../../../../e2e/support/home/landing.page'; + +test.describe('home page — favourites', () => { + test('a favourite desk is listed on the Favourites tab', async ({ + staffPage, + staffApi, + }, testInfo) => { + const desk = deskFor(testInfo.parallelIndex); + + await setUserSetting(staffApi, FAVOURITE_DESKS_KEY, [desk.id]); + + try { + const home = new LandingPage(staffPage); + await home.open(); + await home.showTab('fav'); + + await expect( + home.favourites, + 'the Favourites panel should be on the page', + ).toBeVisible({ timeout: 30_000 }); + await expect( + home.favourites, + `${desk.name} was saved as a favourite desk, so the panel should list ` + + `it — a favourite that never shows up here benefits nobody`, + ).toContainText(desk.name, { timeout: 30_000 }); + } finally { + await setUserSetting(staffApi, FAVOURITE_DESKS_KEY, []); + } + }); + + test('removing a favourite from the home page clears the saved setting', async ({ + staffPage, + staffApi, + }, testInfo) => { + const desk = deskFor(testInfo.parallelIndex); + + await setUserSetting(staffApi, FAVOURITE_DESKS_KEY, [desk.id]); + + try { + const home = new LandingPage(staffPage); + await home.open(); + await home.showTab('fav'); + await expect( + home.favourites, + 'precondition: the favourite is listed before it is removed', + ).toContainText(desk.name, { timeout: 30_000 }); + + // The remove control is behind the row's overflow menu. + await home.favourites + .locator('button[name="favourite-more"]') + .first() + .click({ timeout: 10_000 }); + const remove = staffPage + .locator('.cdk-overlay-container button[name="landing-remove-favourite"]') + .first(); + await expect( + remove, + 'the favourite menu has no remove action', + ).toBeVisible({ timeout: 10_000 }); + await remove.click(); + + // The backend is the assertion: the row leaving the panel proves + // nothing, and the write is debounced. + await expect(async () => { + const settings = await readUserSettings(staffApi); + expect( + settings[FAVOURITE_DESKS_KEY] ?? [], + `${desk.id} should be gone from "${FAVOURITE_DESKS_KEY}"`, + ).not.toContain(desk.id); + }).toPass({ timeout: 30_000 }); + } finally { + await setUserSetting(staffApi, FAVOURITE_DESKS_KEY, []); + } + }); +}); diff --git a/apps/workplace/e2e/local/home-quick-book.spec.ts b/apps/workplace/e2e/local/home-quick-book.spec.ts new file mode 100644 index 0000000000..9bcab915c4 --- /dev/null +++ b/apps/workplace/e2e/local/home-quick-book.spec.ts @@ -0,0 +1,142 @@ +/** + * HOME-09 — the one-click quick-book tile. + * + * ## It does not open the form + * + * The name suggests a shortcut INTO the booking flow. It is not: + * `landing-quick-book.component.ts::book()` picks the first available resource, + * calls `confirmPost()` and navigates to `/book//success`. One click, one + * booking, no confirmation step. That makes it the most dangerous control on the + * page and the only one with no coverage — a user cannot preview what they are + * about to book, so if it books the wrong thing there is nothing to catch it. + * + * So this asserts on what reached the BACKEND, not on the success screen. + * + * ## Why this test cleans up immediately, and its one caveat + * + * The tile books "the first available desk for this building", which is whatever + * the app finds — possibly a desk another worker owns. It cannot be pointed at a + * particular desk, so the booking is deleted as soon as it has been asserted. + * Worth knowing if this ever flakes next to another desk spec: a brief 409 in a + * neighbouring test is this test holding a desk for a second or two. + * + * ## `fixme` — HOME-B2: the tile spins for ever and books nothing + * + * Measured on this stack, with `app.show_quick_book` on so the tiles render: + * clicking the desk tile puts the tile into its loading state and it NEVER + * leaves it. No booking is sent, no message is shown, and the page raises an + * unhandled rejection whose value is a `Response`. + * + * The failing request is **`GET /api/staff/v1/calendars` → 500**, twice. That is + * the calendar-backed surface this suite deliberately does not cover: it needs + * real Microsoft/Google credentials and 500s here, exactly like `/events`. So + * the trigger is our placeholder tenant rather than a defect. + * + * What IS a defect is the handling. `landing-quick-book.component.ts::book()` + * awaits `listAvailableResources()` outside any try/catch, so a rejection there + * kills the handler after `loading` has been set — leaving a permanent spinner + * and no way for the user to know anything went wrong. A misconfigured tenant + * would look like this in production. + * + * (Also seen on that page load, and worth a look on its own: a non-admin's + * browser issues `POST /api/engine/v2/asset_types` and gets a 403.) + * + * The assertions below are what should happen. They need either real tenant + * credentials or the tile not to depend on `/calendars`. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { STAFF_API, deleteBooking } from '../../../../e2e/support/api'; +import { LandingPage } from '../../../../e2e/support/home/landing.page'; +import { + SHOW_QUICK_BOOK, + useSettings, +} from '../../../../e2e/support/home/home.settings'; + +const DAY = 86_400; + +test.describe('home page — quick book', () => { + test.fixme('the desk tile books a desk in one click, and the backend stores it', async ({ + staffPage, + staffApi, + }) => { + // The panel has no default: without this setting the tiles are not + // rendered at all, and the failure reads as a missing selector. + await useSettings(staffPage, SHOW_QUICK_BOOK); + + const home = new LandingPage(staffPage); + await home.open(); + + const tile = home.quickBookTile('desk'); + const present = await tile + .waitFor({ state: 'visible', timeout: 20_000 }) + .then(() => true) + .catch(() => false); + test.skip( + !present, + 'no desk quick-book tile even with `app.show_quick_book` on — the panel is ' + + 'also gated on the `desks` feature being enabled for the org', + ); + + let booking_id: number | undefined; + try { + // The booking POST is the assertion. The success screen only tells + // us the app thinks it worked. + const sent = staffPage + .waitForResponse( + (r) => + r.url().includes(`${STAFF_API}/bookings`) && + r.request().method() === 'POST', + { timeout: 30_000 }, + ) + .catch(() => null); + await tile.click(); + const response = await sent; + if (!response) { + // The flow refuses locally when it can find no free resource, + // and says so in a toast: "No desk available for the current + // building". Reading it turns a bare timeout into the reason. + const message = await staffPage + .locator('.cdk-overlay-container') + .innerText() + .catch(() => ''); + throw new Error( + `the tile was clicked and no booking was sent. On screen: ` + + `"${message.replace(/\s+/g, ' ').trim().slice(0, 300)}". The tile ` + + `books the first AVAILABLE desk, so every desk being held — by ` + + `another spec, or by a leftover all-day booking — leaves it ` + + `nothing to book.`, + ); + } + const body = await response.text(); + expect( + response.status(), + `one-click booking failed: ${body.slice(0, 300)}`, + ).toBeLessThan(300); + + const created = JSON.parse(body); + booking_id = created.id; + expect( + created.booking_type, + 'the desk tile must book a DESK, whatever resource it picked', + ).toBe('desk'); + expect( + created.asset_id, + 'and it must name the desk it booked', + ).toBeTruthy(); + expect( + Number(created.booking_start), + 'the booking should be for now, not for some default in the past — a ' + + 'booking that has already started cannot be checked into', + ).toBeGreaterThan(Math.floor(Date.now() / 1000) - DAY); + + await expect( + staffPage, + 'and the page should land on the success screen', + ).toHaveURL(/#\/book\/desk\/success/, { timeout: 30_000 }); + } finally { + // Immediately, whatever happened: this booking may be holding a desk + // another worker's spec is about to use. + if (booking_id != null) await deleteBooking(staffApi, booking_id); + } + }); +}); diff --git a/apps/workplace/e2e/local/home-scoping.spec.ts b/apps/workplace/e2e/local/home-scoping.spec.ts new file mode 100644 index 0000000000..9b2571cfaf --- /dev/null +++ b/apps/workplace/e2e/local/home-scoping.spec.ts @@ -0,0 +1,151 @@ +/** + * HOME-06 / HOME-07 — the home page shows YOUR bookings and nobody else's. + * + * `booking-scoping.spec.ts` proves `GET /bookings` is caller-scoped, and + * `bookings-scoping.spec.ts` proves the Your Bookings page respects it. This is + * the third surface, and the one a leak would be most visible on: the home page + * is what appears the moment anyone signs in. + * + * The panel is built from the schedule's booking list filtered to today + * (`landing-state.service.ts`), so a scope failure anywhere in that chain shows + * up here. + * + * The control is not optional: a panel that showed nothing at all would pass the + * first test on its own. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { request } from '@playwright/test'; +import { deleteBooking, releaseAsset, uniqueTitle } from '../../../../e2e/support/api'; +import { + APP_URL, + BACKEND_URL, + WORKERS, + deskFor, + roleFor, +} from '../../../../e2e/support/env'; +import { mintToken } from '../../../../e2e/support/auth'; +import { createBookingViaApi } from '../../../../e2e/support/home/home.api'; +import { LandingPage } from '../../../../e2e/support/home/landing.page'; +import { SchedulePage } from '../../../../e2e/support/bookings/schedule.page'; + +const MINUTE = 60; + +async function apiAsOtherUser(workerIndex: number) { + const role = roleFor('staff', workerIndex); + const mint = await mintToken(BACKEND_URL, APP_URL, role.email, role.password); + return request.newContext({ + baseURL: BACKEND_URL, + ignoreHTTPSErrors: true, + extraHTTPHeaders: { Authorization: `Bearer ${mint.accessToken}` }, + }); +} + +/** Still today, locally? The panel shows today and nothing else. */ +function stillToday(unix_seconds: number): boolean { + return new Date(unix_seconds * 1000).toDateString() === new Date().toDateString(); +} + +test.describe('home page — visibility between users', () => { + test("another user's booking today is not on your panel", async ({ + staffPage, + staffApi, + }, testInfo) => { + const mine = testInfo.parallelIndex; + const theirs = (mine + 1) % WORKERS; + test.skip(theirs === mine, 'needs at least two workers to have two distinct users'); + + const their_desk = deskFor(theirs); + const start = Math.floor(Date.now() / 1000) + (60 + 20 * mine) * MINUTE; + test.skip(!stillToday(start), 'this window has crossed midnight'); + const other = await apiAsOtherUser(theirs); + let their_booking: number | undefined; + + // Their desk, their booking, swept by THEM: `GET /bookings` is + // caller-scoped, so this worker cannot see or clear it. + await releaseAsset(other, 'desk', their_desk.id, start - 60, start + 3600); + + try { + const booking = await createBookingViaApi(other, { + type: 'desk', + asset_id: their_desk.id, + asset_name: their_desk.name, + title: uniqueTitle('E2E Home Theirs'), + start, + end: start + 15 * MINUTE, + }); + their_booking = booking.id; + + const home = new LandingPage(staffPage); + await home.open(); + await expect( + home.upcomingCard(their_booking), + `booking ${their_booking} belongs to another user and must not appear ` + + `on this user's home page`, + ).toBeHidden({ timeout: 20_000 }); + + const showing = await home.upcomingIds(); + expect( + showing, + `nothing of theirs should be listed. The panel is showing ` + + `[${showing.join(', ')}]`, + ).not.toContain(their_booking); + } finally { + if (their_booking != null) await deleteBooking(other, their_booking); + await releaseAsset(other, 'desk', their_desk.id, start - 60, start + 3600); + await other.dispose(); + } + }); + + /** + * The control, and why it does not use the panel. + * + * A control is essential here: without one, "nobody sees anything" passes + * the test above. The obvious control — your own booking IS on the panel — + * cannot be relied on this stack, because the panel holds five cards and + * cancelled bookings take slots (HOME-B1). Measured: the panel was showing + * five cancelled bookings and could not show a live one. + * + * So the control proves the same thing on a surface that is not capped: + * the booking is yours, it exists, and YOUR pages show it. Your Bookings + * lists everything for a day, which is exactly the property needed. + */ + test('control: your own booking today is visible to you', async ({ + staffPage, + staffApi, + }, testInfo) => { + // The spare desk, so this does not contend with the desk specs, which + // book the worker's own desk all day today. + const desk = deskFor(WORKERS); + // Close enough to now that an evening run still lands inside today, and + // clear of desk-status (+5 to +45) and home-upcoming (+90 onwards). + const start = Math.floor(Date.now() / 1000) + (46 + 10 * testInfo.parallelIndex) * MINUTE; + test.skip(!stillToday(start), 'this window has crossed midnight'); + let booking_id: number | undefined; + + await releaseAsset(staffApi, 'desk', desk.id, start - 60, start + 3600); + + try { + const booking = await createBookingViaApi(staffApi, { + type: 'desk', + asset_id: desk.id, + asset_name: desk.name, + title: uniqueTitle('E2E Home Mine'), + start, + end: start + 8 * MINUTE, + }); + booking_id = booking.id; + + const schedule = new SchedulePage(staffPage); + await schedule.open(); + await schedule.showBooking(booking_id, start * 1000, ['desk']); + await expect( + schedule.card(booking_id), + `your own booking ${booking_id} must be visible to you, without which ` + + `"nobody sees anything" would pass as success in the test above`, + ).toBeVisible({ timeout: 30_000 }); + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + await releaseAsset(staffApi, 'desk', desk.id, start - 60, start + 3600); + } + }); +}); diff --git a/apps/workplace/e2e/local/home-upcoming.spec.ts b/apps/workplace/e2e/local/home-upcoming.spec.ts new file mode 100644 index 0000000000..626cb4acd1 --- /dev/null +++ b/apps/workplace/e2e/local/home-upcoming.spec.ts @@ -0,0 +1,241 @@ +/** + * HOME-04 / HOME-05 / HOME-08 — the Upcoming panel on the home page. + * + * This panel is the first thing a user sees after signing in, and the only place + * they check "have I got a desk today?". Nothing has tested it: `boot.spec.ts` + * proves the shell renders and org data resolves, which says nothing about + * whether the panel shows the right bookings — or any. + * + * ## How the panel actually works, which is not what its name suggests + * + * "Upcoming" means **today**, and nothing else. `landing-state.service.ts` + * builds it from the SCHEDULE's own booking list: + * + * filtered_bookings().filter(i => i.state !== 'done' && isSameDay(i.date, now)) + * + * then the template shows the first FIVE (`events | slice: 0 : 5`). Three + * consequences, all of which shaped these tests: + * + * - a booking tomorrow is not on it. A first draft of this file booked sixteen + * days out and spent three failures discovering that; + * - a booking that has finished is not on it either; + * - it inherits everything the schedule's query does — including + * `include_deleted: true`. See HOME-B1 on the third test. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { deleteBooking, releaseAsset, uniqueTitle } from '../../../../e2e/support/api'; +import { WORKERS, deskFor } from '../../../../e2e/support/env'; +import { createBookingViaApi } from '../../../../e2e/support/home/home.api'; +import { LandingPage } from '../../../../e2e/support/home/landing.page'; +import { SchedulePage } from '../../../../e2e/support/bookings/schedule.page'; + +const MINUTE = 60; + +/** + * A short window later TODAY, on the desk no worker owns. + * + * The panel only shows today, which puts these specs on the same day as the + * dev's desk specs (all day, own desk), `bookings-checkin` (an hour from now, + * own desk) and `desk-status` (minutes from now, spare desk). So: the SPARE + * desk, and a start well clear of `desk-status`, staggered twenty minutes per + * worker. + */ +function laterToday(workerIndex: number) { + const start = Math.floor(Date.now() / 1000) + (90 + 20 * workerIndex) * MINUTE; + return { start, end: start + 15 * MINUTE }; +} + +/** Is that window still today, locally? Late-evening runs cannot use it. */ +function stillToday(unix_seconds: number): boolean { + return new Date(unix_seconds * 1000).toDateString() === new Date().toDateString(); +} + +test.describe('home page — the Upcoming panel', () => { + /** + * HOME-B1, first facet — a live booking can be crowded off the panel. + * + * This passes in isolation and FAILS under a full suite run, and the reason + * is the finding rather than the test. The panel holds five cards and + * cancelled bookings take slots, so after a busy run today's five slots are + * full of junk: measured at `[1162, 1530, 1642, 1575, 1576]`, every one + * cancelled, with a live booking for the same user on the same day absent. + * + * So this is `fixme` alongside the cancelled-booking test below. Between + * them they state both halves of HOME-B1: cancelled bookings never leave, + * and their presence can hide a real booking. Neither needs changing when + * the panel starts excluding deleted bookings — they will simply go green. + */ + test.fixme('a booking later today is listed on the panel', async ({ + staffPage, + staffApi, + }, testInfo) => { + const desk = deskFor(WORKERS); + const slot = laterToday(testInfo.parallelIndex); + test.skip( + !stillToday(slot.start), + 'this window has crossed midnight, and the panel only shows today', + ); + let booking_id: number | undefined; + + // Only this worker's own window: the spare desk is shared, so a wider + // sweep would delete another worker's booking mid-test. + await releaseAsset(staffApi, 'desk', desk.id, slot.start - 60, slot.end + 60); + + try { + const booking = await createBookingViaApi(staffApi, { + type: 'desk', + asset_id: desk.id, + asset_name: desk.name, + title: uniqueTitle('E2E Home Upcoming'), + start: slot.start, + end: slot.end, + }); + booking_id = booking.id; + + const home = new LandingPage(staffPage); + await home.open(); + + const found = await home + .upcomingCard(booking_id) + .waitFor({ state: 'visible', timeout: 30_000 }) + .then(() => true) + .catch(() => false); + if (!found) { + // Say what the panel IS showing, with the reason it might be + // full: it holds five, and cancelled bookings count (HOME-B1). + const showing = await home.upcomingIds(); + throw new Error( + `booking ${booking_id} is not on the Upcoming panel. It is showing ` + + `${showing.length}: [${showing.join(', ')}]. The panel holds five ` + + `and CANCELLED bookings take slots too, so a user with five ` + + `cancelled bookings today cannot see a real one — that is HOME-B1, ` + + `covered by the third test in this file.`, + ); + } + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + await releaseAsset(staffApi, 'desk', desk.id, slot.start - 60, slot.end + 60); + } + }); + + test('View all hands off to Your Bookings, with the booking listed there', async ({ + staffPage, + staffApi, + }, testInfo) => { + const desk = deskFor(WORKERS); + // Well clear of the first test's window, still today. + const start = Math.floor(Date.now() / 1000) + (200 + 20 * testInfo.parallelIndex) * MINUTE; + const slot = { start, end: start + 15 * MINUTE }; + test.skip( + !stillToday(slot.start), + 'this window has crossed midnight, and the panel only shows today', + ); + let booking_id: number | undefined; + + await releaseAsset(staffApi, 'desk', desk.id, slot.start - 60, slot.end + 60); + + try { + const booking = await createBookingViaApi(staffApi, { + type: 'desk', + asset_id: desk.id, + asset_name: desk.name, + title: uniqueTitle('E2E Home ViewAll'), + start: slot.start, + end: slot.end, + }); + booking_id = booking.id; + + const home = new LandingPage(staffPage); + await home.open(); + await expect( + home.viewAll, + 'the Upcoming panel should offer a View all link', + ).toBeVisible({ timeout: 30_000 }); + await home.viewAll.click(); + + await expect( + staffPage, + 'View all should land on the Your Bookings page', + ).toHaveURL(/#\/your-bookings/, { timeout: 30_000 }); + + // The handoff is only useful if the booking is findable there. + const schedule = new SchedulePage(staffPage); + await schedule.showBooking(booking_id, slot.start * 1000, ['desk']); + await expect( + schedule.card(booking_id), + 'the same booking should be listed on Your Bookings', + ).toBeVisible({ timeout: 30_000 }); + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + await releaseAsset(staffApi, 'desk', desk.id, slot.start - 60, slot.end + 60); + } + }); + + /** + * HOME-B1 — the home page keeps showing bookings you cancelled. + * + * The panel is built from the schedule's booking list, and the schedule asks + * for bookings with `include_deleted: true` (`schedule-state.service.ts`), + * hiding only what the user cancelled in the CURRENT session. So a cancelled + * booking stays on the home page for ever. + * + * Measured on this stack: the panel was showing **five bookings, every one + * of them cancelled** — `deleted: true` on all five, confirmed by id — while + * a live booking for the same user on the same day was absent. Because the + * panel holds exactly five, the junk had crowded the real one out entirely. + * + * Two separate harms, and the second is the worse one: + * + * 1. the page tells a user they have a desk today when they cancelled it; + * 2. it can HIDE a real booking, because cancelled ones take the slots. + * + * This is the same root cause as the 100-row listing problem in the project + * notes, surfacing on the first screen users see. `fixme` until the panel + * either excludes deleted bookings or the schedule stops asking for them. + */ + test.fixme('a cancelled booking leaves the panel', async ({ + staffPage, + staffApi, + }, testInfo) => { + const desk = deskFor(WORKERS); + const start = Math.floor(Date.now() / 1000) + (300 + 20 * testInfo.parallelIndex) * MINUTE; + const slot = { start, end: start + 15 * MINUTE }; + test.skip(!stillToday(slot.start), 'this window has crossed midnight'); + let booking_id: number | undefined; + + await releaseAsset(staffApi, 'desk', desk.id, slot.start - 60, slot.end + 60); + + try { + const booking = await createBookingViaApi(staffApi, { + type: 'desk', + asset_id: desk.id, + asset_name: desk.name, + title: uniqueTitle('E2E Home Cancelled'), + start: slot.start, + end: slot.end, + }); + booking_id = booking.id; + + const home = new LandingPage(staffPage); + await home.open(); + await expect( + home.upcomingCard(booking_id), + 'precondition: the booking is on the panel before it is cancelled', + ).toBeVisible({ timeout: 30_000 }); + + await deleteBooking(staffApi, booking_id); + await staffPage.reload(); + await home.open(); + + await expect( + home.upcomingCard(booking_id), + 'a cancelled booking must NOT stay on the home page — it tells the user ' + + 'they have a desk they no longer have, and it takes one of the five ' + + 'slots the panel has', + ).toBeHidden({ timeout: 30_000 }); + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + await releaseAsset(staffApi, 'desk', desk.id, slot.start - 60, slot.end + 60); + } + }); +}); diff --git a/apps/workplace/e2e/local/parking-api.spec.ts b/apps/workplace/e2e/local/parking-api.spec.ts new file mode 100644 index 0000000000..73f547b64d --- /dev/null +++ b/apps/workplace/e2e/local/parking-api.spec.ts @@ -0,0 +1,94 @@ +/** + * PARK-17 — the booking API's own behaviour for parking. + * + * Every other parking spec asks the backend to do something reasonable. Nothing + * asks it to do something wrong, so nothing would notice a bad request turning + * into a 500 — and a 500 on a booking write is not merely untidy, it is how + * REG-09 poisons a connection for every other request in flight. + * + * API-only: the subject is the backend's rules, and driving a form would only + * add ways to fail. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { + STAFF_API, + currentUser, + deleteBooking, + uniqueTitle, + zonesWithTag, +} from '../../../../e2e/support/api'; +import { PARKING_SLOTS, slotOn } from '../../../../e2e/support/parking/parking.env'; +import { spaceForWorker } from '../../../../e2e/support/parking/parking.seed'; + +test.describe('parking booking API rules', () => { + test('a malformed or impossible parking booking is refused with a 4xx, never a 5xx', async ({ + staffApi, + }, testInfo) => { + const space = await spaceForWorker(testInfo.parallelIndex); + const slot = slotOn(PARKING_SLOTS.api.day, PARKING_SLOTS.api.hour); + const me = await currentUser(staffApi); + const zones = ( + await Promise.all( + ['org', 'building'].map((t) => zonesWithTag(staffApi, t)), + ) + ) + .flat() + .map((z) => z.id); + + const base = { + booking_type: 'parking', + asset_id: space.id, + asset_name: space.name, + booking_start: slot.start, + booking_end: slot.end, + timezone: 'Etc/UTC', + user_email: me.email, + user_id: me.id, + user_name: me.name, + title: uniqueTitle('E2E Parking Bad'), + zones, + }; + + const cases: { name: string; data: Record }[] = [ + { + name: 'a space that does not exist', + data: { ...base, asset_id: 'asset-does-not-exist' }, + }, + { + name: 'an end time before the start time', + data: { ...base, booking_end: slot.start - 3600 }, + }, + { name: 'no asset at all', data: { ...base, asset_id: undefined } }, + { + name: 'a start time that is not a number', + data: { ...base, booking_start: 'tomorrow' }, + }, + ]; + + const created: number[] = []; + try { + for (const { name, data } of cases) { + const res = await staffApi.post(`${STAFF_API}/bookings`, { data }); + const body = await res.text(); + if (res.ok()) { + // Accepting it is a finding rather than a crash, and this + // test is about 5xx — so record it and clean it up. + const id = JSON.parse(body).id; + if (id) created.push(id); + console.warn( + ` ! the backend ACCEPTED "${name}" (HTTP ${res.status()}). ` + + `That may be worth a bug of its own.`, + ); + continue; + } + expect( + res.status(), + `"${name}" must be refused with a 4xx, not a server error. Got ` + + `${res.status()}: ${body.slice(0, 200)}`, + ).toBeLessThan(500); + } + } finally { + for (const id of created) await deleteBooking(staffApi, id); + } + }); +}); diff --git a/apps/workplace/e2e/local/parking-booking.spec.ts b/apps/workplace/e2e/local/parking-booking.spec.ts new file mode 100644 index 0000000000..3aea294928 --- /dev/null +++ b/apps/workplace/e2e/local/parking-booking.spec.ts @@ -0,0 +1,151 @@ +/** + * PARK-01 / PARK-02 — book a parking space through the full UI, as a NON-ADMIN. + * + * Parking is a whole booking type with no coverage at all until now, and it is + * the one that needs the most setting up: a space is an ASSET of a + * `_PARKING_SPACES_` type, on a level zone tagged `parking`, and the local stack + * seeds none of that. `parking.seed.ts` creates it and explains the shape. + * + * Deliberately the same two tests the desk and room suites open with — the happy + * path reaching the backend intact, and teardown really tearing down — so the + * three are comparable at a glance. + * + * ## What makes parking different from a desk + * + * - the flow has THREE screens (form, confirm, success) like meetings, not two + * like desks, and nothing is sent until the second; + * - the form has a PLATE NUMBER field, which no other booking type has; + * - spaces come from the parking asset API rather than from zone metadata. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { + deleteBooking, + getBooking, + listBookings, + releaseAsset, + uniqueTitle, +} from '../../../../e2e/support/api'; +import { + PARKING_SLOTS, + dayBoundsOn, + hhmm, + slotOn, +} from '../../../../e2e/support/parking/parking.env'; +import { spaceForWorker } from '../../../../e2e/support/parking/parking.seed'; +import { ParkingForm } from '../../../../e2e/support/parking/parking-form.page'; +import { + PARKING_BASE_SETTINGS, + useSettings, +} from '../../../../e2e/support/parking/parking.settings'; +import { createParkingBookingViaApi } from '../../../../e2e/support/parking/parking.api'; + +test.describe('parking booking', () => { + test('a non-admin books a parking space in the UI and the backend stores it', async ({ + staffPage, + staffApi, + }, testInfo) => { + const space = await spaceForWorker(testInfo.parallelIndex); + const slot = slotOn(PARKING_SLOTS.booking.day, PARKING_SLOTS.booking.hour); + const { from, to } = dayBoundsOn(PARKING_SLOTS.booking.day); + const title = uniqueTitle('E2E Parking'); + const plate = `E2E ${testInfo.parallelIndex}${Date.now() % 1000}`; + let booking_id: number | undefined; + + // Sweep first, not just after: a run that died between booking and + // cleanup leaves the space held, and every later run then fails with + // something that looks nothing like the cause. + await releaseAsset(staffApi, 'parking', space.id, from, to); + // Without this the form cannot be submitted at all on this stack — see + // PARK-B1 in `parking.settings.ts`. + await useSettings(staffPage, PARKING_BASE_SETTINGS); + + try { + const form = new ParkingForm(staffPage); + await form.open(); + await form.pickDate(slot.date_ms); + + // Converge on the form state: these forms are rebuilt when async + // init completes and restore their defaults (REG-10's family). + await expect(async () => { + await form.setChecked(form.allDay, false); + await form.title.fill(title); + if (await form.plate.count()) await form.plate.fill(plate); + if ((await form.chosenSpaces.count()) === 0) { + await form.chooseSpace(space.name); + } + expect(await form.title.inputValue()).toBe(title); + expect(await form.chosenSpaces.count()).toBe(1); + }).toPass({ timeout: 60_000 }); + + // Times last, then settle: the fields reach the model + // asynchronously and the confirm screen snapshots it when it opens. + await form.setStartTime(hhmm(slot.start)); + await staffPage.waitForTimeout(3_000); + + const [response] = await Promise.all([ + staffPage.waitForResponse( + (r) => + r.url().includes('/api/staff/v1/bookings') && + r.request().method() === 'POST', + { timeout: 30_000 }, + ), + form.confirmAndSend(), + ]); + const body = await response.text(); + expect( + response.status(), + `the parking booking POST failed: ${body}`, + ).toBeLessThan(300); + booking_id = JSON.parse(body).id; + + const stored: any = await getBooking(staffApi, booking_id!); + expect( + stored.booking_type, + 'the booking must be stored as a parking booking', + ).toBe('parking'); + expect(stored.asset_id, 'against the space that was chosen').toBe(space.id); + expect( + hhmm(stored.booking_start), + 'at the time that was chosen', + ).toBe(hhmm(slot.start)); + expect( + stored.zones?.length ?? 0, + 'and carrying its zones — anything scoping bookings by zone cannot see ' + + 'a booking that has none (the room form gets this wrong, ROOM-B2)', + ).toBeGreaterThan(0); + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + await releaseAsset(staffApi, 'parking', space.id, from, to); + } + }); + + test('a deleted parking booking leaves the listing', async ({ + staffApi, + }, testInfo) => { + const space = await spaceForWorker(testInfo.parallelIndex); + const slot = slotOn(PARKING_SLOTS.cleanup.day, PARKING_SLOTS.cleanup.hour); + const { from, to } = dayBoundsOn(PARKING_SLOTS.cleanup.day); + const title = uniqueTitle('E2E Parking Cleanup'); + + await releaseAsset(staffApi, 'parking', space.id, from, to); + + const booking = await createParkingBookingViaApi(staffApi, { + space, + title, + start: slot.start, + end: slot.end, + }); + expect( + (await listBookings(staffApi, 'parking', from, to)).map((b) => b.id), + 'precondition: the booking is in the listing before it is deleted', + ).toContain(booking.id); + + await deleteBooking(staffApi, booking.id); + expect( + (await listBookings(staffApi, 'parking', from, to)).map((b) => b.id), + 'a deleted booking must not come back in the listing — otherwise every ' + + 'spec teardown silently leaks state into the next run, and a space is ' + + 'exclusive, so a leak holds it for everyone', + ).not.toContain(booking.id); + }); +}); diff --git a/apps/workplace/e2e/local/parking-cancel.spec.ts b/apps/workplace/e2e/local/parking-cancel.spec.ts new file mode 100644 index 0000000000..7d7504d597 --- /dev/null +++ b/apps/workplace/e2e/local/parking-cancel.spec.ts @@ -0,0 +1,119 @@ +/** + * PARK-09 / PARK-10 — cancelling a parking booking from the app. + * + * Every other parking spec tears its booking down through the API, which is + * right for a teardown and useless as coverage: the Cancel item in the booking + * menu and the confirmation in front of it are never otherwise pressed. The same + * gap in the room specs was worth a bug that still holds a room after the user + * believes they cancelled (ROOM-B4), and a held parking space has the same + * consequence for whoever is circling the car park. + * + * The pair is deliberate: a dialog whose DECLINE button also deletes is worse + * than one that fails to delete, and only the second test can catch it. + * + * Both read the backend afterwards — the card leaves the screen either way, + * because the page removes it optimistically. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { + deleteBooking, + getBooking, + releaseAsset, + uniqueTitle, +} from '../../../../e2e/support/api'; +import { + PARKING_SLOTS, + dayBoundsOn, + slotOn, +} from '../../../../e2e/support/parking/parking.env'; +import { spaceForWorker } from '../../../../e2e/support/parking/parking.seed'; +import { createParkingBookingViaApi } from '../../../../e2e/support/parking/parking.api'; +import { ParkingSchedulePage } from '../../../../e2e/support/parking/parking-schedule.page'; + +test.describe('cancelling a parking booking from the app', () => { + test('cancelling from the booking menu removes it for real', async ({ + staffPage, + staffApi, + }, testInfo) => { + const space = await spaceForWorker(testInfo.parallelIndex); + const slot = slotOn(PARKING_SLOTS.cancel.day, PARKING_SLOTS.cancel.hour); + const { from, to } = dayBoundsOn(PARKING_SLOTS.cancel.day); + let booking_id: number | undefined; + + await releaseAsset(staffApi, 'parking', space.id, from, to); + + try { + const booking = await createParkingBookingViaApi(staffApi, { + space, + title: uniqueTitle('E2E Parking Cancel'), + start: slot.start, + end: slot.end, + }); + booking_id = booking.id; + + const schedule = new ParkingSchedulePage(staffPage); + await schedule.open(); + await schedule.showParking(); + await schedule.showDayOf(slot.date_ms); + await schedule.startCancel(booking_id); + await schedule.acceptConfirm(); + + await expect(async () => { + const after = await getBooking(staffApi, booking_id!); + expect( + after.deleted, + 'after confirming, the booking must be gone on the BACKEND, not ' + + 'just off the screen — otherwise the space stays held by ' + + 'something the user believes they cancelled', + ).toBeTruthy(); + }).toPass({ timeout: 30_000 }); + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + await releaseAsset(staffApi, 'parking', space.id, from, to); + } + }); + + test('declining the confirmation leaves the booking alone', async ({ + staffPage, + staffApi, + }, testInfo) => { + const space = await spaceForWorker(testInfo.parallelIndex); + const slot = slotOn( + PARKING_SLOTS.cancel_declined.day, + PARKING_SLOTS.cancel_declined.hour, + ); + const { from, to } = dayBoundsOn(PARKING_SLOTS.cancel_declined.day); + let booking_id: number | undefined; + + await releaseAsset(staffApi, 'parking', space.id, from, to); + + try { + const booking = await createParkingBookingViaApi(staffApi, { + space, + title: uniqueTitle('E2E Parking Keep'), + start: slot.start, + end: slot.end, + }); + booking_id = booking.id; + + const schedule = new ParkingSchedulePage(staffPage); + await schedule.open(); + await schedule.showParking(); + await schedule.showDayOf(slot.date_ms); + await schedule.startCancel(booking_id); + await schedule.dismissConfirm(); + + // Give the app a chance to do the wrong thing before believing it did + // the right one: asserting immediately would pass against a delete + // that is merely slow. + await staffPage.waitForTimeout(3_000); + expect( + (await getBooking(staffApi, booking_id)).deleted, + 'declining the confirmation must NOT delete the booking', + ).toBeFalsy(); + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + await releaseAsset(staffApi, 'parking', space.id, from, to); + } + }); +}); diff --git a/apps/workplace/e2e/local/parking-clash.spec.ts b/apps/workplace/e2e/local/parking-clash.spec.ts new file mode 100644 index 0000000000..5b49c6b416 --- /dev/null +++ b/apps/workplace/e2e/local/parking-clash.spec.ts @@ -0,0 +1,197 @@ +/** + * PARK-03 / PARK-04 — a parking space is exclusive for its window. + * + * The same reasoning as desks (REG-02) and rooms (ROOM-03): a double-booked + * space is the failure a user notices fastest, and it is the BACKEND that has to + * refuse it. The picker's own filtering only decides what is offered; it would + * be no defence against a second person booking at the same moment, or against + * any other client. + * + * Everything is created through the API, not the form. The subject is the rule, + * and driving the form twice would add an unrelated way to fail — including + * hitting the picker's own filtering, which hides busy spaces and would make + * "the second booking was refused" indistinguishable from "the space was never + * offered". + * + * Attempted as a SECOND user, deliberately: a check that only looked at the + * caller's own bookings would pass a same-user test and still let two colleagues + * take one space. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { request } from '@playwright/test'; +import { + deleteBooking, + getBooking, + releaseAsset, + uniqueTitle, +} from '../../../../e2e/support/api'; +import { APP_URL, BACKEND_URL, WORKERS, roleFor } from '../../../../e2e/support/env'; +import { mintToken } from '../../../../e2e/support/auth'; +import { + PARKING_SLOTS, + dayBoundsOn, + slotOn, +} from '../../../../e2e/support/parking/parking.env'; +import { spaceForWorker } from '../../../../e2e/support/parking/parking.seed'; +import { + createParkingBookingViaApi, + tryParkingBooking, +} from '../../../../e2e/support/parking/parking.api'; + +async function apiAsOtherUser(workerIndex: number) { + const role = roleFor('staff', workerIndex); + const mint = await mintToken(BACKEND_URL, APP_URL, role.email, role.password); + return request.newContext({ + baseURL: BACKEND_URL, + ignoreHTTPSErrors: true, + extraHTTPHeaders: { Authorization: `Bearer ${mint.accessToken}` }, + }); +} + +test.describe('parking double-booking', () => { + test('a second person cannot take a space that is already booked', async ({ + staffApi, + }, testInfo) => { + const mine = testInfo.parallelIndex; + const theirs = (mine + 1) % WORKERS; + test.skip(theirs === mine, 'needs at least two workers to have two distinct users'); + + const space = await spaceForWorker(mine); + const slot = slotOn(PARKING_SLOTS.clash.day, PARKING_SLOTS.clash.hour); + const { from, to } = dayBoundsOn(PARKING_SLOTS.clash.day); + const other = await apiAsOtherUser(theirs); + let booking_id: number | undefined; + const other_ids: number[] = []; + + await releaseAsset(staffApi, 'parking', space.id, from, to); + + try { + const booking = await createParkingBookingViaApi(staffApi, { + space, + title: uniqueTitle('E2E Parking Clash'), + start: slot.start, + end: slot.end, + }); + booking_id = booking.id; + expect( + (await getBooking(staffApi, booking_id)).deleted, + 'precondition: the space really is held before anyone else tries', + ).toBeFalsy(); + + // Exactly the same window. + const same = await tryParkingBooking(other, { + space, + title: uniqueTitle('E2E Parking Same'), + start: slot.start, + end: slot.end, + }); + if (same.id) other_ids.push(same.id); + // 409 specifically, not "any error": a 5xx from an unhealthy backend + // would satisfy `>= 400` while proving nothing about clash detection, + // and booking POSTs have a known way of returning 500 under load + // (REG-09) — exactly the failure this spec must not absorb. + expect( + same.status, + `the same space and window must be refused with 409, got ` + + `${same.status}: ${same.body.slice(0, 200)}`, + ).toBe(409); + + // A partial overlap: the case a naive check misses, because it starts + // after the existing booking begins and ends after it ends. + const overlap = await tryParkingBooking(other, { + space, + title: uniqueTitle('E2E Parking Overlap'), + start: slot.start + 1800, + end: slot.end + 1800, + }); + if (overlap.id) other_ids.push(overlap.id); + expect( + overlap.status, + `a partly overlapping booking must also be refused with 409, got ` + + `${overlap.status}: ${overlap.body.slice(0, 200)}`, + ).toBe(409); + + // Control: a window that genuinely does not overlap is accepted. + // Without this, a backend refusing everything would pass both checks + // above and look like working clash detection. + const clear = await tryParkingBooking(other, { + space, + title: uniqueTitle('E2E Parking Clear'), + start: slot.end + 3600, + end: slot.end + 7200, + }); + if (clear.id) other_ids.push(clear.id); + expect( + clear.status, + `a non-overlapping window must still be accepted, got ${clear.status}: ` + + `${clear.body.slice(0, 200)}`, + ).toBe(201); + } finally { + // Their bookings must go BY THEM: `GET /bookings` is caller-scoped, + // so this worker's sweep cannot see them and the space would stay + // held by something invisible. + for (const id of other_ids) await deleteBooking(other, id); + if (booking_id != null) await deleteBooking(staffApi, booking_id); + await releaseAsset(staffApi, 'parking', space.id, from, to); + await other.dispose(); + } + }); + + test('the space frees up once the booking is deleted', async ({ + staffApi, + }, testInfo) => { + const mine = testInfo.parallelIndex; + const theirs = (mine + 1) % WORKERS; + test.skip(theirs === mine, 'needs at least two workers to have two distinct users'); + + const space = await spaceForWorker(mine); + const slot = slotOn(PARKING_SLOTS.freed.day, PARKING_SLOTS.freed.hour); + const { from, to } = dayBoundsOn(PARKING_SLOTS.freed.day); + const other = await apiAsOtherUser(theirs); + const other_ids: number[] = []; + + await releaseAsset(staffApi, 'parking', space.id, from, to); + + try { + const booking = await createParkingBookingViaApi(staffApi, { + space, + title: uniqueTitle('E2E Parking Freed'), + start: slot.start, + end: slot.end, + }); + + const while_held = await tryParkingBooking(other, { + space, + title: uniqueTitle('E2E Parking Held'), + start: slot.start, + end: slot.end, + }); + if (while_held.id) other_ids.push(while_held.id); + expect( + while_held.status, + 'precondition: the space is refused while it is held', + ).toBe(409); + + await deleteBooking(staffApi, booking.id); + + const after = await tryParkingBooking(other, { + space, + title: uniqueTitle('E2E Parking After'), + start: slot.start, + end: slot.end, + }); + if (after.id) other_ids.push(after.id); + expect( + after.status, + `once the booking is deleted the space must be bookable again, got ` + + `${after.status}: ${after.body.slice(0, 200)}. A space that stays ` + + `held after a cancellation is invisible to everyone — nobody can ` + + `see what is holding it`, + ).toBe(201); + } finally { + for (const id of other_ids) await deleteBooking(other, id); + await releaseAsset(staffApi, 'parking', space.id, from, to); + await other.dispose(); + } + }); +}); diff --git a/apps/workplace/e2e/local/parking-edit.spec.ts b/apps/workplace/e2e/local/parking-edit.spec.ts new file mode 100644 index 0000000000..e3fb79df24 --- /dev/null +++ b/apps/workplace/e2e/local/parking-edit.spec.ts @@ -0,0 +1,174 @@ +/** + * PARK-11 — changing a parking booking that already exists. + * + * An edit is a different code path from a create: it PATCHes rather than POSTs + * (`saveBooking` branches on the id), so an edit that lost the id would create a + * SECOND booking and hold two spaces. Moving to another space also has to free + * the first one. + * + * The edit starts on the SCHEDULE, because a parking booking cannot be opened + * for editing from the form: `schedule.component.ts::editBooking` loads the form + * and routes to `/book/parking`. + * + * ## The spare space + * + * `parking.seed.ts` creates one space per worker plus one spare, and test 2 + * moves the booking onto the spare. Because it is shared between workers, the + * HOUR is staggered by worker index — the day cannot be, since the schedule's + * sidebar calendar only reaches the displayed month. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { + deleteBooking, + getBooking, + releaseAsset, + uniqueTitle, +} from '../../../../e2e/support/api'; +import { + PARKING_SLOTS, + dayBoundsOn, + hhmm, + hourFor, + slotOn, +} from '../../../../e2e/support/parking/parking.env'; +import { altSpace, spaceForWorker } from '../../../../e2e/support/parking/parking.seed'; +import { createParkingBookingViaApi } from '../../../../e2e/support/parking/parking.api'; +import { + PARKING_BASE_SETTINGS, + useSettings, +} from '../../../../e2e/support/parking/parking.settings'; +import { ParkingForm } from '../../../../e2e/support/parking/parking-form.page'; +import { ParkingSchedulePage } from '../../../../e2e/support/parking/parking-schedule.page'; + +test.describe('editing a parking booking', () => { + test('a new start time chosen on the form is the one stored', async ({ + staffPage, + staffApi, + }, testInfo) => { + const space = await spaceForWorker(testInfo.parallelIndex); + const { day, hour, moved_to } = PARKING_SLOTS.edit; + const from_slot = slotOn(day, hour); + const to_slot = slotOn(day, moved_to); + const { from, to } = dayBoundsOn(day); + let booking_id: number | undefined; + + // The whole day, because the hour being moved INTO has to be free too. + await releaseAsset(staffApi, 'parking', space.id, from, to); + await useSettings(staffPage, PARKING_BASE_SETTINGS); + + try { + const booking = await createParkingBookingViaApi(staffApi, { + space, + title: uniqueTitle('E2E Parking Edit Time'), + start: from_slot.start, + end: from_slot.end, + }); + booking_id = booking.id; + + const schedule = new ParkingSchedulePage(staffPage); + await schedule.open(); + await schedule.showParking(); + await schedule.showDayOf(from_slot.date_ms); + await schedule.startEdit(booking_id); + + const form = new ParkingForm(staffPage); + await expect( + form.chosenSpaces, + 'the edit form should open with the booked space already on it', + ).toHaveCount(1, { timeout: 30_000 }); + + await form.setStartTime(hhmm(to_slot.start)); + await staffPage.waitForTimeout(3_000); + await form.confirmAndSend(); + + await expect(async () => { + const updated = await getBooking(staffApi, booking_id!); + expect( + hhmm(updated.booking_start), + `the booking should now start at ${hhmm(to_slot.start)}`, + ).toBe(hhmm(to_slot.start)); + }).toPass({ timeout: 45_000 }); + + const updated = await getBooking(staffApi, booking_id); + expect(updated.asset_id, 'moving the time must not change the space').toBe( + space.id, + ); + expect( + updated.deleted, + 'an edit must UPDATE the booking, not delete and replace it', + ).toBeFalsy(); + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + await releaseAsset(staffApi, 'parking', space.id, from, to); + } + }); + + test('a booking moved to another space is stored against that space', async ({ + staffPage, + staffApi, + }, testInfo) => { + const space = await spaceForWorker(testInfo.parallelIndex); + const other = await altSpace(); + const day = PARKING_SLOTS.edit.day; + // The spare space is shared, so the hour is staggered per worker. + const slot = slotOn(day, hourFor(8, testInfo.parallelIndex)); + const { from, to } = dayBoundsOn(day); + let booking_id: number | undefined; + + await releaseAsset(staffApi, 'parking', space.id, slot.start - 60, slot.end + 60); + await releaseAsset(staffApi, 'parking', other.id, slot.start - 60, slot.end + 60); + await useSettings(staffPage, PARKING_BASE_SETTINGS); + + try { + const booking = await createParkingBookingViaApi(staffApi, { + space, + title: uniqueTitle('E2E Parking Edit Space'), + start: slot.start, + end: slot.end, + }); + booking_id = booking.id; + + const schedule = new ParkingSchedulePage(staffPage); + await schedule.open(); + await schedule.showParking(); + await schedule.showDayOf(slot.date_ms); + await schedule.startEdit(booking_id); + + const form = new ParkingForm(staffPage); + await expect( + form.chosenSpaces, + 'the edit form should open with the booked space already on it', + ).toHaveCount(1, { timeout: 30_000 }); + + // Converged on the NAME, not the count: the count is 1 before and 1 + // after, so it cannot see this swap, and the choice reaches the model + // asynchronously while the confirm sheet snapshots it when it opens. + await expect(async () => { + if (!(await form.chosenSpaceNames()).includes(other.name)) { + await form.chooseSpace(other.name); + } + expect(await form.chosenSpaceNames()).toContain(other.name); + }).toPass({ timeout: 45_000 }); + await staffPage.waitForTimeout(3_000); + await form.confirmAndSend(); + + await expect(async () => { + const updated = await getBooking(staffApi, booking_id!); + expect( + updated.asset_id, + `the booking should now be held against ${other.name}`, + ).toBe(other.id); + }).toPass({ timeout: 45_000 }); + + const updated = await getBooking(staffApi, booking_id); + expect( + hhmm(updated.booking_start), + 'moving space must not move the booking in time', + ).toBe(hhmm(slot.start)); + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + await releaseAsset(staffApi, 'parking', space.id, slot.start - 60, slot.end + 60); + await releaseAsset(staffApi, 'parking', other.id, slot.start - 60, slot.end + 60); + } + }); +}); diff --git a/apps/workplace/e2e/local/parking-favourites.spec.ts b/apps/workplace/e2e/local/parking-favourites.spec.ts new file mode 100644 index 0000000000..12177b8e10 --- /dev/null +++ b/apps/workplace/e2e/local/parking-favourites.spec.ts @@ -0,0 +1,92 @@ +/** + * PARK-12 — marking a parking space as a favourite. + * + * Favourites are a USER setting, written to the signed-in user's `settings` + * metadata as `favourite_parking_spaces` and read back on every later visit. The room + * and desk equivalents are both green; parking has the same feature, and the + * same failure mode: a star that lights up and is forgotten on reload looks + * perfect in a screenshot and is useless to the person circling the car park + * every morning. + * + * Asserted on the BACKEND, and polled, because the write is debounced (~2.4s in + * `SettingsService.saveUserSetting`) and sends the whole settings blob as one + * `PUT /metadata/{user_id}`. + * + * Cleared either side, through a read-modify-write: this blob is shared with the + * visitor specs' invitee list and the other areas' favourites, and a careless + * write would wipe them. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { spaceForWorker } from '../../../../e2e/support/parking/parking.seed'; +import { + FAVOURITE_PARKING_KEY, + readUserSettings, + setFavouriteParking, +} from '../../../../e2e/support/parking/parking.api'; +import { + PARKING_BASE_SETTINGS, + useSettings, +} from '../../../../e2e/support/parking/parking.settings'; +import { ParkingForm } from '../../../../e2e/support/parking/parking-form.page'; + +test.describe('favourite parking spaces', () => { + test('a space marked as a favourite is saved against the user', async ({ + staffPage, + staffApi, + }, testInfo) => { + const space = await spaceForWorker(testInfo.parallelIndex); + + // Start from none: a favourite left by an earlier run would let the + // assertion pass without this test having done anything. + await setFavouriteParking(staffApi, []); + await useSettings(staffPage, PARKING_BASE_SETTINGS); + + try { + const form = new ParkingForm(staffPage); + await form.open(); + await form.addSpaceButton.click({ timeout: 10_000 }); + + const row = this_row(staffPage, space.name); + // `fav`, a bare attribute — not `favourite`, and not a name. Found by + // asking the failure message to list the row's buttons, which is + // faster than reading the template for every picker in this app. + const star = row.locator('button[fav]').first(); + const found = await star + .waitFor({ state: 'visible', timeout: 20_000 }) + .then(() => true) + .catch(() => false); + if (!found) { + const offered = await staffPage.locator('li[space]').allInnerTexts(); + const buttons = await row.locator('button').evaluateAll((els) => + els.map((el) => + [...el.attributes].map((a) => a.name).join(','), + ), + ); + throw new Error( + `no favourite control on the row for ${space.name}. The picker ` + + `offers ${offered.length} space(s), and that row's buttons ` + + `carry: ${JSON.stringify(buttons)}`, + ); + } + await star.click(); + + await expect(async () => { + const settings = await readUserSettings(staffApi); + expect( + settings[FAVOURITE_PARKING_KEY] ?? [], + `${space.name} (${space.id}) should be saved as a favourite space ` + + `under "${FAVOURITE_PARKING_KEY}". Note the codebase also has a ` + + `dead \`favourite_parking\` constant that nothing reads`, + ).toContain(space.id); + }).toPass({ timeout: 30_000 }); + } finally { + // Not optional: this is a saved user setting, not a booking. + await setFavouriteParking(staffApi, []); + } + }); +}); + +/** The picker row for a space, by name. */ +function this_row(page: import('@playwright/test').Page, name: string) { + return page.locator('li[space]').filter({ hasText: name }).first(); +} diff --git a/apps/workplace/e2e/local/parking-levels.spec.ts b/apps/workplace/e2e/local/parking-levels.spec.ts new file mode 100644 index 0000000000..ab06586be9 --- /dev/null +++ b/apps/workplace/e2e/local/parking-levels.spec.ts @@ -0,0 +1,102 @@ +/** + * PARK-16 — only spaces on a PARKING level are offered. + * + * `ParkingService.levels` filters the org's levels to those tagged `parking`, + * and the picker only asks those levels for spaces. That tag is the entire + * boundary between "this is a car park" and "this is an office floor", and + * nothing tests it — so a change that dropped the filter would offer desks' + * levels as parking, and the first anybody would know is a user booking a + * parking space on the third floor. + * + * The test creates a parking-shaped asset on the ORDINARY level (the one the + * desks live on, which is not tagged `parking`) and asserts the picker does not + * offer it, while the properly-placed space still is. Both halves matter: the + * second is what stops "the picker offers nothing" passing as success. + * + * The decoy is removed in teardown. It is an asset of the same type on a zone + * the parking flow should ignore — harmless, but not something to leave behind. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { ENGINE_API, apiFor, zonesWithTag } from '../../../../e2e/support/api'; +import { spaceForWorker, parkingSeed } from '../../../../e2e/support/parking/parking.seed'; +import { + PARKING_BASE_SETTINGS, + useSettings, +} from '../../../../e2e/support/parking/parking.settings'; +import { ParkingForm } from '../../../../e2e/support/parking/parking-form.page'; + +test.describe('parking level scoping', () => { + test('a space on a level that is not tagged parking is not offered', async ({ + staffPage, + }, testInfo) => { + const proper = await spaceForWorker(testInfo.parallelIndex); + const seed = await parkingSeed(); + const decoy_name = `E2E Decoy Space ${testInfo.parallelIndex}`; + const admin = await apiFor('admin', 0); + let decoy_id: string | undefined; + + await useSettings(staffPage, PARKING_BASE_SETTINGS); + + try { + // The ordinary level — where the desks live, and deliberately NOT + // tagged `parking`. + // + // Filtered rather than taken first: the parking level this suite + // seeds is tagged BOTH `level` and `parking`, so it is in this list + // too and happened to come first. + const levels = await zonesWithTag(admin, 'level'); + const level = levels.find((zone) => zone.id !== seed.zone_id); + expect( + level?.id, + `precondition: the stack has an ordinary level zone to plant a decoy ` + + `on. Levels found: ${JSON.stringify(levels.map((z) => z.name))}`, + ).toBeTruthy(); + + const created = await admin.post(`${ENGINE_API}/assets`, { + data: { + name: decoy_name, + identifier: decoy_name, + zone_id: level!.id, + asset_type_id: seed.type_id, + description: 'Decoy for PARK-16. Safe to delete.', + }, + }); + expect( + created.ok(), + `planting the decoy failed: HTTP ${created.status()} ${await created.text()}`, + ).toBe(true); + decoy_id = (await created.json()).id; + + const form = new ParkingForm(staffPage); + await form.open(); + await form.addSpaceButton.click({ timeout: 10_000 }); + await expect( + staffPage.locator('li[space]').first(), + 'the parking picker did not open', + ).toBeVisible({ timeout: 20_000 }); + + const offered = await staffPage.locator('li[space]').allInnerTexts(); + const names = offered.map((t) => t.split('\n')[0].trim()); + + expect( + names.join(' | '), + `a space on a level that is not tagged \`parking\` must NOT be offered. ` + + `The picker offered: ${JSON.stringify(names)}`, + ).not.toContain(decoy_name); + + // The control: the properly-placed space IS offered, so the + // assertion above is about the tag rather than about an empty picker. + expect( + names.join(' | '), + `the space on the parking level should still be offered, or "nothing is ` + + `offered" would pass as success. The picker offered: ` + + `${JSON.stringify(names)}`, + ).toContain(proper.name); + } finally { + if (decoy_id) { + await admin.delete(`${ENGINE_API}/assets/${decoy_id}`).catch(() => null); + } + await admin.dispose(); + } + }); +}); diff --git a/apps/workplace/e2e/local/parking-restrictions.spec.ts b/apps/workplace/e2e/local/parking-restrictions.spec.ts new file mode 100644 index 0000000000..d9bf5a9a53 --- /dev/null +++ b/apps/workplace/e2e/local/parking-restrictions.spec.ts @@ -0,0 +1,96 @@ +/** + * PARK-13 — the space-restriction requirement. + * + * ## `fixme` — PARK-B1, and it makes parking unbookable + * + * `parking.require_space_restriction` is described by its own schema as + * belonging to the parking REQUEST flow ("Whether users must select a parking + * space restriction in the parking request flow", + * `apps/workplace/src/environments/settings.schema.json`), and the request form + * does render a control for it. + * + * But the validator lives in the SHARED booking form + * (`libs/bookings/src/lib/booking.utilities.ts`) and fires for any + * `booking_type === 'parking'`: + * + * validate(p.space_restrictions, ... require_space_restriction() && !value() + * ? { kind: 'required' } : undefined) + * + * while `parking-form-details.component.ts` never renders a `space_restrictions` + * control at all — measured, zero mentions. So with the setting on, the ordinary + * parking booking form cannot be submitted: pressing Confirm Reservation gives + * + * Some fields are invalid. [space_restrictions] + * + * and there is no field on screen to satisfy. Measured on this stack with NO + * overrides, which is how the whole parking area was blocked until the setting + * was turned off in `PARKING_BASE_SETTINGS`. + * + * The test below is what SHOULD happen: with the requirement on, a space can + * still be booked (whether by rendering the control on this form too, or by + * scoping the validator to the request flow — that is a product decision). Drop + * the `fixme` once either is done. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { deleteBooking, releaseAsset, uniqueTitle } from '../../../../e2e/support/api'; +import { + PARKING_SLOTS, + dayBoundsOn, + slotOn, +} from '../../../../e2e/support/parking/parking.env'; +import { spaceForWorker } from '../../../../e2e/support/parking/parking.seed'; +import { useSettings } from '../../../../e2e/support/parking/parking.settings'; +import { ParkingForm } from '../../../../e2e/support/parking/parking-form.page'; + +test.describe('parking space restrictions', () => { + test.fixme('a space can still be booked when a restriction is required', async ({ + staffPage, + staffApi, + }, testInfo) => { + const space = await spaceForWorker(testInfo.parallelIndex); + const slot = slotOn(PARKING_SLOTS.api.day, 12); + const { from, to } = dayBoundsOn(PARKING_SLOTS.api.day); + const title = uniqueTitle('E2E Parking Restricted'); + let booking_id: number | undefined; + + await releaseAsset(staffApi, 'parking', space.id, from, to); + // The setting this whole finding is about, switched ON deliberately. + await useSettings(staffPage, { + 'app.parking.require_space_restriction': true, + }); + + try { + const form = new ParkingForm(staffPage); + await form.open(); + await form.pickDate(slot.date_ms); + await expect(async () => { + await form.setChecked(form.allDay, false); + await form.title.fill(title); + if ((await form.chosenSpaces.count()) === 0) { + await form.chooseSpace(space.name); + } + expect(await form.title.inputValue()).toBe(title); + }).toPass({ timeout: 60_000 }); + + const [response] = await Promise.all([ + staffPage.waitForResponse( + (r) => + r.url().includes('/api/staff/v1/bookings') && + r.request().method() === 'POST', + { timeout: 30_000 }, + ), + form.confirmAndSend(), + ]); + const body = await response.text(); + expect( + response.status(), + `with a restriction required, the booking should still be possible: ` + + `${body}`, + ).toBeLessThan(300); + booking_id = JSON.parse(body).id; + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + await releaseAsset(staffApi, 'parking', space.id, from, to); + } + }); +}); diff --git a/apps/workplace/e2e/local/parking-scoping.spec.ts b/apps/workplace/e2e/local/parking-scoping.spec.ts new file mode 100644 index 0000000000..eec4944866 --- /dev/null +++ b/apps/workplace/e2e/local/parking-scoping.spec.ts @@ -0,0 +1,134 @@ +/** + * PARK-05 / PARK-06 — one person's parking booking is not another's business. + * + * The desk equivalent is `booking-scoping.spec.ts` and this mirrors it: a + * `GET /bookings` is caller-scoped, and a deletion attempt by somebody else must + * be REJECTED rather than merely hidden in the UI. + * + * ## This does not contradict `parking-clash.spec.ts` + * + * Worth saying plainly, because the two look opposed. A space's AVAILABILITY is + * shared — that is why a second person is refused 409 when the window is taken. + * What is private is the BOOKING: who booked it, what it is called, and the + * plate number on it, which is personal data. Somebody else may discover that a + * space is busy; they may not read the booking or cancel it. + * + * Both tests work entirely through the API, because that is where the boundary + * is. A UI that merely does not display someone else's booking proves nothing + * about whether the data is reachable. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { request } from '@playwright/test'; +import { + STAFF_API, + deleteBooking, + getBooking, + listBookings, + releaseAsset, + uniqueTitle, +} from '../../../../e2e/support/api'; +import { APP_URL, BACKEND_URL, WORKERS, roleFor } from '../../../../e2e/support/env'; +import { mintToken } from '../../../../e2e/support/auth'; +import { + PARKING_SLOTS, + dayBoundsOn, + slotOn, +} from '../../../../e2e/support/parking/parking.env'; +import { spaceForWorker } from '../../../../e2e/support/parking/parking.seed'; +import { createParkingBookingViaApi } from '../../../../e2e/support/parking/parking.api'; + +async function apiAsOtherUser(workerIndex: number) { + const role = roleFor('staff', workerIndex); + const mint = await mintToken(BACKEND_URL, APP_URL, role.email, role.password); + return request.newContext({ + baseURL: BACKEND_URL, + ignoreHTTPSErrors: true, + extraHTTPHeaders: { Authorization: `Bearer ${mint.accessToken}` }, + }); +} + +test.describe('parking booking visibility between users', () => { + test('another user cannot see or delete your parking booking', async ({ + staffApi, + }, testInfo) => { + const mine = testInfo.parallelIndex; + const theirs = (mine + 1) % WORKERS; + test.skip(theirs === mine, 'needs at least two workers to have two distinct users'); + + const space = await spaceForWorker(mine); + const slot = slotOn(PARKING_SLOTS.scoping.day, PARKING_SLOTS.scoping.hour); + const { from, to } = dayBoundsOn(PARKING_SLOTS.scoping.day); + const other = await apiAsOtherUser(theirs); + let booking_id: number | undefined; + + await releaseAsset(staffApi, 'parking', space.id, from, to); + + try { + const booking = await createParkingBookingViaApi(staffApi, { + space, + title: uniqueTitle('E2E Parking Mine'), + start: slot.start, + end: slot.end, + }); + booking_id = booking.id; + + // Not in their listing. + const theirs_list = await listBookings(other, 'parking', from, to); + expect( + theirs_list.map((b) => b.id), + `booking ${booking_id} belongs to another user and must not be in ` + + `their listing`, + ).not.toContain(booking_id); + + // And they cannot delete it. Checked on the backend afterwards, + // because a refusal that deletes anyway is the failure that matters. + const attempt = await other.delete(`${STAFF_API}/bookings/${booking_id}`); + expect( + attempt.status(), + `deleting somebody else's booking must be refused, got ` + + `${attempt.status()}: ${(await attempt.text()).slice(0, 200)}`, + ).toBeGreaterThanOrEqual(400); + expect( + (await getBooking(staffApi, booking_id)).deleted, + 'and the booking must still be there afterwards', + ).toBeFalsy(); + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + await releaseAsset(staffApi, 'parking', space.id, from, to); + await other.dispose(); + } + }); + + test('control: you can see your own parking booking', async ({ + staffApi, + }, testInfo) => { + const space = await spaceForWorker(testInfo.parallelIndex); + const slot = slotOn( + PARKING_SLOTS.scoping_control.day, + PARKING_SLOTS.scoping_control.hour, + ); + const { from, to } = dayBoundsOn(PARKING_SLOTS.scoping_control.day); + let booking_id: number | undefined; + + await releaseAsset(staffApi, 'parking', space.id, from, to); + + try { + const booking = await createParkingBookingViaApi(staffApi, { + space, + title: uniqueTitle('E2E Parking Own'), + start: slot.start, + end: slot.end, + }); + booking_id = booking.id; + + expect( + (await listBookings(staffApi, 'parking', from, to)).map((b) => b.id), + 'you must be able to see your own booking — without this, "nobody ' + + 'sees anything" would pass as success', + ).toContain(booking_id); + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + await releaseAsset(staffApi, 'parking', space.id, from, to); + } + }); +}); diff --git a/apps/workplace/e2e/local/parking-times.spec.ts b/apps/workplace/e2e/local/parking-times.spec.ts new file mode 100644 index 0000000000..0d4bfe9ab1 --- /dev/null +++ b/apps/workplace/e2e/local/parking-times.spec.ts @@ -0,0 +1,149 @@ +/** + * PARK-07 / PARK-08 — when a space is booked for, and the limits on what may be + * chosen. + * + * Test 1 asks the plain question: pick a day, a start time and a length, and are + * THOSE the times stored? Every other parking spec takes whatever the form + * offers, so a form that ignored its own pickers would pass the lot. + * + * Test 2 covers the two settings that fence the pickers in. Both are unset by + * default and both fail quietly in the same way — an out-of-range option is + * simply ABSENT rather than refused — which is why this asserts on the options + * OFFERED rather than trying to book something out of range. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { + deleteBooking, + getBooking, + releaseAsset, + uniqueTitle, +} from '../../../../e2e/support/api'; +import { + PARKING_SLOTS, + dayBoundsOn, + hhmm, + slotOn, +} from '../../../../e2e/support/parking/parking.env'; +import { spaceForWorker } from '../../../../e2e/support/parking/parking.seed'; +import { + LIMITED_HOURS, + PARKING_BASE_SETTINGS, + useSettings, +} from '../../../../e2e/support/parking/parking.settings'; +import { ParkingForm } from '../../../../e2e/support/parking/parking-form.page'; + +const MINUTES = 60; + +test.describe('parking booking times', () => { + test('the day, start time and length chosen on the form are what get stored', async ({ + staffPage, + staffApi, + }, testInfo) => { + const space = await spaceForWorker(testInfo.parallelIndex); + const slot = slotOn(PARKING_SLOTS.times.day, PARKING_SLOTS.times.hour); + const { from, to } = dayBoundsOn(PARKING_SLOTS.times.day); + const title = uniqueTitle('E2E Parking Times'); + let booking_id: number | undefined; + + await releaseAsset(staffApi, 'parking', space.id, from, to); + await useSettings(staffPage, PARKING_BASE_SETTINGS); + + try { + const form = new ParkingForm(staffPage); + await form.open(); + + // The date goes first and ONCE: changing the day resets the duration + // asynchronously, so a date click repeated inside the converging + // block would land after the length is picked and revert it. + await form.pickDate(slot.date_ms); + + await expect(async () => { + await form.setChecked(form.allDay, false); + await form.title.fill(title); + if ((await form.chosenSpaces.count()) === 0) { + await form.chooseSpace(space.name); + } + expect(await form.title.inputValue()).toBe(title); + expect(await form.chosenSpaces.count()).toBe(1); + }).toPass({ timeout: 60_000 }); + + // Times LAST, then settle: they reach the model asynchronously and + // the confirm sheet snapshots the model when it opens. + await form.setDuration(60); + await form.setStartTime(hhmm(slot.start)); + await staffPage.waitForTimeout(3_000); + + const [response] = await Promise.all([ + staffPage.waitForResponse( + (r) => + r.url().includes('/api/staff/v1/bookings') && + r.request().method() === 'POST', + { timeout: 30_000 }, + ), + form.confirmAndSend(), + ]); + const body = await response.text(); + expect(response.status(), `the booking POST failed: ${body}`).toBeLessThan(300); + booking_id = JSON.parse(body).id; + + const stored = await getBooking(staffApi, booking_id!); + expect( + new Date(stored.booking_start * 1000).toDateString(), + 'the booking must land on the day that was picked', + ).toBe(new Date(slot.date_ms).toDateString()); + expect(hhmm(stored.booking_start), 'and start when it was told to').toBe( + hhmm(slot.start), + ); + expect( + (stored.booking_end - stored.booking_start) / MINUTES, + 'and last as long as was picked — a space held for the wrong window is ' + + 'either unavailable to somebody who could have used it, or free ' + + 'when the driver is still parked', + ).toBe(60); + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + await releaseAsset(staffApi, 'parking', space.id, from, to); + } + }); + + test('a maximum length and bookable hours limit what can be chosen', async ({ + staffPage, + }) => { + const max = LIMITED_HOURS['app.bookings.max_duration']; + const hours = LIMITED_HOURS['app.bookings.bookable_hours']; + + await useSettings(staffPage, { ...PARKING_BASE_SETTINGS, ...LIMITED_HOURS }); + + const form = new ParkingForm(staffPage); + await form.open(); + await expect(async () => { + await form.setChecked(form.allDay, false); + expect(await form.allDay.isChecked()).toBe(false); + }).toPass({ timeout: 45_000 }); + + const lengths = await form.durationOptions(); + expect(lengths.length, 'the form should offer some lengths').toBeGreaterThan(0); + expect( + Math.max(...lengths), + `no length beyond the ${max} minute cap may be offered, got ` + + `${JSON.stringify(lengths)}`, + ).toBeLessThanOrEqual(max); + + const times = await form.startTimeOptions(); + expect(times.length, 'the form should offer some start times').toBeGreaterThan(0); + const minutes = times.map((t) => { + const [h, m] = t.split(':').map(Number); + return h * 60 + m; + }); + expect( + Math.min(...minutes), + `no start time before ${hours.start}:00 may be offered, got ` + + `${JSON.stringify(times)}`, + ).toBeGreaterThanOrEqual(hours.start * 60); + expect( + Math.max(...minutes), + `no start time after ${hours.end}:00 may be offered, got ` + + `${JSON.stringify(times)}`, + ).toBeLessThanOrEqual(hours.end * 60); + }); +}); diff --git a/apps/workplace/e2e/local/room-allday.spec.ts b/apps/workplace/e2e/local/room-allday.spec.ts new file mode 100644 index 0000000000..163abb45bd --- /dev/null +++ b/apps/workplace/e2e/local/room-allday.spec.ts @@ -0,0 +1,88 @@ +/** + * ROOM-27 — an all-day meeting. + * + * Every other room spec books an hour or two. All-day is a different code path: + * `event-form.service.ts` replaces the window with `_allDayTimeRange(date)` + * before the booking is built, so whatever start and length were chosen are + * discarded on purpose. A form that stored a long TIMED booking instead would + * look almost identical on screen and behave differently — the room would free + * up at the end time rather than being held for the day. + * + * ## The setting is `events.allow_all_day` + * + * Not `allow_multiday`, which only widens the range of dates the room picker + * offers. The control lives in `meeting-form-details.component.ts`, a different + * component from the flow form — which is why a first attempt found no checkbox + * and nearly concluded that all-day meetings do not exist in this app. + * + * ## `fixme` — ROOM-B7: the all-day flag is ignored + * + * Measured: with `app.events.allow_all_day` on, the All Day checkbox ticked, and + * **the checkbox proven still ticked at the moment the meeting is confirmed**, + * the stored booking is **one hour long**. The room is free for the rest of the + * day while the person who booked it believes they have it all day. + * + * The "still ticked" check matters: it rules out the form rebuilding itself and + * reverting the control (REG-10's family), which would look identical from the + * outside. `bookRoomViaUI` asserts it before sending for exactly this reason. + * + * Same family as ROOM-B3 (the chosen length is not the length booked): + * `postForm` computes an all-day range, and the booking built from it by + * `newBookingFromCalendarEvent` takes `duration` from the event instead. Fixing + * ROOM-B3 properly would most likely fix this too. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { deleteBooking, getBooking, uniqueTitle } from '../../../../e2e/support/api'; +import { ROOM_SLOTS_2, SECOND_DAY, slotFor } from '../../../../e2e/support/room/room.env'; +import { roomForWorker } from '../../../../e2e/support/room/room.seed'; +import { releaseRoom } from '../../../../e2e/support/room/room.api'; +import { + ALLOW_ALL_DAY, + ROOM_BASE_SETTINGS, + useSettings, +} from '../../../../e2e/support/room/room.settings'; +import { bookRoomViaUI } from '../../../../e2e/support/room/room.flows'; + +const DAY = 86_400; +const HOUR = 3600; +const window_from = () => Math.floor(Date.now() / 1000) - 2 * DAY; +const window_to = () => Math.floor(Date.now() / 1000) + 7 * DAY; + +test.describe('an all-day meeting', () => { + test.fixme('an all-day meeting holds the room for the day, not for an hour', async ({ + staffPage, + staffApi, + }, testInfo) => { + const room = await roomForWorker(testInfo.parallelIndex); + const slot = slotFor(ROOM_SLOTS_2.allday.hour, SECOND_DAY); + const title = uniqueTitle('E2E Room AllDay'); + let booking_id: number | undefined; + + await releaseRoom(staffApi, room.id, window_from(), window_to()); + await useSettings(staffPage, { ...ROOM_BASE_SETTINGS, ...ALLOW_ALL_DAY }); + + try { + const created = await bookRoomViaUI(staffPage, staffApi, room, title, { + date: slot.date_ms, + allDay: true, + }); + booking_id = created.id; + + const stored: any = await getBooking(staffApi, booking_id!); + const hours = (stored.booking_end - stored.booking_start) / HOUR; + expect( + hours, + `an all-day meeting must hold the room for the day, not for ${hours} ` + + `hour(s). A long TIMED booking looks the same on screen and frees ` + + `the room at its end time`, + ).toBeGreaterThanOrEqual(8); + expect( + new Date(stored.booking_start * 1000).toDateString(), + 'and it must be on the day that was chosen', + ).toBe(new Date(slot.date_ms).toDateString()); + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + await releaseRoom(staffApi, room.id, window_from(), window_to()); + } + }); +}); diff --git a/apps/workplace/e2e/local/room-approval.spec.ts b/apps/workplace/e2e/local/room-approval.spec.ts new file mode 100644 index 0000000000..72822ba67c --- /dev/null +++ b/apps/workplace/e2e/local/room-approval.spec.ts @@ -0,0 +1,125 @@ +/** + * ROOM-20 / ROOM-21 — whether a new room booking needs approving. + * + * Two states, one of which cannot be reached from the app without hitting a + * backend 500: + * + * default ......................... the app sends `status: 'tentative'` and the + * booking is stored `approved: false`. A + * room held by an unapproved booking is + * still held, so this is not cosmetic. + * `app.bookings.no_approval` ...... the app sends `approved: true` and the + * booking should be stored approved — and + * instead the request dies with a Postgres + * syntax error. That is ROOM-B1, and the + * second test here is `fixme` against it. + * + * The first test is the control for the second: it proves the booking path and + * these assertions work, so the `fixme` below is about the approval setting and + * nothing else. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { deleteBooking, getBooking, uniqueTitle } from '../../../../e2e/support/api'; +import { ROOM_SLOTS_2, SECOND_DAY, slotFor } from '../../../../e2e/support/room/room.env'; +import { roomForWorker } from '../../../../e2e/support/room/room.seed'; +import { releaseRoom } from '../../../../e2e/support/room/room.api'; +import { + NO_APPROVAL, + ROOM_BASE_SETTINGS, + useSettings, +} from '../../../../e2e/support/room/room.settings'; +import { bookRoomViaUI } from '../../../../e2e/support/room/room.flows'; + +const DAY = 86_400; +const window_from = () => Math.floor(Date.now() / 1000) - 2 * DAY; +const window_to = () => Math.floor(Date.now() / 1000) + 7 * DAY; + +test.describe('room booking approval', () => { + test('a room booked with the default settings is stored unapproved', async ({ + staffPage, + staffApi, + }, testInfo) => { + const room = await roomForWorker(testInfo.parallelIndex); + const slot = slotFor(ROOM_SLOTS_2.approval.approved, SECOND_DAY); + const title = uniqueTitle('E2E Room Approval'); + let booking_id: number | undefined; + + await releaseRoom(staffApi, room.id, window_from(), window_to()); + await useSettings(staffPage, ROOM_BASE_SETTINGS); + + try { + const created = await bookRoomViaUI(staffPage, staffApi, room, title, { + date: slot.date_ms, + }); + booking_id = created.id; + const stored: any = await getBooking(staffApi, booking_id); + expect( + stored.approved, + 'with no approval setting the app sends `tentative`, so the booking ' + + 'must be stored unapproved', + ).toBeFalsy(); + expect( + stored.rejected, + 'unapproved is not the same as rejected — a rejected booking here would ' + + 'mean the form sent something quite different', + ).toBeFalsy(); + expect( + stored.deleted, + 'and an unapproved booking still exists and still holds the room', + ).toBeFalsy(); + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + await releaseRoom(staffApi, room.id, window_from(), window_to()); + } + }); + + /** + * ROOM-21, blocked by ROOM-B1 — `no_approval` makes the app send + * `approved: true`, and staff-api answers **HTTP 500** with + * + * syntax error at or near ")" (PQ::PQError) + * + * for any non-admin. Measured three ways, which is what makes it the + * backend's fault rather than the form's: + * + * non-admin, `approved: true`, NO zones .... 500 (the SQL error) + * non-admin, `approved: true`, WITH zones .. 403, correctly refused + * admin, `approved: true` ................... 201 + * + * Desk bookings do it too, so it is not room-specific — but rooms are where + * the app reaches it, because a room booking made through the form carries + * no zones at all (ROOM-B2). The permission check dies instead of refusing + * when it has nothing to check against, and the two bugs compound: fix + * ROOM-B2 and this becomes an honest 403; fix ROOM-B1 and it becomes a 201. + * + * `fixme`, so it costs nothing per run. This is also why `NO_APPROVAL` is + * not in `ROOM_BASE_SETTINGS` — switching it on breaks every room spec. + */ + test.fixme('a room booked with approval skipped is stored approved', async ({ + staffPage, + staffApi, + }, testInfo) => { + const room = await roomForWorker(testInfo.parallelIndex); + const slot = slotFor(ROOM_SLOTS_2.approval.approved, SECOND_DAY, 60); + const title = uniqueTitle('E2E Room No Approval'); + let booking_id: number | undefined; + + await releaseRoom(staffApi, room.id, window_from(), window_to()); + await useSettings(staffPage, { ...ROOM_BASE_SETTINGS, ...NO_APPROVAL }); + + try { + const created = await bookRoomViaUI(staffPage, staffApi, room, title, { + date: slot.date_ms, + }); + booking_id = created.id; + const stored: any = await getBooking(staffApi, booking_id); + expect( + stored.approved, + 'with `app.bookings.no_approval` set, the booking must be stored approved', + ).toBeTruthy(); + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + await releaseRoom(staffApi, room.id, window_from(), window_to()); + } + }); +}); diff --git a/apps/workplace/e2e/local/room-attendees.spec.ts b/apps/workplace/e2e/local/room-attendees.spec.ts new file mode 100644 index 0000000000..2de5ad1f7b --- /dev/null +++ b/apps/workplace/e2e/local/room-attendees.spec.ts @@ -0,0 +1,135 @@ +/** + * ROOM-09 / ROOM-10 — who is coming to the meeting. + * + * A room booking is the only one of the three surfaces with a real attendee + * list: a desk has nobody, a visitor invite has exactly the visitors. So this is + * the only place the list itself can go wrong — silently, because a dropped + * attendee looks identical to a meeting nobody was invited to. + * + * Both tests read the backend. The attendee list is stored in the booking's + * `extension_data`, not as a column: `newBookingFromCalendarEvent` spreads the + * whole calendar event in there, so the attendees, the real title and the event + * times all live under that key. Asserting on a top-level `attendees` field + * finds an empty array on a booking that is perfectly correct. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { + currentUser, + deleteBooking, + getBooking, + uniqueTitle, +} from '../../../../e2e/support/api'; +import { WORKERS, staffEmail } from '../../../../e2e/support/env'; +import { ROOM_SLOTS, slotFor } from '../../../../e2e/support/room/room.env'; +import { roomForWorker } from '../../../../e2e/support/room/room.seed'; +import { releaseRoom, type RoomBooking } from '../../../../e2e/support/room/room.api'; +import { ROOM_BASE_SETTINGS, useSettings } from '../../../../e2e/support/room/room.settings'; +import { bookRoomViaUI } from '../../../../e2e/support/room/room.flows'; + +const DAY = 86_400; +const window_from = () => Math.floor(Date.now() / 1000) - 2 * DAY; +const window_to = () => Math.floor(Date.now() / 1000) + 7 * DAY; + +/** Every attendee address on a stored booking, lower-cased. */ +function attendeeEmails(booking: RoomBooking): string[] { + const list = (booking.extension_data?.attendees ?? []) as { email?: string }[]; + return list.map((a) => `${a.email ?? ''}`.toLowerCase()).filter(Boolean); +} + +test.describe('room booking attendees', () => { + test('an attendee added on the form is stored with the booking', async ({ + staffPage, + staffApi, + }, testInfo) => { + const mine = testInfo.parallelIndex; + const theirs = (mine + 1) % WORKERS; + test.skip(theirs === mine, 'needs at least two workers for a second address'); + + const room = await roomForWorker(mine); + const slot = slotFor(ROOM_SLOTS.attendees.stored); + const guest = staffEmail(theirs); + const title = uniqueTitle('E2E Room Attendees'); + let booking_id: number | undefined; + + await releaseRoom(staffApi, room.id, window_from(), window_to()); + await useSettings(staffPage, ROOM_BASE_SETTINGS); + + try { + const me = await currentUser(staffApi); + const created = await bookRoomViaUI(staffPage, staffApi, room, title, { + date: slot.date_ms, + attendees: [guest], + }); + booking_id = created.id; + + const stored = (await getBooking(staffApi, booking_id)) as RoomBooking; + const emails = attendeeEmails(stored); + expect( + emails, + `the colleague invited on the form must be stored with the booking. ` + + `Stored: ${JSON.stringify(emails)}`, + ).toContain(guest.toLowerCase()); + expect( + emails, + 'and the organiser stays on the list — the app adds them itself, and a ' + + 'meeting without its own host is how a room gets booked by nobody', + ).toContain(me.email.toLowerCase()); + + // The ROOM is an attendee too in a calendar-shaped event. Asserting + // it keeps the list honest: a payload that had dropped the room + // would still contain both people and book nothing. + expect( + emails, + 'and the room itself is on the list, as a resource', + ).toContain(room.email.toLowerCase()); + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + } + }); + + test('an attendee removed before sending is not invited', async ({ + staffPage, + staffApi, + }, testInfo) => { + const mine = testInfo.parallelIndex; + const theirs = (mine + 1) % WORKERS; + test.skip(theirs === mine, 'needs at least two workers for a second address'); + + const room = await roomForWorker(mine); + const slot = slotFor(ROOM_SLOTS.attendees.removed); + const dropped = staffEmail(theirs); + const title = uniqueTitle('E2E Room Attendee Removed'); + let booking_id: number | undefined; + + await releaseRoom(staffApi, room.id, window_from(), window_to()); + await useSettings(staffPage, ROOM_BASE_SETTINGS); + + try { + const me = await currentUser(staffApi); + // Added and then taken off again — the everyday version of this is + // picking the wrong colleague out of a lookup. The correction + // happens inside the send flow, so a retry cannot quietly re-add + // them. + const created = await bookRoomViaUI(staffPage, staffApi, room, title, { + date: slot.date_ms, + attendees: [dropped], + removeAttendees: [dropped], + }); + booking_id = created.id; + + const stored = (await getBooking(staffApi, booking_id)) as RoomBooking; + const emails = attendeeEmails(stored); + expect( + emails, + `the colleague taken off the form must not be invited. ` + + `Stored: ${JSON.stringify(emails)}`, + ).not.toContain(dropped.toLowerCase()); + expect( + emails, + 'and the booking still belongs to the person who made it', + ).toContain(me.email.toLowerCase()); + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + } + }); +}); diff --git a/apps/workplace/e2e/local/room-booking.spec.ts b/apps/workplace/e2e/local/room-booking.spec.ts new file mode 100644 index 0000000000..b500ffa4f9 --- /dev/null +++ b/apps/workplace/e2e/local/room-booking.spec.ts @@ -0,0 +1,188 @@ +/** + * ROOM-01 / ROOM-02 / ROOM-13 — book a room through the full UI, as a NON-ADMIN. + * + * Deliberately the same shape as `desk-booking.spec.ts`: two tests, one that + * proves the happy path reaches the backend intact, one that proves teardown + * really tears down. + * + * ## Rooms are only testable locally in one mode + * + * By default the meeting flow talks to Microsoft/Google through `/events` and + * `/calendars`, both of which 500 on this stack. With + * `app.events.use_bookings = true` the same form saves an ordinary PlaceOS + * booking of type `room` instead, with no outbound call. Every room spec runs in + * that mode — see `room.settings.ts`. A green run here says the PlaceOS-native + * room path works and says nothing about the calendar path. + * + * ## What makes a room different from a desk and from a visitor + * + * - A room is a real engine SYSTEM and has to be created. A desk is a row in + * zone metadata; a visitor is just an email. `room.seed.ts` creates one room + * per worker, on demand, without touching the shared `seed.ts`. + * - A room is EXCLUSIVE for its window, like a desk. Measured before this spec + * was written: the same slot as another user is 409, a partial overlap is + * 409, a clear slot is 201. `room-clash.spec.ts` owns that. + * - The flow has THREE screens — form, confirm, success — and nothing is sent + * until the second. A spec that clicks Confirm once and looks for a booking + * will find none and blame the backend. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { + deleteBooking, + getBooking, + listBookings, + uniqueTitle, +} from '../../../../e2e/support/api'; +import { ROOM_SLOTS, slotFor } from '../../../../e2e/support/room/room.env'; +import { roomForWorker } from '../../../../e2e/support/room/room.seed'; +import { releaseRoom, type RoomBooking } from '../../../../e2e/support/room/room.api'; +import { + ROOM_BASE_SETTINGS, + useSettings, +} from '../../../../e2e/support/room/room.settings'; +import { bookRoomViaUI } from '../../../../e2e/support/room/room.flows'; + +const DAY = 86_400; +const window_from = () => Math.floor(Date.now() / 1000) - 2 * DAY; +const window_to = () => Math.floor(Date.now() / 1000) + 7 * DAY; + +test.describe('room booking', () => { + test('a non-admin books a room in the UI and the backend stores it', async ({ + staffPage, + staffApi, + }, testInfo) => { + const room = await roomForWorker(testInfo.parallelIndex); + const slot = slotFor(ROOM_SLOTS.booking.stores); + const title = uniqueTitle('E2E Room'); + let booking_id: number | undefined; + + // Sweep first, not just after. A run that died between booking and + // cleanup leaves the room held, and every later run then fails with + // "no room called ... in the picker" — which looks nothing like the cause. + const swept = await releaseRoom(staffApi, room.id, window_from(), window_to()); + if (swept) console.log(` swept ${swept} stale booking(s) off ${room.name}`); + await useSettings(staffPage, ROOM_BASE_SETTINGS); + + try { + const created = await bookRoomViaUI(staffPage, staffApi, room, title, { + date: slot.date_ms, + duration: 60, + }); + booking_id = created.id; + + expect(created.id, 'the API returned a booking id').toBeTruthy(); + + // Read it back rather than trusting the response we just parsed. + const stored = (await getBooking(staffApi, booking_id)) as RoomBooking; + expect(stored.booking_type, 'stored as a room booking').toBe('room'); + expect(stored.asset_id, 'against the room this worker owns').toBe(room.id); + + // The meeting name lives in `extension_data`, NOT in the booking's own + // `title`. `newBookingFromCalendarEvent` spreads the whole event into + // extension_data and sets no title, so every room booking is called + // "Room Booking" at this level. Not a bug — the schedule rebuilds the + // event from extension_data and shows the real name — but asserting + // on `stored.title` fails against a booking that is perfectly correct. + expect( + stored.extension_data?.title, + 'the meeting name we typed reached the backend', + ).toBe(title); + expect( + stored.title, + 'and the booking itself carries the generic room title', + ).toBe('Room Booking'); + expect(stored.deleted, 'not soft-deleted').toBeFalsy(); + expect(stored.rejected, 'not rejected').toBeFalsy(); + // Zones are deliberately NOT asserted here — see the `fixme` below. + expect( + new Date(stored.booking_start * 1000).toDateString(), + 'and falls on the day chosen in the form, not the default day', + ).toBe(new Date(slot.date_ms).toDateString()); + + // ...and is discoverable through the listing the app itself uses, + // not only by direct id lookup. + const listed = await listBookings(staffApi, 'room', window_from(), window_to()); + expect( + listed.map((b) => b.id), + 'the new booking appears in the room listing', + ).toContain(booking_id); + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + } + }); + + test('a deleted room booking leaves the listing', async ({ + staffPage, + staffApi, + }, testInfo) => { + const room = await roomForWorker(testInfo.parallelIndex); + const slot = slotFor(ROOM_SLOTS.booking.cleanup); + const title = uniqueTitle('E2E Room Cleanup'); + + await releaseRoom(staffApi, room.id, window_from(), window_to()); + await useSettings(staffPage, ROOM_BASE_SETTINGS); + + const created = await bookRoomViaUI(staffPage, staffApi, room, title, { + date: slot.date_ms, + duration: 60, + }); + expect( + (await listBookings(staffApi, 'room', window_from(), window_to())).map((b) => b.id), + 'precondition: the booking is in the listing before we delete it', + ).toContain(created.id); + + await deleteBooking(staffApi, created.id); + + expect( + (await listBookings(staffApi, 'room', window_from(), window_to())).map((b) => b.id), + 'a deleted booking must not come back in the listing — otherwise every ' + + 'spec teardown silently leaks state into the next run, and a room is ' + + 'exclusive, so a leak holds it for everyone', + ).not.toContain(created.id); + }); + + /** + * ROOM-13, blocked by ROOM-B2 — a room booked through the app is stored + * with NO zones. + * + * The meeting form sends `zones: []`. Desk and visitor bookings both carry + * the org/building/level hierarchy, and anything that scopes bookings by + * zone — a building filter, a report, a per-site view — cannot see a booking + * that has none. + * + * It is also half of ROOM-B1: with no zones, the backend's approval + * permission check has nothing to check against, and instead of refusing + * with 403 it builds a query with an empty list and dies with + * `syntax error at or near ")" (PQ::PQError)`. + * + * A third test in a two-test file, on purpose, and `fixme` so it costs + * nothing per run. Fold it into the first test once the app populates zones. + */ + test.fixme('a room booking carries its zone hierarchy', async ({ + staffPage, + staffApi, + }, testInfo) => { + const room = await roomForWorker(testInfo.parallelIndex); + const slot = slotFor(ROOM_SLOTS.booking.stores, 4); + const title = uniqueTitle('E2E Room Zones'); + let booking_id: number | undefined; + + await releaseRoom(staffApi, room.id, window_from(), window_to()); + await useSettings(staffPage, ROOM_BASE_SETTINGS); + + try { + const created = await bookRoomViaUI(staffPage, staffApi, room, title, { + date: slot.date_ms, + duration: 60, + }); + booking_id = created.id; + const stored = await getBooking(staffApi, booking_id); + expect( + stored.zones.length, + 'a room booking must carry its zones, as desk and visitor bookings do', + ).toBeGreaterThan(0); + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + } + }); +}); diff --git a/apps/workplace/e2e/local/room-cancel.spec.ts b/apps/workplace/e2e/local/room-cancel.spec.ts new file mode 100644 index 0000000000..6a7d0e4a74 --- /dev/null +++ b/apps/workplace/e2e/local/room-cancel.spec.ts @@ -0,0 +1,152 @@ +/** + * ROOM-11 / ROOM-12 — cancelling a room booking FROM THE APP. + * + * Every other room spec tears its booking down through the API, which is right + * for a teardown and useless as coverage: the Cancel item in the booking menu, + * and the confirmation in front of it, are never otherwise exercised. For a + * room that matters more than for a desk — a cancellation that does not take + * leaves the room held against everybody else. + * + * The pair is deliberate. Cancelling and NOT cancelling are equally important: a + * dialog whose decline button also deletes is worse than one that fails to + * delete, and only the second test can catch it. + * + * Both read the backend afterwards. The card leaving the screen proves nothing — + * the app removes it optimistically either way. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { deleteBooking, listBookings, uniqueTitle } from '../../../../e2e/support/api'; +import { ROOM_SLOTS, slotFor } from '../../../../e2e/support/room/room.env'; +import { roomForWorker } from '../../../../e2e/support/room/room.seed'; +import { createRoomBookingViaApi, releaseRoom } from '../../../../e2e/support/room/room.api'; +import { ROOM_BASE_SETTINGS, useSettings } from '../../../../e2e/support/room/room.settings'; +import { RoomSchedulePage } from '../../../../e2e/support/room/schedule.page'; +import type { APIRequestContext } from '@playwright/test'; + +const DAY = 86_400; +const window_from = () => Math.floor(Date.now() / 1000) - 2 * DAY; +const window_to = () => Math.floor(Date.now() / 1000) + 7 * DAY; + +/** Is this booking still live (present and not soft-deleted) on the backend? */ +async function isLive(api: APIRequestContext, id: number): Promise { + const live = await listBookings(api, 'room', window_from(), window_to()); + return live.some((b) => Number(b.id) === id && !b.deleted); +} + +test.describe('cancelling a room booking from the app', () => { + /** + * ROOM-11, blocked by ROOM-B4 — cancelling a room booking from the schedule + * does not work. + * + * `fixme`, because the app is broken here and the test is right. Measured: + * pressing Cancel and confirming fires + * + * DELETE /api/staff/v1/events/1087 -> 500 + * + * and the booking is still live afterwards. In `use_bookings` mode the room + * booking IS a staff-api booking, but the schedule deletes whatever it is + * showing as an EVENT (`schedule.component.ts`: `item instanceof CalendarEvent + * ? removeEvent : removeBooking`) — and a room booking is rebuilt into a + * CalendarEvent for display, so it takes the calendar path and fails. + * + * Worse than a cosmetic bug: the room stays held by a booking the user + * believes they cancelled, so it refuses everyone else while looking free on + * their own screen. + * + * The decline half below passes, so the menu, the dialog and the wiring are + * all fine — it is specifically the delete that goes to the wrong place. + */ + test.fixme('cancelling from the booking menu removes it for real', async ({ + staffPage, + staffApi, + }, testInfo) => { + const room = await roomForWorker(testInfo.parallelIndex); + const slot = slotFor(ROOM_SLOTS.cancel.fromApp); + const title = uniqueTitle('E2E Room Cancel'); + let booking_id: number | undefined; + + await releaseRoom(staffApi, room.id, window_from(), window_to()); + await useSettings(staffPage, ROOM_BASE_SETTINGS); + + try { + // Created through the API: the subject is cancelling, and driving + // the meeting form first would only add an unrelated way to fail. + const created = await createRoomBookingViaApi(staffApi, { + room, + title, + start: slot.start, + end: slot.end, + }); + booking_id = created.id; + + const page = new RoomSchedulePage(staffPage); + await page.open(); + await page.showRooms(); + await page.showDayOf(slot.date_ms); + expect( + await isLive(staffApi, booking_id), + 'precondition: the booking is live before we cancel it', + ).toBe(true); + + await page.startCancel(booking_id); + await page.acceptConfirm(); + + await expect(async () => { + expect( + await isLive(staffApi, booking_id!), + 'the cancelled booking must be gone from the backend, not just the ' + + 'screen — a room still held by a cancelled booking looks free ' + + 'and refuses everyone', + ).toBe(false); + }).toPass({ timeout: 30_000 }); + + await expect( + page.card(booking_id), + 'and its card must leave the schedule', + ).toBeHidden({ timeout: 30_000 }); + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id).catch(() => null); + } + }); + + test('declining the confirmation leaves the booking alone', async ({ + staffPage, + staffApi, + }, testInfo) => { + const room = await roomForWorker(testInfo.parallelIndex); + const slot = slotFor(ROOM_SLOTS.cancel.dismissed); + const title = uniqueTitle('E2E Room Keep'); + let booking_id: number | undefined; + + await releaseRoom(staffApi, room.id, window_from(), window_to()); + await useSettings(staffPage, ROOM_BASE_SETTINGS); + + try { + const created = await createRoomBookingViaApi(staffApi, { + room, + title, + start: slot.start, + end: slot.end, + }); + booking_id = created.id; + + const page = new RoomSchedulePage(staffPage); + await page.open(); + await page.showRooms(); + await page.showDayOf(slot.date_ms); + + await page.startCancel(booking_id); + await page.dismissConfirm(); + + // Wait before asserting: "still there" a millisecond after the click + // would pass even if a delete were already on its way. + await staffPage.waitForTimeout(5_000); + expect( + await isLive(staffApi, booking_id), + 'walking away from the confirmation must not cancel anything', + ).toBe(true); + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id).catch(() => null); + } + }); +}); diff --git a/apps/workplace/e2e/local/room-capacity.spec.ts b/apps/workplace/e2e/local/room-capacity.spec.ts new file mode 100644 index 0000000000..7dfceba9ba --- /dev/null +++ b/apps/workplace/e2e/local/room-capacity.spec.ts @@ -0,0 +1,210 @@ +/** + * ROOM-17 / ROOM-18 — booking a room that is too small for the meeting. + * + * The only place in this suite where the ROOM's own properties constrain the + * booking. Capacity lives on the engine System, so the test data carries it: + * every worker owns an `E2E Small Room` with capacity ONE (`room.env.ts`), and + * one invited colleague is already more people than fit. + * + * ## Two settings, two completely different behaviours + * + * `app.events.strict_capacity_check` is not a stronger version of the default — + * it is a different mechanism: + * + * unset ...... a warning is drawn next to the room list and the booking goes + * through anyway. This is the shipped default, so it is the + * behaviour real users get. + * true ....... `viewConfirm` (meeting-flow-form.component.ts) returns early + * with a toast. The confirm screen never opens and nothing is + * sent. + * + * Test 1 takes the strict path, because "the form refused and the backend never + * heard about it" is the assertion with teeth. Test 2 takes the default path, + * because a warning nobody renders is the failure users would actually hit. + * + * ## Both tests carry their own control + * + * A form that refuses everything, or renders a warning always, would pass a + * one-sided version of either test. So test 1 removes the attendee and shows + * the same form then reaches the confirm screen, and test 2 starts from a room + * that IS big enough and shows the warning appear only when the room changes. + * Nothing but the capacity differs across either pair. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { deleteBooking, listBookings, uniqueTitle } from '../../../../e2e/support/api'; +import { WORKERS, staffEmail } from '../../../../e2e/support/env'; +import { type RoomBooking, releaseRoom } from '../../../../e2e/support/room/room.api'; +import { roomForWorker } from '../../../../e2e/support/room/room.seed'; +import { + ROOM_BASE_SETTINGS, + STRICT_CAPACITY, + useSettings, +} from '../../../../e2e/support/room/room.settings'; +import { MeetingForm } from '../../../../e2e/support/room/meeting-form.page'; + +const DAY = 86_400; +const window_from = () => Math.floor(Date.now() / 1000) - 2 * DAY; +const window_to = () => Math.floor(Date.now() / 1000) + 7 * DAY; + +/** + * The capacity warning, matched on its CLASSES. + * + * The one selector in the room specs that is neither a `name` attribute nor + * test data. The element has no attribute to hold on to and its text is + * translated (`CALENDAR_EVENT.CAPACITY_WARNING`), so the styling is all there + * is. Scoped to the form so a toast or another warning elsewhere on the page + * cannot satisfy it. + */ +const CAPACITY_WARNING = 'meeting-flow-form div.bg-warning.text-warning-content'; + +test.describe('room capacity', () => { + test('a meeting bigger than the room is refused before anything is sent', async ({ + staffPage, + }, testInfo) => { + const mine = testInfo.parallelIndex; + const theirs = (mine + 1) % WORKERS; + test.skip(theirs === mine, 'needs at least two workers for a second address'); + + const small = await roomForWorker(mine, 'small'); + const title = uniqueTitle('E2E Room Capacity'); + const guest = staffEmail(theirs); + + await useSettings(staffPage, { ...ROOM_BASE_SETTINGS, ...STRICT_CAPACITY }); + + // Watch every booking write, not just the successful ones. The point of + // this test is that the backend is never asked at all, and a request + // that was sent and refused would look identical on screen. + const writes: string[] = []; + staffPage.on('request', (r) => { + if (!r.url().includes('/api/staff/v1/bookings')) return; + if (r.method() === 'GET') return; + writes.push(`${r.method()} ${r.url()}`); + }); + + const form = new MeetingForm(staffPage); + await form.open(); + // Without this the small room is not even offered — the picker starts at + // "min. 4 people". See `setRoomSize`. + await form.setRoomSize(1); + await expect(async () => { + await form.title.fill(title); + if ((await form.chosenSpaces.count()) === 0) await form.chooseRoom(small.name); + expect(await form.title.inputValue()).toBe(title); + expect(await form.chosenSpaces.count()).toBe(1); + }).toPass({ timeout: 45_000 }); + await form.addAttendee(guest); + await staffPage.waitForTimeout(2_000); + + // Press Confirm on the FORM. In strict mode this should not even get as + // far as the confirm screen. + await form.confirmViewButton.click(); + await expect( + form.confirmButton, + 'a meeting with more people than the room holds must not reach the ' + + 'confirm screen when `app.events.strict_capacity_check` is set', + ).toBeHidden({ timeout: 10_000 }); + expect( + writes, + 'nothing at all should have been written to the backend', + ).toEqual([]); + + // CONTROL: the same form, one person lighter, gets through. Without + // this, a form that was simply broken would pass the assertions above. + await form.removeAttendee(guest); + await staffPage.waitForTimeout(2_000); + await form.confirmViewButton.click(); + await expect( + form.confirmButton, + 'with the meeting back inside the room capacity the confirm screen must ' + + 'open — otherwise the refusal above was not about capacity at all', + ).toBeVisible({ timeout: 20_000 }); + // Deliberately NOT confirmed: the control is that the screen opens, and + // sending it would leave a booking to clean up for no extra coverage. + }); + + test('a meeting bigger than the room is warned about by default', async ({ + staffPage, + staffApi, + }, testInfo) => { + const mine = testInfo.parallelIndex; + const theirs = (mine + 1) % WORKERS; + test.skip(theirs === mine, 'needs at least two workers for a second address'); + + const room = await roomForWorker(mine); + const small = await roomForWorker(mine, 'small'); + const title = uniqueTitle('E2E Room Capacity Warn'); + const guest = staffEmail(theirs); + + // The default: strict checking OFF, which is what ships. + // + // The day is left at whatever the form offers, unlike the other room + // specs: this test asserts on the FORM, and the only booking it makes is + // in the small room, which nothing else in the suite ever touches — so + // there is no slot to collide over. It is swept either side regardless. + await useSettings(staffPage, ROOM_BASE_SETTINGS); + await releaseRoom(staffApi, small.id, window_from(), window_to()); + + const form = new MeetingForm(staffPage); + await form.open(); + // Both rooms have to be on offer for the swap below, and the small one is + // only offered at the smallest size. See `setRoomSize`. + await form.setRoomSize(1); + await expect(async () => { + await form.title.fill(title); + if ((await form.chosenSpaces.count()) === 0) await form.chooseRoom(room.name); + expect(await form.title.inputValue()).toBe(title); + expect(await form.chosenSpaces.count()).toBe(1); + }).toPass({ timeout: 45_000 }); + await form.addAttendee(guest); + await staffPage.waitForTimeout(2_000); + + // CONTROL FIRST, with a room that fits: no warning. This is what makes + // the assertion below about capacity rather than about the element + // simply always being there. + await expect( + staffPage.locator(CAPACITY_WARNING), + `${room.name} holds ${room.capacity}, so a two-person meeting must not ` + + 'be warned about', + ).toBeHidden({ timeout: 10_000 }); + + // Only the room changes. + await form.removeRoom(); + await form.chooseRoom(small.name); + await expect( + staffPage.locator(CAPACITY_WARNING), + `${small.name} holds ${small.capacity}, so the same meeting must now be ` + + 'warned about', + ).toBeVisible({ timeout: 20_000 }); + + // And it is only a warning: the booking still goes through. That is the + // shipped behaviour, and a user who ignores the warning must not end up + // with a form that silently refuses to submit. + let booking_id: number | undefined; + try { + await form.confirmAndSend(); + await expect( + form.successPanel, + 'the default capacity check is advisory — the booking must still be ' + + 'accepted after the warning', + ).toBeVisible({ timeout: 30_000 }); + const live = (await listBookings( + staffApi, + 'room', + window_from(), + window_to(), + )) as RoomBooking[]; + const made = live.find( + (b) => `${b.extension_data?.title ?? ''}` === title && !b.deleted, + ); + expect( + made, + 'a booking should exist for the over-capacity meeting that was warned ' + + 'about and confirmed anyway', + ).toBeTruthy(); + booking_id = made?.id; + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + await releaseRoom(staffApi, small.id, window_from(), window_to()); + } + }); +}); diff --git a/apps/workplace/e2e/local/room-catering.spec.ts b/apps/workplace/e2e/local/room-catering.spec.ts new file mode 100644 index 0000000000..72469da019 --- /dev/null +++ b/apps/workplace/e2e/local/room-catering.spec.ts @@ -0,0 +1,132 @@ +/** + * ROOM-22 — ordering catering with a room booking. + * + * The last of the room surfaces, and the one with the most moving parts: a + * catering order is not a field on the booking, it is a SEPARATE booking of + * type `catering-order`, created after the meeting itself + * (`postForm` → `createBookingsForEvent(event, 'catering-order', ...)`). So the + * failure this guards against is the quiet one — the meeting is booked, the + * screen says it worked, and the food was never ordered. + * + * ## The menu has to be seeded, and it is not a setting + * + * The form only shows its catering section when the building has a menu, and a + * menu is made of ASSETS: a hidden `_CATERING_` asset category, an asset type + * called `CATERING:`, and an asset per item on the building zone. All + * on the ENGINE api, all needing admin. `catering.seed.ts` does it, idempotently + * and once per process, and explains the shape. + * + * ## Order of operations on the form + * + * The room goes first. "Add catering" is DISABLED until a room is chosen, since + * catering is delivered to a room — so this test cannot be written in the + * obvious order, and `MeetingForm.addCateringItem` says so where somebody will + * read it. + * + * ## `fixme` — ROOM-B5, and it is the worst of the room findings + * + * Ordering catering with a PlaceOS-native room booking cannot work at all. The + * meeting is created (`201`), and the catering order that follows is refused: + * + * POST /api/staff/v1/bookings (booking_type: catering-order) + * -> 422 {"error":"error linking booking to event", + * "failures":[{"field":"event_id", + * "reason":"Could not find metadata for event ARRAY['1138']"}]} + * + * The order is linked to a calendar EVENT by `event_id`, and in `use_bookings` + * mode there is no event — the meeting is a booking, so the id handed over + * (1138 here) is a booking id and the lookup finds nothing. + * + * Measured what is left behind afterwards, because that is what decides how bad + * it is: **the room booking survives, undeleted, and no catering order exists.** + * The user is shown an error on the confirm screen, so they have every reason to + * believe nothing was booked — while the room is held and the food was never + * ordered. `postForm` does call `_removeBookingAfterError` on a catering + * failure, and it did not roll the room booking back. + * + * The assertions below are what SHOULD happen. Drop the `fixme` when the order + * can be linked to a booking rather than only to a calendar event. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { + deleteBooking, + getBooking, + listBookings, + uniqueTitle, +} from '../../../../e2e/support/api'; +import { ROOM_SLOTS_2, SECOND_DAY, slotFor } from '../../../../e2e/support/room/room.env'; +import { roomForWorker } from '../../../../e2e/support/room/room.seed'; +import { cateringMenu } from '../../../../e2e/support/room/catering.seed'; +import { type RoomBooking, releaseRoom } from '../../../../e2e/support/room/room.api'; +import { ROOM_BASE_SETTINGS, useSettings } from '../../../../e2e/support/room/room.settings'; +import { bookRoomViaUI } from '../../../../e2e/support/room/room.flows'; + +const DAY = 86_400; +const window_from = () => Math.floor(Date.now() / 1000) - 2 * DAY; +const window_to = () => Math.floor(Date.now() / 1000) + 7 * DAY; + +test.describe('room booking catering', () => { + test.fixme('catering ordered on the form is stored as its own booking', async ({ + staffPage, + staffApi, + }, testInfo) => { + const room = await roomForWorker(testInfo.parallelIndex); + const menu = await cateringMenu(); + const slot = slotFor(ROOM_SLOTS_2.catering.ordered, SECOND_DAY); + const title = uniqueTitle('E2E Room Catering'); + let booking_id: number | undefined; + const order_ids: number[] = []; + + await releaseRoom(staffApi, room.id, window_from(), window_to()); + await useSettings(staffPage, ROOM_BASE_SETTINGS); + + try { + // Through the shared flow, so a refusal is reported with the + // request, the response and what was on screen — which is what + // makes a catering failure legible at all. + const created = await bookRoomViaUI(staffPage, staffApi, room, title, { + date: slot.date_ms, + catering: [menu.item_name], + }); + booking_id = created.id; + + // And the food, as a booking of its own. Polled, because it is + // created AFTER the meeting and the success screen does not wait + // for it — which is exactly how a silently unordered lunch happens. + await expect(async () => { + const orders = (await listBookings( + staffApi, + 'catering-order', + window_from(), + window_to(), + )) as RoomBooking[]; + const mine = orders.filter( + (o) => !o.deleted && `${o.extension_data?.event_id ?? ''}` === `${booking_id}`, + ); + expect( + mine.length, + `a catering order should exist for meeting ${booking_id}. ` + + `${orders.length} order(s) are live in the window: ` + + `${JSON.stringify( + orders.map((o) => ({ + id: o.id, + event: o.extension_data?.event_id, + deleted: o.deleted, + })), + )}`, + ).toBeGreaterThan(0); + for (const order of mine) order_ids.push(order.id); + }).toPass({ timeout: 45_000 }); + + const order = await getBooking(staffApi, order_ids[0]); + expect( + JSON.stringify((order as RoomBooking).extension_data ?? {}), + `the order should name the item that was chosen (${menu.item_name})`, + ).toContain(menu.item_name); + } finally { + for (const id of order_ids) await deleteBooking(staffApi, id); + if (booking_id != null) await deleteBooking(staffApi, booking_id); + await releaseRoom(staffApi, room.id, window_from(), window_to()); + } + }); +}); diff --git a/apps/workplace/e2e/local/room-clash.spec.ts b/apps/workplace/e2e/local/room-clash.spec.ts new file mode 100644 index 0000000000..3072a890c4 --- /dev/null +++ b/apps/workplace/e2e/local/room-clash.spec.ts @@ -0,0 +1,200 @@ +/** + * ROOM-03 / ROOM-04 — a room is exclusive for its window. + * + * The room equivalent of `desk-clash.spec.ts`, and the same reasoning: a + * double-booked room is the failure users notice fastest, and it is the backend + * that has to refuse it. The UI's own availability check (`queryResourceAvailability`) + * only filters the picker; it would be no defence against a second person + * booking at the same moment, or against any other client. + * + * Everything is created through the API, not the form. The subject is the rule, + * and driving the meeting form twice would add an unrelated way to fail — + * including hitting the picker's own filtering, which hides busy rooms and would + * make "the second booking was refused" indistinguishable from "the room was + * never offered". + * + * Attempted as a SECOND user, deliberately. A check that only looked at the + * caller's own bookings would pass a same-user test and still let two colleagues + * double-book. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { request } from '@playwright/test'; +import { deleteBooking, getBooking, uniqueTitle } from '../../../../e2e/support/api'; +import { APP_URL, BACKEND_URL, WORKERS, roleFor } from '../../../../e2e/support/env'; +import { mintToken } from '../../../../e2e/support/auth'; +import { ROOM_SLOTS, slotFor } from '../../../../e2e/support/room/room.env'; +import { roomForWorker } from '../../../../e2e/support/room/room.seed'; +import { + createRoomBookingViaApi, + releaseRoom, + tryRoomBooking, +} from '../../../../e2e/support/room/room.api'; + +const DAY = 86_400; +const window_from = () => Math.floor(Date.now() / 1000) - 2 * DAY; +const window_to = () => Math.floor(Date.now() / 1000) + 7 * DAY; + +/** + * An API context signed in as a DIFFERENT seeded user. + * + * The same approach `desk-clash.spec.ts` and `visitor-scoping.spec.ts` take: + * mint a real token for another worker's identity rather than reusing a + * sidecar, so the second user is genuinely a second user rather than the same + * bearer twice. + */ +async function apiAsOtherUser(workerIndex: number) { + const role = roleFor('staff', workerIndex); + const mint = await mintToken(BACKEND_URL, APP_URL, role.email, role.password); + return request.newContext({ + baseURL: BACKEND_URL, + ignoreHTTPSErrors: true, + extraHTTPHeaders: { Authorization: `Bearer ${mint.accessToken}` }, + }); +} + +test.describe('room double-booking', () => { + test('a second person cannot book a room that is already taken', async ({ + staffApi, + }, testInfo) => { + const mine = testInfo.parallelIndex; + const theirs = (mine + 1) % WORKERS; + test.skip(theirs === mine, 'needs at least two workers to have two distinct users'); + + const room = await roomForWorker(mine); + const otherStaffApi = await apiAsOtherUser(theirs); + const slot = slotFor(ROOM_SLOTS.clash.taken); + const title = uniqueTitle('E2E Room Clash'); + let booking_id: number | undefined; + const other_ids: number[] = []; + + await releaseRoom(staffApi, room.id, window_from(), window_to()); + + try { + const mine = await createRoomBookingViaApi(staffApi, { + room, + title, + start: slot.start, + end: slot.end, + }); + booking_id = mine.id; + expect( + (await getBooking(staffApi, booking_id)).deleted, + 'precondition: the room really is held before anyone else tries', + ).toBeFalsy(); + + // Exactly the same window. + const same = await tryRoomBooking(otherStaffApi, { + room, + title: uniqueTitle('E2E Room Clash Same'), + start: slot.start, + end: slot.end, + }); + if (same.id) other_ids.push(same.id); + // 409 specifically, not "any error". A 5xx from an unhealthy backend + // would satisfy `>= 400` while proving nothing about clash detection + // — and booking POSTs have a known way of returning 500 under load + // (REG-09), which is exactly the failure this spec must not absorb. + expect( + same.status, + `the same room and window must be refused with 409, got ${same.status}: ` + + `${same.body.slice(0, 200)}`, + ).toBe(409); + + // And a partial overlap — the case a naive check misses, because it + // starts after the existing booking begins and ends after it ends. + const overlap = await tryRoomBooking(otherStaffApi, { + room, + title: uniqueTitle('E2E Room Clash Overlap'), + start: slot.start + 1800, + end: slot.end + 1800, + }); + if (overlap.id) other_ids.push(overlap.id); + expect( + overlap.status, + `a partly overlapping booking must also be refused with 409, got ` + + `${overlap.status}: ${overlap.body.slice(0, 200)}`, + ).toBe(409); + + // Control: a window that genuinely does not overlap is accepted. + // Without this, a backend refusing everything would pass both checks + // above and look like working clash detection. + const clear = await tryRoomBooking(otherStaffApi, { + room, + title: uniqueTitle('E2E Room Clash Clear'), + start: slot.end + 3600, + end: slot.end + 7200, + }); + if (clear.id) other_ids.push(clear.id); + expect( + clear.status, + `a non-overlapping window must still be accepted, got ${clear.status}: ` + + `${clear.body.slice(0, 200)}`, + ).toBe(201); + } finally { + // The other user's bookings have to be removed BY THEM: `GET /bookings` + // is caller-scoped, so the owner's sweep in the next run cannot see + // them, and the room would stay held by something invisible. + for (const id of other_ids) await deleteBooking(otherStaffApi, id); + if (booking_id != null) await deleteBooking(staffApi, booking_id); + await otherStaffApi.dispose(); + } + }); + + test('the room frees up once the booking is deleted', async ({ + staffApi, + }, testInfo) => { + const mine = testInfo.parallelIndex; + const theirs = (mine + 1) % WORKERS; + test.skip(theirs === mine, 'needs at least two workers to have two distinct users'); + + const room = await roomForWorker(mine); + const otherStaffApi = await apiAsOtherUser(theirs); + const slot = slotFor(ROOM_SLOTS.clash.freed); + const other_ids: number[] = []; + + await releaseRoom(staffApi, room.id, window_from(), window_to()); + + try { + const first = await createRoomBookingViaApi(staffApi, { + room, + title: uniqueTitle('E2E Room Freed'), + start: slot.start, + end: slot.end, + }); + + const blocked = await tryRoomBooking(otherStaffApi, { + room, + title: uniqueTitle('E2E Room Freed Blocked'), + start: slot.start, + end: slot.end, + }); + if (blocked.id) other_ids.push(blocked.id); + expect( + blocked.status, + 'precondition: the window is taken while the first booking exists', + ).toBe(409); + + await deleteBooking(staffApi, first.id); + + // Guards a nastier version of the same bug than a plain + // double-booking: a cancelled booking that still blocks the room. + // Users would see it as free and be unable to book it, which is far + // harder to diagnose than being told it is taken. + const after = await tryRoomBooking(otherStaffApi, { + room, + title: uniqueTitle('E2E Room Freed After'), + start: slot.start, + end: slot.end, + }); + if (after.id) other_ids.push(after.id); + expect( + after.status, + `once the booking is deleted the room must be bookable again, got ` + + `${after.status}: ${after.body.slice(0, 200)}`, + ).toBe(201); + } finally { + for (const id of other_ids) await deleteBooking(otherStaffApi, id); + await otherStaffApi.dispose(); + } + }); +}); diff --git a/apps/workplace/e2e/local/room-delegate.spec.ts b/apps/workplace/e2e/local/room-delegate.spec.ts new file mode 100644 index 0000000000..cb77968286 --- /dev/null +++ b/apps/workplace/e2e/local/room-delegate.spec.ts @@ -0,0 +1,100 @@ +/** + * ROOM-28 — booking a room on somebody else's behalf. + * + * The visitor equivalent (VIS-09) found a real defect: the host picker rendered + * from one setting while the choice was only KEPT when a second was also set, so + * the booking came back owned by whoever filled the form. Rooms have the same + * shape — `meeting-form-details.component.ts` renders a host field from + * `events.can_book_for_anyone` — and nothing has checked which user a room + * booking ends up against. + * + * It matters beyond bookkeeping: a room booked "for" a colleague but stored + * against the booker does not appear on the colleague's schedule, so as far as + * the app is concerned they have no room, and the booker holds two. + * + * ## The local user list, again + * + * `app.basic_user_search` is set with it, for the same reason DESK-14 needs it: + * without it the field searches `/api/staff/v1/people`, the calendar directory, + * which 500s on this stack. So this covers the PlaceOS path only. + * + * ## `fixme` — ROOM-B8: the chosen host is discarded + * + * Measured: the colleague was picked from the host field, **the field was proven + * to still show them at the moment the meeting was confirmed**, and the stored + * booking came back owned by the person who filled the form. + * + * That "still showing" check is what makes this a finding rather than a test + * bug — it rules out the form reverting the field before sending, which looks + * identical from outside. `bookRoomViaUI` asserts it for this reason. + * + * The consequence is the same one VIS-09 used to have for visitors: the room + * does not appear on the colleague's schedule, so as far as the app is concerned + * they have no room, and the booker holds two. Desks get this right (DESK-14 is + * green), which is the useful comparison for whoever fixes it. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { + currentUser, + deleteBooking, + getBooking, + uniqueTitle, +} from '../../../../e2e/support/api'; +import { WORKERS, staffEmail } from '../../../../e2e/support/env'; +import { ROOM_SLOTS_2, SECOND_DAY, slotFor } from '../../../../e2e/support/room/room.env'; +import { roomForWorker } from '../../../../e2e/support/room/room.seed'; +import { releaseRoom } from '../../../../e2e/support/room/room.api'; +import { + BOOK_FOR_ANYONE, + ROOM_BASE_SETTINGS, + useSettings, +} from '../../../../e2e/support/room/room.settings'; +import { bookRoomViaUI } from '../../../../e2e/support/room/room.flows'; + +const DAY = 86_400; +const window_from = () => Math.floor(Date.now() / 1000) - 2 * DAY; +const window_to = () => Math.floor(Date.now() / 1000) + 7 * DAY; + +test.describe('booking a room for a colleague', () => { + test.fixme('the chosen colleague is stored as the host, and you as the booker', async ({ + staffPage, + staffApi, + }, testInfo) => { + const mine = testInfo.parallelIndex; + const theirs = (mine + 1) % WORKERS; + test.skip(theirs === mine, 'needs at least two workers to have two distinct users'); + + const room = await roomForWorker(mine); + const colleague = staffEmail(theirs); + const slot = slotFor(ROOM_SLOTS_2.delegate.hour, SECOND_DAY); + const title = uniqueTitle('E2E Room ForColleague'); + let booking_id: number | undefined; + + await releaseRoom(staffApi, room.id, window_from(), window_to()); + await useSettings(staffPage, { ...ROOM_BASE_SETTINGS, ...BOOK_FOR_ANYONE }); + + try { + const me = await currentUser(staffApi); + const created = await bookRoomViaUI(staffPage, staffApi, room, title, { + date: slot.date_ms, + host: colleague, + }); + booking_id = created.id; + + const stored: any = await getBooking(staffApi, booking_id!); + expect( + `${stored.user_email}`.toLowerCase(), + 'the room must be booked FOR the colleague. If this comes back as the ' + + 'person who filled the form, the host choice was discarded — which ' + + 'is exactly what the visitor form used to do (VIS-09)', + ).toBe(colleague.toLowerCase()); + expect( + `${stored.booked_by_email ?? stored.user_email}`.toLowerCase(), + 'and BY the person who filled the form', + ).toBe(me.email.toLowerCase()); + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + await releaseRoom(staffApi, room.id, window_from(), window_to()); + } + }); +}); diff --git a/apps/workplace/e2e/local/room-edit.spec.ts b/apps/workplace/e2e/local/room-edit.spec.ts new file mode 100644 index 0000000000..dfe34b802b --- /dev/null +++ b/apps/workplace/e2e/local/room-edit.spec.ts @@ -0,0 +1,195 @@ +/** + * ROOM-15 / ROOM-16 — changing a room booking that already exists. + * + * Every other room spec creates a booking and then deletes it. Nothing so far + * CHANGES one, and an edit is a different code path in three ways that all + * matter: + * + * - it PATCHes rather than POSTs (`saveBooking` branches on `data.id`, so an + * edit that lost the id would silently create a second booking and leave the + * room double-held); + * - it goes through `schedule.component.ts::edit`, which for an event whose + * `creator` and `mailbox` differ re-queries the CALENDAR — and `/events` + * 500s on this stack, so that branch would make editing impossible here; + * - moving a booking to another room has to free the old one. A booking that + * changed its times but kept its room, or its room but kept its times, holds + * a room nobody can see is held. + * + * ## Why these two book through the UI first, when every other spec does not + * + * A room booking made straight through the API has no `extension_data`, and + * that is where the room lives: the schedule rebuilds a room booking into a + * CalendarEvent (`newCalendarEventFromBooking`) by spreading `extension_data` + * over it, so an API-made booking opens the edit form with NO room on it. + * Measured, and it cost a first draft of this file — the form was empty and it + * looked like the app losing the room on edit. It is not; it is test data the + * app would never have produced. So the booking under test here is made by the + * app, exactly as a user's would be, and only the edit is the subject. + * + * Both assert on the BACKEND afterwards. The schedule updates itself + * optimistically, so the screen showing the new time proves nothing at all. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { deleteBooking, getBooking, uniqueTitle } from '../../../../e2e/support/api'; +import { ROOM_SLOTS_2, SECOND_DAY, slotFor } from '../../../../e2e/support/room/room.env'; +import { roomForWorker } from '../../../../e2e/support/room/room.seed'; +import { releaseRoom } from '../../../../e2e/support/room/room.api'; +import { ROOM_BASE_SETTINGS, useSettings } from '../../../../e2e/support/room/room.settings'; +import { RoomSchedulePage } from '../../../../e2e/support/room/schedule.page'; +import { MeetingForm } from '../../../../e2e/support/room/meeting-form.page'; +import { bookRoomViaUI } from '../../../../e2e/support/room/room.flows'; + +const DAY = 86_400; +const window_from = () => Math.floor(Date.now() / 1000) - 2 * DAY; +const window_to = () => Math.floor(Date.now() / 1000) + 7 * DAY; + +/** "HH:mm" for a slot's start, in the browser's local time. */ +function hhmm(unix_seconds: number): string { + const date = new Date(unix_seconds * 1000); + return `${`${date.getHours()}`.padStart(2, '0')}:${`${date.getMinutes()}`.padStart(2, '0')}`; +} + +test.describe('editing a room booking', () => { + test('the new start time chosen on the form is the one stored', async ({ + staffPage, + staffApi, + }, testInfo) => { + const room = await roomForWorker(testInfo.parallelIndex); + const from = slotFor(ROOM_SLOTS_2.edit.time, SECOND_DAY); + const to = slotFor(ROOM_SLOTS_2.edit.moved_to, SECOND_DAY); + const title = uniqueTitle('E2E Room Edit Time'); + let booking_id: number | undefined; + + // Sweep the whole window, not just the hour being moved out of: the + // hour being moved INTO has to be free as well, or the app refuses the + // edit for a perfectly good reason and the test reads as a bug. + await releaseRoom(staffApi, room.id, window_from(), window_to()); + await useSettings(staffPage, ROOM_BASE_SETTINGS); + + try { + const created = await bookRoomViaUI(staffPage, staffApi, room, title, { + date: from.date_ms, + startTime: hhmm(from.start), + }); + booking_id = created.id; + expect( + (await getBooking(staffApi, booking_id)).booking_start, + 'precondition: the booking starts at the hour it was booked for', + ).toBe(from.start); + + const schedule = new RoomSchedulePage(staffPage); + await schedule.open(); + await schedule.showRooms(); + await schedule.showDayOf(from.date_ms); + await schedule.startEdit(booking_id); + + const form = new MeetingForm(staffPage); + await expect( + form.chosenSpaces, + 'the edit form should open with the booked room already on it', + ).toHaveCount(1, { timeout: 30_000 }); + + // Same trap as booking: the time fields reach the model + // asynchronously and the confirm screen snapshots the model when it + // opens. Set, settle, then confirm. + await form.setStartTime(hhmm(to.start)); + await staffPage.waitForTimeout(4_000); + await form.confirmAndSend(); + + await expect(async () => { + const updated = await getBooking(staffApi, booking_id!); + expect( + updated.booking_start, + `the booking should now start at ${hhmm(to.start)}`, + ).toBe(to.start); + }).toPass({ timeout: 45_000 }); + + const updated = await getBooking(staffApi, booking_id); + expect( + updated.asset_id, + 'moving the time must not change the room', + ).toBe(room.id); + expect( + updated.deleted, + 'an edit must UPDATE the booking, not delete and replace it — a new ' + + 'row would mean the id the user holds is dead', + ).toBeFalsy(); + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + await releaseRoom(staffApi, room.id, window_from(), window_to()); + } + }); + + test('a booking moved to another room is stored against that room', async ({ + staffPage, + staffApi, + }, testInfo) => { + const room = await roomForWorker(testInfo.parallelIndex); + const other = await roomForWorker(testInfo.parallelIndex, 'alt'); + const slot = slotFor(ROOM_SLOTS_2.edit.room, SECOND_DAY); + const title = uniqueTitle('E2E Room Edit Room'); + let booking_id: number | undefined; + + await releaseRoom(staffApi, room.id, window_from(), window_to()); + await releaseRoom(staffApi, other.id, window_from(), window_to()); + await useSettings(staffPage, ROOM_BASE_SETTINGS); + + try { + const created = await bookRoomViaUI(staffPage, staffApi, room, title, { + date: slot.date_ms, + startTime: hhmm(slot.start), + }); + booking_id = created.id; + expect( + (await getBooking(staffApi, booking_id)).asset_id, + 'precondition: the booking starts out in the first room', + ).toBe(room.id); + + const schedule = new RoomSchedulePage(staffPage); + await schedule.open(); + await schedule.showRooms(); + await schedule.showDayOf(slot.date_ms); + await schedule.startEdit(booking_id); + + const form = new MeetingForm(staffPage); + await expect( + form.chosenSpaces, + 'the edit form should open with the booked room already on it', + ).toHaveCount(1, { timeout: 30_000 }); + // The form holds one room, so the old one comes off before the new + // one goes on — otherwise the picker treats the click as unselecting. + await form.removeRoom(); + await form.chooseRoom(other.name); + + // Wait for the NAME to change, not just for a room to be present. + // The count is 1 either way, so it cannot see this swap — and the + // choice reaches the model asynchronously while the confirm screen + // snapshots the model when it opens. A run under full parallel load + // saved the ORIGINAL room this way: the room was picked, the + // snapshot was taken first, and the PATCH carried the old asset id. + await expect(async () => { + expect(await form.chosenRoomNames()).toEqual([other.name]); + }).toPass({ timeout: 30_000 }); + await staffPage.waitForTimeout(4_000); + await form.confirmAndSend(); + + await expect(async () => { + const updated = await getBooking(staffApi, booking_id!); + expect( + updated.asset_id, + `the booking should now be held against ${other.name}`, + ).toBe(other.id); + }).toPass({ timeout: 45_000 }); + + const updated = await getBooking(staffApi, booking_id); + expect( + updated.booking_start, + 'moving room must not move the meeting in time', + ).toBe(slot.start); + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + await releaseRoom(staffApi, room.id, window_from(), window_to()); + await releaseRoom(staffApi, other.id, window_from(), window_to()); + } + }); +}); diff --git a/apps/workplace/e2e/local/room-favourites.spec.ts b/apps/workplace/e2e/local/room-favourites.spec.ts new file mode 100644 index 0000000000..fed0dbdc14 --- /dev/null +++ b/apps/workplace/e2e/local/room-favourites.spec.ts @@ -0,0 +1,96 @@ +/** + * ROOM-19 — marking a room as a favourite. + * + * The only room feature here that outlives the booking flow: a favourite is a + * USER setting, written to the signed-in user's `settings` metadata and read + * back on every later visit. That is what makes it worth a spec — a star that + * lights up and is forgotten on reload looks identical on screen and is useless. + * + * So this asserts twice, on two different things: + * + * 1. the BACKEND holds the room id in `favourite_spaces` afterwards. The write + * is debounced (~2.4 seconds in `SettingsService.saveUserSetting`) and sends + * the whole settings blob as one `PUT /metadata/{user_id}`, so a spec that + * read it immediately would see the old value and call it a bug. + * 2. the "Favorites Only" filter then narrows the picker to it. The list is + * asserted to hold more than one room BEFORE the filter goes on, so + * "narrowed to one" cannot pass in a stack that only has one room to offer. + * + * ## Cleanup matters more here than anywhere else in these specs + * + * Every other room spec cleans up bookings. This one changes a USER's saved + * settings, which persist across runs and are shared with the visitor specs + * (`visitor-invitees` lives in the same blob). It is cleared either side, and + * `setFavouriteSpaces` deliberately reads-modifies-writes so it cannot wipe the + * visitor data as collateral. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { roomForWorker } from '../../../../e2e/support/room/room.seed'; +import { + readUserSettings, + setFavouriteSpaces, +} from '../../../../e2e/support/room/room.api'; +import { ROOM_BASE_SETTINGS, useSettings } from '../../../../e2e/support/room/room.settings'; +import { MeetingForm } from '../../../../e2e/support/room/meeting-form.page'; + +test.describe('favourite rooms', () => { + test('a room marked as a favourite is saved and can be filtered to', async ({ + staffPage, + staffApi, + }, testInfo) => { + const room = await roomForWorker(testInfo.parallelIndex); + + // Start from none. A favourite left behind by an earlier run would make + // step 2 pass without step 1 having done anything. + await setFavouriteSpaces(staffApi, []); + await useSettings(staffPage, ROOM_BASE_SETTINGS); + + try { + const form = new MeetingForm(staffPage); + await form.open(); + await form.addSpaceButton.click(); + const rows = staffPage.locator('button[name="select-space"]'); + await expect(rows.first(), 'the room picker did not open').toBeVisible({ + timeout: 20_000, + }); + const offered_before = await rows.count(); + expect( + offered_before, + 'the picker must offer more than one room, or filtering down to one ' + + 'proves nothing', + ).toBeGreaterThan(1); + + const row = staffPage.locator('li[space]').filter({ hasText: room.name }); + await expect( + row, + `no row for ${room.name} in the picker — it offers ${offered_before}`, + ).toHaveCount(1, { timeout: 20_000 }); + await row.locator('button[name="toggle-space-favourite"]').first().click(); + + // The write is debounced, so poll rather than read once. + await expect(async () => { + const settings = await readUserSettings(staffApi); + expect( + settings.favourite_spaces ?? [], + `${room.name} (${room.id}) should be saved as a favourite room`, + ).toContain(room.id); + }).toPass({ timeout: 30_000 }); + + // "Favorites Only" is a toggle in the picker's filter panel. Its + // label is translated, so it is reached through the `favs` section + // attribute instead. + await staffPage.locator('section[favs] settings-toggle button').first().click(); + await expect( + rows, + 'with Favorites Only on, the picker should offer just the favourite', + ).toHaveCount(1, { timeout: 20_000 }); + await expect( + staffPage.locator('li[space]').filter({ hasText: room.name }), + 'and the one it offers should be the room that was favourited', + ).toHaveCount(1, { timeout: 20_000 }); + } finally { + // Not optional. This is a saved user setting, not a booking. + await setFavouriteSpaces(staffApi, []); + } + }); +}); diff --git a/apps/workplace/e2e/local/room-multi.spec.ts b/apps/workplace/e2e/local/room-multi.spec.ts new file mode 100644 index 0000000000..76ef6da1dd --- /dev/null +++ b/apps/workplace/e2e/local/room-multi.spec.ts @@ -0,0 +1,129 @@ +/** + * ROOM-26 — a meeting in more than one room. + * + * Every room spec so far books exactly one room, and multi-select is not a + * variation on that — it changes the picker's shape. `multipleSpacesEnabled` + * swaps the confirm button from `toggle-space` to **`space-return`**, and a + * spec written against the single-select button waits out its timeout on an + * element that is not in the DOM. That trap is recorded at the top of + * `meeting-form.page.ts`, and this is the test that exercises it. + * + * Why it matters beyond the picker: a two-room meeting has to hold BOTH rooms. + * A form that stored only the first would leave the second bookable by somebody + * else, and the people sent to it would find it occupied. + * + * ## `fixme` — ROOM-B6: only the first room is ever booked + * + * Measured, with `app.events.multiple_spaces` on and both rooms visibly on the + * form before sending: + * + * two rooms on the form -> ONE booking + * [{"id":1579,"asset":"sys-Ko~UXacdGR"}] <- the first room only + * + * The meeting is accepted and the success screen appears, so the user is told + * they have both rooms. The second room is left free for anybody else to book, + * and everyone sent to it arrives to find it occupied — or, worse, double-booked + * by a colleague who saw it as available. + * + * The mechanism is visible in `newBookingFromCalendarEvent`: a booking carries + * ONE `asset_id`, taken from `event.system?.id || event.system_id`, and nothing + * in the `use_bookings` path creates a second booking for the second room. So + * multi-select renders, validates and confirms while only ever being able to + * hold one room. + * + * Either the native path needs a booking per room, or multi-select should not be + * offered when `use_bookings` is on. That is a product decision; the assertions + * below are what should be true once it is made. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { deleteBooking, listBookings, uniqueTitle } from '../../../../e2e/support/api'; +import { ROOM_SLOTS_2, SECOND_DAY, slotFor } from '../../../../e2e/support/room/room.env'; +import { roomForWorker } from '../../../../e2e/support/room/room.seed'; +import { releaseRoom, type RoomBooking } from '../../../../e2e/support/room/room.api'; +import { + MULTI_SPACE, + ROOM_BASE_SETTINGS, + useSettings, +} from '../../../../e2e/support/room/room.settings'; +import { MeetingForm } from '../../../../e2e/support/room/meeting-form.page'; + +const DAY = 86_400; +const window_from = () => Math.floor(Date.now() / 1000) - 2 * DAY; +const window_to = () => Math.floor(Date.now() / 1000) + 7 * DAY; + +test.describe('a meeting in more than one room', () => { + test.fixme('both rooms chosen on the form end up held', async ({ + staffPage, + staffApi, + }, testInfo) => { + const first = await roomForWorker(testInfo.parallelIndex); + const second = await roomForWorker(testInfo.parallelIndex, 'alt'); + const slot = slotFor(ROOM_SLOTS_2.multi.hour, SECOND_DAY); + const title = uniqueTitle('E2E Room Multi'); + const created: number[] = []; + + await releaseRoom(staffApi, first.id, window_from(), window_to()); + await releaseRoom(staffApi, second.id, window_from(), window_to()); + await useSettings(staffPage, { ...ROOM_BASE_SETTINGS, ...MULTI_SPACE }); + + try { + const form = new MeetingForm(staffPage); + await form.open(); + + // Both rooms, one after the other. In multi-select mode the picker + // keeps the modal open and confirms with `space-return`. + await expect(async () => { + await form.title.fill(title); + expect(await form.title.inputValue()).toBe(title); + }).toPass({ timeout: 45_000 }); + + await form.chooseRooms([first.name, second.name]); + await expect( + form.chosenSpaces, + 'both rooms should be on the form before it is sent', + ).toHaveCount(2, { timeout: 20_000 }); + + await staffPage.waitForTimeout(3_000); + await form.confirmAndSend(); + await expect( + form.successPanel, + 'a two-room meeting should be accepted', + ).toBeVisible({ timeout: 30_000 }); + + // Both rooms must be held. Read from the backend by asset, because + // the app may produce one booking per room or one booking carrying + // both — this test cares that neither room is left free. + const live = (await listBookings( + staffApi, + 'room', + window_from(), + window_to(), + )) as RoomBooking[]; + const mine = live.filter( + (b) => !b.deleted && `${b.extension_data?.title ?? ''}` === title, + ); + for (const booking of mine) created.push(booking.id); + + const held = new Set(mine.map((b) => `${b.asset_id}`)); + expect( + [...held], + `both rooms should be held for this meeting. Bookings made: ` + + `${JSON.stringify( + mine.map((b) => ({ id: b.id, asset: b.asset_id })), + )}`, + ).toContain(first.id); + expect( + [...held], + `the SECOND room (${second.id}) must be held too. Bookings made: ` + + `${JSON.stringify( + mine.map((b) => ({ id: b.id, asset: b.asset_id })), + )}. A meeting that quietly booked only the first leaves the other ` + + `bookable, and everyone sent to it finds it occupied`, + ).toContain(second.id); + } finally { + for (const id of created) await deleteBooking(staffApi, id); + await releaseRoom(staffApi, first.id, window_from(), window_to()); + await releaseRoom(staffApi, second.id, window_from(), window_to()); + } + }); +}); diff --git a/apps/workplace/e2e/local/room-scoping.spec.ts b/apps/workplace/e2e/local/room-scoping.spec.ts new file mode 100644 index 0000000000..3ae4634f3b --- /dev/null +++ b/apps/workplace/e2e/local/room-scoping.spec.ts @@ -0,0 +1,124 @@ +/** + * ROOM-05 / ROOM-06 — one person's room booking is not another's business. + * + * The desk equivalent is `booking-scoping.spec.ts`, and this deliberately + * mirrors it: `GET /bookings` is caller-scoped, and a deletion attempt by + * somebody else must be rejected rather than merely hidden in the UI. + * + * ## This does not contradict `room-clash.spec.ts` + * + * Worth saying plainly, because the two look opposed. A room's AVAILABILITY is + * shared — that is exactly why a second person is refused with 409 when the + * window is taken. What is private is the BOOKING: who booked it, what the + * meeting is called, who is attending. Somebody else may discover that the room + * is busy; they may not read the meeting or cancel it. + * + * Both tests work entirely through the API, because that is where the boundary + * is. A UI that merely does not display someone else's booking proves nothing + * about whether the data is reachable. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { request } from '@playwright/test'; +import { STAFF_API, deleteBooking, getBooking, listBookings, uniqueTitle } from '../../../../e2e/support/api'; +import { APP_URL, BACKEND_URL, WORKERS, roleFor } from '../../../../e2e/support/env'; +import { mintToken } from '../../../../e2e/support/auth'; +import { ROOM_SLOTS, slotFor } from '../../../../e2e/support/room/room.env'; +import { roomForWorker } from '../../../../e2e/support/room/room.seed'; +import { createRoomBookingViaApi, releaseRoom } from '../../../../e2e/support/room/room.api'; + +const DAY = 86_400; +const window_from = () => Math.floor(Date.now() / 1000) - 2 * DAY; +const window_to = () => Math.floor(Date.now() / 1000) + 7 * DAY; + +async function apiAsOtherUser(workerIndex: number) { + const role = roleFor('staff', workerIndex); + const mint = await mintToken(BACKEND_URL, APP_URL, role.email, role.password); + return request.newContext({ + baseURL: BACKEND_URL, + ignoreHTTPSErrors: true, + extraHTTPHeaders: { Authorization: `Bearer ${mint.accessToken}` }, + }); +} + +test.describe('room booking visibility between users', () => { + test('another user cannot see or delete your room booking', async ({ + staffApi, + }, testInfo) => { + const mine = testInfo.parallelIndex; + const theirs = (mine + 1) % WORKERS; + test.skip(theirs === mine, 'needs at least two workers to have two distinct users'); + + const room = await roomForWorker(mine); + const slot = slotFor(ROOM_SLOTS.scoping.own); + const title = uniqueTitle('E2E Room Private'); + let booking_id: number | undefined; + + await releaseRoom(staffApi, room.id, window_from(), window_to()); + const other = await apiAsOtherUser(theirs); + + try { + const created = await createRoomBookingViaApi(staffApi, { + room, + title, + start: slot.start, + end: slot.end, + }); + booking_id = created.id; + + // Their listing must not contain it. + const their_list = await listBookings(other, 'room', window_from(), window_to()); + expect( + their_list.map((b) => b.id), + "another user's room listing must not contain your booking", + ).not.toContain(booking_id); + + // ...and a direct delete must be refused, not quietly succeed. + const attempt = await other.delete(`${STAFF_API}/bookings/${booking_id}`); + expect( + attempt.status(), + `deleting somebody else's room booking must be rejected, got ` + + `${attempt.status()}`, + ).toBeGreaterThanOrEqual(400); + + const still_there = await getBooking(staffApi, booking_id); + expect( + still_there.deleted, + 'the booking should survive the other user attempting to delete it', + ).toBeFalsy(); + } finally { + await other.dispose(); + if (booking_id != null) await deleteBooking(staffApi, booking_id); + } + }); + + test('you can see your own room booking in the listing', async ({ + staffApi, + }, testInfo) => { + const room = await roomForWorker(testInfo.parallelIndex); + const slot = slotFor(ROOM_SLOTS.scoping.control); + const title = uniqueTitle('E2E Room Mine'); + let booking_id: number | undefined; + + await releaseRoom(staffApi, room.id, window_from(), window_to()); + + try { + const created = await createRoomBookingViaApi(staffApi, { + room, + title, + start: slot.start, + end: slot.end, + }); + booking_id = created.id; + + // The control for the test above. Without it, a backend that showed + // NOBODY their bookings would pass as watertight security. + const my_list = await listBookings(staffApi, 'room', window_from(), window_to()); + expect( + my_list.map((b) => b.id), + 'you must be able to see your own room booking', + ).toContain(booking_id); + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + } + }); +}); diff --git a/apps/workplace/e2e/local/room-times.spec.ts b/apps/workplace/e2e/local/room-times.spec.ts new file mode 100644 index 0000000000..9a2ef616b9 --- /dev/null +++ b/apps/workplace/e2e/local/room-times.spec.ts @@ -0,0 +1,188 @@ +/** + * ROOM-07 / ROOM-08 / ROOM-14 — when the meeting is, and the limits on what may + * be chosen. + * + * Test 1 asks the plain question nothing else here asks: if a user picks a day, + * a start time and a length, are THOSE the times that get stored? Everything + * else in the room specs takes whatever the form offers, so a form that ignored + * the pickers would have passed the lot. + * + * Test 2 covers the two settings that fence the pickers in — a maximum meeting + * length and the hours a meeting may start. Both are unset by default, so + * nothing else sees them, and both fail quietly in the same way: the option is + * simply absent rather than refused. That is why this asserts on the options + * OFFERED. "A three-hour meeting is rejected" would be testing something the + * form never lets you attempt. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { deleteBooking, getBooking, uniqueTitle } from '../../../../e2e/support/api'; +import { ROOM_SLOTS, slotFor } from '../../../../e2e/support/room/room.env'; +import { roomForWorker } from '../../../../e2e/support/room/room.seed'; +import { releaseRoom } from '../../../../e2e/support/room/room.api'; +import { + LIMITED_HOURS, + ROOM_BASE_SETTINGS, + useSettings, +} from '../../../../e2e/support/room/room.settings'; +import { bookRoomViaUI } from '../../../../e2e/support/room/room.flows'; +import { MeetingForm } from '../../../../e2e/support/room/meeting-form.page'; + +const DAY = 86_400; +const window_from = () => Math.floor(Date.now() / 1000) - 2 * DAY; +const window_to = () => Math.floor(Date.now() / 1000) + 7 * DAY; + +const MAX_MINUTES = LIMITED_HOURS['app.events.max_duration']; +const HOURS = LIMITED_HOURS['app.events.bookable_hours']; + +/** "HH:mm" as minutes past midnight, for comparing option values. */ +function minutesOfDay(hhmm: string): number { + const [h, m] = hhmm.split(':').map(Number); + return h * 60 + m; +} + +test.describe('room booking times', () => { + test('the day, start time and length chosen on the form are what get stored', async ({ + staffPage, + staffApi, + }, testInfo) => { + const room = await roomForWorker(testInfo.parallelIndex); + const slot = slotFor(ROOM_SLOTS.times.chosen); + const title = uniqueTitle('E2E Room Times'); + let booking_id: number | undefined; + + await releaseRoom(staffApi, room.id, window_from(), window_to()); + await useSettings(staffPage, ROOM_BASE_SETTINGS); + + try { + const form = new MeetingForm(staffPage); + await form.open(); + + // Pick from what the form itself offers rather than naming an hour: + // the options run on a fixed step, so a hardcoded "14:30" is a test + // that stops working after 14:30. + const times = await form.startTimeOptions(); + expect( + times.length, + 'the form offered no start times at all — nothing can be chosen', + ).toBeGreaterThan(1); + const lengths = await form.durationOptions(); + const chosen_time = times[1]; + const chosen_length = lengths.find((m) => m >= 90) ?? lengths[lengths.length - 1]; + + const created = await bookRoomViaUI(staffPage, staffApi, room, title, { + date: slot.date_ms, + startTime: chosen_time, + duration: chosen_length, + }); + booking_id = created.id; + + const stored = await getBooking(staffApi, booking_id); + const start = new Date(stored.booking_start * 1000); + expect( + start.toDateString(), + 'the meeting must be on the day picked in the date field', + ).toBe(new Date(slot.date_ms).toDateString()); + expect( + start.getHours() * 60 + start.getMinutes(), + `and start at the ${chosen_time} that was picked, not the form's default`, + ).toBe(minutesOfDay(chosen_time)); + // The LENGTH is deliberately not asserted here — see the `fixme` + // below. ROOM-B3: the form and the confirmation both show the length + // that was picked, and the booking is made for 60 minutes anyway. + expect( + (stored.booking_end - stored.booking_start) / 60, + 'the meeting has some length at all', + ).toBeGreaterThan(0); + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + } + }); + + test('a maximum length and bookable hours limit what can be chosen', async ({ + staffPage, + }) => { + await useSettings(staffPage, { ...ROOM_BASE_SETTINGS, ...LIMITED_HOURS }); + + const form = new MeetingForm(staffPage); + await form.open(); + + const lengths = await form.durationOptions(); + expect( + lengths.length, + 'the form offered no meeting lengths at all, so the cap cannot be judged', + ).toBeGreaterThan(0); + expect( + Math.max(...lengths), + `with a ${MAX_MINUTES} minute cap nothing longer may be offered — the form ` + + `is the only place this is enforced, the backend stores any length`, + ).toBeLessThanOrEqual(MAX_MINUTES); + + const times = await form.startTimeOptions(); + expect( + times.length, + 'the form offered no start times at all, so the hours cannot be judged', + ).toBeGreaterThan(0); + expect( + Math.min(...times.map(minutesOfDay)), + `no meeting may start before ${HOURS.start}:00 once bookable hours are set`, + ).toBeGreaterThanOrEqual(HOURS.start * 60); + expect( + Math.max(...times.map(minutesOfDay)), + `nor after ${HOURS.end}:00 — an option outside the window is one a user can ` + + 'pick and then be refused for', + ).toBeLessThanOrEqual(HOURS.end * 60); + }); + + /** + * ROOM-14, blocked by ROOM-B3 — the meeting length chosen on the form is not + * the length booked. + * + * Measured while writing the test above. The form is asked for a 90 minute + * meeting; the duration field shows "1 hour 30 minutes", the confirmation + * shows 6:00 PM – 7:30 PM, and the request sent is: + * + * booking_start ............... 6:00 PM + * booking_end ................. 7:00 PM <- 60 minutes + * extension_data.event_end .... 7:30 PM <- the event knows it is 90 + * + * So the CalendarEvent carries the right end time and the Booking built from + * it does not: `newBookingFromCalendarEvent` takes `event.duration`, which + * is still the default. **A user is shown one time range and the room is + * held for another** — anyone booking a long meeting loses the second half + * of it, and the room looks free to everybody else. + * + * Order does not help: setting the length before the start time and after it + * both post 60. Not a timing problem either — the field still reads 90 four + * seconds later, immediately before the confirmation is sent. + * + * `fixme`, so it costs nothing per run. Drop the marker once the app carries + * the chosen length through, and fold it back into the test above. + */ + test.fixme('the meeting length chosen on the form is what gets booked', async ({ + staffPage, + staffApi, + }, testInfo) => { + const room = await roomForWorker(testInfo.parallelIndex); + const slot = slotFor(ROOM_SLOTS.times.limits, 5); + const title = uniqueTitle('E2E Room Length'); + let booking_id: number | undefined; + + await releaseRoom(staffApi, room.id, window_from(), window_to()); + await useSettings(staffPage, ROOM_BASE_SETTINGS); + + try { + const created = await bookRoomViaUI(staffPage, staffApi, room, title, { + date: slot.date_ms, + duration: 90, + }); + booking_id = created.id; + const stored = await getBooking(staffApi, booking_id); + expect( + (stored.booking_end - stored.booking_start) / 60, + 'a 90 minute meeting must hold the room for 90 minutes', + ).toBe(90); + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + } + }); +}); diff --git a/apps/workplace/e2e/local/visitor-duplicate.spec.ts b/apps/workplace/e2e/local/visitor-duplicate.spec.ts new file mode 100644 index 0000000000..235c736bd6 --- /dev/null +++ b/apps/workplace/e2e/local/visitor-duplicate.spec.ts @@ -0,0 +1,107 @@ +/** + * VIS-24 — the same visitor cannot be booked twice into one slot. + * + * ## `fixme` — VIS-B1, and this test is the guard for the fix + * + * The backend accepts the same visitor twice for the same window, returning + * **201 both times**. A desk refuses this, and product's expectation is that a + * visitor should too — two invites for one person at one time means reception + * expects them twice and a pass may be issued twice. + * + * The finding has lived in a throwaway reproducer + * (`e2e/support/repro/vis-b1-duplicate-visitor.ts`) since the visitor work. That + * script proves the behaviour once; it does not protect the fix. This does: when + * the backend starts refusing the second invite, this test goes green and stays + * that way. + * + * Written API-only on purpose. The subject is the backend's rule, and driving + * the invite form twice would add unrelated ways to fail. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { + STAFF_API, + currentUser, + deleteBooking, + uniqueTitle, + zonesWithTag, +} from '../../../../e2e/support/api'; +import { VISITOR_SLOTS, visitorFor } from '../../../../e2e/support/visitor/visitor.env'; +import { + createBookingViaApi, + deleteGuest, + releaseVisitor, +} from '../../../../e2e/support/visitor/visitor.api'; + +const DAY = 86_400; +const window_from = () => Math.floor(Date.now() / 1000) - 2 * DAY; +const window_to = () => Math.floor(Date.now() / 1000) + 3 * DAY; + +/** A fixed hour tomorrow, local, as unix seconds. */ +function tomorrowAt(hour: number): number { + const day = new Date(); + day.setDate(day.getDate() + 1); + day.setHours(hour, 0, 0, 0); + return Math.floor(day.valueOf() / 1000); +} + +test.describe('the same visitor twice', () => { + test.fixme('a second invite for the same visitor and slot is refused', async ({ + staffApi, + }, testInfo) => { + const visitor = visitorFor(testInfo.parallelIndex, VISITOR_SLOTS.duplicate.same); + const start = tomorrowAt(9); + const me = await currentUser(staffApi); + const zones = ( + await Promise.all( + ['org', 'building', 'level'].map((t) => zonesWithTag(staffApi, t)), + ) + ) + .flat() + .map((z) => z.id); + const ids: number[] = []; + + await releaseVisitor(staffApi, visitor.email, window_from(), window_to()); + + const invite = (title: string) => ({ + booking_type: 'visitor', + asset_id: visitor.email, + asset_name: visitor.name, + booking_start: start, + booking_end: start + 3600, + timezone: 'Etc/UTC', + user_email: me.email, + user_id: me.id, + user_name: me.name, + title, + zones, + attendees: [{ name: visitor.name, email: visitor.email }], + }); + + try { + const first = await createBookingViaApi( + staffApi, + invite(uniqueTitle('E2E Visit Dup 1')), + ); + ids.push(first.id); + + // The second one, identical in every way that matters. + const res = await staffApi.post(`${STAFF_API}/bookings`, { + data: invite(uniqueTitle('E2E Visit Dup 2')), + }); + const body = await res.text(); + if (res.ok()) ids.push(JSON.parse(body).id); + + expect( + res.status(), + `the same visitor in the same window must be refused — a desk is ` + + `(REG-02 / 409), and two invites for one person at one time mean ` + + `reception expects them twice. Got ${res.status()}: ` + + `${body.slice(0, 200)}`, + ).toBe(409); + } finally { + for (const id of ids) await deleteBooking(staffApi, id); + await deleteGuest(staffApi, visitor.email); + await releaseVisitor(staffApi, visitor.email, window_from(), window_to()); + } + }); +}); diff --git a/apps/workplace/e2e/local/visitor-group-clash.spec.ts b/apps/workplace/e2e/local/visitor-group-clash.spec.ts new file mode 100644 index 0000000000..32aa1e24dc --- /dev/null +++ b/apps/workplace/e2e/local/visitor-group-clash.spec.ts @@ -0,0 +1,136 @@ +/** + * VIS-25 — a host makes two group invites on the same day. + * + * ## `fixme` — VIS-B9, and this test is the guard for the fix + * + * A host cannot make two group invites that overlap, whoever the visitors are. + * The app names a group container `${host_email}[${date the invite was + * CREATED}]`, so every group invite that host makes on a given day shares ONE + * asset id — and the backend rightly refuses overlapping bookings on one asset. + * The second invite fails with `409 Conflicting booking` pointing at a `group` + * row. + * + * What shows it is accidental rather than intended: the same two visits booked + * at different TIMES, or on different days, are accepted — measured while + * writing this file, which is why the test overlaps them deliberately. A + * receptionist booking two nine-o'clock groups for two different teams is an + * ordinary thing to do, and today the second is refused with a message about a + * conflicting booking that names nothing the user recognises. + * + * The finding has lived in a reproducer since the visitor work + * (`e2e/support/repro/vis-b9-group-clash.ts`). This is the guard: two + * non-overlapping group invites on one day, which should both be accepted. + * + * API-only: the subject is the container's naming and the backend's rule, and + * driving the invite form twice would add unrelated ways to fail. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { + STAFF_API, + currentUser, + deleteBooking, + uniqueTitle, + zonesWithTag, +} from '../../../../e2e/support/api'; +import { VISITOR_SLOTS, visitorFor } from '../../../../e2e/support/visitor/visitor.env'; +import { deleteGuest, releaseVisitor } from '../../../../e2e/support/visitor/visitor.api'; + +const DAY = 86_400; +const window_from = () => Math.floor(Date.now() / 1000) - 2 * DAY; +const window_to = () => Math.floor(Date.now() / 1000) + 3 * DAY; + +function tomorrowAt(hour: number): number { + const day = new Date(); + day.setDate(day.getDate() + 1); + day.setHours(hour, 0, 0, 0); + return Math.floor(day.valueOf() / 1000); +} + +test.describe('two group invites on one day', () => { + test.fixme('a host can make two group invites for the SAME time', async ({ + staffApi, + }, testInfo) => { + const worker = testInfo.parallelIndex; + const morning = [ + visitorFor(worker, VISITOR_SLOTS.group_clash.morning_a), + visitorFor(worker, VISITOR_SLOTS.group_clash.morning_b), + ]; + const afternoon = [ + visitorFor(worker, VISITOR_SLOTS.group_clash.afternoon_a), + visitorFor(worker, VISITOR_SLOTS.group_clash.afternoon_b), + ]; + const me = await currentUser(staffApi); + const zones = ( + await Promise.all( + ['org', 'building', 'level'].map((t) => zonesWithTag(staffApi, t)), + ) + ) + .flat() + .map((z) => z.id); + const ids: number[] = []; + + for (const visitor of [...morning, ...afternoon]) { + await releaseVisitor(staffApi, visitor.email, window_from(), window_to()); + } + + /** + * A group invite, shaped the way the app builds one: a `group` container + * named after the host and the day it was created, plus one `visitor` + * booking per person linked by `parent_id`. + */ + const groupInvite = async ( + people: { email: string; name: string }[], + start: number, + label: string, + ) => { + const container_asset = `${me.email}[${new Date().toDateString()}]`; + const container = await staffApi.post(`${STAFF_API}/bookings`, { + data: { + booking_type: 'group', + asset_id: container_asset, + asset_name: container_asset, + booking_start: start, + booking_end: start + 3600, + timezone: 'Etc/UTC', + user_email: me.email, + user_id: me.id, + user_name: me.name, + title: uniqueTitle(`E2E Group ${label}`), + zones, + }, + }); + const body = await container.text(); + if (container.ok()) ids.push(JSON.parse(body).id); + return { status: container.status(), body }; + }; + + try { + const first = await groupInvite(morning, tomorrowAt(9), 'AM'); + expect( + first.status, + `precondition: the first group invite of the day is accepted. Got ` + + `${first.status}: ${first.body.slice(0, 200)}`, + ).toBe(201); + + // A different set of visitors, the SAME hour, the same host. Two + // teams arriving at nine is an ordinary thing for a receptionist to + // book, and this is the case VIS-B9 refuses. + const second = await groupInvite(afternoon, tomorrowAt(9), 'AM-2'); + expect( + second.status, + `a second group invite for the same hour, with DIFFERENT visitors, ` + + `must be accepted. Got ${second.status}: ${second.body.slice(0, 200)}. ` + + `A 409 here is VIS-B9: the container is named ` + + `\`\${host}[\${creation date}]\`, so both invites share one asset id ` + + `and the backend refuses the overlap — nothing about the VISITORS ` + + `conflicts at all`, + ).toBe(201); + } finally { + for (const id of ids) await deleteBooking(staffApi, id); + for (const visitor of [...morning, ...afternoon]) { + await deleteGuest(staffApi, visitor.email); + await releaseVisitor(staffApi, visitor.email, window_from(), window_to()); + } + } + }); +}); diff --git a/e2e/README.md b/e2e/README.md index eee525b7be..7b71f97237 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -88,6 +88,14 @@ e2e/ shared engine, all apps invite-form.page.ts locators for the invite form your-bookings.page.ts locators for the schedule + details modal visitor.flows.ts multi-step UI flows (inviteVisitorViaUI) + room/ room-booking coverage, kept out of the files above + room.env.ts per-worker rooms + slot table + room.seed.ts creates the rooms as engine Systems (needs ADMIN) + room.api.ts room-booking helpers, sweeps, tryRoomBooking + room.settings.ts per-context app settings presets (use_bookings mode) + meeting-form.page.ts locators for the meeting form + room picker + schedule.page.ts extends visitor/your-bookings.page.ts for event cards + room.flows.ts multi-step UI flows (bookRoomViaUI) repro/ standalone reproducers for confirmed backend bugs preflight.ts "is the stack up?" — fails in 1s, not 90 preflight.setup.ts setup project — `local` depends on it, `mock` does not @@ -199,17 +207,22 @@ otherwise registering an OAuth app would require a token that requires an OAuth staff-api only dereferences tenant credentials when it instantiates a PlaceCalendar client, which happens on the calendar-backed routes and nowhere else. So a placeholder tenant unblocks the **entire PlaceOS-native booking surface** — -desks, lockers, parking, visitors — with zero external calls. Verified: +desks, lockers, parking, visitors and **rooms** — with zero external calls. Verified: ``` -GET /bookings?type=desk|locker|parking|visitor -> 200 (placeholder tenant) -GET /calendars, GET /events -> 500 (need real credentials) +GET /bookings?type=desk|locker|parking|visitor|room -> 200 (placeholder tenant) +GET /calendars, GET /events -> 500 (need real credentials) ``` -**Room/calendar events are the only surface that needs a real Microsoft/Google -tenant.** Those specs are therefore opt-in, must live under a separate project, and -must never be part of the PR gate — wiring real credentials in would make the suite -depend on an external service, which is the one thing it is designed not to do. +**The room CALENDAR path is the only surface that needs a real Microsoft/Google +tenant — the room itself is not.** With `app.events.use_bookings = true` the meeting +form saves an ordinary `room` booking through `/bookings`, which is how +`apps/workplace/e2e/local/room-*.spec.ts` runs locally (see +[`ROOM_E2E_HANDOVER.md`](ROOM_E2E_HANDOVER.md)). Anything genuinely calendar-backed +stays opt-in, must live under a separate project, and must never be part of the PR +gate — wiring real credentials in would make the suite depend on an external service, +which is the one thing it is designed not to do. A green room run says nothing about +the calendar path. ## CI diff --git a/e2e/ROOM_E2E_HANDOVER.md b/e2e/ROOM_E2E_HANDOVER.md new file mode 100644 index 0000000000..f2ecf2faa0 --- /dev/null +++ b/e2e/ROOM_E2E_HANDOVER.md @@ -0,0 +1,276 @@ +# Room booking e2e — handover + +Working notes for the room-booking coverage, built after the visitor work and in the +same shape. Written to be picked up cold. The coverage contract lives in +[`../E2E_USER_STORIES.md`](../E2E_USER_STORIES.md) (§1b, ROOM-01 … ROOM-14); harness +conventions in [`README.md`](README.md); the visitor equivalent of this file is +[`VISITOR_E2E_HANDOVER.md`](VISITOR_E2E_HANDOVER.md). + +**Status: 17 of 22 tests passing, 5 blocked (ROOM-B1 … ROOM-B5).** Ten spec files. +Nothing in the pre-existing desk suite was changed. The only edits to existing files are +in `e2e/support/visitor/your-bookings.page.ts`: the constructor argument became +`protected`, and the form that "Edit" lands on became an overridable hook — both so the +room schedule page can inherit the schedule rather than copy it. + +**2026-09-16 added seven tests** covering the scenarios this file used to list as "still +to create": editing the time, editing the room, the capacity check in both its modes, +favourites, and approval — plus catering, which turned out to be broken in this mode +(ROOM-B5, a new finding). The eighth, **checking in, is blocked by the stack itself** and +is written up below rather than left as an open to-do. + +## The one thing to understand before reading anything else + +`E2E_USER_STORIES.md` had rooms down as **out of scope (external)**, because the meeting +flow talks to Microsoft/Google through `/events` and `/calendars`, and both 500 on this +stack. + +That is only true in the **default** mode. With **`app.events.use_bookings = true`** the +same form saves an ordinary PlaceOS booking of type `room`, and availability comes from +the bookings list (`event-form.service.ts`, `book_internal`). No outbound call. Every +room spec runs in that mode. + +**Say this out loud in any review:** a green run proves the PlaceOS-native room path +works and says **nothing** about the calendar path. Nobody has yet answered whether real +customers run one, the other, or both — and that answer decides how much this coverage is +worth. It is an open question in the contract, not something a spec can settle. + +--- + +## How to run + +```bash +e2e/stack/up.sh # colima has to be running first +export E2E_BACKEND_URL=https://localhost:9443 +bun run e2e:typecheck # nothing else type-checks the specs +bunx playwright test --config apps/workplace/playwright.config.ts +``` + +Room specs only: + +```bash +bunx playwright test --config apps/workplace/playwright.config.ts \ + --project=local apps/workplace/e2e/local/room-*.spec.ts +``` + +The first room run **creates the rooms** (three engine Systems per worker) and the first +catering run creates the **menu**; both need the admin identity from `seed.ts` to be +present, and both are left in place between runs. + +Runs on 2026-09-16 after this work: the **full suite** at 4 workers gave 52 passed, +6 skipped, 1 failed — that one failure being the room-swap race described under *Traps*, +which is now waited on properly. With the fix, the **room files** ran twice back to back at +**18 passed, 5 skipped, 0 failed** (1.3 min, 1.2 min). Eighteen rather than seventeen +because the count includes the preflight check. + +--- + +## What was built + +### Support — `e2e/support/room/` + +Seven files, ~1,000 lines, sharing nothing with the desk or visitor support code. + +| File | Holds | +|---|---| +| `room.env.ts` | **three** rooms per worker (`roomFor(worker, variant)` — `main`, `alt`, `small`), one time slot per spec file (`ROOM_SLOTS`, and `ROOM_SLOTS_2` on `SECOND_DAY` for the later files), `slotFor(hour, daysAhead, minutes)` | +| `room.seed.ts` | creates the rooms as engine **Systems**, on demand, idempotent, cached per process. Needs ADMIN | +| `catering.seed.ts` | creates a catering menu — a hidden `_CATERING_` asset category, a `CATERING:` asset type, one asset on the building. Needs ADMIN, and the asset APIs are on the **engine** api | +| `room.api.ts` | create/sweep room bookings, plus `tryRoomBooking` — which returns the status instead of throwing, because the clash specs assert on `409` | +| `room.settings.ts` | `useSettings()`, `ROOM_BASE_SETTINGS`, `LIMITED_HOURS`, and `NO_APPROVAL` (deliberately **unused** — it triggers ROOM-B1) | +| `meeting-form.page.ts` | the meeting form: date, start time, duration, the room picker, attendees | +| `schedule.page.ts` | `RoomSchedulePage` — **extends** `visitor/your-bookings.page.ts`, overriding only what is room-shaped | +| `room.flows.ts` | `bookRoomViaUI` — form → confirm → success, returning the created booking | + +### Specs — `apps/workplace/e2e/local/` + +| File | Tests | State | +|---|---|---| +| `room-booking.spec.ts` | 3 | 2 passing (stored correctly; teardown really tears down); 1 **`fixme`** — zones, ROOM-B2 | +| `room-clash.spec.ts` | 2 | passing — same slot and partial overlap both refused `409` as a second user, with a control; and the room frees up after deletion | +| `room-scoping.spec.ts` | 2 | passing — another user cannot see or delete yours; control that you can see your own | +| `room-times.spec.ts` | 3 | 2 passing (day and start time stored as picked; limits control what is offered); 1 **`fixme`** — length, ROOM-B3 | +| `room-attendees.spec.ts` | 2 | passing — an attendee added is stored; one removed before sending is not invited | +| `room-cancel.spec.ts` | 2 | 1 **`fixme`** — cancelling from the app, ROOM-B4; 1 passing — declining the confirmation leaves it alone | +| `room-edit.spec.ts` | 2 | passing — a new start time is stored; a booking moved to another room is stored against it | +| `room-capacity.spec.ts` | 2 | passing — strict mode refuses before anything is sent (red-checked); the default only warns and still books | +| `room-favourites.spec.ts` | 1 | passing — a favourite is saved to the user's settings and the filter narrows to it | +| `room-approval.spec.ts` | 2 | 1 passing — the default stores the booking unapproved; 1 **`fixme`** — `no_approval`, ROOM-B1 | +| `room-catering.spec.ts` | 1 | **`fixme`** — the order is refused in this mode, ROOM-B5 | + +Two tests per file, matching every other spec file in the suite, except where a third is a +`fixme` placeholder for a specific bug (fold it into its sibling when the bug is fixed) or +where the subject is genuinely one thing (favourites, catering). + +**Four tests self-skip on a single worker** (clash ×2, scoping, attendees ×2 need a +genuinely different second user). Run with the default worker count. + +--- + +## Findings — four, all reproduced and minimised, none filed + +| ID | Finding | Evidence | +|---|---|---| +| **ROOM-B5** | **Catering cannot be ordered with a PlaceOS-native room booking.** The meeting saves (`201`); the catering order that follows is refused **422 `error linking booking to event` / `Could not find metadata for event ARRAY['1138']`**. Orders are linked to a calendar EVENT by `event_id`, and in `use_bookings` mode the id handed over is a *booking* id, so the lookup finds nothing. **The room booking is left behind undeleted and no order exists** — the user sees an error and reasonably believes nothing was booked, while the room is held and the food was never ordered. | `room-catering.spec.ts` (`fixme`). Booked through the app with a seeded menu; the 422 body and the surviving booking were both measured on 2026-09-16 | +| **ROOM-B4** | **Cancelling a room booking from the schedule does nothing.** Confirming fires `DELETE /api/staff/v1/events/` → **500**, and the booking is still live. `schedule.component.ts` deletes whatever it displays as an event (`item instanceof CalendarEvent ? removeEvent : removeBooking`), and a room booking is rebuilt into a `CalendarEvent` for display, so it takes the calendar path. The room stays held by a booking the user believes they cancelled. | `room-cancel.spec.ts` test 1 (`fixme`). Test 2 passes, which is what shows the menu, dialog and wiring are fine and the delete is not | +| **ROOM-B3** | **The meeting length picked is not the length booked.** Ask for 90: field reads "1 hour 30 minutes", confirmation shows 6:00–7:30 PM, request sends `booking_end` 7:00 PM while `extension_data.event_end` says 7:30. `newBookingFromCalendarEvent` takes `event.duration`, still the default. The user sees one range; the room is held for another. | `room-times.spec.ts` test 3 (`fixme`). Not timing and not ordering — both orders tried, and the field still reads 90 four seconds later, immediately before sending | +| **ROOM-B2** | A room booked through the app is stored with **`zones: []`**, where desk and visitor bookings carry org/building/level. Anything scoping by zone cannot see it, and it is what walks the request into ROOM-B1. | `room-booking.spec.ts` test 3 (`fixme`) | +| **ROOM-B1** | A non-admin sending `approved: true` **without** zones gets **500 `syntax error at or near ")" (PQ::PQError)`**. With zones it is correctly refused `403`; an admin gets `201`; desks do it too, so it is not room-specific — the approval permission check dies instead of refusing when it has no zones. Compounds with ROOM-B2, which is why a room booking made through the form reaches it: that booking carries no zones at all. | `room-approval.spec.ts` test 2 (`fixme`). Re-measured through the app on 2026-09-16 by dropping the `fixme`: the booking POST is a `500` with an empty body | + +None of these are test defects. ROOM-B3 and ROOM-B4 are the two a user would actually be +hurt by — both hold a room against everybody else while showing the person who booked it +something different. + +--- + +## Decisions already taken (so they are not re-litigated) + +- **`use_bookings` mode, not the calendar.** The only mode that runs locally at all. The + cost is stated above and in `room.settings.ts`; it is not hidden in a green run. +- **No `seed.ts` change.** Rooms are the first resource that must genuinely be created, + and the seeding lives in `room.seed.ts` instead, so a room mistake cannot break the + desk specs. +- **The schedule page object is inherited, not copied.** The rule being followed: if two + areas need one element it stays in its original file and the other area inherits. Hence + the single `private` → `protected` change in `your-bookings.page.ts`. +- **Settings per browser context**, via `localStorage['PLACEOS.setting_overrides']`, the + same mechanism the visitor specs use — deliberately duplicated rather than imported, + because a shared file could break both areas. +- **Three rooms per worker, not one** (2026-09-16). `alt` exists so a booking can be moved + between rooms, and `small` has capacity 1 so a single guest is already over it. Capacity + is a property of the System, so it cannot be a per-test setting; the alternative was + adding eight attendees through the autocomplete to overflow a normal room, which is + slower and gives eight more ways to fail for unrelated reasons. +- **The catering menu is seeded, and kept to one item.** The subject is whether an order + reaches the backend, not the menu, so `catering.seed.ts` creates exactly one item. +- **One room per worker, one slot per spec file.** A room is exclusive like a desk, so two + specs on one worker booking the same hour would clash with each other rather than with + the thing under test. +- **The clash and scoping specs work through the API, not the form.** The picker hides + busy rooms, so through the UI "refused" and "never offered" are indistinguishable. + +--- + +## Next steps + +1. ~~`E2E_USER_STORIES.md` rows~~ — **done 2026-09-16.** §1b holds ROOM-01 … ROOM-23 and + the five findings; WP-E2E-15 is now **partial** rather than out of scope, split into + the native path (covered) and the calendar path (still external). +2. ~~The seven scenarios this file used to list~~ — **done 2026-09-16**, as seven tests: + ROOM-15 … ROOM-22. Two of them land as `fixme` on bugs (ROOM-21 on ROOM-B1, ROOM-22 on + the new ROOM-B5), and the eighth — check-in, ROOM-23 — is blocked by the stack; see + below. +3. **Push, and open the PR.** Still not done, and deliberately: the branch has never been + pushed. Two commits are local. +4. **File the five findings.** Needs a project and an owner. **ROOM-B5, ROOM-B4 and + ROOM-B3 first** — all three leave a room held while telling the user something else. +5. **Stale booking accumulation** — shared with the visitor work and bigger than either. + Cancelled bookings pile up per user and the schedule counts them against a 100-row + limit; past that, card-based specs fail for a reason that has nothing to do with the + app. Backend change or purge step, and the user's decision. +6. **What is left to write**, now that the original seven are done: equipment/asset + requests on a meeting (the other half of "catering and equipment", and a separate flow + from catering) · recurring bookings in `use_bookings` mode · multi-room meetings + (`multipleSpacesEnabled` changes the picker's confirm button and the form's shape) · + the picker's features/facilities filter, which has the same shape as favourites. + +## Checking in is blocked by the stack, not by effort + +Worth stating properly, because it looks like an oversight. The check-in control in +`event-details-modal.component.ts` is gated on a live websocket **binding**: + +```html + +``` + +The button renders only when `room_status()` is set and is not `'free'`, so it needs a +**`Bookings` driver module running on the room's System**. Measured on this stack: +`GET /drivers` returns one (`spec_helper`) and `GET /modules` returns one +(`PrivateHelper`). There is no `Bookings` driver to add, so no room can have that module +and the control can never appear — no selector, no setting and no seeding changes that. + +Unblocking it means building and running a real driver inside the e2e stack, which is a +stack change. Contrast VIS-11: visitor check-in is a plain staff-api call, which is why it +is covered and this is not. + +**Cannot be tested here at all:** real Outlook/Google invites, free/busy, attendee +availability · email of any kind · room panels and signage · recurring meetings on the +calendar path · checking in to a room (above). + +--- + +## Traps that cost time — do not re-derive + +- **`GET /systems` returns a BARE ARRAY**, not `{ results: [] }`. Reading `.results` + gives `undefined`, the fallback returns `[]`, and it looks exactly like "the search + index has not caught up". Half an hour went on that theory. +- **The room picker's confirm button is `toggle-space` ("Select Item").** `space-return` + only renders in multi-select mode; clicking it waits the full timeout against an + element that was never in the DOM. +- **`meeting-flow-success` has no box.** Its only child is `absolute inset-0`, so + Playwright reports the host as not visible while the screen is plainly up. Wait on + `a[name="meeting-created-continue"]` inside it. +- **Set the date ONCE, before the fill/retry loop.** Changing the date resets the duration + asynchronously; a repeated date click lands after the duration pick and silently + reverts it. +- **Time and duration reach the model ~2 seconds late**, and the confirm modal snapshots + the model when it opens. Set them last, then wait. +- **Limits are `app.events.*`, not `app.bookings.*`.** The meeting form reads the event + settings; setting the bookings keys changes nothing and the form happily offers an + 8-hour meeting. +- **A room booking renders as an EVENT card** — `a[name="view-event-details"]`, + `?event=`, `event-details-modal` — not the booking card the desk and visitor specs + use. This is also the root of ROOM-B4. +- **The booking's own `title` is always the literal "Room Booking".** The real meeting + name is in `extension_data.title`, and the schedule rebuilds the event from there, so it + displays correctly. Asserting on `stored.title` fails against a perfectly correct + booking. +- **Attendees live in `extension_data.attendees`**, not in a column, and the list includes + the room itself as a resource. +- **The flow has three screens** — form, confirm, success — and nothing is sent until the + second. A spec that clicks Confirm once and then looks for a booking finds none and + blames the backend. +- **Rooms ARE exclusive, like desks**: same slot `409`, partial overlap `409`, clear slot + `201`. Unlike visitors, who can be double-booked (VIS-B1). Assert `409` specifically — + a REG-09 `500` satisfies `>= 400` while proving nothing. + +### Added 2026-09-16, and every one of these cost a failing run first + +- **The picker only offers rooms for 4+ people until you say otherwise.** The form has a + "minimum people" radio (`space-list-field`, values 1 / 4 / 10) that starts at **4**, and + the picker filters to rooms at least that big. A capacity-1 room is simply absent, and + the picker's own error says the room does not exist — which sent the first capacity + attempt looking for a seeding bug. `MeetingForm.setRoomSize(1)` is the fix. +- **A capacity of 0 escapes that filter entirely.** `Space` maps a falsy capacity to + **-1**, and the filter lets anything negative through — so a capacity-0 room is always + offered and is displayed as "Capacity 2". Do not reach for 0 to make a room "too small": + it makes every capacity comparison degenerate. +- **An API-made booking cannot be edited through the form.** The room lives in + `extension_data`, which is what `newCalendarEventFromBooking` spreads over the event — so + a booking created by `createRoomBookingViaApi` opens the edit form with **no room on it** + and looks exactly like the app losing the room. `room-edit.spec.ts` books through the UI + for that reason, and says so. +- **Assets, asset types and asset categories are on the ENGINE api.** + `/api/staff/v1/assets` is a `404` here. That matters for anything catering-shaped, since + a catering menu is made of assets. +- **An asset type needs a `brand`.** Without it, `POST /asset_types` is a `422` + `"brand: should not be nil"`. The app's own menu editor always sends one, so this only + bites a seeder. +- **"Add catering" is disabled until a room is chosen**, because catering is delivered to a + room. A spec that orders food before picking a room waits out the full timeout on a + button that was never going to enable. +- **A favourite is a USER setting, not a room property.** It goes into the user's + `settings` metadata as `favourite_spaces`, debounced ~2.4 seconds, written as the WHOLE + blob — which is shared with the visitor specs' saved invitee list. Read-modify-write, or + you will wipe their data as collateral. `room.api.ts` does. +- **The chosen ROOM reaches the model late too, not just the times.** Caught by a full + parallel run, not by the spec in isolation: `room-edit`'s "move to another room" picked + the new room, the confirm screen snapshotted the model before the change landed, and the + `PATCH` carried the ORIGINAL room id — so the test failed saying the room had not + changed, which is exactly what it would say if the app were broken. Worth knowing + because **the room count cannot see this**: it is 1 before and 1 after, so only the NAME + shows the swap. `MeetingForm.chosenRoomNames()` exists for that, and the spec waits on it + before confirming. +- **One flake seen, once**: `room-clash` "the room frees up once the booking is deleted" + failed on the first run against a freshly started stack and passed on every run since, + including in isolation. The cause was not captured, because that run used a reporter that + swallowed it. If it reappears, run with the default reporter and keep the HTML report. diff --git a/e2e/support/bookings/bookings.api.ts b/e2e/support/bookings/bookings.api.ts new file mode 100644 index 0000000000..a4996c2c89 --- /dev/null +++ b/e2e/support/bookings/bookings.api.ts @@ -0,0 +1,228 @@ +/** + * Backend helpers for the YOUR BOOKINGS page specs. + * + * ## Why this creates its own bookings instead of importing the other areas + * + * This page lists every booking type at once, so its specs need a desk booking + * and a visitor booking side by side. The visitor helper that could do the + * second one lives in `visitor/visitor.api.ts`, and importing it here would tie + * two areas together — exactly what the separation between support folders is + * for. A booking is a `POST /bookings` with a type on it, so the honest cost of + * keeping them apart is the thirty lines below. + * + * What this does NOT do is duplicate the *behaviour* the other areas own: it + * makes no assertions about how a visitor invite or a room booking is stored. + * Those belong to `visitor-*.spec.ts` and `room-*.spec.ts`. Here a booking is + * only ever a row that ought to show up on a page. + */ +import { APIRequestContext } from '@playwright/test'; +import { Booking, STAFF_API, currentUser, releaseAsset, zonesWithTag } from '../api'; + +export interface ScheduleBooking extends Booking { + checked_in?: boolean; + extension_data?: Record; +} + +/** Every zone the seeded org has, which is what a real booking carries. */ +async function allZones(api: APIRequestContext): Promise { + const zones = await Promise.all( + ['org', 'building', 'level'].map((tag) => zonesWithTag(api, tag)), + ); + return zones.flat().map((z) => z.id); +} + +/** + * Create a booking of any type, as the signed-in user. + * + * `asset_id` is what the type means by an asset: a desk id for `desk`, the + * visitor's email for `visitor`, a parking space id for `parking`. The page + * under test does not care which — it cares that the card renders on the right + * day under the right filter. + */ +/** + * Is this failure the known concurrent-write defect rather than a bad request? + * + * REG-09 / PPT-2642: a burst of booking writes poisons a staff-api connection, + * and the request that lands on it fails in a way that has nothing to do with + * what was sent. The visitor area found the two 400/500 shapes; a THIRD turned + * up here, under a fifteen-test parallel run: + * + * 422 {"error":"error validating booking data","failures":[]} + * + * An empty `failures` list is the tell. A genuine validation error names the + * field it is unhappy about; this one has nothing to say, because nothing was + * actually wrong with the payload — the same payload succeeds on retry. + * + * Delete this whole retry once the stack runs a staff-api built on + * pg-orm >= 2.2.4, and do not read a green run as evidence the image is fixed. + */ +function isConcurrentWriteFailure(status: number, body: string): boolean { + if (status === 400 && /Expected BeginObject but was EOF/.test(body)) return true; + if (status === 500 && /could not serialize access/.test(body)) return true; + if (status === 422 && /"failures"\s*:\s*\[\s*\]/.test(body)) return true; + return false; +} + +/** + * Did this booking actually PERSIST? + * + * ## A 201 is not proof the row exists + * + * Measured under a fifteen-test parallel run: `POST /bookings` returned **201 + * with an id**, and `GET /bookings/` then returned **404 "could not + * find booking with id"**. The row was never committed. Everything downstream + * then looks like a different bug — the card never appears on the schedule, and + * the listing says the booking is not live — which is how this cost two + * debugging sessions before the id was read back. + * + * This is REG-09 / PPT-2642 doing more damage than its row currently describes: + * crystal-db clears a connection's transaction flag only after the COMMIT it + * issues, so a COMMIT that fails leaves the connection dirty. The response is + * already written by then, hence a 201 for nothing. + * + * So every create here reads the booking back before returning it, and treats a + * missing row as a failed attempt. Delete this, like the retry around it, once + * the stack runs a staff-api built on pg-orm >= 2.2.4. + */ +async function persisted(api: APIRequestContext, id: number): Promise { + const res = await api.get(`${STAFF_API}/bookings/${id}`); + return res.ok(); +} + +export async function createBookingViaApi( + api: APIRequestContext, + options: { + type: string; + asset_id: string; + asset_name?: string; + title: string; + start: number; + end: number; + /** Only visitor bookings need these, and only to look realistic. */ + attendees?: { name?: string; email: string }[]; + }, +): Promise { + const me = await currentUser(api); + const data = { + booking_type: options.type, + asset_id: options.asset_id, + asset_name: options.asset_name ?? options.asset_id, + booking_start: options.start, + booking_end: options.end, + timezone: 'Etc/UTC', + user_email: me.email, + user_id: me.id, + user_name: me.name, + title: options.title, + zones: await allZones(api), + ...(options.attendees ? { attendees: options.attendees } : {}), + }; + + let last = ''; + for (let attempt = 1; attempt <= 4; attempt++) { + const res = await api.post(`${STAFF_API}/bookings`, { data }); + const body = await res.text(); + if (res.ok()) { + const created = JSON.parse(body); + if (await persisted(api, created.id)) return created; + last = `HTTP 201 for booking ${created.id}, which then 404s — see persisted()`; + console.warn(` ! phantom 201 on attempt ${attempt}/4 — ${last}`); + await new Promise((r) => setTimeout(r, 400 * attempt)); + continue; + } + + last = `HTTP ${res.status()} ${body.slice(0, 200)}`; + if (!isConcurrentWriteFailure(res.status(), body)) { + throw new Error( + `create ${options.type} booking failed: ${last}. ` + + `A 409 means the asset is already held for that window — check the ` + + `spec swept its own day first, and that two spec files are not ` + + `sharing a day (see SCHEDULE_DAYS).`, + ); + } + console.warn( + ` ! concurrent-write failure on attempt ${attempt}/4, retrying — ${last}`, + ); + // A short pause lets the poisoned connection cycle out of the pool. + await new Promise((r) => setTimeout(r, 400 * attempt)); + } + throw new Error( + `create ${options.type} booking still failing after 4 attempts. If every ` + + `attempt looks like REG-09, the stack is running a staff-api older than ` + + `pg-orm 2.2.4. Last: ${last}`, + ); +} + +/** Clear every live booking of a type against an asset in a window. */ +export async function releaseFor( + api: APIRequestContext, + type: string, + asset_id: string, + from: number, + to: number, +): Promise { + return releaseAsset(api, type, asset_id, from, to); +} + +/** + * Is this booking still live — present, and not soft-deleted? + * + * The page removes a cancelled card optimistically, so "the card went away" is + * not evidence of anything. Every cancel assertion in these specs comes through + * here instead. + */ +export async function isLive( + api: APIRequestContext, + type: string, + id: number, + from: number, + to: number, +): Promise { + const res = await api.get(`${STAFF_API}/bookings`, { + params: { + type, + period_start: String(from), + period_end: String(to), + include_deleted: 'true', + }, + }); + if (!res.ok()) { + throw new Error(`GET /bookings failed: HTTP ${res.status()} ${await res.text()}`); + } + const body = await res.json(); + const list: ScheduleBooking[] = Array.isArray(body) ? body : (body.results ?? []); + return list.some((b) => Number(b.id) === id && !b.deleted); +} + +/** A booking read straight from the backend, by id. */ +export async function readBooking( + api: APIRequestContext, + id: number, +): Promise { + const res = await api.get(`${STAFF_API}/bookings/${id}`); + if (!res.ok()) { + throw new Error(`GET /bookings/${id} failed: HTTP ${res.status()} ${await res.text()}`); + } + return res.json(); +} + +/** Best-effort guest removal, for the visitor bookings these specs seed. */ +export async function deleteGuestByEmail( + api: APIRequestContext, + email: string, +): Promise { + try { + const res = await api.get(`${STAFF_API}/guests`, { + params: { q: email }, + }); + if (!res.ok()) return; + const body = await res.json(); + const list = Array.isArray(body) ? body : (body.results ?? []); + for (const guest of list) { + if (`${guest.email}`.toLowerCase() !== email.toLowerCase()) continue; + await api.delete(`${STAFF_API}/guests/${guest.id ?? guest.email}`); + } + } catch { + /* teardown must never mask the real result */ + } +} diff --git a/e2e/support/bookings/bookings.env.ts b/e2e/support/bookings/bookings.env.ts new file mode 100644 index 0000000000..02105877ee --- /dev/null +++ b/e2e/support/bookings/bookings.env.ts @@ -0,0 +1,115 @@ +/** + * Identities and time slots for the YOUR BOOKINGS page specs. + * + * Kept out of `e2e/support/{env,api,flows}.ts`, out of `visitor/` and out of + * `room/` on purpose — the same rule the visitor and room work followed. The + * desk specs depend on the shared files and the other two areas depend on their + * own; nothing schedule-shaped should be able to break any of them. + * + * ## What this page is, and why its slots look different + * + * `/your-bookings` does not create anything. It LISTS what other flows created, + * so these specs seed a booking through the API and then assert on the page. The + * risk is therefore not contention over an asset, it is contention over a DAY: + * the schedule shows one day at a time, and two specs seeding the same day for + * the same worker would see each other's cards and miscount. + * + * So every spec file owns a **day**, not an hour. They sit five or more days + * out, clear of: + * + * - the desk specs, which book ALL DAY TODAY and sweep ±2 days; + * - the room specs, which use +3 and +4; + * - anything a person booked by hand while poking at the stack. + * + * The check-in spec is the one exception and has to be near the clock — a + * check-in is only offered while the booking is about to start or running. + */ +import { staffEmail } from '../env'; + +/** One day per spec file, as an offset from today. */ +export const SCHEDULE_DAYS = { + /** bookings-list.spec.ts */ + list: 5, + /** bookings-filters.spec.ts */ + filters: 6, + /** bookings-cancel.spec.ts, test 1 */ + cancel: 7, + /** + * bookings-cancel.spec.ts, test 2. + * + * A day of its own rather than another hour on day 7. The sweep that makes a + * spec recover from its own past failures clears a WHOLE DAY, so two tests + * sharing a day can clear each other — and with `fullyParallel` there is no + * ordering guarantee to lean on. Seen once: the second test's pre-sweep + * removed the first test's booking, and the failure read as "the booking was + * not live before anyone pressed Cancel", which is true and points nowhere. + */ + cancel_declined: 10, + /** bookings-edit.spec.ts */ + edit: 8, + /** bookings-scoping.spec.ts */ + scoping: 9, + /** bookings-past.spec.ts — deliberately BEHIND today, so the booking is over */ + past: -2, + /** bookings-empty.spec.ts — a day nothing is ever seeded on */ + empty: 13, +} as const; + +/** + * A booking window on a given day, in unix seconds, plus the local ms timestamp + * the schedule's calendar needs. + * + * Fixed hours on a fixed day rather than "now plus an hour": these specs assert + * which day a card appears on, and a window that moves with the clock cannot be + * compared with anything. Local time, not UTC, because the schedule groups cards + * by the BROWSER's day and the browser shares this process's clock. + */ +/** + * The local day a slot falls on, as a unix-second window. + * + * Sweeps and listings use this rather than "the slot plus or minus a day": + * a window that spills into the neighbouring days reaches into whatever another + * spec file is doing there. + */ +export function dayBoundsOn(dayOffset: number) { + const start = new Date(); + start.setDate(start.getDate() + dayOffset); + start.setHours(0, 0, 0, 0); + const end = new Date(start); + end.setHours(23, 59, 59, 0); + return { + from: Math.floor(start.valueOf() / 1000), + to: Math.floor(end.valueOf() / 1000), + }; +} + +export function slotOn(dayOffset: number, hour: number, minutes = 60) { + const start = new Date(); + start.setDate(start.getDate() + dayOffset); + start.setHours(hour, 0, 0, 0); + return { + start: Math.floor(start.valueOf() / 1000), + end: Math.floor(start.valueOf() / 1000) + minutes * 60, + date_ms: start.valueOf(), + }; +} + +/** + * A visitor address owned by THESE specs. + * + * Deliberately a different prefix from the visitor specs' own addresses + * (`e2e-visitor-*`): their sweeps delete by address, so sharing one would let + * two areas delete each other's data. `tag` separates the spec files from each + * other in the same way. + */ +export function scheduleVisitorFor(workerIndex: number, tag: string) { + return { + email: `yb-visitor-${workerIndex}-${tag}@example.com`, + name: `YB Visitor ${workerIndex} ${tag}`, + }; +} + +/** The colleague these specs use as "somebody else". */ +export function otherStaffEmail(workerIndex: number, workers: number): string { + return staffEmail((workerIndex + 1) % workers); +} diff --git a/e2e/support/bookings/bookings.settings.ts b/e2e/support/bookings/bookings.settings.ts new file mode 100644 index 0000000000..0f77356224 --- /dev/null +++ b/e2e/support/bookings/bookings.settings.ts @@ -0,0 +1,49 @@ +/** + * Per-test app settings for the YOUR BOOKINGS specs. + * + * Deliberately a copy of the same twenty-line helper the visitor and room areas + * each keep, rather than an import from either: the three areas are kept apart + * on purpose, and a shared settings file is exactly the sort of thing that + * breaks all three at once. The duplication is the price of that, and it is + * cheap. + * + * Two rules the app imposes on overrides: + * - keys MUST start with `app.`, or they are dropped on load and the default + * quietly stays in place; + * - matching is EXACT — `app.visitors` does not satisfy a read of + * `app.visitors.allow_editing`. + * + * Applied per BROWSER CONTEXT through the app's debug override rather than + * seeded onto a zone, so specs needing different settings still run in parallel + * instead of serialising the suite on shared state. + */ +import { Page } from '@playwright/test'; + +export async function useSettings( + page: Page, + overrides: Record, +): Promise { + const bad = Object.keys(overrides).filter((k) => !k.startsWith('app.')); + if (bad.length) { + throw new Error( + `setting override keys must start with "app." — the app drops the rest on ` + + `load, leaving the default quietly in place. Got: ${bad.join(', ')}`, + ); + } + await page.addInitScript((value) => { + localStorage.setItem('PLACEOS.setting_overrides', JSON.stringify(value)); + }, overrides); +} + +/** + * Let a visitor invite be edited. + * + * Off by default, and its absence is silent: `can_edit` is false for a visitor + * booking, so the schedule's overflow menu simply has no Edit item. A spec that + * forgot this reads as "the Edit action is missing", which is true and sounds + * like a bug. Measured here first, which is why the page object's error message + * now lists what the menu did contain. + */ +export const ALLOW_VISITOR_EDITING = { + 'app.visitors.allow_editing': true, +}; diff --git a/e2e/support/bookings/schedule.page.ts b/e2e/support/bookings/schedule.page.ts new file mode 100644 index 0000000000..142625c623 --- /dev/null +++ b/e2e/support/bookings/schedule.page.ts @@ -0,0 +1,270 @@ +/** + * The YOUR BOOKINGS page, as its own page object. + * + * ## Why this inherits rather than copies, and why it is not the owner + * + * The schedule was first built for the visitor specs, so + * `visitor/your-bookings.page.ts` owns it: opening the list, walking the sidebar + * calendar, the cards, the details modal, the overflow menu and the confirm + * dialogs all live there. The rule this suite follows is that a shared element + * stays in the file that introduced it and other areas inherit — so the room + * specs extend it, and so does this. + * + * What is ADDED here is everything the page's own specs need and no other area + * did: the five type filters, the filter panel, the empty state, and reading + * which cards are on screen with their type. + * + * If the visitor area is ever retired, this is where the schedule should move + * to — it is the page's own folder. Until then, moving it would rewrite specs + * that are already green for no behavioural gain. + */ +import { Locator, expect } from '@playwright/test'; +import { YourBookingsPage } from '../visitor/your-bookings.page'; + +/** The five type filters the schedule offers, by the name in their markup. */ +export type ScheduleType = 'event' | 'desk' | 'parking' | 'visitor' | 'locker'; + +export class SchedulePage extends YourBookingsPage { + /** + * A type filter toggle — the VISIBLE one. + * + * Every filter is in the DOM more than once: the sidebar renders a set for + * wide screens and the filter card renders another for narrow ones, and the + * locker filter appears twice within the card alone. Only one copy is on + * screen at a time. + * + * `:visible` is load-bearing, not tidiness. A plain `.first()` picks + * whichever copy comes first in the DOM, which is the HIDDEN one for parking + * and lockers — and clicking a hidden element does not fail fast, it waits. + * Measured: a spec sat on that click for **16 minutes** until the test + * timeout, then reported "the filter did not turn off", which is true and + * says nothing about why. + */ + filterToggle(type: ScheduleType): Locator { + return this.page + .locator(`button[name="schedule-toggle-${type}-filter"]:visible`) + .first(); + } + + /** Is a type currently being shown? Read from the checkbox, not the class. */ + async isShown(type: ScheduleType): Promise { + const checkbox = this.filterToggle(type).locator('input[type="checkbox"]'); + if (!(await checkbox.count())) return false; + return checkbox.isChecked().catch(() => false); + } + + /** + * Make sure a type is shown, or hidden. + * + * ENSURE, never blindly toggle. Clicking a filter that is already in the + * wanted state inverts it, and the list then looks like the booking is + * missing — the single most misleading failure this page can produce. + */ + async setShown(type: ScheduleType, shown: boolean): Promise { + const toggle = this.filterToggle(type); + if (!(await toggle.count())) { + const in_dom = await this.page + .locator(`button[name="schedule-toggle-${type}-filter"]`) + .count(); + throw new Error( + in_dom + ? `the ${type} filter is in the DOM ${in_dom} time(s) but none of ` + + `them is visible, so it cannot be clicked. The schedule renders ` + + `one set of filters for wide screens and another for narrow ones.` + : `the schedule has no ${type} filter at all. It is only rendered ` + + `when that booking type is enabled for the org ` + + `(schedule-state.service.ts: \`_canLoadBookingType\`), so this is ` + + `a configuration difference, not a selector problem.`, + ); + } + // CONVERGE, do not click once and hope. The schedule re-renders its + // filters while its per-type requests come back — most visibly right + // after a reload — and a click that lands mid-render is undone by it. + // Seen exactly once, on the locker filter after a reload: the click + // registered and the state was back on a moment later. + // + // Clicking again is safe here BECAUSE the click is guarded by the state + // check: a filter already in the wanted state is never touched. + await expect(async () => { + if ((await this.isShown(type)) !== shown) { + // An explicit timeout, so a control that cannot be clicked says + // so in seconds rather than sitting until the test times out. + await toggle.click({ timeout: 10_000 }); + } + expect( + await this.isShown(type), + `the ${type} filter did not stay turned ${shown ? 'on' : 'off'}`, + ).toBe(shown); + }).toPass({ timeout: 30_000 }); + } + + /** + * Show only these types, and nothing else. + * + * A type whose filter is not on screen is skipped rather than fatal: which + * filters exist depends on what the org has enabled, and a spec about desks + * should not fail because lockers are switched off for this deployment. + */ + async showOnly(types: ScheduleType[]): Promise { + const all: ScheduleType[] = ['event', 'desk', 'parking', 'visitor', 'locker']; + for (const type of all) { + if (!(await this.filterToggle(type).count())) continue; + await this.setShown(type, types.includes(type)); + } + } + + /** The "remove this filter" chip for a type, shown above the list. */ + filterChip(type: ScheduleType): Locator { + return this.page + .locator(`button[name="schedule-remove-${type}-filter"]`) + .first(); + } + + /** Every booking card on screen, whatever its type. */ + get cards(): Locator { + return this.page.locator('a[name="view-booking-details"]'); + } + + /** Every room/event card on screen — a different card component entirely. */ + get eventCards(): Locator { + return this.page.locator('a[name="view-event-details"]'); + } + + /** + * How many cards of any kind are rendered. + * + * Both kinds are counted on purpose: a filter bug that leaves ROOM cards on + * screen while hiding desks would otherwise read as success. + */ + async cardCount(): Promise { + return (await this.cards.count()) + (await this.eventCards.count()); + } + + /** + * Wait for the list to settle on a count. + * + * The schedule loads each booking type in its own request, so the card count + * climbs for a moment after the page is otherwise ready. Asserting straight + * away is how a spec ends up measuring the loading state. + */ + async waitForCards(expected: number, timeout = 20_000): Promise { + await expect(async () => { + expect(await this.cardCount()).toBe(expected); + }).toPass({ timeout }); + } + + /** + * The empty state shown for a day with nothing on it. + * + * Matched on its ILLUSTRATION, because that is the only stable handle: the + * block carries no attribute and no id, and its message is translated + * (`APP.WORKPLACE.SCHEDULE_EMPTY`). An image path is not app copy, so this + * does not break the no-visible-text rule — but it does mean a redesign that + * renames the asset will fail this locator rather than silently pass. + */ + get emptyState(): Locator { + return this.page.locator('img[src*="no-events"]').first(); + } + + /** + * Which form "Edit" is expected to land on — settable, because on THIS page + * it depends on the booking, not on the page. + * + * Every other area knows its answer up front: a visitor invite always opens + * the visitor form, a room booking always opens the meeting form. The + * schedule lists every type at once and routes per booking + * (`schedule.component.ts`: `edit` for events, `editBooking` for the rest), + * so the expectation belongs to the test rather than to the class. Setting it + * lets the inherited `startEdit` be reused as-is instead of copied. + */ + private _edit_form = 'invite-visitor-form'; + + expectEditForm(selector: string): void { + this._edit_form = selector; + } + + protected override get editForm(): Locator { + return this.page.locator(this._edit_form); + } + + protected override get editFormHint(): string { + return ( + 'The schedule offers Edit per booking type and routes accordingly: desks, ' + + 'parking, lockers and visitors go to /book/ via `editBooking`, and ' + + 'room bookings go to the meeting form via `edit`. A booking that has ' + + 'finished, or one somebody else made, may offer no Edit item at all. ' + + `This test was expecting "${this._edit_form}".` + ); + } + + /** + * Put a known booking on screen: right filters, right day, card present. + * + * ## Why this reloads rather than just waiting + * + * The schedule's list can be STALE. Under a full parallel run — fifteen + * tests hammering this page at once — a page opened moments after a booking + * was created has been seen listing a set of cards that did not include it, + * while the booking was live on the backend and rendered perfectly on the + * next load. Measured twice, in the cancel specs, with card sets made up + * entirely of older bookings. + * + * Waiting longer does not help, because nothing refetches: the list is built + * once per day change. So this re-applies the view and reloads if the card is + * not there, which is what a person would do. + * + * It is a WORKAROUND, and it is deliberately confined to this helper rather + * than hidden inside `card()`, so it is obvious in the specs that use it and + * easy to delete. If the staleness turns out to be a real defect rather than + * a side effect of this suite's own load, this is the thing to point at. + */ + async showBooking( + bookingId: number, + date_ms: number, + types: ScheduleType[], + /** + * Optional: what the BACKEND says about this booking right now. + * + * Worth the extra argument. "The card is not on screen" has two very + * different causes — the page cannot see a booking that exists, or the + * booking no longer exists because something deleted it — and they look + * identical from the DOM. A spec that passes this gets the answer in the + * failure message instead of a debugging session. + */ + probe?: () => Promise, + ): Promise { + let attempt = 0; + await expect(async () => { + attempt += 1; + if (attempt > 1) { + await this.page.reload(); + await this.waitForLoaded(); + } + await this.showOnly(types); + await this.showDayOf(date_ms); + await this.waitForLoaded(); + const rendered = await this.renderedBookingIds(); + if (!rendered.includes(bookingId)) { + const backend = probe ? await probe().catch((e) => `probe failed: ${e}`) : 'not probed'; + expect( + rendered, + `booking ${bookingId} is not on screen (attempt ${attempt}). ` + + `Rendered: [${rendered.join(', ')}]. Backend says: ${backend}`, + ).toContain(bookingId); + } + }).toPass({ timeout: 90_000 }); + } + + /** The loading bar the schedule shows while its per-type requests are out. */ + get loadingBar(): Locator { + return this.page.locator('mat-progress-bar'); + } + + /** Wait for the schedule to stop loading, so a count is worth reading. */ + async waitForLoaded(timeout = 30_000): Promise { + await expect(this.loadingBar, 'the schedule never stopped loading').toHaveCount( + 0, + { timeout }, + ); + } +} diff --git a/e2e/support/desk/desk-form.page.ts b/e2e/support/desk/desk-form.page.ts new file mode 100644 index 0000000000..6c15fbcee3 --- /dev/null +++ b/e2e/support/desk/desk-form.page.ts @@ -0,0 +1,455 @@ +/** + * Page object for `/book/desk/form` — the desk booking flow. + * + * SELECTOR POLICY, the same one the rest of the suite settled on: nothing here + * depends on visible text, because every label is translated. The exceptions are + * called out where they occur. + * + * ## What the dev's `bookDeskViaUI` already does, and why this exists anyway + * + * `e2e/support/flows.ts` books a desk end to end and is left untouched: it is + * what the original desk specs use. It also fixes two choices — All Day on, + * Require locker off — which is right for "prove a desk can be booked" and wrong + * for everything added here, where the TIMES and the ALL-DAY flag are the + * subject rather than a means to an end. + * + * So this is a page object rather than a flow: it exposes the fields and lets + * each spec drive them. Anything about the form that was learned the hard way is + * recorded here. + * + * ## The form is rebuilt while it initialises (REG-10 / PPT-2643) + * + * When async init completes the form is rebuilt and defaults are restored — + * title back to "Booking", All Day back off, Require locker back ON. It is a + * RACE, not a step: on a warm run it lands before anything is typed. Every + * setter here is therefore used inside a converging block by its callers, the + * same workaround `bookDeskViaUI` uses, and for the same reason it means these + * specs cannot DETECT that bug (REG-10 has its own row saying so). + */ +import { Locator, Page, expect } from '@playwright/test'; + +export class DeskForm { + constructor(private readonly page: Page) {} + + get root(): Locator { + return this.page.locator('desk-flow-form'); + } + + /** The title input. `name$=".title"` because the name is form-scoped. */ + get title(): Locator { + return this.page.locator('input[name$=".title"]').first(); + } + + /** + * The date field's button. + * + * NOT `#date button`: that id belongs to the MEETING form's own markup. The + * desk form uses the shared `a-date-field` component, which renders a bare + * button with no id and no name — so the field element is the handle and the + * button is its only child of that kind. Measured; the borrowed meeting + * selector simply timed out against an element that does not exist here. + */ + get dateButton(): Locator { + return this.root.locator('a-date-field button, date-field button').first(); + } + + get datePicker(): Locator { + return this.page.locator('.cdk-overlay-container date-calendar'); + } + + /** + * Scoped to the FORM, not the page. + * + * The desk picker modal carries its own date and time fields, so an + * unscoped `button[time-field]` can match the modal's copy instead — and + * then the click changes nothing about what gets booked. + */ + get startTimeTrigger(): Locator { + return this.root.locator('button[time-field]').first(); + } + + get durationTrigger(): Locator { + return this.root.locator('button[duration-field]').first(); + } + + /** The All Day checkbox — matched through its label's text, see below. */ + get allDay(): Locator { + return this.page.locator( + 'mat-checkbox:has-text("All Day") input[type="checkbox"]', + ); + } + + /** + * The "Require locker" checkbox. + * + * It defaults to CHECKED and no lockers are seeded, which makes the form + * unsatisfiable with no error shown — the single most confusing thing about + * this form. Every spec has to clear it. + * + * Matched on label text, which breaks the no-visible-text rule and is + * inherited from the dev's `setCheckbox` helper: these two checkboxes carry + * no name or id at all. Changing that is an app change, not a test change. + */ + get requireLocker(): Locator { + return this.page.locator( + 'mat-checkbox:has-text("Require locker") input[type="checkbox"]', + ); + } + + /** The user field, rendered only when booking for others is enabled. */ + get userField(): Locator { + return this.root.locator('a-user-field, a-user-search-field').first(); + } + + /** + * Choose who the booking is for, from the host field's autocomplete. + * + * ## It has to be TYPED, not filled + * + * `fill()` sets the value and dispatches an input event, and this field + * ignores it: nothing searched, no panel, no requests to `/users` at all. + * Measured — the probe that found this saw zero network calls. The input + * carries a `keyboard` directive and the search is driven from real key + * events, so `pressSequentially` is the only thing that works. + * + * ## And the option shows the NAME + * + * Each option renders the user's name above their email, so an option for + * `e2e-staff-1@place.tech` reads "E2E Staff 1 (non-admin)". Matching is done + * on the email, which is in the option too, with punctuation stripped from + * both sides so a display style like "E2E Staff 1" cannot cause a miss. + * + * The choice only lands when an option is PICKED. Leaving the text in the + * box books the desk for whoever filled the form, silently — the same trap + * the visitor and attendee fields have. + */ + async chooseUser(email: string): Promise { + const flat = (value: string) => value.toLowerCase().replace(/[^a-z0-9]/g, ''); + const input = this.userField.locator('input').first(); + const options = this.page.locator('mat-option'); + + for (let attempt = 1; attempt <= 3; attempt++) { + await input.click(); + // CLEAR IT PROPERLY. The field arrives pre-filled with the signed-in + // user's name and `fill('')` does not stick — the model writes it + // straight back. Typing then APPENDS, and the search goes out as + // "e2e staff 0 (non-admin)e-staff-1", which finds nobody. Select-all + // and overwrite is what works. + await input.press('ControlOrMeta+a'); + await input.press('Backspace'); + await input.pressSequentially(email, { delay: 30 }); + const appeared = await options + .first() + .waitFor({ state: 'visible', timeout: 10_000 }) + .then(() => true) + .catch(() => false); + if (!appeared) continue; + + const texts = await options.allInnerTexts(); + const index = texts.findIndex((text) => flat(text).includes(flat(email))); + if (index < 0) continue; + await options.nth(index).click(); + return; + } + + const offered = await options.allInnerTexts(); + throw new Error( + `no autocomplete option for ${email} after 3 tries. The panel offered ` + + `${offered.length}: ${JSON.stringify( + offered.map((t) => t.replace(/\s+/g, ' ').trim()).slice(0, 8), + )}. The user has to exist in the directory — seed.ts creates one staff ` + + `user per worker — and the field only searches on real key events, so ` + + `check the typing reached it.`, + ); + } + + get addDeskButton(): Locator { + return this.page.locator('button[name="add-desk"]'); + } + + /** The desks currently chosen on the form. */ + get chosenDesks(): Locator { + return this.root.locator('[name="remove-desk"], button[name="remove-desk"]'); + } + + get confirmViewButton(): Locator { + return this.page.locator('button[name="open-desk-confirm"]'); + } + + /** The final confirm, which lives in the overlay rather than in a dialog. */ + get confirmButton(): Locator { + return this.page.locator('.cdk-overlay-container button[name="confirm-desk"]'); + } + + async open(): Promise { + await this.page.goto('/#/book/desk/form'); + await expect( + this.root, + 'the desk form never rendered — is the `desks` feature enabled?', + ).toBeVisible({ timeout: 30_000 }); + await expect( + this.title, + 'the desk form rendered without its title field', + ).toBeVisible({ timeout: 30_000 }); + } + + /** Set a checkbox to a state, never blindly toggling it. */ + async setChecked(box: Locator, want: boolean): Promise { + if (!(await box.count())) return; + if ((await box.isChecked()) === want) return; + // `force`, because Material hides the real input behind its own markup. + await box.click({ force: true, timeout: 10_000 }); + } + + /** + * Pick a desk by name from the picker. + * + * Three steps: `add-desk` opens it, `select-desk` chooses a row, and + * `toggle-desk` confirms — the last is only enabled once a row is selected. + * The desk is matched on its NAME, which is test data we seeded + * (`E2E Desk 0`), not app copy. + */ + async chooseDesk(name: string): Promise { + await this.addDeskButton.click(); + const option = this.page + .locator('button[name="select-desk"]') + .filter({ hasText: name }) + .first(); + const found = await option + .waitFor({ state: 'visible', timeout: 20_000 }) + .then(() => true) + .catch(() => false); + if (!found) { + // Say what IS offered. "Not found" cannot tell apart a desk that was + // never seeded, one on the wrong zone, and one the picker is hiding + // because it is already booked at the chosen time. + const offered = await this.page + .locator('button[name="select-desk"]') + .allInnerTexts(); + throw new Error( + `no desk called "${name}" in the picker. It offers ${offered.length}: ` + + `${JSON.stringify(offered.map((t) => t.split('\n')[0].trim()))}. ` + + `Check the desk is seeded (seed.ts writes WORKERS + 1 of them into ` + + `the level zone's \`desks\` metadata), and that it is free at the ` + + `time chosen — a booked desk is simply absent.`, + ); + } + await option.click(); + const confirm = this.page.locator('button[name="toggle-desk"]'); + await expect( + confirm, + 'the desk picker has no confirm button', + ).toBeEnabled({ timeout: 10_000 }); + await confirm.click(); + } + + /** + * The names of the desks currently on the form. + * + * `chosenDesks` counts the remove buttons, which is enough to know a desk is + * chosen and NOT enough to know WHICH — and when a booking is moved from one + * desk to another the count never changes. Asserting on the name is the only + * way to see the swap land. + */ + async chosenDeskNames(): Promise { + const rows = this.root.locator('desk-list-field div[desk]'); + const texts = await rows.allInnerTexts(); + return texts.map((t) => t.split('\n')[0].trim()); + } + + /** + * Take the chosen desk off the form, so another can be picked. + * + * CONVERGING, because the form is rebuilt while it initialises and a click + * that lands mid-rebuild does nothing — seen under a full parallel run as + * "the desk was not removed from the form" on a click that plainly + * happened. Re-clicking is safe: the loop stops as soon as the row is gone. + */ + async removeDesk(): Promise { + const before = await this.chosenDesks.count(); + if (!before) return; + await expect(async () => { + if ((await this.chosenDesks.count()) >= before) { + await this.chosenDesks.first().click({ timeout: 10_000 }); + } + expect( + await this.chosenDesks.count(), + 'the desk was not removed from the form', + ).toBeLessThan(before); + }).toPass({ timeout: 30_000 }); + } + + /** + * Open the date picker, and keep trying until it is actually open. + * + * The picker is a `customTooltip` attached to the date button, which opens on + * click — so one click SHOULD be enough. It is not: the desk form is rebuilt + * when its async initialisation completes (REG-10), and a click that lands on + * the old element does nothing at all, silently. Measured: the calendar + * failed to appear about one attempt in two on a cold page, and the failure + * surfaced later as a timeout clicking a day cell that was never there. + */ + async openDatePicker(): Promise { + await expect(async () => { + if (!(await this.datePicker.first().isVisible().catch(() => false))) { + await this.dateButton.click({ timeout: 10_000 }); + } + await expect( + this.datePicker.first(), + 'the date picker did not open', + ).toBeVisible({ timeout: 5_000 }); + }).toPass({ timeout: 45_000 }); + } + + /** + * Drive the date picker to a given day. + * + * The calendar is a month grid of day cells with exactly one carrying the + * "today" ring, so the only reliable way to reach a date is to find that + * cell and count from it. The ring is drawn from a value captured at page + * load, which is why its own day-of-month is read back and compared with the + * browser clock rather than assumed — that is what makes this survive a run + * crossing midnight. + * + * Cells outside the bookable range are DISABLED rather than absent + * (`date-calendar.component.ts`: `[disabled]="day.id < from() || day.id > to()"`), + * and for desks the range is 90 days by default (`desks.available_period`). + * A disabled cell is reported as such instead of timing out on a click. + */ + async pickDate(timestamp_ms: number): Promise { + // ONE converging attempt covering open, read, click and close. + // + // Every step here has been seen to fail on its own: the picker does not + // open (the form rebuilt underneath the click), it opens with an EMPTY + // grid for a tick, or it is torn down and recreated between reading the + // cells and clicking one — which leaves the locator pointing at a + // detached node and reports "0 cells" for a calendar that is plainly on + // screen. Retrying the whole sequence is the only thing that is stable, + // and it is safe because picking a date is idempotent. + const target = new Date(timestamp_ms); + target.setHours(0, 0, 0, 0); + + await expect(async () => { + await this.openDatePicker(); + const cells = this.datePicker.locator('button[name="schedule-set-date"]'); + const grid = await cells.evaluateAll((els) => + els.map((el) => ({ + text: (el.textContent ?? '').trim(), + disabled: (el as HTMLButtonElement).disabled, + today: !!el.querySelector('div.border-secondary'), + })), + ); + expect(grid.length, 'the calendar rendered no day cells').toBeGreaterThan(0); + + const today_index = grid.findIndex((cell) => cell.today); + expect( + today_index, + `no "today" cell to count from. The grid holds ${grid.length} cells: ` + + `${JSON.stringify(grid.map((c) => c.text))}`, + ).toBeGreaterThanOrEqual(0); + + const label = Number(grid[today_index].text); + const browser_now = await this.page.evaluate(() => Date.now()); + const anchor = [0, -1] + .map((offset) => { + const day = new Date(browser_now); + day.setHours(0, 0, 0, 0); + day.setDate(day.getDate() + offset); + return day; + }) + .find((day) => day.getDate() === label); + expect( + anchor, + `the picker marks day ${label} as today, which is neither today nor ` + + `yesterday by the browser clock`, + ).toBeTruthy(); + + const days = Math.round((target.valueOf() - anchor!.valueOf()) / 86_400_000); + const index = today_index + days; + expect( + index >= 0 && index < grid.length, + `${target.toDateString()} is ${days} day(s) from the cell marked today ` + + `(index ${today_index} of ${grid.length}), outside the month grid ` + + `the picker shows`, + ).toBe(true); + expect( + grid[index].disabled, + `${target.toDateString()} is offered but DISABLED. Desks can be booked ` + + '`desks.available_period` days ahead (90 by default), so the day is ' + + 'either too far out or in the past', + ).toBe(false); + + await cells.nth(index).click({ timeout: 10_000 }); + await expect( + this.datePicker.first(), + 'the date picker stayed open after choosing a day', + ).toBeHidden({ timeout: 10_000 }); + }).toPass({ timeout: 60_000 }); + } + + /** Set the start time from the options offered, as "HH:mm". */ + async setStartTime(time: string): Promise { + await this.startTimeTrigger.click(); + const option = this.page.locator(`button[data-time="${time}"]`); + await expect( + option, + `no start-time option for ${time} — the form offers times on a fixed step ` + + `and only inside its bookable hours, so pick one it lists`, + ).toBeVisible({ timeout: 10_000 }); + await option.click(); + } + + /** Set the length in minutes, from the options offered. */ + async setDuration(minutes: number): Promise { + await this.durationTrigger.click(); + const option = this.page.locator(`button[data-duration="${minutes}"]`); + await expect( + option, + `no ${minutes} minute option — lengths come on a fixed step up to ` + + '`max_duration`', + ).toBeVisible({ timeout: 10_000 }); + await option.click(); + } + + /** Every start time on offer, read from `data-time` rather than the label. */ + async startTimeOptions(): Promise { + await this.startTimeTrigger.click(); + const options = this.page.locator('button[data-time]'); + await expect(options.first()).toBeVisible({ timeout: 10_000 }); + const values = await options.evaluateAll((els) => + els.map((el) => el.getAttribute('data-time') ?? ''), + ); + await this.page.keyboard.press('Escape'); + return values.filter(Boolean); + } + + /** Every length on offer, in minutes. */ + async durationOptions(): Promise { + await this.durationTrigger.click(); + const options = this.page.locator('button[data-duration]'); + await expect(options.first()).toBeVisible({ timeout: 10_000 }); + const values = await options.evaluateAll((els) => + els.map((el) => Number(el.getAttribute('data-duration'))), + ); + await this.page.keyboard.press('Escape'); + return values.filter((v) => Number.isFinite(v)); + } + + /** + * Move to the confirm dialog and send. + * + * If the dialog never appears the form was silently invalid — the message + * says so rather than reporting a bare selector timeout, because that is + * what actually happens when "Require locker" is left on. + */ + async confirmAndSend(): Promise { + await this.confirmViewButton.click(); + await expect( + this.confirmButton, + 'the confirm dialog did not open — the form was silently invalid. Check ' + + '"Require locker" is cleared, a desk is selected, and the time is not ' + + 'in the past', + ).toBeVisible({ timeout: 20_000 }); + await this.confirmButton.click(); + } +} diff --git a/e2e/support/desk/desk-schedule.page.ts b/e2e/support/desk/desk-schedule.page.ts new file mode 100644 index 0000000000..29332cecd0 --- /dev/null +++ b/e2e/support/desk/desk-schedule.page.ts @@ -0,0 +1,50 @@ +/** + * The schedule, as the DESK specs need it. + * + * A desk booking cannot be edited from the desk form directly: the form is + * loaded with a booking by the schedule (`schedule.component.ts::editBooking` + * sets the form model and then routes to `/book/desk`). So a spec about SAVING + * an edited desk booking has to start on the schedule. + * + * The schedule itself belongs to `visitor/your-bookings.page.ts`, which + * introduced it — so this inherits and overrides only the one thing that is + * desk-shaped: the form that pressing Edit lands on. Nothing is copied. + * + * What is tested WHERE, so the two do not drift: + * + * bookings-edit.spec.ts ..... that Edit ROUTES to the desk form with the + * booking loaded. Belongs to the page. + * desk-edit.spec.ts ......... that a change SAVES correctly. Belongs to the + * desk form, and uses this to get there. + */ +import { Locator } from '@playwright/test'; +import { YourBookingsPage } from '../visitor/your-bookings.page'; + +export class DeskSchedulePage extends YourBookingsPage { + protected override get editForm(): Locator { + return this.page.locator('desk-flow-form'); + } + + protected override get editFormHint(): string { + return ( + 'A desk booking is edited through `schedule.component.ts::editBooking`, ' + + 'which routes to /book/desk with the form model already set. The Edit item ' + + 'is only offered for a booking the user owns; a booking that has finished ' + + 'may not offer it at all.' + ); + } + + /** Make sure desk bookings are among the types being shown. */ + async showDesks(): Promise { + const toggle = this.page + .locator('button[name="schedule-toggle-desk-filter"]:visible') + .first(); + const checkbox = toggle.locator('input[type="checkbox"]'); + if (!(await checkbox.count())) return; + // ENSURE, never blindly toggle: clicking a filter that is already on + // turns it off, and the list then looks like the booking is missing. + if (!(await checkbox.isChecked().catch(() => false))) { + await toggle.click({ timeout: 10_000 }); + } + } +} diff --git a/e2e/support/desk/desk.api.ts b/e2e/support/desk/desk.api.ts new file mode 100644 index 0000000000..a9b02b1eb5 --- /dev/null +++ b/e2e/support/desk/desk.api.ts @@ -0,0 +1,168 @@ +/** + * Backend helpers for the desk specs added after the dev's originals. + * + * Thin on purpose. A desk booking is an ordinary staff-api booking with + * `booking_type: 'desk'`, and the shared helpers in `../api.ts` already list, + * read, delete and sweep them — so this file adds only the one thing they do + * not: creating one. + * + * The retry is the same one the visitor and Your Bookings areas carry, for the + * same reason (REG-09 / PPT-2642: a burst of booking writes poisons a staff-api + * connection and the request that lands on it fails for reasons unrelated to + * what was sent). Duplicated rather than shared, like the rest of the per-area + * support, so no area can break another. + */ +import { APIRequestContext } from '@playwright/test'; +import { + Booking, + ENGINE_API, + STAFF_API, + currentUser, + zonesWithTag, +} from '../api'; + +function isConcurrentWriteFailure(status: number, body: string): boolean { + if (status === 400 && /Expected BeginObject but was EOF/.test(body)) return true; + if (status === 500 && /could not serialize access/.test(body)) return true; + // An empty `failures` list is the tell: a genuine validation error names the + // field it is unhappy about. + if (status === 422 && /"failures"\s*:\s*\[\s*\]/.test(body)) return true; + return false; +} + +/** + * Did this booking actually PERSIST? + * + * A 201 is not proof. Measured under parallel load: `POST /bookings` answered + * 201 with an id and `GET /bookings/` then 404'd — the row was never + * committed. That is REG-09 / PPT-2642 (a failed COMMIT on a dirty connection, + * after the response has already been written), and it presents as every + * downstream symptom except the real one. Reading the row back is the only + * reliable check. + */ +async function persisted(api: APIRequestContext, id: number): Promise { + const res = await api.get(`${STAFF_API}/bookings/${id}`); + return res.ok(); +} + +/** + * Create a desk booking straight through the API. + * + * For specs whose subject is NOT the booking form — editing, status display, + * check-in. Driving the form first would add an unrelated way for them to fail, + * and `bookDeskViaUI` already covers that path. + */ +export async function createDeskBookingViaApi( + api: APIRequestContext, + options: { + desk: { id: string; name: string }; + title: string; + start: number; + end: number; + /** Who the booking is FOR, if not the caller. */ + user?: { email: string; id?: string; name?: string }; + }, +): Promise { + const me = await currentUser(api); + const zones = ( + await Promise.all(['org', 'building', 'level'].map((t) => zonesWithTag(api, t))) + ) + .flat() + .map((z) => z.id); + const holder = options.user ?? me; + + const data = { + booking_type: 'desk', + asset_id: options.desk.id, + asset_name: options.desk.name, + booking_start: options.start, + booking_end: options.end, + timezone: 'Etc/UTC', + user_email: holder.email, + user_id: holder.id ?? '', + user_name: holder.name ?? holder.email, + booked_by_email: me.email, + booked_by_id: me.id, + booked_by_name: me.name, + title: options.title, + zones, + }; + + let last = ''; + for (let attempt = 1; attempt <= 4; attempt++) { + const res = await api.post(`${STAFF_API}/bookings`, { data }); + const body = await res.text(); + if (res.ok()) { + const created = JSON.parse(body); + // A 201 is not proof the row exists — see `persisted`. + if (await persisted(api, created.id)) return created; + last = `HTTP 201 for booking ${created.id}, which then 404s`; + console.warn(` ! phantom 201 on attempt ${attempt}/4 — ${last}`); + await new Promise((r) => setTimeout(r, 400 * attempt)); + continue; + } + last = `HTTP ${res.status()} ${body.slice(0, 200)}`; + if (!isConcurrentWriteFailure(res.status(), body)) { + throw new Error( + `create desk booking failed: ${last}. A 409 means the desk is already ` + + `held for that window — check the spec swept its own day first, and ` + + `that two spec files are not sharing a day (see DESK_DAYS). The ` + + `spare desk is shared between workers, so it also needs a day per ` + + `worker (see dayFor).`, + ); + } + console.warn(` ! concurrent-write failure on attempt ${attempt}/4 — ${last}`); + await new Promise((r) => setTimeout(r, 400 * attempt)); + } + throw new Error(`create desk booking still failing after 4 attempts. Last: ${last}`); +} + +/** + * The signed-in user's own settings blob. + * + * Favourite desks are a USER setting, not a property of the desk: the app writes + * the whole blob to `PUT /metadata/{user_id}` (debounced ~2.4s), with + * `favourite_desks` one key inside `details`. Read here so a spec can prove a + * favourite outlived the page rather than checking a star turned blue. + */ +export async function readUserSettings( + api: APIRequestContext, +): Promise> { + const me = await currentUser(api); + const res = await api.get(`${ENGINE_API}/metadata/${me.id}`, { + params: { name: 'settings' }, + }); + if (!res.ok()) { + throw new Error(`read user settings failed: HTTP ${res.status()} ${await res.text()}`); + } + const body = await res.json(); + return body?.settings?.details ?? {}; +} + +/** + * Set the user's favourite desks, leaving every other setting alone. + * + * Read-modify-write, because the app PUTs the WHOLE settings blob and this must + * do the same: a PUT carrying only `favourite_desks` would wipe the visitor + * specs' saved invitee list and the room specs' favourite rooms, which live in + * the same object. + */ +export async function setFavouriteDesks( + api: APIRequestContext, + ids: string[], +): Promise { + const me = await currentUser(api); + const details = await readUserSettings(api); + const res = await api.put(`${ENGINE_API}/metadata/${me.id}`, { + data: { + name: 'settings', + description: '', + details: { ...details, favourite_desks: ids }, + }, + }); + if (!res.ok()) { + throw new Error( + `write favourite_desks failed: HTTP ${res.status()} ${await res.text()}`, + ); + } +} diff --git a/e2e/support/desk/desk.env.ts b/e2e/support/desk/desk.env.ts new file mode 100644 index 0000000000..7a29e8ec8b --- /dev/null +++ b/e2e/support/desk/desk.env.ts @@ -0,0 +1,113 @@ +/** + * Identities and slots for the DESK BOOKING specs added on top of the dev's + * original four. + * + * Kept in its own folder, like `visitor/`, `room/` and `bookings/`: the shared + * `e2e/support/{env,api,flows}.ts` are what the ORIGINAL desk specs depend on, + * and nothing added later should be able to break those. The desk identity + * itself still comes from the shared `env.ts` — `deskFor` is seeded data, not + * behaviour, and duplicating it would let the two drift. + * + * ## The spare desk + * + * `seed.ts` creates **WORKERS + 1** desks, so there is always exactly one desk + * no worker owns. That is the only way to test moving a booking from one desk to + * another without a seeding change: `altDesk()` returns it. Because it is SHARED + * between workers, the spec that books it staggers its HOUR by worker index — + * see `hourFor`, and the note below on why the hour and not the day. + */ +import { WORKERS, deskFor } from '../env'; + +/** + * A day and an hour per spec file. + * + * ## Days stay SMALL, and that is not arbitrary + * + * Two calendars are involved and the tighter one wins: + * + * - the desk FORM's picker reaches 90 days ahead (`desks.available_period`); + * - the SCHEDULE's sidebar calendar — which the edit specs must use to open a + * booking — shows a single month grid, and nothing in the page navigates + * between months. Only the displayed month and a few trailing days can be + * selected at all. + * + * A first draft used +14 to +30 and every edit test failed with "the calendar + * did not select the day", because those dates fell into the next month. + * Everything here now sits within four days of today, which is reachable + * whatever today is, and separation comes from the hour instead. + * + * Days 5 to 13 are deliberately avoided: the Your Bookings specs own those, on + * this same desk. + */ +export const DESK_SLOTS = { + /** desk-times.spec.ts */ + times: { day: 1, hour: 10 }, + /** desk-edit.spec.ts, test 1 — `moved_to` is the hour it is moved into */ + edit_time: { day: 2, hour: 9, moved_to: 13 }, + /** desk-edit.spec.ts, test 2 — moves onto the SHARED spare desk */ + edit_desk: { day: 2, hour: 16 }, + /** desk-settings.spec.ts (all-day) */ + settings: { day: 3, hour: 9 }, + /** desk-delegate.spec.ts */ + delegate: { day: 4, hour: 9 }, + /** desk-status.spec.ts */ + status: { day: 4, hour: 15 }, + /** desk-api.spec.ts — no UI, so only the window has to be clear */ + api: { day: 1, hour: 20 }, +} as const; + +/** + * Stagger an hour by worker, for slots on the SHARED spare desk. + * + * Days cannot be staggered — see above — so the hour is. Four workers land on + * four consecutive hours, which keeps them inside any sensible bookable-hours + * setting. + */ +export function hourFor(base: number, workerIndex: number): number { + return base + workerIndex; +} + +/** The desk no worker owns, for "move it to another desk". */ +export function altDesk(): { id: string; name: string } { + return deskFor(WORKERS); +} + +/** A booking window on a day, in unix seconds, plus the local ms timestamp. */ +export function slotOn(dayOffset: number, hour: number, minutes = 60) { + const start = new Date(); + start.setDate(start.getDate() + dayOffset); + start.setHours(hour, 0, 0, 0); + return { + start: Math.floor(start.valueOf() / 1000), + end: Math.floor(start.valueOf() / 1000) + minutes * 60, + date_ms: start.valueOf(), + }; +} + +/** + * The local day a slot falls on, as a unix-second window. + * + * Sweeps use this rather than "the slot plus or minus a day": a window that + * spills into neighbouring days reaches into whatever another spec file is + * doing there, and the sweep that lets a spec recover from its own past + * failures would then clear somebody else's booking. + */ +export function dayBoundsOn(dayOffset: number) { + const start = new Date(); + start.setDate(start.getDate() + dayOffset); + start.setHours(0, 0, 0, 0); + const end = new Date(start); + end.setHours(23, 59, 59, 0); + return { + from: Math.floor(start.valueOf() / 1000), + to: Math.floor(end.valueOf() / 1000), + }; +} + +/** "HH:mm" for a unix timestamp, in the browser's local time. */ +export function hhmm(unix_seconds: number): string { + const date = new Date(unix_seconds * 1000); + const hh = `${date.getHours()}`.padStart(2, '0'); + const mm = `${date.getMinutes()}`.padStart(2, '0'); + return `${hh}:${mm}`; +} diff --git a/e2e/support/desk/desk.settings.ts b/e2e/support/desk/desk.settings.ts new file mode 100644 index 0000000000..36b7cf1259 --- /dev/null +++ b/e2e/support/desk/desk.settings.ts @@ -0,0 +1,78 @@ +/** + * Per-test app settings for the desk specs. + * + * The third copy of this twenty-line helper — `visitor/`, `room/`, `bookings/` + * and now here. Deliberate: a shared settings file is the one thing that could + * break all four areas at once, and the point of separate folders is that it + * cannot. + * + * Desk settings are `app.bookings.*`, NOT `app.events.*`. The meeting form reads + * the event keys and the desk form reads the booking keys, and setting the wrong + * family changes nothing at all while looking right — that cost a run in the + * room work (see `room.settings.ts`). + */ +import { Page } from '@playwright/test'; + +export async function useSettings( + page: Page, + overrides: Record, +): Promise { + const bad = Object.keys(overrides).filter((k) => !k.startsWith('app.')); + if (bad.length) { + throw new Error( + `setting override keys must start with "app." — the app drops the rest on ` + + `load, leaving the default quietly in place. Got: ${bad.join(', ')}`, + ); + } + await page.addInitScript((value) => { + localStorage.setItem('PLACEOS.setting_overrides', JSON.stringify(value)); + }, overrides); +} + +/** + * Cap the booking length and confine it to office hours. + * + * Both unset by default, so nothing else in the suite sees them, and both fail + * quietly in the same way: an out-of-range option is simply ABSENT from the + * picker rather than refused. That is why the times spec asserts on what is + * offered. + */ +export const LIMITED_HOURS = { + 'app.bookings.max_duration': 60, + 'app.bookings.bookable_hours': { start: 9, end: 17 }, +}; + +/** Allow all-day desk bookings — the form's All Day checkbox. */ +export const ALLOW_ALL_DAY = { + 'app.bookings.allow_all_day': true, +}; + +/** + * Book a desk on somebody else's behalf. + * + * ONE setting, unlike visitors. `can_book_for_anyone` is read only by the + * visitor forms (`visitor-invite-form.component.ts`); the desk form reads + * `can_book_for_others` alone (`desk-form-details.component.ts`) and renders its + * user field from that. Setting the visitor key here as well would look thorough + * and do nothing, so it is left out on purpose. + */ +export const BOOK_FOR_OTHERS = { + 'app.bookings.can_book_for_others': true, + // WITHOUT THIS THE FIELD CANNOT FIND ANYBODY HERE. + // + // The host field has two search backends (`user-search-field.component.ts`): + // + // basic_user_search ....... `GET /api/engine/v2/users`, the PlaceOS user + // list. Local, and the seeded staff users are in + // it. + // otherwise ............... `GET /api/staff/v1/people`, the calendar + // DIRECTORY. Microsoft/Google backed, and a 500 + // on this stack — measured, with the panel then + // showing "No users found." + // + // So the basic search is forced on. Say this out loud in review: these specs + // cover booking for a colleague through the PlaceOS user list, and say + // nothing about the directory-backed picker a tenant with real credentials + // would use. Same boundary as the room specs' `use_bookings` mode. + 'app.basic_user_search': true, +}; diff --git a/e2e/support/home/home.api.ts b/e2e/support/home/home.api.ts new file mode 100644 index 0000000000..8224a3f914 --- /dev/null +++ b/e2e/support/home/home.api.ts @@ -0,0 +1,189 @@ +/** + * Backend helpers for the HOME PAGE specs. + * + * Creating a booking, and reading or clearing the user settings the page's + * sidebar is built from (colleagues and favourites). Both duplicated rather than + * imported from another area, for the reason every one of these folders repeats: + * a shared file is the thing that could break all of them at once. + * + * The REG-09 guards are here too — the retry, and reading the row back, because + * a 201 with an id is not proof the row exists. + */ +import { APIRequestContext } from '@playwright/test'; +import { + Booking, + ENGINE_API, + STAFF_API, + currentUser, + zonesWithTag, +} from '../api'; + +function isConcurrentWriteFailure(status: number, body: string): boolean { + if (status === 400 && /Expected BeginObject but was EOF/.test(body)) return true; + if (status === 500 && /could not serialize access/.test(body)) return true; + // An empty `failures` list is the tell: a real validation error names a field. + if (status === 422 && /"failures"\s*:\s*\[\s*\]/.test(body)) return true; + return false; +} + +async function persisted(api: APIRequestContext, id: number): Promise { + const res = await api.get(`${STAFF_API}/bookings/${id}`); + return res.ok(); +} + +/** Create a booking of any type, as the signed-in user. */ +export async function createBookingViaApi( + api: APIRequestContext, + options: { + type: string; + asset_id: string; + asset_name?: string; + title: string; + start: number; + end: number; + }, +): Promise { + const me = await currentUser(api); + const zones = ( + await Promise.all(['org', 'building', 'level'].map((t) => zonesWithTag(api, t))) + ) + .flat() + .map((z) => z.id); + + const data = { + booking_type: options.type, + asset_id: options.asset_id, + asset_name: options.asset_name ?? options.asset_id, + booking_start: options.start, + booking_end: options.end, + timezone: 'Etc/UTC', + user_email: me.email, + user_id: me.id, + user_name: me.name, + title: options.title, + zones, + }; + + let last = ''; + for (let attempt = 1; attempt <= 4; attempt++) { + const res = await api.post(`${STAFF_API}/bookings`, { data }); + const body = await res.text(); + if (res.ok()) { + const created = JSON.parse(body); + if (await persisted(api, created.id)) return created; + last = `HTTP 201 for booking ${created.id}, which then 404s`; + console.warn(` ! phantom 201 on attempt ${attempt}/4 — ${last}`); + await new Promise((r) => setTimeout(r, 400 * attempt)); + continue; + } + last = `HTTP ${res.status()} ${body.slice(0, 200)}`; + if (!isConcurrentWriteFailure(res.status(), body)) { + throw new Error( + `create ${options.type} booking failed: ${last}. A 409 means the asset ` + + `is already held for that window — check the spec swept first, and ` + + `that two spec files are not sharing a slot (see HOME_SLOTS).`, + ); + } + console.warn(` ! concurrent-write failure on attempt ${attempt}/4 — ${last}`); + await new Promise((r) => setTimeout(r, 400 * attempt)); + } + throw new Error(`create ${options.type} booking still failing after 4 attempts. Last: ${last}`); +} + +/** The signed-in user's own settings blob. */ +export async function readUserSettings( + api: APIRequestContext, +): Promise> { + const me = await currentUser(api); + const res = await api.get(`${ENGINE_API}/metadata/${me.id}`, { + params: { name: 'settings' }, + }); + if (!res.ok()) { + throw new Error(`read user settings failed: HTTP ${res.status()} ${await res.text()}`); + } + const body = await res.json(); + return body?.settings?.details ?? {}; +} + +/** + * Write one key of the user's settings, leaving the rest alone. + * + * Read-modify-write, because the app PUTs the WHOLE blob: a PUT carrying one key + * would wipe the visitor specs' invitee list and every other area's favourites, + * which share this object. + */ +export async function setUserSetting( + api: APIRequestContext, + key: string, + value: unknown, +): Promise { + const me = await currentUser(api); + const details = await readUserSettings(api); + const res = await api.put(`${ENGINE_API}/metadata/${me.id}`, { + data: { + name: 'settings', + description: '', + details: { ...details, [key]: value }, + }, + }); + if (!res.ok()) { + throw new Error(`write ${key} failed: HTTP ${res.status()} ${await res.text()}`); + } +} + +/** The key the app stores favourite desks under, inside `settings`. */ +export const FAVOURITE_DESKS_KEY = 'favourite_desks'; + +/** + * The colleague list is its OWN metadata document, not a key inside `settings`. + * + * `landing-state.service.ts` reads `showMetadata(user.id, 'contacts')` and + * writes `updateMetadata(user.id, { name: 'contacts', details: users })` — a + * whole document called `contacts`, holding an array of user objects. + * + * Worth writing down because `libs/common/src/lib/constants.ts` also carries + * `FAVORITE_TEAM_MEMBERS: 'favourite_team_members'`, which looks exactly like + * the right thing and is not what this page uses. A first draft of the + * colleagues spec asserted on that key and failed twice: the add saved nothing + * under it, and a colleague seeded under it was not listed. + */ +export const CONTACTS_DOC = 'contacts'; + +/** The user's saved colleagues. */ +export async function readContacts( + api: APIRequestContext, +): Promise<{ email: string; name?: string }[]> { + const me = await currentUser(api); + const res = await api.get(`${ENGINE_API}/metadata/${me.id}`, { + params: { name: CONTACTS_DOC }, + }); + if (!res.ok()) { + throw new Error(`read contacts failed: HTTP ${res.status()} ${await res.text()}`); + } + const body = await res.json(); + const details = body?.[CONTACTS_DOC]?.details; + return Array.isArray(details) ? details : []; +} + +/** + * Set the user's saved colleagues. + * + * Its own document, so unlike the `settings` blob this can be written wholesale + * without trampling another area's data. + */ +export async function setContacts( + api: APIRequestContext, + users: { email: string; name?: string }[], +): Promise { + const me = await currentUser(api); + const res = await api.put(`${ENGINE_API}/metadata/${me.id}`, { + data: { + name: CONTACTS_DOC, + description: 'Contacts for the User', + details: users, + }, + }); + if (!res.ok()) { + throw new Error(`write contacts failed: HTTP ${res.status()} ${await res.text()}`); + } +} diff --git a/e2e/support/home/home.env.ts b/e2e/support/home/home.env.ts new file mode 100644 index 0000000000..48df2fbc0a --- /dev/null +++ b/e2e/support/home/home.env.ts @@ -0,0 +1,58 @@ +/** + * Slots and identities for the HOME PAGE specs. + * + * Its own folder, like every other area, so nothing home-shaped can break the + * desk, visitor, room, Your Bookings or parking specs. + * + * ## What is different about this page + * + * The home page CREATES almost nothing — it lists what other flows created. So + * the risk is not contention over an asset, it is contention over the day being + * displayed: the Upcoming panel shows the next few bookings a user has, whatever + * day they fall on, so ANY leftover booking belonging to this worker can show up + * in it. + * + * That is why these specs assert on specific booking ids rather than on how many + * cards the panel holds, and why they sweep their own asset first. The one + * exception is the quick-book spec, which really does book a desk. + * + * Days sit clear of the other areas: desks use today and +1 to +4, the room + * specs +3 and +4, Your Bookings +5 to +13. The home page uses +16 onwards, and + * TODAY for the panel that only shows imminent bookings. + */ +export const HOME_SLOTS = { + /** home-upcoming.spec.ts — the panel lists what is coming up */ + upcoming: { day: 16, hour: 9 }, + /** home-upcoming.spec.ts — View all hands off to Your Bookings */ + view_all: { day: 17, hour: 9 }, + /** home-upcoming.spec.ts — a cancelled booking leaves the panel */ + cancelled: { day: 18, hour: 9 }, + /** home-scoping.spec.ts */ + scoping: { day: 19, hour: 9 }, + /** home-scoping.spec.ts, the control */ + scoping_control: { day: 20, hour: 9 }, +} as const; + +export function slotOn(dayOffset: number, hour: number, minutes = 60) { + const start = new Date(); + start.setDate(start.getDate() + dayOffset); + start.setHours(hour, 0, 0, 0); + return { + start: Math.floor(start.valueOf() / 1000), + end: Math.floor(start.valueOf() / 1000) + minutes * 60, + date_ms: start.valueOf(), + }; +} + +/** The local day a slot falls on, as a unix-second window, for sweeps. */ +export function dayBoundsOn(dayOffset: number) { + const start = new Date(); + start.setDate(start.getDate() + dayOffset); + start.setHours(0, 0, 0, 0); + const end = new Date(start); + end.setHours(23, 59, 59, 0); + return { + from: Math.floor(start.valueOf() / 1000), + to: Math.floor(end.valueOf() / 1000), + }; +} diff --git a/e2e/support/home/home.settings.ts b/e2e/support/home/home.settings.ts new file mode 100644 index 0000000000..c3e8b44c2f --- /dev/null +++ b/e2e/support/home/home.settings.ts @@ -0,0 +1,42 @@ +/** + * Per-test app settings for the HOME PAGE specs. + * + * The fifth copy of this helper, and deliberately so — one shared settings file + * could break every area at once, which is the whole point of keeping the + * folders apart. + * + * Home-page settings are mostly ROOT-level keys (`app.show_quick_book`, + * `app.hide_landing_spaces`) rather than the `app.bookings.*` / `app.events.*` + * families the forms use. Worth knowing before hunting a setting that appears to + * do nothing. + */ +import { Page } from '@playwright/test'; + +export async function useSettings( + page: Page, + overrides: Record, +): Promise { + const bad = Object.keys(overrides).filter((k) => !k.startsWith('app.')); + if (bad.length) { + throw new Error( + `setting override keys must start with "app." — the app drops the rest on ` + + `load, leaving the default quietly in place. Got: ${bad.join(', ')}`, + ); + } + await page.addInitScript((value) => { + localStorage.setItem('PLACEOS.setting_overrides', JSON.stringify(value)); + }, overrides); +} + +/** + * Show the one-click quick-book tiles. + * + * `landing.component.ts` renders the panel only when `show_quick_book` is set, + * and the setting has NO default — so on a stack with no configuration the + * tiles simply are not there, and a spec looking for them reports a missing + * selector rather than a missing setting. Measured: without this, the desk tile + * never appears. + */ +export const SHOW_QUICK_BOOK = { + 'app.show_quick_book': true, +}; diff --git a/e2e/support/home/landing.page.ts b/e2e/support/home/landing.page.ts new file mode 100644 index 0000000000..582bfb7034 --- /dev/null +++ b/e2e/support/home/landing.page.ts @@ -0,0 +1,218 @@ +/** + * Page object for `/#/landing` — the workplace home page. + * + * SELECTOR POLICY, as everywhere here: nothing depends on visible text, because + * every label is translated. This page is a mixed bag for that, and each + * exception is named where it occurs: + * + * named, and used as such .... upcoming-view-all · landing-view-space · + * landing-book-room · open-colleague-search · + * add-colleague · remove-colleague · + * meeting-with-colleague · book-favourite · + * landing-remove-favourite + * component hosts ............ landing-upcoming · landing-quick-book · + * landing-availability · landing-colleagues · + * landing-favourites + * IMAGE PATHS ................ the quick-book tiles carry no attribute at all; + * the only stable handle is the icon each one + * renders (`assets/icons/desk-placeholder.svg`). + * An asset path is not app copy, but a redesign + * that renames the file will fail the locator + * rather than silently pass. + * + * ## The sidebar has two tabs and they share the panel + * + * Colleagues and Favourites are two tabs of the same sidebar + * (`landing.component.ts`: `tab()` is `'people'` or `'fav'`), and only one is in + * the DOM at a time. Neither button has a name, so they are reached through the + * material icon each renders — `people` and `star`-ish — via `tabButton`. + */ +import { Locator, Page, expect } from '@playwright/test'; + +export class LandingPage { + constructor(private readonly page: Page) {} + + async open(): Promise { + await this.page.goto('/#/landing'); + await expect( + this.page.locator('app-landing, landing-upcoming, landing-availability').first(), + 'the home page never rendered', + ).toBeVisible({ timeout: 30_000 }); + } + + /** The Upcoming panel, and the booking cards inside it. */ + get upcoming(): Locator { + return this.page.locator('landing-upcoming'); + } + + /** A card in the Upcoming panel, matched on the booking id in its href. */ + upcomingCard(bookingId: number): Locator { + return this.upcoming.locator( + `a[href*="booking=${bookingId}"], a[href*="event=${bookingId}"]`, + ); + } + + /** Every booking id the Upcoming panel is showing, for failure messages. */ + async upcomingIds(): Promise { + const hrefs = await this.upcoming + .locator('a[name="view-booking-details"], a[name="view-event-details"]') + .evaluateAll((els) => els.map((e) => e.getAttribute('href') ?? '')); + return hrefs + .map((h) => Number(/(?:booking|event)=(\d+)/.exec(h)?.[1])) + .filter((n) => Number.isFinite(n)); + } + + get viewAll(): Locator { + return this.page.locator('a[name="upcoming-view-all"]').first(); + } + + /** + * A quick-book tile. + * + * Matched on the icon it renders, because the tiles carry no attributes. + * Note what these do: a tile does NOT open the booking form — it books the + * first available resource immediately (`landing-quick-book.component.ts`: + * `book()` calls `confirmPost()`) and lands on `/book//success`. + */ + quickBookTile(type: 'desk' | 'parking' | 'locker' | 'visitor'): Locator { + return this.page + .locator('landing-quick-book button') + .filter({ has: this.page.locator(`img[src*="${type}-placeholder"]`) }) + .first(); + } + + get quickBook(): Locator { + return this.page.locator('landing-quick-book'); + } + + /** The availability panel, listing bookable rooms for the building. */ + get availability(): Locator { + return this.page.locator('landing-availability'); + } + + /** A "book this room" button in the availability panel, by room name. */ + bookRoom(name: string): Locator { + return this.availability + .locator('button[name="landing-book-room"]') + .filter({ hasText: name }) + .first(); + } + + /** Every room the availability panel offers, for failure messages. */ + async offeredRooms(): Promise { + const texts = await this.availability + .locator('button[name="landing-book-room"]') + .allInnerTexts(); + return texts.map((t) => t.split('\n')[0].trim()); + } + + /** + * Switch the sidebar to a tab. + * + * The two buttons carry no name, so they are found by the material icon + * inside them. `people` is the colleagues tab; the other one is favourites. + */ + async showTab(tab: 'people' | 'fav'): Promise { + const buttons = this.page.locator('app-landing button:has(icon)'); + const count = await buttons.count(); + for (let i = 0; i < count; i++) { + const icon = (await buttons.nth(i).locator('icon').first().textContent()) ?? ''; + // The material ligatures the two tab buttons render: `people` and + // `favorite`. Read from the template, because neither button has a + // name and their labels are translated. + const wanted = tab === 'people' ? 'people' : 'favorite'; + if (icon.trim() === wanted) { + await buttons.nth(i).click({ timeout: 10_000 }); + return; + } + } + // Not fatal: the sidebar is hidden on narrow screens and by + // `hide_landing_sidebar`, and the favourites tab is the default when + // colleagues are switched off. + console.warn(` ! no "${tab}" tab button found on the landing sidebar`); + } + + get colleagues(): Locator { + return this.page.locator('landing-colleagues'); + } + + get favourites(): Locator { + return this.page.locator('landing-favourites'); + } + + /** + * Search for a colleague and add them. + * + * The search panel is opened with `open-colleague-search`, the input is the + * only one inside it, and each result carries `add-colleague`. Typed rather + * than filled: these user fields are driven from real key events, and + * `fill()` leaves the search untouched — the same trap the desk host field + * has. + */ + async addColleague(email: string): Promise { + await this.page.locator('button[name="open-colleague-search"]').first().click(); + const input = this.colleagues.locator('input').first(); + await expect(input, 'the colleague search did not open').toBeVisible({ + timeout: 10_000, + }); + const flat = (value: string) => value.toLowerCase().replace(/[^a-z0-9]/g, ''); + + for (let attempt = 1; attempt <= 3; attempt++) { + await input.click(); + await input.press('ControlOrMeta+a'); + await input.press('Backspace'); + await input.pressSequentially(email, { delay: 30 }); + const results = this.colleagues.locator('button[name="add-colleague"]'); + const appeared = await results + .first() + .waitFor({ state: 'visible', timeout: 10_000 }) + .then(() => true) + .catch(() => false); + if (!appeared) continue; + const texts = await results.allInnerTexts(); + const index = texts.findIndex((t) => flat(t).includes(flat(email))); + if (index < 0) continue; + await results.nth(index).click(); + return; + } + const offered = await this.colleagues + .locator('button[name="add-colleague"]') + .allInnerTexts(); + throw new Error( + `no colleague result for ${email} after 3 tries. The panel offered ` + + `${offered.length}: ${JSON.stringify(offered.slice(0, 6))}. The user has ` + + `to exist in the directory, and the field only searches on real key ` + + `events — check the typing reached it.`, + ); + } + + /** Every colleague row in the sidebar. */ + get colleagueRows(): Locator { + return this.colleagues.locator('button[name="colleague-more"]'); + } + + /** + * Find a colleague row by address, matching on NORMALISED text. + * + * The row shows the person's display NAME, not their address: a colleague + * saved as `e2e-staff-2@place.tech` renders as "E2E Staff 2 (non-admin)". So + * a substring match on the address finds nothing, and Playwright's own + * `hasText` cannot bridge the hyphens either. Both sides are stripped to + * letters and digits before comparing, which makes "e2estaff2" match. + * + * Returns the row's index, or -1 — the caller decides whether that is fatal, + * because "not listed" and "listed under a different label" want different + * messages. + */ + async colleagueIndex(email: string): Promise { + const flat = (value: string) => value.toLowerCase().replace(/[^a-z0-9]/g, ''); + const wanted = flat(email.split('@')[0]); + const rows = this.colleagues.locator('[colleague], li, [user]'); + const texts = await rows.allInnerTexts().catch(() => [] as string[]); + const index = texts.findIndex((text) => flat(text).includes(wanted)); + if (index >= 0) return index; + // Fall back to the overflow buttons, which is one per colleague: with a + // single colleague seeded that is unambiguous. + return (await this.colleagueRows.count()) === 1 ? 0 : -1; + } +} diff --git a/e2e/support/parking/parking-form.page.ts b/e2e/support/parking/parking-form.page.ts new file mode 100644 index 0000000000..61325df8c1 --- /dev/null +++ b/e2e/support/parking/parking-form.page.ts @@ -0,0 +1,311 @@ +/** + * Page object for `/book/parking/form` — the parking booking flow. + * + * SELECTOR POLICY, as everywhere else here: nothing depends on visible text, + * because every label is translated. This form is the WORST of the four for + * that — its buttons carry bare attributes (`confirm`, `clear-form`, + * `add-space`, `select`) rather than names — so the attributes are the handles, + * and each is written down below with where it lives. + * + * Flow shape, which differs from desks and from meetings: + * + * form -> confirm -> success (`parking-flow.component.ts`) + * + * `Confirm Reservation` on the form only changes the view. Nothing is sent until + * the confirm screen's own button, so a spec that clicks once and looks for a + * booking finds none and blames the backend. + */ +import { Locator, Page, expect } from '@playwright/test'; + +export class ParkingForm { + constructor(private readonly page: Page) {} + + get root(): Locator { + return this.page.locator('parking-flow-form'); + } + get title(): Locator { + return this.page.locator('input[name$=".title"]').first(); + } + /** The plate number field — parking's own, with no equivalent elsewhere. */ + get plate(): Locator { + return this.page.locator('input[name$=".plate_number"]').first(); + } + get allDay(): Locator { + return this.page.locator('input[name$=".all_day"]').first(); + } + get dateButton(): Locator { + return this.root.locator('a-date-field button, date-field button').first(); + } + get datePicker(): Locator { + return this.page.locator('.cdk-overlay-container date-calendar'); + } + /** Scoped to the FORM: the picker modal has its own time fields. */ + get startTimeTrigger(): Locator { + return this.root.locator('button[time-field]').first(); + } + get durationTrigger(): Locator { + return this.root.locator('button[duration-field]').first(); + } + /** "Add Parking Space" — a bare `add-space` attribute, no name. */ + get addSpaceButton(): Locator { + return this.page.locator('parking-space-list-field button[add-space]'); + } + /** The spaces chosen on the form, one row each. */ + get chosenSpaces(): Locator { + return this.page.locator('parking-space-list-field [space], parking-space-list-field div[space]'); + } + /** `Confirm Reservation` on the FORM — moves to the confirm screen. */ + get confirmViewButton(): Locator { + return this.root.locator('button[confirm]').first(); + } + /** + * The confirm step's own button — this is what sends the booking. + * + * The confirm step is a **bottom sheet**, not a routed view like the desk and + * meeting flows: `viewConfirm()` calls `_bottom_sheet.open(...)`. So it lives + * in the CDK overlay, and a spec that waits for it inside the page body + * waits for ever. + */ + get confirmButton(): Locator { + return this.page + .locator('.cdk-overlay-container parking-flow-confirm button[confirm]') + .first(); + } + get successPanel(): Locator { + return this.page.locator('parking-flow-success'); + } + + async open(): Promise { + await this.page.goto('/#/book/parking/form'); + await expect( + this.root, + 'the parking form never rendered — is the `parking` feature enabled for ' + + 'this org, and is there a level tagged `parking`?', + ).toBeVisible({ timeout: 30_000 }); + await expect( + this.title, + 'the parking form rendered without its title field', + ).toBeVisible({ timeout: 30_000 }); + } + + /** Set a checkbox to a state, never blindly toggling it. */ + async setChecked(box: Locator, want: boolean): Promise { + if (!(await box.count())) return; + if ((await box.isChecked()) === want) return; + // `force`, because Material hides the real input behind its own markup. + await box.click({ force: true, timeout: 10_000 }); + } + + /** + * Open the date picker, converging. + * + * Same reasoning as the desk form: these forms are rebuilt when async init + * completes, and a click that lands on the old element does nothing at all. + */ + async openDatePicker(): Promise { + await expect(async () => { + if (!(await this.datePicker.first().isVisible().catch(() => false))) { + await this.dateButton.click({ timeout: 10_000 }); + } + await expect( + this.datePicker.first(), + 'the date picker did not open', + ).toBeVisible({ timeout: 5_000 }); + }).toPass({ timeout: 45_000 }); + } + + /** + * Drive the date picker to a given day. + * + * The calendar is a month grid with exactly one cell carrying the "today" + * ring, so the only reliable way to reach a date is to count from it. The + * whole sequence converges, because the picker has been seen opening with an + * empty grid and being torn down between reading the cells and clicking one. + */ + async pickDate(timestamp_ms: number): Promise { + const target = new Date(timestamp_ms); + target.setHours(0, 0, 0, 0); + + await expect(async () => { + await this.openDatePicker(); + const cells = this.datePicker.locator('button[name="schedule-set-date"]'); + const grid = await cells.evaluateAll((els) => + els.map((el) => ({ + text: (el.textContent ?? '').trim(), + disabled: (el as HTMLButtonElement).disabled, + today: !!el.querySelector('div.border-secondary'), + })), + ); + expect(grid.length, 'the calendar rendered no day cells').toBeGreaterThan(0); + + const today_index = grid.findIndex((cell) => cell.today); + expect( + today_index, + `no "today" cell to count from. The grid holds ${grid.length} cells`, + ).toBeGreaterThanOrEqual(0); + + const label = Number(grid[today_index].text); + const browser_now = await this.page.evaluate(() => Date.now()); + const anchor = [0, -1] + .map((offset) => { + const day = new Date(browser_now); + day.setHours(0, 0, 0, 0); + day.setDate(day.getDate() + offset); + return day; + }) + .find((day) => day.getDate() === label); + expect( + anchor, + `the picker marks day ${label} as today, which is neither today nor ` + + `yesterday by the browser clock`, + ).toBeTruthy(); + + const days = Math.round((target.valueOf() - anchor!.valueOf()) / 86_400_000); + const index = today_index + days; + expect( + index >= 0 && index < grid.length, + `${target.toDateString()} is outside the month grid the picker shows`, + ).toBe(true); + expect( + grid[index].disabled, + `${target.toDateString()} is offered but disabled`, + ).toBe(false); + + await cells.nth(index).click({ timeout: 10_000 }); + await expect( + this.datePicker.first(), + 'the date picker stayed open after choosing a day', + ).toBeHidden({ timeout: 10_000 }); + }).toPass({ timeout: 60_000 }); + } + + /** + * Pick a parking space by name. + * + * Three steps: `add-space` opens the modal, `select` on a row previews that + * space, and `toggle-parking` takes it. The space is matched on its NAME, + * which is test data we seeded (`E2E Parking 0`), not app copy. + */ + async chooseSpace(name: string): Promise { + await this.addSpaceButton.click({ timeout: 10_000 }); + const modal = this.page.locator('parking-select-modal, mat-dialog-container'); + await expect(modal.first(), 'the parking picker did not open').toBeVisible({ + timeout: 20_000, + }); + + const row = this.page.locator('li[space]').filter({ hasText: name }).first(); + const found = await row + .waitFor({ state: 'visible', timeout: 20_000 }) + .then(() => true) + .catch(() => false); + if (!found) { + // Say what IS on offer. "Not found" cannot tell apart a space that + // was never seeded, one on a level that is not tagged `parking`, and + // one the picker is hiding because it is already booked. + const offered = await this.page.locator('li[space]').allInnerTexts(); + throw new Error( + `no parking space called "${name}" in the picker. It offers ` + + `${offered.length}: ` + + `${JSON.stringify(offered.map((t) => t.split('\n')[0].trim()))}. ` + + `Check parking.seed.ts ran (a level tagged \`parking\`, a ` + + `\`_PARKING_SPACES_\` asset type, and one asset per worker), and ` + + `that the space is free at the time chosen.`, + ); + } + await row.locator('button[select]').first().click(); + + const done = this.page.locator('button[name="toggle-parking"]'); + await expect(done, 'the picker has no confirm button').toBeVisible({ + timeout: 10_000, + }); + await expect( + done, + 'the picker confirm button is disabled, so no space is previewed', + ).toBeEnabled({ timeout: 10_000 }); + await done.click(); + await expect( + this.chosenSpaces, + `"${name}" was picked but the form shows no space`, + ).toHaveCount(1, { timeout: 20_000 }); + } + + /** The names of the spaces currently on the form. */ + async chosenSpaceNames(): Promise { + const texts = await this.chosenSpaces.allInnerTexts(); + return texts.map((t) => t.split('\n')[0].trim()); + } + + /** Set the start time from the options offered, as "HH:mm". */ + async setStartTime(time: string): Promise { + await this.startTimeTrigger.click(); + const option = this.page.locator(`button[data-time="${time}"]`); + await expect( + option, + `no start-time option for ${time} — the form offers times on a fixed step ` + + `and only inside its bookable hours`, + ).toBeVisible({ timeout: 10_000 }); + await option.click(); + } + + /** Set the length in minutes, from the options offered. */ + async setDuration(minutes: number): Promise { + await this.durationTrigger.click(); + const option = this.page.locator(`button[data-duration="${minutes}"]`); + await expect( + option, + `no ${minutes} minute option — lengths come on a fixed step up to the ` + + `maximum duration setting`, + ).toBeVisible({ timeout: 10_000 }); + await option.click(); + } + + /** Every start time on offer, read from `data-time` rather than the label. */ + async startTimeOptions(): Promise { + await this.startTimeTrigger.click(); + const options = this.page.locator('button[data-time]'); + await expect(options.first()).toBeVisible({ timeout: 10_000 }); + const values = await options.evaluateAll((els) => + els.map((el) => el.getAttribute('data-time') ?? ''), + ); + await this.page.keyboard.press('Escape'); + return values.filter(Boolean); + } + + /** Every length on offer, in minutes. */ + async durationOptions(): Promise { + await this.durationTrigger.click(); + const options = this.page.locator('button[data-duration]'); + await expect(options.first()).toBeVisible({ timeout: 10_000 }); + const values = await options.evaluateAll((els) => + els.map((el) => Number(el.getAttribute('data-duration'))), + ); + await this.page.keyboard.press('Escape'); + return values.filter((v) => Number.isFinite(v)); + } + + /** + * Move to the confirm step, then send. + * + * If the sheet never opens the form was refused locally, and `viewConfirm()` + * shows a toast naming the invalid fields — so that toast is read and + * included, rather than reporting a bare selector timeout. + */ + async confirmAndSend(): Promise { + await this.confirmViewButton.click(); + const opened = await this.confirmButton + .waitFor({ state: 'visible', timeout: 20_000 }) + .then(() => true) + .catch(() => false); + if (!opened) { + const message = await this.page + .locator('.cdk-overlay-container') + .innerText() + .catch(() => ''); + throw new Error( + `the confirm sheet did not open, so the form was refused locally. ` + + `On screen: "${message.replace(/\s+/g, ' ').trim().slice(0, 300)}"`, + ); + } + await this.confirmButton.click(); + } +} diff --git a/e2e/support/parking/parking-schedule.page.ts b/e2e/support/parking/parking-schedule.page.ts new file mode 100644 index 0000000000..6fafa80a38 --- /dev/null +++ b/e2e/support/parking/parking-schedule.page.ts @@ -0,0 +1,42 @@ +/** + * The schedule, as the PARKING specs need it. + * + * A parking booking cannot be cancelled or edited from the parking form: the + * schedule does both (`schedule.component.ts::editBooking` loads the form and + * routes to `/book/parking`). So the specs for those start on the schedule. + * + * The schedule belongs to `visitor/your-bookings.page.ts`, which introduced it, + * so this inherits and overrides only what is parking-shaped — the form that + * Edit lands on. Nothing is copied. + */ +import { Locator } from '@playwright/test'; +import { YourBookingsPage } from '../visitor/your-bookings.page'; + +export class ParkingSchedulePage extends YourBookingsPage { + protected override get editForm(): Locator { + return this.page.locator('parking-flow-form'); + } + + protected override get editFormHint(): string { + return ( + 'A parking booking is edited through `schedule.component.ts::editBooking`, ' + + 'which routes to /book/parking with the form model already set. The Edit ' + + 'item is only offered for a booking the user owns, and a booking that has ' + + 'finished may not offer it at all.' + ); + } + + /** Make sure parking bookings are among the types being shown. */ + async showParking(): Promise { + const toggle = this.page + .locator('button[name="schedule-toggle-parking-filter"]:visible') + .first(); + const checkbox = toggle.locator('input[type="checkbox"]'); + if (!(await checkbox.count())) return; + // ENSURE, never blindly toggle: clicking a filter that is already on + // turns it off, and the list then looks like the booking is missing. + if (!(await checkbox.isChecked().catch(() => false))) { + await toggle.click({ timeout: 10_000 }); + } + } +} diff --git a/e2e/support/parking/parking.api.ts b/e2e/support/parking/parking.api.ts new file mode 100644 index 0000000000..457f8f3ea9 --- /dev/null +++ b/e2e/support/parking/parking.api.ts @@ -0,0 +1,170 @@ +/** + * Backend helpers for the PARKING specs. + * + * Thin: a parking booking is an ordinary staff-api booking with + * `booking_type: 'parking'`, so the shared helpers already list, read, delete + * and sweep them. This adds creating one, and the two guards every area needs + * against REG-09 — the retry, and reading the row back, because a 201 with an id + * is not proof the row exists. + */ +import { APIRequestContext } from '@playwright/test'; +import { + Booking, + ENGINE_API, + STAFF_API, + currentUser, + zonesWithTag, +} from '../api'; + +function isConcurrentWriteFailure(status: number, body: string): boolean { + if (status === 400 && /Expected BeginObject but was EOF/.test(body)) return true; + if (status === 500 && /could not serialize access/.test(body)) return true; + // An empty `failures` list is the tell: a real validation error names a field. + if (status === 422 && /"failures"\s*:\s*\[\s*\]/.test(body)) return true; + return false; +} + +async function persisted(api: APIRequestContext, id: number): Promise { + const res = await api.get(`${STAFF_API}/bookings/${id}`); + return res.ok(); +} + +/** + * Create a parking booking straight through the API. + * + * For specs whose subject is not the form — clash rules, visibility, cancelling + * from the schedule. The zones include the PARKING level as well as the org and + * building, because that is the level the space lives on. + */ +export async function createParkingBookingViaApi( + api: APIRequestContext, + options: { + space: { id: string; name: string }; + title: string; + start: number; + end: number; + zone_id?: string; + }, +): Promise { + const me = await currentUser(api); + const zones = ( + await Promise.all(['org', 'building'].map((t) => zonesWithTag(api, t))) + ) + .flat() + .map((z) => z.id); + if (options.zone_id) zones.push(options.zone_id); + + const data = { + booking_type: 'parking', + asset_id: options.space.id, + asset_name: options.space.name, + booking_start: options.start, + booking_end: options.end, + timezone: 'Etc/UTC', + user_email: me.email, + user_id: me.id, + user_name: me.name, + title: options.title, + zones, + }; + + let last = ''; + for (let attempt = 1; attempt <= 4; attempt++) { + const res = await api.post(`${STAFF_API}/bookings`, { data }); + const body = await res.text(); + if (res.ok()) { + const created = JSON.parse(body); + if (await persisted(api, created.id)) return created; + last = `HTTP 201 for booking ${created.id}, which then 404s`; + console.warn(` ! phantom 201 on attempt ${attempt}/4 — ${last}`); + await new Promise((r) => setTimeout(r, 400 * attempt)); + continue; + } + last = `HTTP ${res.status()} ${body.slice(0, 200)}`; + if (!isConcurrentWriteFailure(res.status(), body)) { + throw new Error( + `create parking booking failed: ${last}. A 409 means the space is ` + + `already held for that window — check the spec swept first, and ` + + `that two spec files are not sharing a slot (see PARKING_SLOTS).`, + ); + } + console.warn(` ! concurrent-write failure on attempt ${attempt}/4 — ${last}`); + await new Promise((r) => setTimeout(r, 400 * attempt)); + } + throw new Error(`create parking booking still failing after 4 attempts. Last: ${last}`); +} + +/** Try to book and report what happened, without throwing — for clash specs. */ +export async function tryParkingBooking( + api: APIRequestContext, + options: Parameters[1], +): Promise<{ status: number; body: string; id?: number }> { + try { + const booking = await createParkingBookingViaApi(api, options); + return { status: 201, body: '', id: booking.id }; + } catch (error) { + const message = `${(error as Error).message}`; + const status = Number(/HTTP (\d+)/.exec(message)?.[1] ?? 0); + return { status, body: message }; + } +} + +/** + * The signed-in user's own settings blob. + * + * Favourite parking spaces are a USER setting (`favourite_parking`), written as + * part of the whole blob to `PUT /metadata/{user_id}` and debounced ~2.4s. Read + * here so a spec can prove a favourite outlived the page. + */ +export async function readUserSettings( + api: APIRequestContext, +): Promise> { + const me = await currentUser(api); + const res = await api.get(`${ENGINE_API}/metadata/${me.id}`, { + params: { name: 'settings' }, + }); + if (!res.ok()) { + throw new Error(`read user settings failed: HTTP ${res.status()} ${await res.text()}`); + } + const body = await res.json(); + return body?.settings?.details ?? {}; +} + +/** + * Set the user's favourite parking spaces, leaving every other setting alone. + * + * ## The key is `favourite_parking_spaces` + * + * Not `favourite_parking`. Both names exist in the codebase: + * `parking-select-modal.component.ts` exports `FAV_PARKING_KEY = + * 'favourite_parking_spaces'` and is what actually reads and writes the setting, + * while `libs/common/src/lib/constants.ts` carries + * `FAVORITE_PARKING_SPACES: 'favourite_parking'`. Nothing reads the constant, so + * it is dead rather than broken — but a future feature that trusts it would see + * no favourites, and this spec asserting on the wrong one cost a run. + * + * Read-modify-write, because the app PUTs the WHOLE settings blob: a PUT + * carrying only this key would wipe the visitor specs' invitee list and the + * other areas' favourites, which share this object. + */ +export const FAVOURITE_PARKING_KEY = 'favourite_parking_spaces'; + +export async function setFavouriteParking( + api: APIRequestContext, + ids: string[], +): Promise { + const me = await currentUser(api); + const details = await readUserSettings(api); + const res = await api.put(`${ENGINE_API}/metadata/${me.id}`, { + data: { + name: 'settings', + description: '', + details: { ...details, favourite_parking_spaces: ids }, + }, + }); + if (!res.ok()) { + throw new Error( + `write favourite_parking failed: HTTP ${res.status()} ${await res.text()}`, + ); + } +} diff --git a/e2e/support/parking/parking.env.ts b/e2e/support/parking/parking.env.ts new file mode 100644 index 0000000000..12f13f8083 --- /dev/null +++ b/e2e/support/parking/parking.env.ts @@ -0,0 +1,97 @@ +/** + * Identities and slots for the PARKING specs. + * + * Its own folder, like every other area: nothing parking-shaped should be able + * to break the desk, visitor, room or Your Bookings specs. + * + * A parking space is EXCLUSIVE for its window, like a desk and unlike a visitor, + * so the isolation rule is the desk one — every worker owns a space + * (`parking.seed.ts`) and every spec sweeps it before booking. + * + * Days stay small for the same reason they do for desks: the schedule's sidebar + * calendar only reaches the displayed month, and the edit and cancel specs have + * to use it. Days 1 to 4 are shared with the desk specs, which is safe because + * the ASSET differs — a sweep is per asset, not per day. + */ +export const PARKING_PREFIX = 'e2e-parking-'; + +/** The name of the space this worker owns. */ +export function spaceFor(workerIndex: number): { name: string } { + return { name: `E2E Parking ${workerIndex}` }; +} + +/** + * How many spaces are seeded: one per worker, plus one spare. + * + * The spare is what makes "move the booking to another space" testable without + * borrowing a space another worker is using. Because it is SHARED, the spec that + * books it staggers its hour by worker index — the same approach the spare desk + * needs, and for the same reason: days cannot be staggered, since the schedule's + * sidebar calendar only reaches the displayed month. + */ +export function spaceCount(workers: number): number { + return workers + 1; +} + +/** Stagger an hour by worker, for slots on the SHARED spare space. */ +export function hourFor(base: number, workerIndex: number): number { + return base + workerIndex; +} + +/** A day and an hour per spec file. */ +export const PARKING_SLOTS = { + /** parking-booking.spec.ts */ + booking: { day: 1, hour: 9 }, + /** parking-booking.spec.ts, the teardown test */ + cleanup: { day: 1, hour: 11 }, + /** parking-clash.spec.ts */ + clash: { day: 2, hour: 9 }, + /** parking-clash.spec.ts, the freed test */ + freed: { day: 2, hour: 13 }, + /** parking-scoping.spec.ts */ + scoping: { day: 3, hour: 9 }, + /** parking-scoping.spec.ts, the control */ + scoping_control: { day: 3, hour: 13 }, + /** parking-times.spec.ts */ + times: { day: 4, hour: 10 }, + /** parking-cancel.spec.ts */ + cancel: { day: 1, hour: 15 }, + /** parking-cancel.spec.ts, the declined test */ + cancel_declined: { day: 2, hour: 16 }, + /** parking-edit.spec.ts */ + edit: { day: 3, hour: 16, moved_to: 18 }, + /** parking-api.spec.ts */ + api: { day: 4, hour: 20 }, +} as const; + +export function slotOn(dayOffset: number, hour: number, minutes = 60) { + const start = new Date(); + start.setDate(start.getDate() + dayOffset); + start.setHours(hour, 0, 0, 0); + return { + start: Math.floor(start.valueOf() / 1000), + end: Math.floor(start.valueOf() / 1000) + minutes * 60, + date_ms: start.valueOf(), + }; +} + +/** The local day a slot falls on, as a unix-second window, for sweeps. */ +export function dayBoundsOn(dayOffset: number) { + const start = new Date(); + start.setDate(start.getDate() + dayOffset); + start.setHours(0, 0, 0, 0); + const end = new Date(start); + end.setHours(23, 59, 59, 0); + return { + from: Math.floor(start.valueOf() / 1000), + to: Math.floor(end.valueOf() / 1000), + }; +} + +/** "HH:mm" for a unix timestamp, in the browser's local time. */ +export function hhmm(unix_seconds: number): string { + const date = new Date(unix_seconds * 1000); + const hh = `${date.getHours()}`.padStart(2, '0'); + const mm = `${date.getMinutes()}`.padStart(2, '0'); + return `${hh}:${mm}`; +} diff --git a/e2e/support/parking/parking.seed.ts b/e2e/support/parking/parking.seed.ts new file mode 100644 index 0000000000..7ca64b19ff --- /dev/null +++ b/e2e/support/parking/parking.seed.ts @@ -0,0 +1,197 @@ +/** + * Seeding parking, which is the most involved resource in this suite. + * + * ## What a bookable parking space is made of + * + * Working backwards from `libs/bookings/src/lib/parking.service.ts` and + * `libs/assets/src/lib/parking-assets.fn.ts`, three things have to exist and the + * stack seeds none of them: + * + * 1. a LEVEL zone tagged **`parking`**. `ParkingService.levels` filters the + * org's levels down to those carrying that tag, and with none the form has + * nowhere to look. + * 2. a hidden asset CATEGORY named exactly `_PARKING_`, and inside it an asset + * TYPE named exactly `_PARKING_SPACES_`. The app creates these itself the + * first time an admin saves a space, which is why a fresh stack has neither. + * 3. one ASSET of that type per worker, on that zone. That is the space. + * + * ## Deliberate choices + * + * - **A NEW level zone, not a tag on the existing one.** The desks live on the + * seeded level, and adding a tag to it would change data every desk spec + * depends on. A separate `E2E Parking Level` under the same building keeps + * parking's needs entirely inside parking's own seeding. + * - **One space per worker, plus one spare.** A parking space is exclusive for a + * window, like a desk, so each worker owning one removes the only real + * contention; the spare exists so a booking can be MOVED between spaces. + * - **The asset APIs are on the ENGINE api**, not staff-api, and creating any of + * this needs ADMIN. Both learned the hard way in the catering seeder; see + * `room/catering.seed.ts`. + * - **Idempotent and cached per process**: the first caller creates, everyone + * after finds, and nothing is deleted between runs. + */ +import { APIRequestContext } from '@playwright/test'; +import { ENGINE_API, apiFor, zonesWithTag } from '../api'; +import { WORKERS } from '../env'; +import { PARKING_PREFIX, spaceCount, spaceFor } from './parking.env'; + +const PARKING_LEVEL_NAME = 'E2E Parking Level'; +/** Exact names the app looks for. Neither is ours to choose. */ +const PARKING_CATEGORY = '_PARKING_'; +const PARKING_TYPE = '_PARKING_SPACES_'; + +export interface ParkingSpaceIdentity { + id: string; + name: string; +} + +export interface ParkingSeed { + zone_id: string; + type_id: string; + spaces: ParkingSpaceIdentity[]; +} + +async function listAll( + api: APIRequestContext, + path: string, + params: Record = {}, +): Promise { + const res = await api.get(`${ENGINE_API}/${path}`, { + params: { limit: '500', ...params }, + }); + if (!res.ok()) { + throw new Error(`list ${path} failed: HTTP ${res.status()} ${await res.text()}`); + } + const body = await res.json(); + return (Array.isArray(body) ? body : (body?.data ?? body?.results ?? [])) as any[]; +} + +async function create( + api: APIRequestContext, + path: string, + data: Record, +): Promise { + const res = await api.post(`${ENGINE_API}/${path}`, { data }); + if (!res.ok()) { + throw new Error( + `create ${path} failed: HTTP ${res.status()} ${await res.text()}. ` + + `Payload: ${JSON.stringify(data).slice(0, 300)}`, + ); + } + return res.json(); +} + +/** The parking-tagged level, created under the seeded building if absent. */ +async function ensureParkingLevel(api: APIRequestContext): Promise { + const [building] = await zonesWithTag(api, 'building'); + if (!building?.id) { + throw new Error( + 'no building zone to hang a parking level off — the stack is not seeded. ' + + 'Run e2e/stack/up.sh first.', + ); + } + const levels = await zonesWithTag(api, 'parking'); + const existing = levels.find((z) => z.name === PARKING_LEVEL_NAME); + if (existing) return existing.id; + + const zone = await create(api, 'zones', { + name: PARKING_LEVEL_NAME, + display_name: PARKING_LEVEL_NAME, + description: 'Parking level owned by the e2e suite. Safe to delete.', + // BOTH tags: `level` is what the org hierarchy reads, `parking` is what + // ParkingService.levels filters on. One without the other leaves the + // level invisible to one of them. + tags: ['level', 'parking'], + parent_id: building.id, + }); + return zone.id; +} + +/** The hidden category and the space type inside it. */ +async function ensureSpaceType(api: APIRequestContext): Promise { + const categories = await listAll(api, 'asset_categories', { hidden: 'true' }); + let category = categories.find((c) => c.name === PARKING_CATEGORY); + category = + category ?? + (await create(api, 'asset_categories', { name: PARKING_CATEGORY, hidden: true })); + + const types = await listAll(api, 'asset_types', { category_id: category.id }); + let type = types.find((t) => t.name === PARKING_TYPE); + type = + type ?? + (await create(api, 'asset_types', { + name: PARKING_TYPE, + category_id: category.id, + // Required: without it the POST is a 422 "brand: should not be nil". + brand: 'E2E', + description: 'Parking spaces owned by the e2e suite. Safe to delete.', + })); + return type.id; +} + +export async function ensureParking(): Promise { + const admin = await apiFor('admin', 0); + try { + const zone_id = await ensureParkingLevel(admin); + const type_id = await ensureSpaceType(admin); + const existing = await listAll(admin, 'assets', { zone_id, type_id }); + const spaces: ParkingSpaceIdentity[] = []; + + // One per worker PLUS one spare — see `spaceCount`. + for (let i = 0; i < spaceCount(WORKERS); i++) { + const want = spaceFor(i); + const found = existing.find((a) => a.name === want.name); + if (found) { + spaces.push({ id: found.id, name: want.name }); + continue; + } + const asset = await create(admin, 'assets', { + name: want.name, + identifier: want.name, + zone_id, + asset_type_id: type_id, + description: `${PARKING_PREFIX}${i}`, + }); + spaces.push({ id: asset.id, name: want.name }); + } + return { zone_id, type_id, spaces }; + } finally { + await admin.dispose(); + } +} + +let cache: Promise | null = null; + +/** The parking space this worker owns, creating the set on first use. */ +export async function spaceForWorker( + workerIndex: number, +): Promise { + cache = cache ?? ensureParking(); + const seed = await cache.catch((error) => { + // Do not poison the cache: a stack that was still starting up should not + // fail every later spec with the same stale error. + cache = null; + throw error; + }); + const space = seed.spaces[workerIndex]; + if (!space) { + throw new Error( + `no parking space seeded for worker ${workerIndex}. ${seed.spaces.length} ` + + `exist, one per worker, so E2E_WORKERS is smaller than the number ` + + `Playwright is running.`, + ); + } + return space; +} + +/** The space no worker owns, for "move it to another space". */ +export async function altSpace(): Promise { + const seed = await parkingSeed(); + return seed.spaces[seed.spaces.length - 1]; +} + +/** The whole seed, for specs that need the zone as well as a space. */ +export async function parkingSeed(): Promise { + cache = cache ?? ensureParking(); + return cache; +} diff --git a/e2e/support/parking/parking.settings.ts b/e2e/support/parking/parking.settings.ts new file mode 100644 index 0000000000..94418bc4d2 --- /dev/null +++ b/e2e/support/parking/parking.settings.ts @@ -0,0 +1,77 @@ +/** + * Per-test app settings for the parking specs. + * + * The fourth copy of this helper (visitor, room, bookings, desk, parking) and + * deliberately so: one shared settings file could break every area at once. + * + * Parking reads BOTH `app.parking.*` and `app.bookings.*` depending on the + * setting, which is worth knowing before hunting a setting that "does nothing". + */ +import { Page } from '@playwright/test'; + +export async function useSettings( + page: Page, + overrides: Record, +): Promise { + const bad = Object.keys(overrides).filter((k) => !k.startsWith('app.')); + if (bad.length) { + throw new Error( + `setting override keys must start with "app." — the app drops the rest on ` + + `load, leaving the default quietly in place. Got: ${bad.join(', ')}`, + ); + } + await page.addInitScript((value) => { + localStorage.setItem('PLACEOS.setting_overrides', JSON.stringify(value)); + }, overrides); +} + +/** + * Settings every parking spec runs with. + * + * ## PARK-B1: without this, a parking space cannot be booked AT ALL here + * + * On this stack, with no overrides at all, pressing "Confirm Reservation" + * produces: + * + * Some fields are invalid. [space_restrictions] + * + * and the sheet never opens. The field it names is **not on the booking form** — + * `parking-form-details.component.ts` never renders a `space_restrictions` + * control (measured: zero mentions). The validator lives in the SHARED booking + * form (`booking.utilities.ts`) and fires for any `booking_type === 'parking'` + * when `parking.require_space_restriction` is set, while the setting's own + * schema describes it as belonging to the **parking REQUEST flow** + * ("Whether users must select a parking space restriction in the parking request + * flow", `settings.schema.json`), which does render a control for it. + * + * So a deployment that switches it on for requests silently makes ordinary + * parking bookings impossible, with an error naming a field nobody can fill in. + * That is PARK-B1, and `parking-restrictions.spec.ts` carries it as a `fixme`. + * + * Turning it off here is what lets every other parking scenario be covered. It + * also means these specs say nothing about the restriction feature itself. + */ +export const PARKING_BASE_SETTINGS = { + 'app.parking.require_space_restriction': false, +}; + +/** + * Cap the booking length and confine it to office hours. + * + * Unset by default, and an out-of-range option is ABSENT rather than refused — + * so the times spec asserts on what the form offers. + */ +export const LIMITED_HOURS = { + 'app.bookings.max_duration': 60, + 'app.bookings.bookable_hours': { start: 9, end: 17 }, +}; + +/** + * Restrict parking to the user's home location. + * + * `ParkingService` reads this and refuses spaces away from the user's home + * building (`restrict_home_location` / `deny_parking_access`). + */ +export const RESTRICT_TO_HOME = { + 'app.parking.restrict_home_location': true, +}; diff --git a/e2e/support/room/catering.seed.ts b/e2e/support/room/catering.seed.ts new file mode 100644 index 0000000000..ea3649e280 --- /dev/null +++ b/e2e/support/room/catering.seed.ts @@ -0,0 +1,160 @@ +/** + * Seeding a catering menu, so the meeting form offers catering at all. + * + * ## Why this is needed + * + * The meeting form only renders its catering section when + * `CateringStateService.available_menu()` is non-empty + * (`meeting-flow-form.component.ts`: `has_catering`), and that menu is built + * from ASSETS, not from settings. So without seeding, the whole feature is + * invisible and a spec would be asserting against a section that can never + * exist. + * + * ## The three things a menu is made of + * + * Working backwards from `libs/assets/src/lib/catering-assets.fn.ts`: + * + * 1. a HIDDEN asset category named exactly `_CATERING_`. The app creates this + * itself when an admin saves a menu item, which is why it does not exist on + * a fresh stack. + * 2. an asset TYPE in that category whose name starts with `CATERING:`. The + * part after the prefix is the caterer's name, and `_STANDALONE_` is the + * special case meaning "no caterer". + * 3. an ASSET of that type on the BUILDING zone. Its `other_data` holds the + * price, the stock quantity and the category — `toCateringItem` reads every + * menu-ish field from there, not from columns. + * + * ## These live on the ENGINE api, not staff-api + * + * `/api/staff/v1/assets` is a 404 on this image; assets, asset types and asset + * categories are all `/api/engine/v2/*`. Measured, because the obvious guess + * wasted a first attempt. Creating them needs ADMIN, like rooms. + */ +import { APIRequestContext } from '@playwright/test'; +import { ENGINE_API, apiFor, zonesWithTag } from '../api'; + +/** The hidden category the app itself uses. Exact name matters. */ +const CATERING_CATEGORY = '_CATERING_'; +/** `CATERING:` + caterer name. The prefix is what marks a type as catering. */ +export const CATERING_CATEGORY_TYPE = 'CATERING:E2E Caterer'; +export const CATERING_ITEM_NAME = 'E2E Sandwich Platter'; +export const CATERING_ITEM_PRICE = 12; + +export interface CateringMenu { + category_id: string; + type_id: string; + item_id: string; + item_name: string; + caterer: string; + zone_id: string; +} + +async function listAll(api: APIRequestContext, path: string, params: Record = {}) { + const res = await api.get(`${ENGINE_API}/${path}`, { + params: { limit: '500', ...params }, + }); + if (!res.ok()) { + throw new Error(`list ${path} failed: HTTP ${res.status()} ${await res.text()}`); + } + const body = await res.json(); + return (Array.isArray(body) ? body : (body?.data ?? body?.results ?? [])) as any[]; +} + +async function create(api: APIRequestContext, path: string, data: Record) { + const res = await api.post(`${ENGINE_API}/${path}`, { data }); + if (!res.ok()) { + throw new Error( + `create ${path} failed: HTTP ${res.status()} ${await res.text()}. ` + + `Payload: ${JSON.stringify(data).slice(0, 300)}`, + ); + } + return res.json(); +} + +/** + * Make sure there is one catering item on the building, and return it. + * + * Idempotent and cached per process, exactly like `room.seed.ts`: the first + * caller creates, everyone after finds. One item is enough — the subject is + * whether an order reaches the backend, not the menu. + */ +export async function ensureCateringMenu(): Promise { + const admin = await apiFor('admin', 0); + try { + const [building] = await zonesWithTag(admin, 'building'); + if (!building?.id) { + throw new Error('no building zone — the stack is not seeded'); + } + + const categories = await listAll(admin, 'asset_categories', { hidden: 'true' }); + let category = categories.find((c) => c.name === CATERING_CATEGORY); + category = + category ?? + (await create(admin, 'asset_categories', { + name: CATERING_CATEGORY, + hidden: true, + })); + + const types = await listAll(admin, 'asset_types', { category_id: category.id }); + let type = types.find((t) => t.name === CATERING_CATEGORY_TYPE); + type = + type ?? + (await create(admin, 'asset_types', { + name: CATERING_CATEGORY_TYPE, + category_id: category.id, + // `brand` is REQUIRED — an asset type without it is a 422 + // "should not be nil", which is not obvious from the app, since + // the app's own menu editor always sends one. + brand: 'E2E', + description: 'Catering menu owned by the e2e suite. Safe to delete.', + })); + + const assets = await listAll(admin, 'assets', { + zone_id: building.id, + type_id: type.id, + }); + let item = assets.find((a) => a.name === CATERING_ITEM_NAME); + item = + item ?? + (await create(admin, 'assets', { + name: CATERING_ITEM_NAME, + identifier: CATERING_ITEM_NAME, + zone_id: building.id, + asset_type_id: type.id, + // Everything menu-shaped lives in `other_data` — see + // `toCateringItem`. A quantity of 0 means "out of stock" and the + // item is then offered but cannot be ordered. + other_data: { + category: 'Food', + description: 'Seeded by the e2e suite', + unit_price: CATERING_ITEM_PRICE, + quantity: 100, + options: [], + tags: [], + accept_points: false, + discount_cap: 0, + hide_for_zones: [], + }, + })); + + return { + category_id: category.id, + type_id: type.id, + item_id: item.id, + item_name: CATERING_ITEM_NAME, + caterer: CATERING_CATEGORY_TYPE.replace('CATERING:', ''), + zone_id: building.id, + }; + } finally { + await admin.dispose(); + } +} + +let cache: Promise | null = null; +export async function cateringMenu(): Promise { + cache = cache ?? ensureCateringMenu(); + return cache.catch((error) => { + cache = null; + throw error; + }); +} diff --git a/e2e/support/room/meeting-form.page.ts b/e2e/support/room/meeting-form.page.ts new file mode 100644 index 0000000000..3287cb9223 --- /dev/null +++ b/e2e/support/room/meeting-form.page.ts @@ -0,0 +1,592 @@ +/** + * Page object for `/book/meeting/form` — the room booking flow. + * + * SELECTOR POLICY, the same one the desk and visitor work settled on: nothing + * here depends on visible text, because every label is translated. The meeting + * flow is the best-behaved surface of the three for this — the load-bearing + * controls all carry `name` attributes already: + * + * open-meeting-confirm · confirm-meeting · clear-form-meeting + * add-space · select-space · space-return · remove-space + * start-time · toggle-attendees-meeting · toggle-spaces-meeting + * + * plus the ids `#title`, `#date` and `#end-time` (which is the DURATION field, + * not a time — a trap worth naming). + * + * THE FLOW IS THREE SCREENS, not one: form -> confirm -> success. `Confirm` on + * the form only changes the view; the request is not sent until `confirm-meeting` + * on the second screen. A spec that clicks the first and then looks for a + * booking will find nothing and blame the backend. + */ +import { Locator, Page, expect } from '@playwright/test'; + +export class MeetingForm { + constructor(private readonly page: Page) {} + + get root(): Locator { + return this.page.locator('meeting-flow-form'); + } + get title(): Locator { + return this.page.locator('#title'); + } + get dateButton(): Locator { + return this.page.locator('#date button').first(); + } + get datePicker(): Locator { + return this.page.locator('.cdk-overlay-container date-calendar'); + } + /** + * Scoped to the FORM, not the page. + * + * The room picker carries its own date, start-time and end-time fields, so + * an unscoped `button[time-field]` can match the modal's copy instead of the + * form's — and then the click lands somewhere that has no effect on what + * gets booked. + */ + get startTimeTrigger(): Locator { + return this.root.locator('button[time-field]').first(); + } + /** `#end-time` is the DURATION control, despite the id. */ + get durationTrigger(): Locator { + return this.root.locator('button[duration-field]').first(); + } + get addSpaceButton(): Locator { + return this.page.locator('button[name="add-space"]'); + } + get chosenSpaces(): Locator { + return this.root.locator('space-list-field button[name="remove-space"]'); + } + get confirmViewButton(): Locator { + return this.page.locator('button[name="open-meeting-confirm"]'); + } + get confirmButton(): Locator { + return this.page.locator('button[name="confirm-meeting"]'); + } + /** + * The success screen's "done" link. + * + * NOT the `meeting-flow-success` host element: its only child is + * `absolute inset-0`, so the host itself has no box and Playwright reports + * it as not visible even while the screen is plainly on show. Waiting on it + * times out after a booking that worked perfectly. This link is inside, has + * a real box, and carries a stable name. + */ + get successPanel(): Locator { + return this.page.locator('a[name="meeting-created-continue"]'); + } + get attendeeEntry(): Locator { + return this.page.locator('a-user-list-field input').first(); + } + get attendeeChips(): Locator { + return this.page.locator('a-user-list-field mat-chip-row[user]'); + } + + /** Open the form and wait for it to render. */ + async open(): Promise { + await this.page.goto('/#/book/meeting/form'); + await expect( + this.root, + 'the meeting form never rendered — is the `spaces` feature enabled?', + ).toBeVisible({ timeout: 30_000 }); + await expect( + this.title, + 'the meeting form rendered without its title field', + ).toBeVisible({ timeout: 30_000 }); + } + + /** + * Pick a room by name from the space picker. + * + * Three steps, all needed: `add-space` opens the modal, `select-space` on a + * row previews that room, and `toggle-space` — the "Select Item" button — + * takes it and closes. Closing the modal any other way silently drops the + * choice. + * + * NOT `space-return`, which is the confirm button for MULTI-select and is + * not rendered at all when the form takes one room. A click on it waits the + * full timeout against an element that was never in the DOM. + * + * The room is matched on its NAME, which is test data we created + * (`E2E Room 0`), not app copy — so this does not break the no-visible-text + * rule. There is no id or data- attribute on the row to use instead. + */ + async chooseRoom(name: string): Promise { + await this.addSpaceButton.click(); + const modal = this.page.locator('space-select-modal, mat-dialog-container'); + await expect( + modal.first(), + 'the room picker did not open', + ).toBeVisible({ timeout: 20_000 }); + + const option = this.page + .locator('button[name="select-space"]') + .filter({ hasText: name }); + const found = await option + .first() + .waitFor({ state: 'visible', timeout: 20_000 }) + .then(() => true) + .catch(() => false); + if (!found) { + // Say what IS on offer. "Not found" cannot tell apart a room that was + // never seeded, one on the wrong zone, and one the picker is filtering + // out for being unavailable at the chosen time. + const offered = await this.page + .locator('button[name="select-space"]') + .allInnerTexts(); + throw new Error( + `no room called "${name}" in the picker. It offers ${offered.length}: ` + + `${JSON.stringify(offered.map((t) => t.split('\n')[0].trim()))}. ` + + `Check the room is seeded (room.seed.ts), sits on the building AND ` + + `level zone, is bookable, and is free at the time chosen.`, + ); + } + await option.first().click(); + + const done = this.page.locator('button[name="toggle-space"]'); + await expect( + done, + 'the room picker has no "Select Item" button — in multi-select mode the ' + + 'confirm button is `space-return` instead', + ).toBeVisible({ timeout: 10_000 }); + await expect( + done, + 'the "Select Item" button is disabled, so no room is previewed — the row ' + + 'click did not register', + ).toBeEnabled({ timeout: 10_000 }); + await done.click(); + await expect( + this.chosenSpaces, + `"${name}" was picked but the form shows no room`, + ).toHaveCount(1, { timeout: 20_000 }); + } + + /** + * The names of the rooms currently on the form. + * + * `chosenSpaces` counts the remove buttons, which is enough to know a room + * is chosen and NOT enough to know WHICH — and when a booking is moved from + * one room to another the count never changes. Asserting on the name is the + * only way to see the swap land. + */ + async chosenRoomNames(): Promise { + const rows = this.root.locator('space-list-field div[space]'); + const texts = await rows.allInnerTexts(); + return texts.map((t) => t.split('\n')[0].trim()); + } + + /** + * Pick SEVERAL rooms, for multi-select mode. + * + * The picker behaves differently when `app.events.multiple_spaces` is on: + * each row is toggled into a selection and the modal stays open, so the + * rooms are chosen one after another and the whole set is confirmed once — + * with `space-return`, which is NOT rendered in single-select mode at all. + * + * That asymmetry is the trap recorded at the top of this file, seen from the + * other direction: `chooseRoom` confirms with `toggle-space` and would wait + * out its timeout here. + */ + async chooseRooms(names: string[]): Promise { + await this.addSpaceButton.click(); + const modal = this.page.locator('space-select-modal, mat-dialog-container'); + await expect(modal.first(), 'the room picker did not open').toBeVisible({ + timeout: 20_000, + }); + + for (const name of names) { + const option = this.page + .locator('button[name="select-space"]') + .filter({ hasText: name }) + .first(); + const found = await option + .waitFor({ state: 'visible', timeout: 20_000 }) + .then(() => true) + .catch(() => false); + if (!found) { + const offered = await this.page + .locator('button[name="select-space"]') + .allInnerTexts(); + throw new Error( + `no room called "${name}" in the picker. It offers ${offered.length}: ` + + `${JSON.stringify(offered.map((t) => t.split('\n')[0].trim()))}.`, + ); + } + await option.click(); + // Each pick has to be added to the selection before the next row is + // chosen — and the add button starts DISABLED, becoming enabled only + // once the row click has registered. Measured: checking `isEnabled` + // immediately reads false, the add is skipped, and the form ends up + // with one room instead of two while every step appeared to work. + const add = this.page.locator('button[name="toggle-space"]'); + await expect( + add, + `the picker's add button never enabled after choosing "${name}"`, + ).toBeEnabled({ timeout: 10_000 }); + await add.click(); + } + + const done = this.page.locator('button[name="space-return"]'); + await expect( + done, + 'the picker has no `space-return` button — that control only exists in ' + + 'MULTI-select mode, so check `app.events.multiple_spaces` is set', + ).toBeVisible({ timeout: 10_000 }); + await done.click(); + } + /** + * Set the "minimum people" radio above the room list. + * + * ## Read this before wondering why a room is "not seeded" + * + * The form asks how many people the meeting is for and the picker only + * offers rooms that hold at least that many. It defaults to **4**, so a + * room with capacity 1 or 2 is simply absent from the picker until this is + * changed — and the picker's error then says the room does not exist, which + * is not what is wrong. Measured: with the default, a capacity-1 room is + * missing; after clicking "Min. 1 person", all of them are offered. + * + * The three options are 1, 4 and 10 (`space-list-field.component.ts`). + * Matched on the radio's `value` attribute rather than its position or its + * translated label — "Min. 1 person" is copy. + * + * A capacity of 0 escapes the filter altogether, because `Space` maps a + * falsy capacity to **-1** and the filter lets anything negative through. So + * a capacity-0 room is always offered and is displayed as "Capacity 2". + * That is a trap for a spec that wants a room too small to book: use a real + * small capacity and this control, not 0. + */ + async setRoomSize(minimum: 1 | 4 | 10): Promise { + const radio = this.page + .locator('space-list-field mat-radio-button') + .filter({ has: this.page.locator(`input[value="${minimum}"]`) }); + await expect( + radio, + `no "minimum ${minimum} people" option above the room list — the sizes ` + + 'offered are 1, 4 and 10', + ).toHaveCount(1, { timeout: 20_000 }); + await radio.click(); + await expect( + radio.locator('input[type="radio"]'), + `the minimum-${minimum} option did not take`, + ).toBeChecked({ timeout: 10_000 }); + } + + /** + * Take the currently chosen room off the form. + * + * Needed before picking a different one: the form holds a single room, and + * `chooseRoom` on a form that already has one opens the picker with that + * room selected — so the "Select Item" button toggles it OFF rather than + * taking the new one. Editing a booking to move it to another room is the + * only flow that hits this. + */ + async removeRoom(): Promise { + const count = await this.chosenSpaces.count(); + if (!count) return; + await this.chosenSpaces.first().click(); + await expect( + this.chosenSpaces, + 'the room was not removed from the form', + ).toHaveCount(count - 1, { timeout: 10_000 }); + } + + /** + * Add an attendee, and make sure it actually landed. + * + * Typing an address opens an autocomplete offering "add external user", and + * Enter picks it — but only once that panel has caught up. Fire Enter too + * early and the address just sits in the box: no chip, no error, and the + * meeting is booked one person short. Same trap the visitor chip list has, + * same fix: retry until the chip appears. + */ + async addAttendee(email: string): Promise { + const before = await this.attendeeChips.count(); + for (let attempt = 1; attempt <= 3; attempt++) { + await this.attendeeEntry.fill(email); + await this.attendeeEntry.press('Enter'); + const landed = await expect(this.attendeeChips) + .toHaveCount(before + 1, { timeout: 5_000 }) + .then(() => true) + .catch(() => false); + if (landed) return; + } + const shown = await this.attendeeChips.allInnerTexts(); + throw new Error( + `${email} could not be added as an attendee after 3 tries. The list holds ` + + `${shown.length}: ${JSON.stringify(shown.map((t) => t.split('\n')[0].trim()))}.`, + ); + } + + /** Take an attendee off the list before sending. */ + async removeAttendee(email: string): Promise { + const local = email.split('@')[0]; + const texts = await this.attendeeChips.allInnerTexts(); + const index = texts.findIndex((text) => { + const label = text.split('\n')[0].trim(); + return label === email || label === local; + }); + if (index < 0) { + throw new Error( + `no attendee chip for ${email}. The list holds ${texts.length}: ` + + `${JSON.stringify(texts.map((t) => t.split('\n')[0].trim()))}. A chip ` + + `shows the name or the part before the @, never the full address.`, + ); + } + await this.attendeeChips.nth(index).locator('button[remove]').click(); + await expect( + this.attendeeChips, + `removing ${email} did not shorten the attendee list`, + ).toHaveCount(texts.length - 1, { timeout: 10_000 }); + } + + get addCateringButton(): Locator { + return this.page.locator('button[name="add-catering-item"]'); + } + + /** The catering orders listed on the form, one row per order. */ + get cateringOrders(): Locator { + return this.page.locator('catering-list-field [order]'); + } + + /** + * Order one item from the catering menu. + * + * Four steps, and the first one is a trap: **the button is disabled until a + * room is chosen** (`catering-list-field.component.ts`: + * `disabled() || !rooms().length`). Catering is delivered to a room, so a + * meeting without one cannot order — and a spec that adds catering before + * picking a room waits out the timeout on a button that will never enable. + * + * add-catering-item ....... opens the menu modal + * select-catering-item .... opens that item's details, with the quantity + * toggle-catering ......... "Add this item" — adds it to the order + * catering-return ......... "Confirm Selection" — closes and returns it + * + * Nothing is ordered on the backend at this point: catering orders are sent + * as their own `catering-order` bookings only when the meeting itself is + * confirmed (`postForm` → `createBookingsForEvent`). + */ + async addCateringItem(name: string): Promise { + await expect( + this.addCateringButton, + 'the catering section is missing — is a catering menu seeded for this ' + + 'building? See `catering.seed.ts`', + ).toBeVisible({ timeout: 20_000 }); + await expect( + this.addCateringButton, + 'the "add catering" button is disabled — a room has to be chosen first, ' + + 'because catering is delivered to a room', + ).toBeEnabled({ timeout: 20_000 }); + await this.addCateringButton.click(); + + const item = this.page + .locator('button[name="select-catering-item"]') + .filter({ hasText: name }); + const found = await item + .first() + .waitFor({ state: 'visible', timeout: 20_000 }) + .then(() => true) + .catch(() => false); + if (!found) { + const offered = await this.page + .locator('button[name="select-catering-item"]') + .allInnerTexts(); + throw new Error( + `no catering item called "${name}". The menu offers ${offered.length}: ` + + `${JSON.stringify(offered.map((t) => t.split('\n')[0].trim()))}. ` + + `A menu item is an ASSET of a \`CATERING:\` asset type on the ` + + `building zone — see \`catering.seed.ts\`.`, + ); + } + await item.first().click(); + + const add = this.page.locator('button[name="toggle-catering"]'); + await expect(add, 'the item details did not open').toBeVisible({ timeout: 10_000 }); + await add.click(); + const done = this.page.locator('button[name="catering-return"]'); + await expect(done, 'the catering modal has no confirm button').toBeVisible({ + timeout: 10_000, + }); + await done.click(); + await expect( + this.cateringOrders, + `"${name}" was added but the form lists no catering order`, + ).toHaveCount(1, { timeout: 20_000 }); + } + + /** + * Tick or clear All Day. + * + * Rendered only when `app.events.allow_multiday` is set, and matched by + * position rather than by label: the checkbox carries no name and its text + * is translated. It is the only `mat-checkbox` on the form in the default + * configuration — if that changes, this needs a better handle rather than a + * different index. + * + * ENSURE, never blindly toggle. `force`, because Material hides the real + * input behind its own markup. + */ + async setAllDay(want: boolean): Promise { + // Bound to the form's OWN `all_day` field, so the input's name ends in + // `.all_day`. Not "the first mat-checkbox on the form": that is + // "notify new attendees only" when attendees are in play, and ticking it + // instead leaves the meeting an ordinary one-hour booking while every + // step appears to have worked. + const box = this.root.locator('input[name$=".all_day"]').first(); + if (!(await box.count())) { + throw new Error( + 'no All Day checkbox on the meeting form — it is only rendered when ' + + '`app.events.allow_multiday` is set', + ); + } + if ((await box.isChecked()) === want) return; + await box.click({ force: true, timeout: 10_000 }); + await expect(box, `the All Day checkbox did not turn ${want ? 'on' : 'off'}`) + .toBeChecked({ checked: want, timeout: 10_000 }); + } + + /** + * Choose who the meeting is for, from the host field's autocomplete. + * + * TYPED, not filled: the field is driven from real key events and `fill()` + * leaves its search untouched — measured on the desk form, where a filled + * value produced no requests at all. It also arrives pre-filled with the + * signed-in user, so it is cleared with select-all first rather than + * `fill('')`, which the model writes straight back. + * + * Options show the person's NAME above their address, so matching strips + * everything but letters and digits from both sides. + */ + async chooseHost(email: string): Promise { + const flat = (value: string) => value.toLowerCase().replace(/[^a-z0-9]/g, ''); + const field = this.root + .locator('a-user-search-field, host-select-field') + .first(); + await expect( + field, + 'no host field on the meeting form — it is only rendered when ' + + '`app.events.can_book_for_anyone` (or `can_book_for_others`) is set', + ).toBeVisible({ timeout: 20_000 }); + + const input = field.locator('input').first(); + const options = this.page.locator('mat-option'); + for (let attempt = 1; attempt <= 3; attempt++) { + await input.click(); + await input.press('ControlOrMeta+a'); + await input.press('Backspace'); + // PROVE it is empty before typing. The field arrives pre-filled with + // the signed-in user and the model writes that value back, so a + // clear that did not take leaves the old name in place — the search + // then runs on it and the panel offers the WRONG person, which is + // exactly what happened on the first run of the room delegate spec. + const cleared = await expect(input) + .toHaveValue('', { timeout: 5_000 }) + .then(() => true) + .catch(() => false); + if (!cleared) continue; + await input.pressSequentially(email, { delay: 30 }); + // The search is debounced (~300ms). Reading the options before it + // fires returns whatever the last term produced. + await this.page.waitForTimeout(800); + const appeared = await options + .first() + .waitFor({ state: 'visible', timeout: 10_000 }) + .then(() => true) + .catch(() => false); + if (!appeared) continue; + const texts = await options.allInnerTexts(); + const index = texts.findIndex((t) => flat(t).includes(flat(email))); + if (index < 0) continue; + await options.nth(index).click(); + return; + } + const offered = await options.allInnerTexts(); + throw new Error( + `no host option for ${email} after 3 tries. The panel offered ` + + `${offered.length}: ${JSON.stringify(offered.slice(0, 6))}. Without ` + + `\`app.basic_user_search\` the field searches the calendar directory, ` + + `which 500s on this stack.`, + ); + } + + /** Is All Day currently ticked? */ + async isAllDay(): Promise { + const box = this.root.locator('input[name$=".all_day"]').first(); + if (!(await box.count())) return false; + return box.isChecked(); + } + + /** What the host field currently shows, for proving a choice stuck. */ + async hostValue(): Promise { + const field = this.root + .locator('a-user-search-field, host-select-field') + .first(); + if (!(await field.count())) return ''; + const input = field.locator('input').first(); + if (await input.count()) return (await input.inputValue()) || ''; + return (await field.innerText()).trim(); + } + + /** Set the start time from the options the form offers, as "HH:mm". */ + async setStartTime(hhmm: string): Promise { + await this.startTimeTrigger.click(); + const option = this.page.locator(`button[data-time="${hhmm}"]`); + await expect( + option, + `no start-time option for ${hhmm} — the form offers times on a fixed step ` + + `and only inside its bookable hours, so pick one it lists`, + ).toBeVisible({ timeout: 10_000 }); + await option.click(); + } + + /** Set how long the meeting runs, in minutes. */ + async setDuration(minutes: number): Promise { + await this.durationTrigger.click(); + const option = this.page.locator(`button[data-duration="${minutes}"]`); + await expect( + option, + `no ${minutes} minute option — lengths come on a fixed step up to ` + + '`max_duration`', + ).toBeVisible({ timeout: 10_000 }); + await option.click(); + } + + /** Every start time on offer, read from `data-time` rather than the label. */ + async startTimeOptions(): Promise { + await this.startTimeTrigger.click(); + const options = this.page.locator('button[data-time]'); + await expect(options.first()).toBeVisible({ timeout: 10_000 }); + const values = await options.evaluateAll((els) => + els.map((el) => el.getAttribute('data-time') ?? ''), + ); + await this.page.keyboard.press('Escape'); + return values.filter(Boolean); + } + + /** Every meeting length on offer, in minutes. */ + async durationOptions(): Promise { + await this.durationTrigger.click(); + const options = this.page.locator('button[data-duration]'); + await expect(options.first()).toBeVisible({ timeout: 10_000 }); + const values = await options.evaluateAll((els) => + els.map((el) => Number(el.getAttribute('data-duration'))), + ); + await this.page.keyboard.press('Escape'); + return values.filter((v) => Number.isFinite(v)); + } + + /** + * Move to the confirm screen, then send. + * + * Separate from filling the form so a spec can assert on the confirm screen + * in between — and so the two clicks are never mistaken for one. + */ + async confirmAndSend(): Promise { + await this.confirmViewButton.click(); + await expect( + this.confirmButton, + 'the confirm screen did not appear after pressing Confirm on the form', + ).toBeVisible({ timeout: 20_000 }); + await this.confirmButton.click(); + } +} diff --git a/e2e/support/room/room.api.ts b/e2e/support/room/room.api.ts new file mode 100644 index 0000000000..e82cebdf60 --- /dev/null +++ b/e2e/support/room/room.api.ts @@ -0,0 +1,162 @@ +/** + * Backend helpers for the room specs. + * + * Deliberately thin. A room booking is an ordinary staff-api booking with + * `booking_type: 'room'`, so the shared helpers in `../api.ts` already do most + * of the work — this file adds only what is room-shaped, and adds nothing to + * the shared file. + * + * Measured against the running stack before any spec was written: + * POST /bookings type `room` 201 + * the same room and slot, as another user 409 {"error":"Conflicting booking"} + * partly overlapping 409 + * a slot that does not overlap 201 + * So a room behaves like a DESK, not like a visitor: exclusive for its window, + * and the backend — not the UI — is what enforces it. + */ +import { APIRequestContext } from '@playwright/test'; +import { + Booking, + ENGINE_API, + STAFF_API, + currentUser, + releaseAsset, + zonesWithTag, +} from '../api'; + +export interface RoomBooking extends Booking { + attendees?: { name?: string; email: string }[]; + // The shared `Booking` types this as `{}`, which is right for a type that has + // to cover every booking kind and useless for asserting on one. + extension_data?: Record; +} + +/** Every live booking on this room in the window, cleared. */ +export async function releaseRoom( + api: APIRequestContext, + room_id: string, + from: number, + to: number, +): Promise { + return releaseAsset(api, 'room', room_id, from, to); +} + +/** + * Create a room booking straight through the API. + * + * For specs whose subject is NOT the form — clash rules, visibility, cancelling + * from the schedule. Driving the booking form first would only add an unrelated + * way for them to fail. + */ +export async function createRoomBookingViaApi( + api: APIRequestContext, + options: { + room: { id: string; name: string }; + title: string; + start: number; + end: number; + attendees?: { name?: string; email: string }[]; + }, +): Promise { + const me = await currentUser(api); + const zones = ( + await Promise.all(['org', 'building', 'level'].map((t) => zonesWithTag(api, t))) + ) + .flat() + .map((z) => z.id); + + const res = await api.post(`${STAFF_API}/bookings`, { + data: { + booking_type: 'room', + asset_id: options.room.id, + asset_name: options.room.name, + booking_start: options.start, + booking_end: options.end, + timezone: 'Etc/UTC', + user_email: me.email, + user_id: me.id, + user_name: me.name, + title: options.title, + zones, + ...(options.attendees ? { attendees: options.attendees } : {}), + }, + }); + const body = await res.text(); + if (!res.ok()) { + throw new Error( + `create room booking failed: HTTP ${res.status()} ${body}. ` + + `A 409 here means the room is already held for that window — check the ` + + `spec swept it first, and that two specs are not sharing a slot.`, + ); + } + return JSON.parse(body); +} + +/** + * The signed-in user's own settings blob. + * + * Favourite rooms are a USER SETTING, not a property of the room: the app + * writes the whole blob to `PUT /metadata/{user_id}` (debounced ~2.4s), and + * `favourite_spaces` is one key inside `details`. Read here so a spec can prove + * a favourite outlived the page rather than just checking a star turned blue. + */ +export async function readUserSettings( + api: APIRequestContext, +): Promise> { + const me = await currentUser(api); + const res = await api.get(`${ENGINE_API}/metadata/${me.id}`, { + params: { name: 'settings' }, + }); + if (!res.ok()) { + throw new Error(`read user settings failed: HTTP ${res.status()} ${await res.text()}`); + } + const body = await res.json(); + return body?.settings?.details ?? {}; +} + +/** + * Set the user's favourite rooms, leaving every other setting alone. + * + * Read-modify-write, because the app PUTs the WHOLE settings blob and this has + * to do the same — a PUT carrying only `favourite_spaces` would wipe the + * visitor specs' saved invitee list, which lives in the same object. + */ +export async function setFavouriteSpaces( + api: APIRequestContext, + ids: string[], +): Promise { + const me = await currentUser(api); + const details = await readUserSettings(api); + const res = await api.put(`${ENGINE_API}/metadata/${me.id}`, { + data: { + name: 'settings', + description: '', + details: { ...details, favourite_spaces: ids }, + }, + }); + if (!res.ok()) { + throw new Error( + `write favourite_spaces failed: HTTP ${res.status()} ${await res.text()}`, + ); + } +} + +/** + * Try to book a room and report what happened, without throwing. + * + * The clash spec needs the refusal itself, so it cannot use a helper that turns + * a 409 into an exception. + */ +export async function tryRoomBooking( + api: APIRequestContext, + options: Parameters[1], +): Promise<{ status: number; body: string; id?: number }> { + try { + const booking = await createRoomBookingViaApi(api, options); + return { status: 201, body: '', id: booking.id }; + } catch (error) { + const message = `${(error as Error).message}`; + const status = Number(/HTTP (\d+)/.exec(message)?.[1] ?? 0); + return { status, body: message }; + } +} diff --git a/e2e/support/room/room.env.ts b/e2e/support/room/room.env.ts new file mode 100644 index 0000000000..3c04dbc7b0 --- /dev/null +++ b/e2e/support/room/room.env.ts @@ -0,0 +1,150 @@ +/** + * Room identities for the e2e suite. + * + * Kept out of `e2e/support/env.ts` and out of `e2e/support/visitor/` on purpose: + * the desk specs depend on the first, the visitor specs on the second, and + * nothing room-shaped should be able to break either. + * + * A room is EXCLUSIVE for a time range, exactly like a desk and unlike a + * visitor — the backend refuses an overlapping booking with 409 (measured, see + * `room-clash.spec.ts`). So the isolation rule is the desk one: every worker + * gets its own room, and every spec sweeps it before booking. + * + * Unlike a desk, a room is a real engine SYSTEM and has to be created. See + * `room.seed.ts` — it is seeded from the specs rather than from the shared + * `e2e/support/seed.ts`, so no file the desk specs rely on is touched. + */ +export const ROOM_PREFIX = 'e2e-room-'; + +/** + * Time slots, one per spec file. + * + * A worker owns one room, and two specs on that worker booking the same hour + * would clash with each other rather than with the thing under test. Distinct + * slots make that impossible by construction instead of by hoping Playwright + * schedules them apart. + * + * Hours are local, on a day a few days out so nothing collides with a booking a + * human made while poking at the stack. + */ +export const ROOM_SLOTS = { + /** room-booking.spec.ts */ + booking: { stores: 9, cleanup: 10 }, + /** room-clash.spec.ts */ + clash: { taken: 11, freed: 12 }, + /** room-scoping.spec.ts */ + scoping: { own: 13, control: 14 }, + /** room-times.spec.ts */ + times: { chosen: 15, limits: 16 }, + /** room-attendees.spec.ts */ + attendees: { stored: 17, removed: 18 }, + /** room-cancel.spec.ts */ + cancel: { fromApp: 19, dismissed: 20 }, +} as const; + +/** + * Slots for the specs added after the first six files, on a DIFFERENT DAY. + * + * The hours above fill 9 to 20, and the ones left over (early morning, late + * evening) are outside the hours the form offers — so a spec that has to pick a + * time through the UI could not use them. Moving to `SECOND_DAY` reopens the + * whole working day instead. Same rule as before within it: one hour per test, + * because a worker's specs share one room and a sweep is per room, not per hour. + */ +export const SECOND_DAY = 4; + +export const ROOM_SLOTS_2 = { + /** room-edit.spec.ts — `moved_to` is the hour a booking is moved INTO */ + edit: { time: 9, moved_to: 10, room: 11 }, + /** room-capacity.spec.ts */ + capacity: { strict: 12, warning: 13 }, + /** room-favourites.spec.ts */ + favourites: { toggled: 14 }, + /** room-approval.spec.ts — blocked by ROOM-B1 */ + approval: { approved: 15 }, + /** room-catering.spec.ts */ + catering: { ordered: 16 }, + /** room-multi.spec.ts — two rooms at once */ + multi: { hour: 17 }, + /** room-allday.spec.ts */ + allday: { hour: 18 }, + /** room-delegate.spec.ts */ + delegate: { hour: 19 }, + /** room-features.spec.ts — no booking, so only the picker matters */ + features: { hour: 20 }, +} as const; + +export interface RoomIdentity { + /** Engine system id, filled in by the seeder — not known up front. */ + id: string; + name: string; + email: string; + capacity: number; +} + +/** + * Three rooms per worker, not one. + * + * - `main` is the room every other spec books. Capacity 8. + * - `alt` exists so a booking can be MOVED from one room to another + * (ROOM-16). A second room is the only way to tell "the room was changed" + * from "the room field was ignored". + * - `small` has capacity ONE, so a single attendee is already over it + * (ROOM-17). Capacity is a property of the System and cannot be set per + * test, so the alternative would be adding eight attendees through the + * autocomplete to overflow `main` — slower, and every extra chip is another + * way for the test to fail for an unrelated reason. + * + * The names deliberately share no prefix with each other's full name, because + * the picker is searched by name text: "E2E Room 0" does not appear inside + * "E2E Alt Room 0" or "E2E Small Room 0". + */ +export type RoomVariant = 'main' | 'alt' | 'small'; + +export const ROOM_VARIANTS: RoomVariant[] = ['main', 'alt', 'small']; + +/** The name, address and capacity of a room this worker owns. */ +export function roomFor( + workerIndex: number, + variant: RoomVariant = 'main', +): Omit { + switch (variant) { + case 'alt': + return { + name: `E2E Alt Room ${workerIndex}`, + email: `${ROOM_PREFIX}alt-${workerIndex}@place.tech`, + capacity: 8, + }; + case 'small': + return { + name: `E2E Small Room ${workerIndex}`, + email: `${ROOM_PREFIX}small-${workerIndex}@place.tech`, + capacity: 1, + }; + default: + return { + name: `E2E Room ${workerIndex}`, + email: `${ROOM_PREFIX}${workerIndex}@place.tech`, + capacity: 8, + }; + } +} + +/** + * A booking window, in unix seconds, for a given slot. + * + * Fixed hours on a fixed day rather than "now plus an hour": the room specs + * assert on stored times, and a window that moves with the clock cannot be + * compared to anything. Three days out keeps it clear of today's schedule view + * and of anything a person has booked by hand. + */ +export function slotFor(hour: number, daysAhead = 3, minutes = 60) { + const start = new Date(); + start.setDate(start.getDate() + daysAhead); + start.setHours(hour, 0, 0, 0); + return { + start: Math.floor(start.valueOf() / 1000), + end: Math.floor(start.valueOf() / 1000) + minutes * 60, + date_ms: start.valueOf(), + }; +} diff --git a/e2e/support/room/room.flows.ts b/e2e/support/room/room.flows.ts new file mode 100644 index 0000000000..9190d11d83 --- /dev/null +++ b/e2e/support/room/room.flows.ts @@ -0,0 +1,330 @@ +/** + * Booking a room through the full UI. + * + * Returns what the BACKEND created, parsed from the real POST response, never + * anything the page rendered — the same principle as `bookDeskViaUI` and + * `inviteVisitorViaUI`, and the reason this suite can tell "the screen looked + * right" apart from "the data is right". + * + * ## Why the form is filled twice + * + * REG-10 / PPT-2643: the booking form is rebuilt when its async init completes + * and restores defaults, silently dropping anything typed before that. The desk + * flow works around it by converging on the form state, and the meeting form has + * the same shape, so the same guard applies here. It is a workaround, and it + * means this flow cannot DETECT that bug — which is exactly why REG-10 has its + * own row in the coverage doc rather than being considered covered. + * + * ## Two clicks, not one + * + * `Confirm` on the form only switches to the confirm screen. Nothing is sent + * until `confirm-meeting` on that second screen. Both live in + * `MeetingForm.confirmAndSend`. + */ +import { APIRequestContext, Page, expect } from '@playwright/test'; +import { MeetingForm } from './meeting-form.page'; +import { RoomBooking } from './room.api'; + +/** How long to let the form settle before filling it a second time. */ +const SETTLE_MS = 1_500; + +interface CapturedPost { + status: number; + body: string; + request: string; + request_bytes: number; +} + +/** + * Watch every booking POST the page makes. + * + * Bodies are read while the response is still live — reading them later, after + * the page has navigated, throws and loses the evidence. + */ +function collectBookingPosts(page: Page) { + const posts: CapturedPost[] = []; + const pending: Promise[] = []; + const onResponse = (r: import('@playwright/test').Response) => { + if (!r.url().includes('/api/staff/v1/bookings')) return; + if (r.request().method() !== 'POST') return; + const request = r.request().postData() ?? ''; + pending.push( + r + .text() + .then((body) => { + posts.push({ status: r.status(), body, request, request_bytes: request.length }); + }) + .catch(() => { + posts.push({ status: r.status(), body: '', request, request_bytes: request.length }); + }), + ); + }; + page.on('response', onResponse); + return { + async stop(): Promise { + page.off('response', onResponse); + await Promise.all(pending); + return posts; + }, + }; +} + +/** + * Fill, wait, fill again, and only accept it once the second pass sticks. + * + * See the REG-10 note above. `toPass` rather than a bare double-fill so a form + * still resolving its defaults gets another go instead of failing the spec. + */ +async function fillAndSettle(page: Page, fill: () => Promise): Promise { + await expect(async () => { + await fill(); + await page.waitForTimeout(SETTLE_MS); + await fill(); + }).toPass({ timeout: 45_000 }); +} + +/** + * Book a room through the form and return the booking the backend stored. + * + * `startTime` and `duration` are optional: a spec that does not care takes + * whatever the form offers, and one that asserts on stored times says so. + */ +export async function bookRoomViaUI( + page: Page, + api: APIRequestContext, + room: { id: string; name: string }, + title: string, + options: { + /** Local ms timestamp of the day to book. Defaults to the form's day. */ + date?: number; + /** "HH:mm", from the options the form offers. */ + startTime?: string; + /** Minutes. */ + duration?: number; + /** Addresses to add to the meeting. */ + attendees?: string[]; + /** + * Added, then taken off again before sending. The correction has to + * happen INSIDE the fill, or a retry re-adds the person and invites + * someone the user removed. + */ + removeAttendees?: string[]; + /** + * Catering item names to order. Added AFTER the fill loop, because the + * catering modal is a three-click flow of its own and a retry would + * order lunch twice. + */ + catering?: string[]; + /** + * Tick All Day. Replaces the window entirely — `_allDayTimeRange` + * overwrites whatever start and length were chosen — so it is set inside + * the fill loop and no times are passed with it. + */ + allDay?: boolean; + /** + * Book for somebody else. Needs `events.can_book_for_anyone`, or the + * field is not rendered at all. + */ + host?: string; + } = {}, +): Promise { + const form = new MeetingForm(page); + await form.open(); + + // THE ORDER HERE IS LOAD-BEARING, and every step of it was paid for: + // + // 1. the date goes FIRST and exactly ONCE. Changing the date resets the + // duration to the default, asynchronously — so a date click repeated + // inside the retry loop lands AFTER the duration pick and quietly + // reverts a 90 minute meeting to the default. That reads as "the form + // ignored the duration field", and is not that at all. + // 2. title and room go through the retry loop, because those are what the + // REG-10 form rebuild wipes. + // 3. the times go LAST, after the rebuild can no longer undo them, and are + // given time to land before anything is confirmed. + if (options.date !== undefined) { + await setDate(form, page, options.date); + await page.waitForTimeout(SETTLE_MS); + } + + const fill = async () => { + await form.title.fill(title); + if (options.allDay !== undefined) { + await form.setAllDay(options.allDay); + } + if (options.host) await form.chooseHost(options.host); + // ROOM BEFORE THE TIMES. The picker carries its own date, start and end + // filters, and taking a room from it writes those back over the form. + if ((await form.chosenSpaces.count()) === 0) await form.chooseRoom(room.name); + for (const attendee of options.attendees ?? []) { + const already = await form.attendeeChips + .filter({ hasText: attendee.split('@')[0] }) + .count(); + if (!already) await form.addAttendee(attendee); + } + for (const attendee of options.removeAttendees ?? []) { + await form.removeAttendee(attendee); + } + expect(await form.title.inputValue()).toBe(title); + expect(await form.chosenSpaces.count()).toBe(1); + }; + + await fillAndSettle(page, fill); + + // Catering needs the room to be chosen first — the button is disabled until + // then — so it cannot go in the fill above, and it is not idempotent. + for (const item of options.catering ?? []) { + await form.addCateringItem(item); + } + + // The time and duration fields apply to the model ASYNCHRONOUSLY — measured + // at up to ~2 seconds — and the confirm modal snapshots the model when it + // opens. Set them, then let them land. + if (options.duration !== undefined) await form.setDuration(options.duration); + if (options.startTime) await form.setStartTime(options.startTime); + if (options.startTime || options.duration !== undefined) { + await page.waitForTimeout(4_000); + } + + // PROVE the form still holds what was asked for, immediately before sending. + // + // Without this, "the booking came back wrong" has two possible causes that + // look identical: the app discarded the choice, or the form rebuilt itself + // and reverted the control before anything was sent (REG-10's family). A + // finding is only worth reporting once the second has been ruled out. + if (options.allDay !== undefined) { + expect( + await form.isAllDay(), + 'the All Day checkbox must still be set when the meeting is confirmed — ' + + 'if this fails the form reverted it, and nothing can be concluded ' + + 'about what the app stores', + ).toBe(options.allDay); + } + if (options.host) { + const shown = await form.hostValue(); + const flat = (value: string) => value.toLowerCase().replace(/[^a-z0-9]/g, ''); + expect( + flat(shown), + `the host field must still show ${options.host} when the meeting is ` + + `confirmed; it shows "${shown}"`, + ).toContain(flat(options.host.split('@')[0])); + } + + const posts = collectBookingPosts(page); + await form.confirmAndSend(); + + // Do NOT just assert on the success screen. When the post is refused the + // confirm dialog stays open and shows a toast, and "success screen never + // appeared" is the least informative way to report that. Gather the + // evidence first, then fail with it. + let reached_success = await form.successPanel + .waitFor({ state: 'visible', timeout: 30_000 }) + .then(() => true) + .catch(() => false); + let captured = await posts.stop(); + let created = captured.filter((p) => p.status >= 200 && p.status < 300); + + // RETRY past REG-09, and only past REG-09. + // + // The confirm dialog stays open when a post is refused, so the send can be + // pressed again without rebuilding the form. That is worth doing when — and + // only when — every failure looks like the concurrent-write defect + // (PPT-2642): a burst of booking writes poisons a staff-api connection and + // the request that lands on it fails for reasons unrelated to what was sent. + // Its shapes are a 400 "Expected BeginObject but was EOF", a 500 "could not + // serialize access", and a 422 whose `failures` list is EMPTY — a real + // validation error always names the field it is unhappy about. + // + // Seen here on a full parallel room run: 422 with no failures, on a payload + // that had just worked twice. Delete this with the rest of the REG-09 + // handling once the stack runs staff-api on pg-orm >= 2.2.4, and do not read + // a green run as evidence the image is fixed. + const looks_like_reg09 = (p: CapturedPost) => + (p.status === 400 && /Expected BeginObject but was EOF/.test(p.body)) || + (p.status === 500 && /could not serialize access/.test(p.body)) || + (p.status === 422 && /"failures"\s*:\s*\[\s*\]/.test(p.body)); + + for (let attempt = 1; attempt <= 3; attempt++) { + if (reached_success && created.length) break; + if (!captured.length || !captured.every(looks_like_reg09)) break; + console.warn( + ` ! REG-09 shaped failure on send attempt ${attempt}/3, pressing confirm ` + + `again — ${captured.map((p) => `HTTP ${p.status}`).join(', ')}`, + ); + await page.waitForTimeout(600 * attempt); + const retry = collectBookingPosts(page); + if (!(await form.confirmButton.isVisible().catch(() => false))) break; + await form.confirmButton.click(); + reached_success = await form.successPanel + .waitFor({ state: 'visible', timeout: 30_000 }) + .then(() => true) + .catch(() => false); + captured = await retry.stop(); + created = captured.filter((p) => p.status >= 200 && p.status < 300); + } + + if (!reached_success || !created.length) { + const attempts = captured + .map( + (p) => + `HTTP ${p.status} (sent ${p.request_bytes} bytes): ` + + `${p.body.slice(0, 200) || ''}` + + `\n request: ${p.request.slice(0, 1200)}`, + ) + .join('\n | '); + const message = await page + .locator('.cdk-overlay-container') + .innerText() + .catch(() => ''); + throw new Error( + `the room booking did not go through. ` + + `Booking POSTs: ${attempts || 'none at all — the form refused locally'}. ` + + `On screen: "${message.replace(/\s+/g, ' ').trim().slice(0, 220)}"`, + ); + } + return JSON.parse(created[created.length - 1].body); +} + +/** Drive the form's date picker to a given day. */ +async function setDate(form: MeetingForm, page: Page, timestamp_ms: number) { + await form.dateButton.click(); + const calendar = form.datePicker; + await expect(calendar, 'the date picker did not open').toBeVisible({ timeout: 10_000 }); + + const cells = calendar.locator('button[name="schedule-set-date"]'); + const today_index = await cells.evaluateAll((els) => + els.findIndex((el) => !!el.querySelector('div.border-secondary')), + ); + if (today_index < 0) throw new Error('no "today" cell in the date picker to count from'); + + const label = Number(((await cells.nth(today_index).textContent()) ?? '').trim()); + const browser_now = await page.evaluate(() => Date.now()); + const anchor = [0, -1] + .map((offset) => { + const day = new Date(browser_now); + day.setHours(0, 0, 0, 0); + day.setDate(day.getDate() + offset); + return day; + }) + .find((day) => day.getDate() === label); + if (!anchor) { + throw new Error( + `the picker marks day ${label} as today, which is neither today nor ` + + `yesterday by the browser clock`, + ); + } + + const target = new Date(timestamp_ms); + target.setHours(0, 0, 0, 0); + const days = Math.round((target.valueOf() - anchor.valueOf()) / 86_400_000); + const index = today_index + days; + if (index < 0 || index >= (await cells.count())) { + throw new Error( + `${target.toDateString()} is outside the days the picker is showing`, + ); + } + await cells.nth(index).click(); + await expect(calendar, 'the date picker stayed open after choosing a day').toBeHidden({ + timeout: 10_000, + }); +} diff --git a/e2e/support/room/room.seed.ts b/e2e/support/room/room.seed.ts new file mode 100644 index 0000000000..07605f67a6 --- /dev/null +++ b/e2e/support/room/room.seed.ts @@ -0,0 +1,135 @@ +/** + * Creating the rooms the room specs book. + * + * WHY THIS IS NOT IN `e2e/support/seed.ts`: that file is what the desk specs + * depend on, and a mistake in it breaks them. Rooms are the first resource this + * suite needs that must genuinely be created — a desk is a row in zone + * metadata, a visitor is just an email, but a room is an engine SYSTEM — so the + * seeding lives here, next to the specs that need it, and runs on demand. + * + * It is idempotent and safe to call from every spec: the first caller creates, + * everyone after finds. Rooms are left in place between runs; they are cheap, + * and deleting them would make parallel workers race each other. + * + * Needs ADMIN. Creating a system is not something a normal staff user may do, + * which is also why the specs cannot seed themselves with their own bearer. + */ +import { APIRequestContext } from '@playwright/test'; +import { ENGINE_API, apiFor, zonesWithTag } from '../api'; +import { WORKERS } from '../env'; +import { ROOM_VARIANTS, RoomIdentity, RoomVariant, roomFor } from './room.env'; + +/** + * `GET /systems` answers with a BARE ARRAY, not `{ results: [] }`. + * + * Worth stating because reading `.results` off it yields `undefined`, the + * fallback kicks in, and you get an empty list that looks exactly like "the + * search index has not caught up yet". Half an hour went into that theory + * before the response was actually printed. + */ +async function listSystems(api: APIRequestContext): Promise { + const res = await api.get(`${ENGINE_API}/systems`, { params: { limit: '500' } }); + if (!res.ok()) { + throw new Error(`list systems failed: HTTP ${res.status()} ${await res.text()}`); + } + const body = await res.json(); + return Array.isArray(body) ? body : (body?.results ?? []); +} + +/** Every room this suite owns, keyed by variant then worker index. */ +export type RoomSet = Record; + +/** + * Make sure every worker has its bookable rooms, and return them. + * + * Three per worker — see `ROOM_VARIANTS` in `room.env.ts` for why `alt` and + * `small` exist. They are created in the same pass as `main` rather than on + * first use, because listing every system in the org is the expensive part and + * doing it once is the whole point of the cache below. + * + * Rooms are placed on BOTH the building and the level zone: the app asks for + * systems by zone and different screens ask with different zones, so a room on + * only one of them appears in some places and not others. + */ +export async function ensureRooms(): Promise { + const admin = await apiFor('admin', 0); + try { + const [building] = await zonesWithTag(admin, 'building'); + const [level] = await zonesWithTag(admin, 'level'); + if (!building?.id) { + throw new Error( + 'no building zone to put a room in — the stack is not seeded. ' + + 'Run e2e/stack/up.sh first.', + ); + } + const zones = [building.id, level?.id].filter(Boolean) as string[]; + const existing = await listSystems(admin); + const rooms = { main: [], alt: [], small: [] } as RoomSet; + + for (const variant of ROOM_VARIANTS) { + for (let i = 0; i < WORKERS; i++) { + const want = roomFor(i, variant); + const found = existing.find( + (s) => `${s.email}`.toLowerCase() === want.email.toLowerCase(), + ); + if (found) { + rooms[variant].push({ ...want, id: found.id }); + continue; + } + const res = await admin.post(`${ENGINE_API}/systems`, { + data: { + name: want.name, + display_name: want.name, + email: want.email, + capacity: want.capacity, + bookable: true, + // `signage: false` matters: the app's room lookup filters + // signage systems out, so a room created without it is + // invisible in the picker while existing perfectly well. + signage: false, + zones, + description: 'Room owned by the e2e suite. Safe to delete.', + }, + }); + if (!res.ok()) { + throw new Error( + `create room ${want.name} failed: HTTP ${res.status()} ${await res.text()}`, + ); + } + rooms[variant].push({ ...want, id: (await res.json()).id }); + } + } + return rooms; + } finally { + await admin.dispose(); + } +} + +/** + * The room this worker owns, creating the set on first use. + * + * Cached for the life of the process so twenty specs do not each re-list every + * system in the org. + */ +let cache: Promise | null = null; +export async function roomForWorker( + workerIndex: number, + variant: RoomVariant = 'main', +): Promise { + cache = cache ?? ensureRooms(); + const rooms = await cache.catch((error) => { + // Do not poison the cache: a stack that was still starting up should not + // fail every later spec with the same stale error. + cache = null; + throw error; + }); + const room = rooms[variant][workerIndex]; + if (!room) { + throw new Error( + `no ${variant} room seeded for worker ${workerIndex}. ` + + `${rooms[variant].length} exist, one per worker, so E2E_WORKERS is ` + + `smaller than the number Playwright is running.`, + ); + } + return room; +} diff --git a/e2e/support/room/room.settings.ts b/e2e/support/room/room.settings.ts new file mode 100644 index 0000000000..1aa12a01c0 --- /dev/null +++ b/e2e/support/room/room.settings.ts @@ -0,0 +1,179 @@ +/** + * Per-test app settings for the room specs. + * + * ## The setting that makes room coverage possible at all + * + * Room booking has two completely different back ends: + * + * default -> POST /events, and free/busy from /calendars. + * Both are Microsoft/Google backed and return + * 500 on a stack with placeholder tenant + * credentials. Untestable locally, which is + * why `E2E_USER_STORIES.md` had rooms down as + * "out of scope (external)". + * app.events.use_bookings = true -> the room is saved as an ordinary PlaceOS + * booking of type `room`, and availability + * comes from the bookings list + * (`event-form.service.ts`, `book_internal`). + * No outbound call at all. + * + * Everything here runs in the second mode. **State that plainly to anyone reading + * a green run:** this proves the PlaceOS-native room path works, and says nothing + * about the calendar path a tenant with real Microsoft credentials would use. + * + * Applied per BROWSER CONTEXT through the same debug override the visitor specs + * use, rather than seeded onto a zone, so specs needing different settings still + * run in parallel instead of serialising the suite on shared state. + */ +import { Page } from '@playwright/test'; + +/** + * Force app settings for ONE page, before its first navigation. + * + * Deliberately a copy of the visitor helper rather than an import from it: the + * two areas are kept apart on purpose, and twenty lines of duplication is a + * cheaper price than a shared file that can break both. + * + * Two rules the app imposes: + * - keys MUST start with `app.`, anything else is dropped on load; + * - matching is EXACT — `app.events` will not satisfy a read of + * `app.events.use_bookings`. + */ +export async function useSettings( + page: Page, + overrides: Record, +): Promise { + const bad = Object.keys(overrides).filter((k) => !k.startsWith('app.')); + if (bad.length) { + throw new Error( + `setting override keys must start with "app." — the app drops the rest on ` + + `load, leaving the default quietly in place. Got: ${bad.join(', ')}`, + ); + } + await page.addInitScript((value) => { + localStorage.setItem('PLACEOS.setting_overrides', JSON.stringify(value)); + }, overrides); +} + +/** The mode every room spec runs in. See the note at the top of this file. */ +export const ROOM_BOOKINGS_MODE = { + 'app.events.use_bookings': true, +}; + +/** + * Skip the approval step, so a new booking is stored `approved` rather than + * `tentative` (`event-form.service.ts`, `_performBooking`). + * + * ## NOT in the base settings, on purpose — it makes booking a room 500 + * + * With this on, the app sends `approved: true`, and staff-api answers **HTTP 500 + * with `syntax error at or near ")" (PQ::PQError)`** for any non-admin. Found + * while writing the first room spec; minimised to a seven-field request: + * + * staff, no `approved` field .................. 201 + * staff, `approved: true` ..................... 500 <- SQL syntax error + * staff, `approved: false` .................... 201 + * staff, `approved: true` WITH `zones` ........ 403 <- correctly refused + * admin, `approved: true` ..................... 201 + * staff, `approved: true`, a DESK booking ..... 500 <- not room-specific + * + * So the backend's approval permission check blows up instead of refusing when + * it has no zones to check against, and the meeting form hands it exactly that + * because it sends `zones: []` for a room booking. Two bugs meeting in the + * middle; both are written up as ROOM-B1. + * + * Left defined rather than deleted so the constant has somewhere to live once + * the backend is fixed and approval coverage is written. + */ +export const NO_APPROVAL = { + 'app.bookings.no_approval': true, +}; + +/** + * Cap the meeting length and confine it to office hours. + * + * `app.events.*`, NOT `app.bookings.*`. The meeting form reads its limits from + * the EVENT settings (`event-form.service.ts`: `app.events.max_duration`, + * `app.events.bookable_hours`), while the desk and visitor forms read + * `app.bookings.*`. Setting the bookings keys here changes nothing at all and + * the form happily offers an 8 hour meeting — which is exactly what happened on + * the first run of `room-times.spec.ts`. + */ +export const LIMITED_HOURS = { + 'app.events.max_duration': 60, + 'app.events.bookable_hours': { start: 9, end: 17 }, +}; + +/** + * Refuse a meeting with more people in it than the rooms can hold. + * + * OFF by default, and the two states behave completely differently rather than + * one being a stronger version of the other: + * + * unset ......... a WARNING is rendered next to the room list, and the booking + * goes through regardless. + * true .......... `viewConfirm` stops before opening the confirm screen and + * shows a toast. Nothing is sent. + * + * Both halves are covered by `room-capacity.spec.ts`, because the one users + * complain about is the one that lets them book a room nobody fits in. + */ +export const STRICT_CAPACITY = { + 'app.events.strict_capacity_check': true, +}; + +/** + * Let a meeting hold more than one room. + * + * Two keys mean the same thing (`multipleSpacesEnabled` in + * `events/utilities.ts` accepts either), and the mode changes the picker: the + * confirm button becomes `space-return` instead of `toggle-space`, which is the + * trap recorded at the top of `meeting-form.page.ts`. + */ +export const MULTI_SPACE = { + 'app.events.multiple_spaces': true, +}; + +/** + * Offer an All Day control on the meeting form. + * + * `app.events.allow_all_day`, read in `meeting-form-details.component.ts` — + * NOT `allow_multiday`, which only widens the date range the room picker + * offers. A first attempt at the all-day test set the multiday key, found no + * checkbox, and nearly concluded the app has no all-day meetings at all. + */ +export const ALLOW_ALL_DAY = { + 'app.events.allow_all_day': true, +}; + +/** Let a meeting span more than one day (widens the picker's dates). */ +export const ALLOW_MULTIDAY = { + 'app.events.allow_multiday': true, +}; + +/** + * Book a meeting on somebody else's behalf. + * + * Two different controls, depending on which key is set + * (`meeting-form-details.component.ts`): `can_book_for_anyone` renders a user + * SEARCH field, `can_book_for_others` renders a `host-select-field` of people + * the user may book for. The search field is the one that behaves like the desk + * form's, so that is the one these specs use. + */ +export const BOOK_FOR_ANYONE = { + 'app.events.can_book_for_anyone': true, + // The local user list, not the calendar directory — see the desk settings + // for why: /api/staff/v1/people 500s on this stack. + 'app.basic_user_search': true, +}; + +/** + * Settings shared by every room spec. + * + * Just the booking mode. Bookings therefore land as `tentative`, which is the + * app's default behaviour anyway — and `NO_APPROVAL` cannot be added here until + * ROOM-B1 is fixed. + */ +export const ROOM_BASE_SETTINGS = { + ...ROOM_BOOKINGS_MODE, +}; diff --git a/e2e/support/room/schedule.page.ts b/e2e/support/room/schedule.page.ts new file mode 100644 index 0000000000..657a51dad9 --- /dev/null +++ b/e2e/support/room/schedule.page.ts @@ -0,0 +1,104 @@ +/** + * The schedule, as the ROOM specs need it. + * + * Inherits the shared behaviour from `YourBookingsPage` — opening the list, + * the day navigation, the details modal, the overflow menu, the confirmation + * dialogs — and overrides only what a room booking does differently. Nothing is + * copied. + * + * ## Why an override is needed at all + * + * A room booking does not render as a booking card. In `use_bookings` mode the + * schedule loads room bookings, converts them back into calendar events + * (`newCalendarEventFromBooking`) and renders `event-card`: + * + * visitor / desk ..... + * room ............... + * + * and the details modal is `event-details-modal`, not `booking-details-modal`. + * Everything inherited that looks a card up goes through `card()`, so + * overriding that one method redirects the lot. + * + * The filter is different too: rooms sit behind the ROOMS toggle + * (`schedule-toggle-event-filter`), not the visitor one. + */ +import { Locator, expect } from '@playwright/test'; +import { YourBookingsPage } from '../visitor/your-bookings.page'; + +export class RoomSchedulePage extends YourBookingsPage { + /** The card for a room booking, matched on the id in its href. */ + override card(bookingId: number): Locator { + return this.page.locator( + `a[name="view-event-details"][href*="event=${bookingId}"]`, + ); + } + + /** The ids of every room/event card on screen, for failure messages. */ + override async renderedBookingIds(): Promise { + const hrefs = await this.page + .locator('a[name="view-event-details"]') + .evaluateAll((els) => els.map((e) => e.getAttribute('href') ?? '')); + return hrefs + .map((h) => Number(/event=(\d+)/.exec(h)?.[1])) + .filter((n) => Number.isFinite(n)); + } + + /** Make sure room bookings are among the types being shown. */ + async showRooms(): Promise { + const toggle = this.page + .locator('button[name="schedule-toggle-event-filter"]') + .first(); + // ENSURE, never blindly toggle: clicking a filter that is already on + // turns it off, and the list then looks like the booking is missing. + const checkbox = toggle.locator('input[type="checkbox"]'); + if (await checkbox.count()) { + if (!(await checkbox.isChecked().catch(() => false))) await toggle.click(); + } + } + + /** + * Editing a room booking lands on the MEETING form, not the visitor one. + * + * The rest of `startEdit` — details modal, overflow menu, the Edit item + * found by its icon — is identical, so only these two hooks are overridden. + */ + protected override get editForm(): Locator { + return this.page.locator('meeting-flow-form'); + } + + protected override get editFormHint(): string { + return ( + 'A room booking is edited through `schedule.component.ts::edit`, which is ' + + 'only offered for an event the user owns and which has not finished. Note ' + + 'that path re-queries the CALENDAR when `creator !== mailbox`, and ' + + '/events 500s on this stack — so a missing Edit item and a failed edit are ' + + 'two different problems.' + ); + } + + /** Open a room booking's details and wait for the modal. */ + override async openDetails(bookingId: number): Promise { + const card = this.card(bookingId); + const found = await card + .waitFor({ state: 'visible', timeout: 30_000 }) + .then(() => true) + .catch(() => false); + if (!found) { + const rendered = await this.renderedBookingIds(); + throw new Error( + `no card for room booking ${bookingId} on /your-bookings. ` + + `${rendered.length} event card(s) are rendered: ` + + `[${rendered.join(', ')}]. Check the booking falls on the day being ` + + `shown (\`showDayOf\`), that the ROOMS filter is on (\`showRooms\`), ` + + `and that \`app.events.use_bookings\` is set — without it the ` + + `schedule asks the calendar instead and lists nothing at all.`, + ); + } + await card.click(); + const modal = this.page.locator('event-details-modal'); + await expect(modal, 'the event details modal did not open').toBeVisible({ + timeout: 20_000, + }); + return modal; + } +} diff --git a/e2e/support/visitor/visitor.env.ts b/e2e/support/visitor/visitor.env.ts index 7b19a76d9c..2ab4a49392 100644 --- a/e2e/support/visitor/visitor.env.ts +++ b/e2e/support/visitor/visitor.env.ts @@ -42,6 +42,12 @@ export const VISITOR_SLOTS = { validation: { badEmail: 18, keptA: 19, removedB: 20 }, /** visitor-details.spec.ts */ details: { extras: 21, company: 22 }, + /** visitor-duplicate.spec.ts — the guard for VIS-B1 */ + duplicate: { same: 23 }, + /** visitor-group-clash.spec.ts — the guard for VIS-B9 */ + group_clash: { morning_a: 24, morning_b: 25, afternoon_a: 26, afternoon_b: 27 }, + /** visitor-edit.spec.ts, the group-time test added later */ + group_time: { memberA: 28, memberB: 29 }, /** visitor-times.spec.ts */ times: { chosen: 23, limits: 24 }, } as const; diff --git a/e2e/support/visitor/your-bookings.page.ts b/e2e/support/visitor/your-bookings.page.ts index eb81f6a58e..e1e0dfda10 100644 --- a/e2e/support/visitor/your-bookings.page.ts +++ b/e2e/support/visitor/your-bookings.page.ts @@ -29,7 +29,12 @@ import { } from './calendar'; export class YourBookingsPage { - constructor(private readonly page: Page) {} + // `protected`, not `private`, so another area can inherit this page rather + // than copy it. The schedule is not visitor-specific — the room specs need + // the same list, cards, details modal and overflow menu — so the shared + // behaviour stays here, its owner, and `room/schedule.page.ts` extends it + // with what is room-shaped (event cards instead of booking cards). + constructor(protected readonly page: Page) {} /** Open the list and wait for the schedule to render. */ async open(): Promise { @@ -297,11 +302,30 @@ export class YourBookingsPage { } /** - * Open a booking for editing and land on the pre-filled invite form. + * The form that pressing Edit is expected to land on. * - * Needs `visitors.allow_editing` — without it `can_edit` is false for a - * visitor booking and the menu simply has no Edit item, which would look - * like a broken selector. + * A hook, not a constant, because `startEdit` below is shared and only this + * differs by booking type — a room booking lands on the meeting form. See + * `room/schedule.page.ts`. + */ + protected get editForm(): Locator { + return this.page.locator('invite-visitor-form'); + } + + /** What to suggest when the Edit item is missing. Type-specific. */ + protected get editFormHint(): string { + return ( + 'Visitor bookings need `app.visitors.allow_editing = true`, and a booking ' + + 'that is checked in or already done cannot be edited at all.' + ); + } + + /** + * Open a booking for editing and land on its pre-filled form. + * + * For visitors that needs `visitors.allow_editing` — without it `can_edit` + * is false and the menu simply has no Edit item, which would look like a + * broken selector. */ async startEdit(bookingId: number): Promise { const modal = await this.openDetails(bookingId); @@ -319,14 +343,13 @@ export class YourBookingsPage { throw new Error( `no Edit action in the booking menu. The menu contains ` + `${items.length} item(s): ${JSON.stringify(items)}. ` + - `Visitor bookings need \`app.visitors.allow_editing = true\`, and a ` + - `booking that is checked in or already done cannot be edited at all.`, + this.editFormHint, ); } await edit.click(); await expect( - this.page.locator('invite-visitor-form'), - 'editing did not land on the visitor form', + this.editForm, + `pressing Edit did not land on the expected form. ${this.editFormHint}`, ).toBeVisible({ timeout: 30_000 }); } }