From dcbd667be07a971deeab6ad1dbd9b8b7b532e507 Mon Sep 17 00:00:00 2001 From: sharmilaseenivasan17 Date: Tue, 15 Sep 2026 16:02:51 +1000 Subject: [PATCH 01/10] test(e2e): add visitor invite coverage for workplace 23 tests across 11 spec files, mirroring the existing desk coverage: invite (single and group), cancel from the app, form validation, settings, visitor details, times, booking for a colleague, check-in/check-out, editing, and visibility between users. 22 pass; 1 is fixme, blocked on an app bug where saving an edit in single-visitor mode throws and sends nothing. Visitor support code lives in e2e/support/visitor/ so the desk specs cannot be affected - no shared support file is changed. Also adds e2e/tsconfig.json and a bun run e2e:typecheck script; nothing type-checked the specs before. Co-Authored-By: Claude Opus 5 (1M context) --- E2E_USER_STORIES.md | 77 +++- .../e2e/local/visitor-cancel.spec.ts | 120 ++++++ .../e2e/local/visitor-checkin.spec.ts | 231 +++++++++++ .../e2e/local/visitor-delegate.spec.ts | 206 ++++++++++ .../e2e/local/visitor-details.spec.ts | 131 ++++++ apps/workplace/e2e/local/visitor-edit.spec.ts | 319 +++++++++++++++ .../workplace/e2e/local/visitor-group.spec.ts | 171 ++++++++ .../e2e/local/visitor-invite.spec.ts | 163 ++++++++ .../e2e/local/visitor-scoping.spec.ts | 180 +++++++++ .../e2e/local/visitor-settings.spec.ts | 131 ++++++ .../workplace/e2e/local/visitor-times.spec.ts | 136 +++++++ .../e2e/local/visitor-validation.spec.ts | 143 +++++++ e2e/README.md | 8 + e2e/VISITOR_E2E_HANDOVER.md | 197 +++++++++ e2e/support/repro/vis-b1-duplicate-visitor.ts | 88 ++++ e2e/support/repro/vis-b6-burst.ts | 117 ++++++ e2e/support/repro/vis-b9-group-clash.ts | 110 +++++ e2e/support/visitor/calendar.ts | 150 +++++++ e2e/support/visitor/invite-form.page.ts | 323 +++++++++++++++ e2e/support/visitor/visitor.api.ts | 203 ++++++++++ e2e/support/visitor/visitor.env.ts | 67 ++++ e2e/support/visitor/visitor.flows.ts | 378 ++++++++++++++++++ e2e/support/visitor/visitor.settings.ts | 121 ++++++ e2e/support/visitor/your-bookings.page.ts | 332 +++++++++++++++ e2e/tsconfig.json | 26 ++ package.json | 1 + 26 files changed, 4122 insertions(+), 7 deletions(-) create mode 100644 apps/workplace/e2e/local/visitor-cancel.spec.ts create mode 100644 apps/workplace/e2e/local/visitor-checkin.spec.ts create mode 100644 apps/workplace/e2e/local/visitor-delegate.spec.ts create mode 100644 apps/workplace/e2e/local/visitor-details.spec.ts create mode 100644 apps/workplace/e2e/local/visitor-edit.spec.ts create mode 100644 apps/workplace/e2e/local/visitor-group.spec.ts create mode 100644 apps/workplace/e2e/local/visitor-invite.spec.ts create mode 100644 apps/workplace/e2e/local/visitor-scoping.spec.ts create mode 100644 apps/workplace/e2e/local/visitor-settings.spec.ts create mode 100644 apps/workplace/e2e/local/visitor-times.spec.ts create mode 100644 apps/workplace/e2e/local/visitor-validation.spec.ts create mode 100644 e2e/VISITOR_E2E_HANDOVER.md create mode 100644 e2e/support/repro/vis-b1-duplicate-visitor.ts create mode 100644 e2e/support/repro/vis-b6-burst.ts create mode 100644 e2e/support/repro/vis-b9-group-clash.ts create mode 100644 e2e/support/visitor/calendar.ts create mode 100644 e2e/support/visitor/invite-form.page.ts create mode 100644 e2e/support/visitor/visitor.api.ts create mode 100644 e2e/support/visitor/visitor.env.ts create mode 100644 e2e/support/visitor/visitor.flows.ts create mode 100644 e2e/support/visitor/visitor.settings.ts create mode 100644 e2e/support/visitor/your-bookings.page.ts create mode 100644 e2e/tsconfig.json diff --git a/E2E_USER_STORIES.md b/E2E_USER_STORIES.md index 2d5fa45467..5d503904d4 100644 --- a/E2E_USER_STORIES.md +++ b/E2E_USER_STORIES.md @@ -118,13 +118,71 @@ 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. | todo | +| 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-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-04 | P2 | Mock mode still renders the landing page with no backend at all. | **done** — `landing.spec.ts` (project `mock`) | +## 1a. Workplace — visitor invites + +Twenty-three tests in eleven files under `apps/workplace/e2e/local/visitor-*.spec.ts`, with +their own support code in `e2e/support/visitor/` — deliberately separate from +`e2e/support/{env,api,flows}.ts` so nothing visitor-shaped can break the desk specs. +Working notes: [`e2e/VISITOR_E2E_HANDOVER.md`](e2e/VISITOR_E2E_HANDOVER.md). + +**Not covered, and why.** A second building cannot be exercised — the local stack +seeds one org, one building, one level, so a building/level selector test would +assert against a list of one. That needs a seeding change, which was deliberately +avoided here (see *Decisions* in the handover). Everything email-shaped is out too: +there is no mail server in the stack and the test identities are not real mailboxes. + +**Workplace only.** `concierge` and `visitor-kiosk` also show visitors, but the desk specs +cover neither (zero mentions anywhere in `e2e/`) and neither is in the sparse checkout this +was built in. Extending there is a separate piece of work, not an oversight. + +| ID | P | Story | Status | +|----|---|-------|--------| +| VIS-01 | P1 | A **non-admin** invites a visitor through the full UI; the backend stores the booking with the right address, title and zones, **and** a guest record is created from it. | **done** — `local/visitor-invite.spec.ts` | +| VIS-02 | P1 | A deleted visitor invite leaves the listing — and the guest record it created is removed too, since that **outlives the booking**. | **done** — `local/visitor-invite.spec.ts` | +| VIS-03 | P1 | Inviting several visitors at once creates a `group` container plus one `visitor` booking per person, linked by `parent_id`, each carrying its own address. | **done** — `local/visitor-group.spec.ts`. The container-to-member link is what PPT-2638 broke. | +| VIS-04 | P1 | The visitor listing returns the **members**, not the container: `GET /bookings?type=visitor` on an N-person group gives N, not N+1. | **done** — `local/visitor-group.spec.ts` | +| VIS-05 | P1 | One user's visitor invite is **not** visible to another user, and cannot be deleted by them. | **done** — `local/visitor-scoping.spec.ts`. The desk equivalent is WP-E2E-08; a visitor invite leaks a named outside person, not just a seat. | +| VIS-06 | P1 | Control for VIS-05: you *can* see your own invite and its guest record, so "nobody sees anything" cannot pass as success. | **done** — `local/visitor-scoping.spec.ts` | +| VIS-07 | P2 | With all-day invites allowed, an all-day invite is **stored** as all-day rather than as a long timed booking. | **done** — `local/visitor-settings.spec.ts` | +| VIS-08 | P2 | With a reason required, sending without one is refused by the form and nothing reaches the backend. | **done** — `local/visitor-settings.spec.ts` | +| VIS-09 | P1 | Booking on behalf of somebody else stores **them** as the host (`user_*`) and **you** as the booker (`booked_by_*`). | **done** — `local/visitor-delegate.spec.ts`. Needs `visitors.can_book_for_anyone` **and** `visitors.can_book_for_others`: one without the other renders the host picker and then silently discards the choice. | +| VIS-10 | P1 | The booker's schedule flags a delegated invite as **Associate** (and shows "For {name}"); the host's own listing shows it as theirs, unflagged. | **done** — `local/visitor-delegate.spec.ts`. Visibility is asymmetric — the booker only sees it at all because the schedule sends `include_booked_by=true`. | +| VIS-11 | P1 | A visitor is checked **in** and then back **out**, both recorded on the backend; after checking out the control is gone, so one invite cannot be used twice. | **done** — `local/visitor-checkin.spec.ts`. Check-in and check-out are the same button; only the check-out asks for confirmation. | +| VIS-12 | P1 | A booking too far ahead offers no check-in control, **and** the API refuses one for the timing reason — so hiding the button is not the only thing stopping it. | **done** — `local/visitor-checkin.spec.ts`. The two gates are different sizes: the app hides the button until **15 minutes** before the start, the backend accepts within **1 hour**. | +| VIS-13 | P2 | Re-saving a delegated invite **for a future day** keeps the original host, and keeps the day, rather than resetting either to the person editing. | **done** — `local/visitor-edit.spec.ts` | +| VIS-14 | P2 | Removing one visitor from a group invite leaves the other members untouched. | **done** — `local/visitor-edit.spec.ts` (PPT-2634 / PPT-2638) | +| VIS-15 | P2 | The same edit works when the app is in **single-visitor** mode. | **blocked** — `local/visitor-edit.spec.ts`, `test.fixme`. VIS-B8 below. The identical flow in the default mode is green (VIS-13), so this is the mode, not the flow. | +| VIS-16 | P1 | Cancelling an invite **from the app** — the booking menu and its confirmation — really removes it, on the backend and not just on screen. | **done** — `local/visitor-cancel.spec.ts`. Every other spec tears down through the API, so this button was never once pressed. | +| VIS-17 | P1 | Declining that confirmation leaves the invite alone. | **done** — `local/visitor-cancel.spec.ts`. A dialog whose decline button also deletes is worse than one that fails to delete, and only this catches it. | +| VIS-18 | P1 | An address that is not an email is refused **before anything is sent**. | **done** — `local/visitor-validation.spec.ts`. Asserts on the network, not on a message: the backend stores whatever it is given, so the form is the only guard. Includes a control that a valid address does send. | +| VIS-19 | P2 | A visitor taken off the list before sending is not invited, and the rest still are. | **done** — `local/visitor-validation.spec.ts` | +| VIS-20 | P2 | A pass number and the international-visitor flag are stored with the invite. | **done** — `local/visitor-details.spec.ts`. Both behind settings that are off by default. | +| VIS-21 | P2 | The company is stored, and the separate guest record carries the visitor's name. | **done** — `local/visitor-details.spec.ts`. The guest record is what reception reads and it outlives the booking. | +| VIS-22 | P1 | The start time and length chosen on the form are the ones stored. | **done** — `local/visitor-times.spec.ts`. Everything else here takes the default hour, so a form that ignored the picker would have passed the whole suite. | +| VIS-23 | P2 | A maximum visit length and bookable hours limit what the form offers. | **done** — `local/visitor-times.spec.ts`. Asserts on the options offered, because an out-of-range choice is absent rather than refused. Red-checked: without the settings the form offers 240 minutes against a 60 minute cap. | + +### Findings from building this coverage + +Six, all confirmed against the running stack, **none filed yet** and none of them test +defects. Listed here so the coverage record is honest about what the suite knows. + +| ID | Finding | +|---|---| +| **VIS-B8** | **In single-visitor mode only** (`bookings.multiple_visitors: false`), saving an edit throws `_.toJSON is not a function` and sends nothing — the user sees only that raw string. `Booking` declares `attendees` as `User[]` but assigns the raw server JSON to it, the edit path copies that into the form's `assets`, and saving calls `.toJSON()` on every entry. The multi-visitor paths escape it by rebuilding each person with `new User(...)`. Measured both modes, one and two visitors, today and a future day: the **mode** is the only thing that decides it. Blocks VIS-15. | +| **VIS-B9** | **A host cannot make two overlapping group invites, whoever the visitors are.** The app names a group container `${host_email}[${date the invite was CREATED}]`, so every group invite that host makes that 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. Booking the same two visits on different days is accepted, which is what shows this is accidental. Reproducer: `e2e/support/repro/vis-b9-group-clash.ts`. | +| **VIS-B1** | The same visitor can be booked twice into one slot; the backend returns 201 both times. Desks refuse this, and product says visitors should too. Reproducer: `e2e/support/repro/vis-b1-duplicate-visitor.ts`. | +| **Host flags** | `visitors.can_book_for_anyone` renders the host picker, but `sendInvite` keeps the choice only if `visitors.can_book_for_others` is **also** set. One without the other silently discards the host — no error, no clue. | +| **Listing limit** | `GET /bookings` defaults to `limit=100` and the schedule sends `include_deleted=true`, so past 100 soft-deleted bookings a user stops seeing new ones. This broke the card-dependent specs until 428 stale rows were cleared, and it looks nothing like its cause. See the run-hygiene note in *Notes & blockers*. | +| **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. | + ## 2. Auth & session Grounded in the auth.cr work (PPT-2536), where every production failure was an @@ -156,7 +214,7 @@ task that found it, so the row can be traced. | REG-06 | P2 | Timezone parsing does not error for a building with an unusual timezone. | "Fix error when parsing timezones" | todo | | REG-07 | P2 | Level selection does not persist once the selector is hidden/disabled. | "Fix level selections persisting when selector is disabled/hidden" | todo | | REG-08 | P1 | An authority with a **relative** `login_url` still reaches a usable login page. | Found 2026-07-30, this suite | **blocked** — currently worked around in `seed.ts`; ts-client resolves a relative `login_url` against the authority host **without its port**, so any non-443 deployment dead-ends. Needs a ts-client/init fix before a spec can assert the good behaviour. | -| REG-09 | P1 | Concurrent `POST /bookings` do not 500. | **[PPT-2642](https://acaprojects.atlassian.net/browse/PPT-2642)** | **fixed at source, not yet in the image this suite runs against.** Root cause was in pg-orm: crystal-db clears a connection's transaction flag only *after* the COMMIT it issues, so a COMMIT that fails — the serialization failures a booking burst produces — leaves it set, and the one place connections return to the pool could not see it. The connection went back to the pool with its `BEGIN` still open; reads kept working inside the orphaned transaction while every write returned 500 until restart. Fixed in [pg-orm#19](https://github.com/spider-gazelle/pg-orm/pull/19) (v2.2.4) and picked up by staff-api in #386. Measured with images differing only by that version: control stranded a connection in 3 of 4 bursts and ended unable to write; fixed, 0 of 11 and still writable. **`e2e/stack` pulls `placeos/staff-api:latest`, which is a release image — so this suite keeps running the old behaviour until the next platform release.** Harmless for now: the suite does not burst concurrent bookings, so nothing here trips it. Reproducer kept at `e2e/support/repro/reg09-concurrent-bookings.ts`. | +| REG-09 | P1 | Concurrent `POST /bookings` do not 500. | **[PPT-2642](https://acaprojects.atlassian.net/browse/PPT-2642)** | **fixed at source, not yet in the image this suite runs against.** Root cause was in pg-orm: crystal-db clears a connection's transaction flag only *after* the COMMIT it issues, so a COMMIT that fails — the serialization failures a booking burst produces — leaves it set, and the one place connections return to the pool could not see it. The connection went back to the pool with its `BEGIN` still open; reads kept working inside the orphaned transaction while every write returned 500 until restart. Fixed in [pg-orm#19](https://github.com/spider-gazelle/pg-orm/pull/19) (v2.2.4) and picked up by staff-api in #386. Measured with images differing only by that version: control stranded a connection in 3 of 4 bursts and ended unable to write; fixed, 0 of 11 and still writable. **`e2e/stack` pulls `placeos/staff-api:latest`, which is a release image — so this suite keeps running the old behaviour until the next platform release.** **Corrected 2026-09-14: this suite now DOES burst concurrent bookings.** The line that used to sit here — "harmless for now, nothing here bursts concurrent bookings" — stopped being true when the group-invite specs (VIS-03/VIS-04) landed: a group invite posts the container and every member at once, which is exactly the burst. Measured with `e2e/support/repro/vis-b6-burst.ts` (no browser, 4 concurrent POSTs x 12 rounds): 13x 201, 29x 400, 6x 500, clean in the early rounds and degrading after. Before a rollback-and-retry was added to the visitor flows the group specs failed about one run in three; with it, 10 of 10 runs clean and the recovery fired twice. That retry is a **workaround living in `e2e/support/visitor/visitor.flows.ts`**, to be deleted once the staff-api image carries pg-orm v2.2.4 — it is also why the suite being green must not be read as REG-09 being gone from the image. Reproducers: `e2e/support/repro/reg09-concurrent-bookings.ts` and `vis-b6-burst.ts`. | | REG-10 | P1 | The booking form does not discard user input while it is still initialising. | **[PPT-2643](https://acaprojects.atlassian.net/browse/PPT-2643)** | **blocked** — the form is rebuilt when async init completes and restores defaults (title, All Day, Require locker), silently dropping anything typed before that. A real user can hit this; they would just see their title or options revert. `bookDeskViaUI` converges on the state to work around it, which means **the suite no longer detects it** — hence this row. Investigated 2026-08-05 against #478 (`a0360486`): **the bug is still live**, established by reading the code rather than by running this suite, and fixed in **PR #479**. `newForm`'s protected branch is never taken by the flows — the current user is restored from cache ~50ms after bootstrap, while `NewDeskFlowComponent.ngOnInit` calls `loadForm` then `newForm` only after org init plus a 300ms settle — and `loadForm` had no capture at all. The shipped e2e suite meanwhile is stable at 6 consecutive full runs, 8/8, `--retries=0`, which is precisely the problem: **it passes either way**. Removing the block could not be shown to be safe *or* unsafe from here: the race needs initialisation to be slow relative to typing, and this machine wins it every time. Two failed attempts to prove otherwise, both recorded so nobody repeats them: (1) a synthetic "type during init, assert it survives" spec passed with *and* without the fix, even with the API responses held to widen the window; (2) removing the block appeared to prove the bug survived — it did not. That red was a Playwright **strict-mode violation**, not a reverted value: opening the desk-select modal puts a second "All Day" checkbox in the DOM (`desk-filters`, bound to the same field), so an unscoped locator matched two elements and threw, with both checked. Scoping the locator to `desk-flow-form` then broke it a second way, because `setCheckbox` silently returns when its locator matches nothing, turning a narrower scope into a no-op and a genuinely invalid form. Both experiments were reverted. **The block stays and this row stays blocked even once #479 lands** — not because the app is unfixed, but because this suite cannot tell either way on fast hardware. The guard for REG-10 is the unit specs in `libs/bookings/src/test/booking-form.service.spec.ts`; unblocking this row needs artificial slowness (throttled CPU), not another e2e attempt. | ## 4. Platform & configuration @@ -179,17 +237,22 @@ Config gaps caused several production incidents, and they are invisible to UI sp 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. -- **Three rows are blocked on product fixes, not on test effort** (REG-08, REG-09, REG-10). All - were found by this suite. Leaving them visible here is the point — a blocked row is coverage +- **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 information, a deleted row is not. - **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. Filed as PPT-2642, root-caused to pg-orm and fixed in v2.2.4. It reaches this suite when the next platform release rebuilds `placeos/staff-api:latest`; until then the suite runs the old - behaviour, which is harmless here because nothing in it bursts concurrent bookings. - Two reproducers are kept next to the finding: `reg09-concurrent-bookings.ts` (the burst) and - `reg09-client-abort.ts` (which ruled out client disconnects). + behaviour — and since the group-invite specs landed, **the suite trips it**. Group invites post + a container plus every member at once, which is the burst; the visitor flows roll back a + partial group and retry to absorb it. Delete that retry when the image is updated, and do not + read a green run as evidence the image is fixed. + Three reproducers are kept next to the finding: `reg09-concurrent-bookings.ts` (the burst), + `reg09-client-abort.ts` (which ruled out client disconnects) and `vis-b6-burst.ts` (the same + burst with no browser at all, which is what proved the visitor 400s were REG-09 rather than a + form bug). - **REG-10 is invisible to this suite by design.** `bookDeskViaUI` converges on the form state, so nothing here will catch it regressing. PPT-2643's fix landed in #478; whether it is *complete* is genuinely unresolved, and the honest summary is that this suite cannot answer it diff --git a/apps/workplace/e2e/local/visitor-cancel.spec.ts b/apps/workplace/e2e/local/visitor-cancel.spec.ts new file mode 100644 index 0000000000..1e928b4f77 --- /dev/null +++ b/apps/workplace/e2e/local/visitor-cancel.spec.ts @@ -0,0 +1,120 @@ +/** + * VIS-16 / VIS-17 — cancelling an invite FROM THE APP. + * + * Every other spec here tears its invite down through the API, which is right + * for a teardown and useless as coverage: it means the Cancel button a real user + * presses, and the confirmation box in front of it, were never once exercised. + * A broken confirm dialog would have gone unnoticed by the whole suite. + * + * The pair is deliberate. Cancelling and NOT cancelling are equally important: + * a dialog whose decline button also deletes is a worse bug 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 + * on its own — the app removes it optimistically. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { listBookings, deleteBooking, uniqueTitle } from '../../../../e2e/support/api'; +import { VISITOR_SLOTS, visitorFor } from '../../../../e2e/support/visitor/visitor.env'; +import { deleteGuest, releaseVisitor } from '../../../../e2e/support/visitor/visitor.api'; +import { + SINGLE_VISITOR_MODE, + useSettings, +} from '../../../../e2e/support/visitor/visitor.settings'; +import { inviteVisitorViaUI } from '../../../../e2e/support/visitor/visitor.flows'; +import { YourBookingsPage } from '../../../../e2e/support/visitor/your-bookings.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) + 2 * 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, 'visitor', window_from(), window_to()); + return live.some((b) => Number(b.id) === id && !b.deleted); +} + +test.describe('cancelling a visitor invite from the app', () => { + test('cancelling from the booking menu removes it for real', async ({ + staffPage, + staffApi, + }, testInfo) => { + const visitor = visitorFor(testInfo.parallelIndex, VISITOR_SLOTS.cancel.fromApp); + const reason = uniqueTitle('E2E Visit Cancel'); + let booking_id: number | undefined; + + await releaseVisitor(staffApi, visitor.email, window_from(), window_to()); + await useSettings(staffPage, SINGLE_VISITOR_MODE); + + try { + const created = await inviteVisitorViaUI(staffPage, staffApi, visitor, reason); + booking_id = created.id; + + const page = new YourBookingsPage(staffPage); + await page.open(); + await page.showVisitors(); + await page.showDayOf(created.booking_start * 1000); + expect( + await isLive(staffApi, booking_id), + 'precondition: the invite is live before we cancel it', + ).toBe(true); + + await page.startCancel(booking_id); + await page.acceptConfirm(); + + // The backend is the judge. The card disappearing is the app being + // optimistic, and it does that whether or not the call succeeded. + await expect(async () => { + expect( + await isLive(staffApi, booking_id!), + 'the cancelled invite must be gone from the backend, not just the screen', + ).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); + await deleteGuest(staffApi, visitor.email); + } + }); + + test('declining the confirmation leaves the invite alone', async ({ + staffPage, + staffApi, + }, testInfo) => { + const visitor = visitorFor(testInfo.parallelIndex, VISITOR_SLOTS.cancel.dismissed); + const reason = uniqueTitle('E2E Visit Keep'); + let booking_id: number | undefined; + + await releaseVisitor(staffApi, visitor.email, window_from(), window_to()); + await useSettings(staffPage, SINGLE_VISITOR_MODE); + + try { + const created = await inviteVisitorViaUI(staffPage, staffApi, visitor, reason); + booking_id = created.id; + + const page = new YourBookingsPage(staffPage); + await page.open(); + await page.showVisitors(); + await page.showDayOf(created.booking_start * 1000); + + 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); + await deleteGuest(staffApi, visitor.email); + } + }); +}); diff --git a/apps/workplace/e2e/local/visitor-checkin.spec.ts b/apps/workplace/e2e/local/visitor-checkin.spec.ts new file mode 100644 index 0000000000..9c5caf08ea --- /dev/null +++ b/apps/workplace/e2e/local/visitor-checkin.spec.ts @@ -0,0 +1,231 @@ +/** + * VIS-11 / VIS-12 — checking a visitor in from the booking details modal. + * + * Check-in is the one visitor behaviour that is genuinely time-sensitive, and + * the backend rule is strict: + * + * POST /bookings/:id/check_in?state=true + * -> 405 {"error":"Can only check in an 1.0 hour before the booking start"} + * + * So these specs pin the start time instead of taking the form's default. They + * also create their bookings through the API rather than the invite form: the + * subject is check-in, and driving the form first would only add an unrelated + * way to fail. The desk suite sidesteps all of this by booking all-day, which is + * not an option when the window under test is an hour wide. + * + * Test 1 walks the whole visit: check in, then check out. They are the same + * button — the app flips it rather than showing a second one — and only the + * check-out asks for confirmation. Test 2 owns the other side: the times when + * check-in must NOT be offered. + * + * Neither test trusts the schedule's default day. It opens on whatever "today" + * was at page load and never moves, so both drive the sidebar calendar to the + * booking's OWN day with `showDayOf`. An earlier version instead kept both + * bookings inside today and skipped near midnight; that still failed when a run + * crossed midnight between creating the booking and loading the page, and it + * quietly skipped the check-in coverage every evening. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { + currentUser, + deleteBooking, + getBooking, + uniqueTitle, + zonesWithTag, +} from '../../../../e2e/support/api'; +import { VISITOR_SLOTS, visitorFor } from '../../../../e2e/support/visitor/visitor.env'; +import { + checkInViaApi, + createBookingViaApi, + deleteGuest, + releaseVisitor, +} from '../../../../e2e/support/visitor/visitor.api'; +import { + SINGLE_VISITOR_MODE, + useSettings, +} from '../../../../e2e/support/visitor/visitor.settings'; +import { YourBookingsPage } from '../../../../e2e/support/visitor/your-bookings.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) + 2 * DAY; + +/** + * A fixed hour tomorrow, local time, as unix seconds. + * + * Local rather than UTC because the schedule groups cards by the browser's own + * day, and the browser and this process share a clock and a timezone — nothing + * in the config pins either. + */ +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); +} + +async function inviteAt( + api: APIRequestContext, + visitor: { email: string; name: string }, + title: string, + startSeconds: number, +) { + const me = await currentUser(api); + const zones = ( + await Promise.all(['org', 'building', 'level'].map((t) => zonesWithTag(api, t))) + ) + .flat() + .map((z) => z.id); + + return createBookingViaApi(api, { + booking_type: 'visitor', + asset_id: visitor.email, + asset_name: visitor.name, + booking_start: startSeconds, + booking_end: startSeconds + 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 }], + }); +} + +test.describe('visitor check-in', () => { + test('a visitor can be checked in and back out again', async ({ + staffPage, + staffApi, + }, testInfo) => { + const visitor = visitorFor(testInfo.parallelIndex, VISITOR_SLOTS.checkin.checkIn); + const title = uniqueTitle('E2E Visit CheckIn'); + // Must satisfy BOTH gates, which are different sizes and easy to + // conflate: + // the app shows the button only within 15 MINUTES of the start + // (`Booking.state` returns 'future' before that); + // the backend accepts a check-in within 1 HOUR of the start. + // 10 minutes clears the tighter one with margin, and stays in the future + // so a slow run cannot let the booking start underneath the test. This + // one cannot move to a fixed hour like its neighbour does — a check-in + // has to happen next to its own start time. + // It is deliberately NOT pinned to today either: 10 minutes from now can + // land on tomorrow, and that is fine, because the view is driven to the + // booking's own day below. + const start = Math.floor(Date.now() / 1000) + 10 * 60; + + await releaseVisitor(staffApi, visitor.email, window_from(), window_to()); + await useSettings(staffPage, SINGLE_VISITOR_MODE); + + const booking = await inviteAt(staffApi, visitor, title, start); + try { + expect( + (await getBooking(staffApi, booking.id)).checked_in, + 'precondition: a new invite is not checked in', + ).toBeFalsy(); + + const page = new YourBookingsPage(staffPage); + await page.open(); + await page.showVisitors(); + await page.showDayOf(start * 1000); + const modal = await page.openDetails(booking.id); + + const check_in = page.checkInButton(modal); + await expect( + check_in, + 'the check-in control is missing — it is hidden by ' + + '`bookings.hide_checkin`/`visitors.hide_checkin`, by a booking that ' + + 'is already done, or by `auto_checkin`', + ).toBeVisible({ timeout: 20_000 }); + await check_in.click(); + + // Assert on the stored record, not on the button turning green. + await expect(async () => { + expect((await getBooking(staffApi, booking.id)).checked_in).toBe(true); + }).toPass({ timeout: 20_000 }); + + // ...and back out again. This is the SAME button — the app flips it + // once you are checked in rather than showing a second one — and it + // asks for confirmation first, which check-in does not. + await check_in.click(); + await page.acceptConfirm(); + + await expect(async () => { + const out = await getBooking(staffApi, booking.id); + expect( + out.checked_in, + 'checking out must clear the flag on the backend, not just on screen', + ).toBeFalsy(); + }).toPass({ timeout: 20_000 }); + + // Once checked out the control disappears altogether: the app keeps + // `checked_out_at` and hides it, so a visitor cannot be checked in + // twice off one invite. Without this, a check-out that only redrew + // the button would still look like a pass. + await expect( + check_in, + 'the check-in control must not come back after checking out', + ).toBeHidden({ timeout: 20_000 }); + } finally { + await deleteBooking(staffApi, booking.id); + await deleteGuest(staffApi, visitor.email); + } + }); + + test('a booking too far ahead offers no check-in, and the backend refuses too', async ({ + staffPage, + staffApi, + }, testInfo) => { + const visitor = visitorFor(testInfo.parallelIndex, VISITOR_SLOTS.checkin.tooEarly); + const title = uniqueTitle('E2E Visit Too Early'); + // A fixed hour tomorrow, not "3 hours from now": the gates are the + // subject here, and 10am tomorrow is outside both of them at every hour + // of the day, so the test behaves the same at 09:00 and at 23:59. + const start = tomorrowAt(10); + + await releaseVisitor(staffApi, visitor.email, window_from(), window_to()); + await useSettings(staffPage, SINGLE_VISITOR_MODE); + + const booking = await inviteAt(staffApi, visitor, title, start); + try { + const page = new YourBookingsPage(staffPage); + await page.open(); + await page.showVisitors(); + await page.showDayOf(start * 1000); + const modal = await page.openDetails(booking.id); + + // The app's own guard: `Booking.state` is 'future' until 15 minutes + // before the start, and the button is only rendered for + // upcoming/started/in_progress. So there is nothing to click — an + // earlier version of this spec expected to click and be refused, + // which was wrong about the app rather than finding a bug. + await expect( + page.checkInButton(modal), + 'a booking 3h away must not offer check-in at all', + ).toBeHidden(); + + // ...and the rule is not client-side only. Without this, hiding the + // button would be enough to pass while the API accepted a check-in + // from anyone at any time. + const refused = await checkInViaApi(staffApi, booking.id); + expect( + refused.status, + `the backend must refuse an early check-in, got ${refused.status} ` + + `${refused.body}`, + ).toBeGreaterThanOrEqual(400); + expect( + refused.body, + 'and refuse it for the timing reason, not some unrelated error', + ).toMatch(/before the booking start/i); + + expect( + (await getBooking(staffApi, booking.id)).checked_in, + 'a refused check-in must leave the booking not checked in', + ).toBeFalsy(); + } finally { + await deleteBooking(staffApi, booking.id); + await deleteGuest(staffApi, visitor.email); + } + }); +}); diff --git a/apps/workplace/e2e/local/visitor-delegate.spec.ts b/apps/workplace/e2e/local/visitor-delegate.spec.ts new file mode 100644 index 0000000000..3c8c5ed8c6 --- /dev/null +++ b/apps/workplace/e2e/local/visitor-delegate.spec.ts @@ -0,0 +1,206 @@ +/** + * VIS-09 / VIS-10 — inviting a visitor ON BEHALF OF somebody else. + * + * Three people are involved in a delegated invite and the model only names two + * of them, which is the thing most likely to be got wrong: + * + * user_email / user_id / user_name the HOST — who the visitor is here to see + * booked_by_email / booked_by_name the BOOKER — who filled the form in + * asset_id + attendees the VISITOR + * + * "Booked for" and "host" are the same field. There is no third role. + * + * Visibility follows from that split, and it is not symmetric: + * + * the HOST sees the invite in a plain `GET /bookings` listing + * the BOOKER sees it ONLY with `include_booked_by=true` + * + * The schedule page always sends that flag, which is why an invite you made for + * someone else turns up in your own list — carrying an "Associate" badge to say + * it is not yours. Test two asserts both halves of that. + * + * Requires `visitors.can_book_for_anyone` AND `basic_user_search`; see + * `invite-form.page.ts` for why the other host control cannot work locally. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { + STAFF_API, + currentUser, + deleteBooking, + getBooking, + listBookings, + uniqueTitle, +} from '../../../../e2e/support/api'; +import { WORKERS, roleFor, staffEmail } from '../../../../e2e/support/env'; +import { VISITOR_SLOTS, visitorFor } from '../../../../e2e/support/visitor/visitor.env'; +import { deleteGuest, releaseVisitor } from '../../../../e2e/support/visitor/visitor.api'; +import { + BOOK_FOR_ANYONE, + SINGLE_VISITOR_MODE, + useSettings, +} from '../../../../e2e/support/visitor/visitor.settings'; +import { inviteVisitorViaUI } from '../../../../e2e/support/visitor/visitor.flows'; +import { YourBookingsPage } from '../../../../e2e/support/visitor/your-bookings.page'; + +const DAY = 86_400; +const window_from = () => Math.floor(Date.now() / 1000) - 2 * DAY; +const window_to = () => Math.floor(Date.now() / 1000) + 2 * DAY; + +const DELEGATE_MODE = { ...SINGLE_VISITOR_MODE, ...BOOK_FOR_ANYONE }; + +/** The seeded non-admin this worker will nominate as host — never itself. */ +function hostFor(workerIndex: number) { + const other = (workerIndex + 1) % WORKERS; + return { + index: other, + email: staffEmail(other), + // seed.ts names them exactly this; the host field displays the NAME + // once chosen, so the flow needs it to tell "set" from "not set". + name: `E2E Staff ${other} (non-admin)`, + role: roleFor('staff', other), + }; +} + +test.describe('visitor invite on behalf of another host', () => { + test('the chosen host is stored as the host and you as the booker', async ({ + staffPage, + staffApi, + }, testInfo) => { + const mine = testInfo.parallelIndex; + const host = hostFor(mine); + test.skip(host.index === mine, 'needs at least two workers for a second identity'); + + const visitor = visitorFor(mine, VISITOR_SLOTS.delegate.single); + const reason = uniqueTitle('E2E Delegate Visit'); + let booking_id: number | undefined; + + await releaseVisitor(staffApi, visitor.email, window_from(), window_to()); + await useSettings(staffPage, DELEGATE_MODE); + + try { + const me = await currentUser(staffApi); + const created = await inviteVisitorViaUI(staffPage, staffApi, visitor, reason, { + host: { email: host.email, name: host.name }, + }); + booking_id = created.id; + + const stored = await getBooking(staffApi, booking_id); + expect(stored.user_email, 'the chosen host owns the booking').toBe(host.email); + expect(stored.booked_by_email, 'and we are recorded as the booker').toBe( + me.email, + ); + expect( + stored.user_email, + 'host and booker must actually differ, or this proves nothing', + ).not.toBe(stored.booked_by_email); + expect(stored.asset_id, 'the visitor is unchanged by delegating').toBe( + visitor.email, + ); + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + await deleteGuest(staffApi, visitor.email); + } + }); + + test('the booker sees it flagged as Associate, the host sees it as their own', async ({ + staffPage, + staffApi, + browser, + }, testInfo) => { + const mine = testInfo.parallelIndex; + const host = hostFor(mine); + test.skip(host.index === mine, 'needs at least two workers for a second identity'); + + const visitor = visitorFor(mine, VISITOR_SLOTS.delegate.groupA); + const reason = uniqueTitle('E2E Delegate View'); + let booking_id: number | undefined; + + await releaseVisitor(staffApi, visitor.email, window_from(), window_to()); + await useSettings(staffPage, DELEGATE_MODE); + + try { + const created = await inviteVisitorViaUI(staffPage, staffApi, visitor, reason, { + host: { email: host.email, name: host.name }, + }); + booking_id = created.id; + + // --- the BOOKER's view ------------------------------------------- + const mine_page = new YourBookingsPage(staffPage); + await mine_page.open(); + await mine_page.showVisitors(); + + await expect( + mine_page.card(booking_id), + 'an invite you made for someone else still appears in your own list — ' + + 'the schedule sends include_booked_by=true, which is what surfaces it', + ).toBeVisible({ timeout: 30_000 }); + await expect( + mine_page.associateBadge(booking_id), + 'and it is badged "Associate", because the host is not you. If this ' + + 'fails, check whether a third badge was added to that corner of ' + + 'the card — the locator excludes only the checked-in one', + ).toBeVisible(); + await expect( + mine_page.bookedForLine(booking_id), + 'the card names the host it was booked for', + ).toContainText(host.name); + + // --- the HOST's view ---------------------------------------------- + // A real second browser context as the host, not a re-query as us. + const host_ctx = await browser.newContext({ + storageState: host.role.storagePath, + ignoreHTTPSErrors: true, + }); + const host_page = await host_ctx.newPage(); + try { + const theirs = new YourBookingsPage(host_page); + await theirs.open(); + await theirs.showVisitors(); + + await expect( + theirs.card(booking_id), + 'the host sees the invite in their own list', + ).toBeVisible({ timeout: 30_000 }); + await expect( + theirs.associateBadge(booking_id), + 'but NOT badged Associate — it is their booking, not someone else’s', + ).toBeHidden(); + await expect( + theirs.bookedForLine(booking_id), + 'and no "For {name}" line, for the same reason', + ).toBeHidden(); + } finally { + await host_ctx.close(); + } + + // The API rule underneath both views, asserted directly so a UI + // change cannot hide a backend regression. + const booker_plain = await listBookings( + staffApi, + 'visitor', + window_from(), + window_to(), + ); + expect( + booker_plain.map((b) => b.id), + 'a plain listing is caller-scoped, so the BOOKER does not see it', + ).not.toContain(booking_id); + + const booker_included = await staffApi.get(`${STAFF_API}/bookings`, { + params: { + type: 'visitor', + period_start: String(window_from()), + period_end: String(window_to()), + include_booked_by: 'true', + }, + }); + expect( + ((await booker_included.json()) as { id: number }[]).map((b) => b.id), + 'with include_booked_by=true they do — this is what the schedule sends', + ).toContain(booking_id); + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + await deleteGuest(staffApi, visitor.email); + } + }); +}); diff --git a/apps/workplace/e2e/local/visitor-details.spec.ts b/apps/workplace/e2e/local/visitor-details.spec.ts new file mode 100644 index 0000000000..47d5d90346 --- /dev/null +++ b/apps/workplace/e2e/local/visitor-details.spec.ts @@ -0,0 +1,131 @@ +/** + * VIS-20 / VIS-21 — the details carried alongside the visitor's address. + * + * None of these change whether an invite saves, which is exactly why they are + * worth a spec: a field that silently stops being stored looks completely normal + * on screen. Reception is the one who finds out, at the door. + * + * Where they end up is not uniform, and that is the trap: + * `international` -> written explicitly into `extension_data` for visitor + * bookings (`buildBookingExtensionData`) + * `pass_number`, + * `company` -> plain form fields, copied into `extension_data` + * automatically by the `Booking` constructor + * the visitor -> `asset_id` / `attendees`, and a separate guest record + * So all three are asserted on what came back from the backend, not on the form. + * + * Pass number and the international tick are both behind `visitors.allow_pass_number` + * / `visitors.allow_international`, off by default — the classic "only breaks for + * the customers who switched it on" shape. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { deleteBooking, getBooking, uniqueTitle } from '../../../../e2e/support/api'; +import { VISITOR_SLOTS, visitorFor } from '../../../../e2e/support/visitor/visitor.env'; +import { + deleteGuest, + releaseVisitor, + searchGuests, + type VisitorBooking, +} from '../../../../e2e/support/visitor/visitor.api'; +import { + ALLOW_EXTRAS, + SINGLE_VISITOR_MODE, + useSettings, +} from '../../../../e2e/support/visitor/visitor.settings'; +import { inviteVisitorViaUI } from '../../../../e2e/support/visitor/visitor.flows'; + +const DAY = 86_400; +const window_from = () => Math.floor(Date.now() / 1000) - 2 * DAY; +const window_to = () => Math.floor(Date.now() / 1000) + 2 * DAY; + +test.describe('visitor invite details', () => { + test('a pass number and the international flag are stored with the invite', async ({ + staffPage, + staffApi, + }, testInfo) => { + const visitor = visitorFor(testInfo.parallelIndex, VISITOR_SLOTS.details.extras); + const reason = uniqueTitle('E2E Visit Extras'); + const pass_number = `E2E-PASS-${testInfo.parallelIndex}-${Date.now() % 100000}`; + let booking_id: number | undefined; + + await releaseVisitor(staffApi, visitor.email, window_from(), window_to()); + await useSettings(staffPage, { ...SINGLE_VISITOR_MODE, ...ALLOW_EXTRAS }); + + try { + const created = await inviteVisitorViaUI(staffPage, staffApi, visitor, reason, { + passNumber: pass_number, + international: true, + }); + booking_id = created.id; + + // `getBooking` returns the shared `Booking`, whose `extension_data` + // is typed as empty. `VisitorBooking` is the same row with the + // visitor keys spelled out — see `visitor.api.ts`. + const stored = (await getBooking(staffApi, booking_id)) as VisitorBooking; + expect( + stored.extension_data?.pass_number, + 'the pass number typed into the form must reach the backend — ' + + 'reception reads this one', + ).toBe(pass_number); + expect( + stored.extension_data?.international, + 'and the international tick must be stored as a real true, not dropped', + ).toBe(true); + + // The visitor themselves, alongside the extras, so a booking that + // stored the flags against the wrong person cannot pass. + expect(stored.asset_id, 'the invite is still for this visitor').toBe( + visitor.email, + ); + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + await deleteGuest(staffApi, visitor.email); + } + }); + + test('the company is stored, and the guest record carries the visitor name', async ({ + staffPage, + staffApi, + }, testInfo) => { + const visitor = visitorFor(testInfo.parallelIndex, VISITOR_SLOTS.details.company); + const reason = uniqueTitle('E2E Visit Company'); + let booking_id: number | undefined; + + await releaseVisitor(staffApi, visitor.email, window_from(), window_to()); + await deleteGuest(staffApi, visitor.email); + await useSettings(staffPage, SINGLE_VISITOR_MODE); + + try { + const created = await inviteVisitorViaUI(staffPage, staffApi, visitor, reason); + booking_id = created.id; + + // `getBooking` returns the shared `Booking`, whose `extension_data` + // is typed as empty. `VisitorBooking` is the same row with the + // visitor keys spelled out — see `visitor.api.ts`. + const stored = (await getBooking(staffApi, booking_id)) as VisitorBooking; + expect( + stored.extension_data?.company, + 'the company typed into the form must reach the backend', + ).toBe(visitor.company); + expect( + stored.extension_data?.visitor_name, + 'and so must the visitor name, which is stored separately from the address', + ).toBe(visitor.name); + + // The guest record is a second, independent copy of who is coming — + // it is what reception and the kiosk read, and it outlives the + // booking. A name lost here is a nameless person at the door. + await expect(async () => { + const guests = await searchGuests(staffApi, visitor.email); + const guest = guests.find( + (g) => g.email?.toLowerCase() === visitor.email.toLowerCase(), + ); + expect(guest, 'a guest record was created for the visitor').toBeTruthy(); + expect(guest!.name, 'and it carries their name').toBe(visitor.name); + }).toPass({ timeout: 20_000 }); + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + await deleteGuest(staffApi, visitor.email); + } + }); +}); diff --git a/apps/workplace/e2e/local/visitor-edit.spec.ts b/apps/workplace/e2e/local/visitor-edit.spec.ts new file mode 100644 index 0000000000..8b9a1efac8 --- /dev/null +++ b/apps/workplace/e2e/local/visitor-edit.spec.ts @@ -0,0 +1,319 @@ +/** + * VIS-13 / VIS-14 / VIS-15 — editing an invite that already exists. + * + * ## Which invites can actually be edited + * + * Editing works in MULTI-visitor mode, which is what the app ships with + * (`bookings.multiple_visitors` defaults to true). It is broken in + * SINGLE-visitor mode: pressing Update throws `_.toJSON is not a function`, no + * request is sent, and the user sees only that raw string. That is VIS-B8, and + * the third test here is the placeholder for it. + * + * Measured, all three created and edited through the app — multi with one + * visitor and with two both save cleanly (`PATCH`, "updated successfully"); + * single fails every time, on today's date and on a future one alike. The mode + * decides it, not the date and not the number of people. + * + * Cause: `Booking` declares `attendees` as `User[]` but assigns the raw server + * JSON to it (`booking.class.ts`), so at runtime they are plain objects. In + * single-visitor mode the edit path hands that straight to the form's `assets` + * (`invite-visitor-form.component.ts`), and saving calls `.toJSON()` on every + * entry (`booking-form.service.ts`). Plain JSON has no such method. The + * multi-visitor paths escape it because they rebuild each person with + * `new User(...)` first. + * + * ## Why THREE tests in this file, when every other spec has two + * + * The third is a deliberate placeholder for VIS-B8 and is `fixme`, so it costs + * nothing per run. Fold it away once the app is fixed: at that point it is the + * same assertion as the first test with one setting changed. + * + * ## What the first two guard + * + * - Re-saving a DELEGATED invite used to overwrite the host with whoever had + * the form open. The booker is usually the one editing, so the bug quietly + * reassigned the visit to them. This one is booked for a FUTURE day as well, + * since an invite worth editing is usually one that has not happened yet. + * - Removing one visitor from a group used to take others with it, or leave the + * removed one behind (PPT-2634, PPT-2638). A group edit rewrites several + * bookings at once, so "it looked right on screen" is worth nothing here — + * every assertion reads the backend. + * + * Editing is off by default (`visitors.allow_editing`), which is why the Edit + * action simply is not in the menu unless a spec turns it on. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { + currentUser, + deleteBooking, + getBooking, + listBookings, + uniqueTitle, +} from '../../../../e2e/support/api'; +import { WORKERS, staffEmail } from '../../../../e2e/support/env'; +import { VISITOR_SLOTS, visitorFor } from '../../../../e2e/support/visitor/visitor.env'; +import { + deleteGuest, + releaseGroupContainers, + releaseVisitor, +} from '../../../../e2e/support/visitor/visitor.api'; +import { + ALLOW_EDITING, + BOOK_FOR_ANYONE, + GROUP_VISITOR_MODE, + SINGLE_VISITOR_MODE, + useSettings, +} from '../../../../e2e/support/visitor/visitor.settings'; +import { + inviteVisitorViaUI, + inviteVisitorsViaUI, +} from '../../../../e2e/support/visitor/visitor.flows'; +import { InviteVisitorForm } from '../../../../e2e/support/visitor/invite-form.page'; +import { YourBookingsPage } from '../../../../e2e/support/visitor/your-bookings.page'; + +const DAY = 86_400; +// Wide enough to sweep the future-dated invite below. A window that only +// reached +2 days would leave a booking from a crashed run sitting on day 3, +// invisible to every later sweep. +const window_from = () => Math.floor(Date.now() / 1000) - 2 * DAY; +const window_to = () => Math.floor(Date.now() / 1000) + 7 * DAY; + +/** A fixed hour, some days out, in local time — the browser shares this clock. */ +function daysAhead(days: number, hour = 10): number { + const day = new Date(); + day.setDate(day.getDate() + days); + day.setHours(hour, 0, 0, 0); + return day.valueOf(); +} + +test.describe('editing a visitor invite', () => { + test('re-saving a delegated invite for a future day keeps the original host', async ({ + staffPage, + staffApi, + }, testInfo) => { + const mine = testInfo.parallelIndex; + const other = (mine + 1) % WORKERS; + test.skip(other === mine, 'needs at least two workers for a second identity'); + + const host = { email: staffEmail(other), name: `E2E Staff ${other} (non-admin)` }; + const visitor = visitorFor(mine, VISITOR_SLOTS.edit.details); + const reason = uniqueTitle('E2E Edit Host'); + const new_reason = uniqueTitle('E2E Edit Host Updated'); + // Three days out. A visit worth editing is normally one still to come, + // and it also proves the edit path does not quietly depend on the + // booking being today. + const visit_day = daysAhead(3); + let booking_id: number | undefined; + const created_ids: number[] = []; + + await releaseVisitor(staffApi, visitor.email, window_from(), window_to()); + await releaseGroupContainers(staffApi, window_from(), window_to()); + await useSettings(staffPage, { + ...GROUP_VISITOR_MODE, + ...BOOK_FOR_ANYONE, + ...ALLOW_EDITING, + }); + + try { + const me = await currentUser(staffApi); + // Multi-visitor mode with one guest: the mode the app ships with, + // and the one where saving an edit works at all (VIS-B8). + const created = await inviteVisitorsViaUI( + staffPage, + staffApi, + [visitor], + reason, + { host, date: visit_day }, + ); + created_ids.push(...created.map((b) => b.id)); + const member = created.find((b) => b.booking_type === 'visitor'); + expect(member, 'the invite created a visitor booking to edit').toBeTruthy(); + booking_id = member!.id; + + const stored = await getBooking(staffApi, booking_id); + expect( + stored.user_email, + 'precondition: the invite starts out hosted by somebody else', + ).toBe(host.email); + expect( + new Date(stored.booking_start * 1000).toDateString(), + 'precondition: the invite really is on the future day we picked', + ).toBe(new Date(visit_day).toDateString()); + + // Re-open it and change only the reason. The schedule opens on + // today, so it has to be walked to the visit's own day first. + const bookings_page = new YourBookingsPage(staffPage); + await bookings_page.open(); + await bookings_page.showVisitors(); + await bookings_page.showDayOf(visit_day); + await bookings_page.startEdit(booking_id); + + const form = new InviteVisitorForm(staffPage); + await expect(async () => { + await form.reason.fill(new_reason); + expect(await form.reason.inputValue()).toBe(new_reason); + }).toPass({ timeout: 30_000 }); + await form.sendButton.click(); + + await expect(async () => { + const updated = await getBooking(staffApi, booking_id!); + expect(updated.title, 'the new reason was saved').toBe(new_reason); + }).toPass({ timeout: 30_000 }); + + const updated = await getBooking(staffApi, booking_id); + expect( + updated.user_email, + 'the host must survive the edit — it must NOT become whoever had the ' + + 'form open, which is the regression this test exists for', + ).toBe(host.email); + expect(updated.user_email, 'and still differ from the editor').not.toBe( + me.email, + ); + expect( + new Date(updated.booking_start * 1000).toDateString(), + 'and the visit must still be on the day it was booked for', + ).toBe(new Date(visit_day).toDateString()); + } finally { + for (const id of created_ids) await deleteBooking(staffApi, id).catch(() => null); + await releaseGroupContainers(staffApi, window_from(), window_to()); + await deleteGuest(staffApi, visitor.email); + } + }); + + test('removing one visitor from a group leaves the others alone', async ({ + staffPage, + staffApi, + }, testInfo) => { + const kept = visitorFor(testInfo.parallelIndex, VISITOR_SLOTS.edit.memberA); + const removed = visitorFor(testInfo.parallelIndex, VISITOR_SLOTS.edit.memberB); + const reason = uniqueTitle('E2E Edit Group'); + const created_ids: number[] = []; + + for (const v of [kept, removed]) { + await releaseVisitor(staffApi, v.email, window_from(), window_to()); + } + await releaseGroupContainers(staffApi, window_from(), window_to()); + await useSettings(staffPage, { ...GROUP_VISITOR_MODE, ...ALLOW_EDITING }); + + try { + const bookings = await inviteVisitorsViaUI( + staffPage, + staffApi, + [kept, removed], + reason, + ); + created_ids.push(...bookings.map((b) => b.id)); + + const members = bookings.filter((b) => b.booking_type === 'visitor'); + const kept_booking = members.find((b) => b.asset_id === kept.email); + const removed_booking = members.find((b) => b.asset_id === removed.email); + expect(kept_booking, 'precondition: both visitors were booked').toBeTruthy(); + expect(removed_booking, 'precondition: both visitors were booked').toBeTruthy(); + + // Edit the group from either member — they share a container. + const bookings_page = new YourBookingsPage(staffPage); + await bookings_page.open(); + await bookings_page.showVisitors(); + await bookings_page.startEdit(kept_booking!.id); + + const form = new InviteVisitorForm(staffPage); + await expect( + form.chips, + 'the edit form should load with both visitors already listed', + ).toHaveCount(2, { timeout: 30_000 }); + + // Not a `hasText` filter on the address: a chip renders only the + // part before the @, so that matched nothing and read as a broken + // remove button. `removeVisitorChip` matches what is really shown. + await form.removeVisitorChip(removed.email); + await expect(form.chips, 'one visitor was removed from the list').toHaveCount(1); + + await form.sendButton.click(); + + // Read the backend, not the screen. A group edit rewrites several + // rows and the screen shows only what the form thinks it did. + await expect(async () => { + const live = ( + await listBookings(staffApi, 'visitor', window_from(), window_to()) + ).filter((b) => !b.deleted); + const addresses = live.map((b) => b.asset_id); + expect( + addresses, + 'the visitor we removed must no longer hold a booking', + ).not.toContain(removed.email); + expect( + addresses, + 'and the one we kept must still have theirs — removing a visitor ' + + 'must not take the rest of the group with it (PPT-2634)', + ).toContain(kept.email); + }).toPass({ timeout: 30_000 }); + } finally { + const leftovers = await listBookings( + staffApi, + 'visitor', + window_from(), + window_to(), + ); + for (const b of leftovers) { + if ([kept.email, removed.email].includes(b.asset_id)) { + await deleteBooking(staffApi, b.id); + } + } + for (const id of created_ids) await deleteBooking(staffApi, id); + for (const v of [kept, removed]) await deleteGuest(staffApi, v.email); + await releaseGroupContainers(staffApi, window_from(), window_to()); + } + }); + + /** + * VIS-15 — the SAME edit, in single-visitor mode. This is the one that is + * broken (VIS-B8): `_.toJSON is not a function`, no request sent, nothing + * saved. Kept as `fixme` so the bug has a home in the suite rather than + * only in a document. + * + * When the app is fixed, drop the `fixme` and this should pass as written. + * Evidence it is the mode and not anything else: the first test above does + * the same thing in multi-visitor mode and is green. + */ + test.fixme('an invite made in single-visitor mode can be re-saved', async ({ + staffPage, + staffApi, + }, testInfo) => { + const visitor = visitorFor(testInfo.parallelIndex, VISITOR_SLOTS.edit.details); + const reason = uniqueTitle('E2E Edit Single'); + const new_reason = uniqueTitle('E2E Edit Single Updated'); + let booking_id: number | undefined; + + await releaseVisitor(staffApi, visitor.email, window_from(), window_to()); + await useSettings(staffPage, { ...SINGLE_VISITOR_MODE, ...ALLOW_EDITING }); + + try { + const created = await inviteVisitorViaUI(staffPage, staffApi, visitor, reason); + booking_id = created.id; + + const bookings_page = new YourBookingsPage(staffPage); + await bookings_page.open(); + await bookings_page.showVisitors(); + await bookings_page.startEdit(booking_id); + + const form = new InviteVisitorForm(staffPage); + await expect(async () => { + await form.reason.fill(new_reason); + expect(await form.reason.inputValue()).toBe(new_reason); + }).toPass({ timeout: 30_000 }); + await form.sendButton.click(); + + await expect(async () => { + const updated = await getBooking(staffApi, booking_id!); + expect( + updated.title, + 'the edit must reach the backend — in single-visitor mode it ' + + 'currently throws in the browser and sends nothing (VIS-B8)', + ).toBe(new_reason); + }).toPass({ timeout: 30_000 }); + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + await deleteGuest(staffApi, visitor.email); + } + }); +}); diff --git a/apps/workplace/e2e/local/visitor-group.spec.ts b/apps/workplace/e2e/local/visitor-group.spec.ts new file mode 100644 index 0000000000..fe4562e2f4 --- /dev/null +++ b/apps/workplace/e2e/local/visitor-group.spec.ts @@ -0,0 +1,171 @@ +/** + * VIS-03 / VIS-04 — invite SEVERAL visitors at once, through the full UI. + * + * This is the mode workplace ships with (`bookings.multiple_visitors` defaults + * to true), and it is not "the single flow, twice". One send produces N+1 + * records: + * + * - a GROUP CONTAINER booking, `booking_type: 'group'`, carrying the shared + * group reference in `extension_data`; + * - one `booking_type: 'visitor'` booking PER visitor, each with `parent_id` + * pointing at that container. + * + * Two consequences that shaped this file: + * + * - `GET /bookings?type=visitor` does NOT return the container, because the + * container is type `group`. Expecting N+1 from a visitor-typed listing is + * the obvious mistake, and the second test exists to stop anyone making it. + * - Waiting on a single POST response would assert against the container and + * silently ignore every member, so `inviteVisitorsViaUI` collects them all. + * + * The container-to-member link is what PPT-2638 broke and fixed, so asserting + * on `parent_id` rather than merely on "three bookings exist" is the point. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { deleteBooking, listBookings, uniqueTitle } from '../../../../e2e/support/api'; +import { VISITOR_SLOTS, visitorFor } from '../../../../e2e/support/visitor/visitor.env'; +import { + deleteGuest, + releaseGroupContainers, + releaseVisitor, + searchGuests, +} from '../../../../e2e/support/visitor/visitor.api'; +import { + GROUP_VISITOR_MODE, + useSettings, +} from '../../../../e2e/support/visitor/visitor.settings'; +import { inviteVisitorsViaUI } from '../../../../e2e/support/visitor/visitor.flows'; + +const DAY = 86_400; +const window_from = () => Math.floor(Date.now() / 1000) - 2 * DAY; +const window_to = () => Math.floor(Date.now() / 1000) + 2 * DAY; + +/** + * Sweep both halves. `releaseVisitor` lists `type=visitor` and so cannot see a + * group container; a run that died between creating the container and its + * members leaves one orphaned (VIS-B7), and without this they accumulate. + */ +async function sweep(api: Parameters[0], emails: string[]) { + for (const email of emails) { + await releaseVisitor(api, email, window_from(), window_to()); + } + await releaseGroupContainers(api, window_from(), window_to()); +} + +test.describe('visitor invite (group)', () => { + test('inviting two visitors creates a container plus one booking each', async ({ + staffPage, + staffApi, + }, testInfo) => { + const visitors = [ + visitorFor(testInfo.parallelIndex, VISITOR_SLOTS.group.a), + visitorFor(testInfo.parallelIndex, VISITOR_SLOTS.group.b), + ]; + const reason = uniqueTitle('E2E Group Visit'); + const created_ids: number[] = []; + + await sweep(staffApi, visitors.map((v) => v.email)); + await useSettings(staffPage, GROUP_VISITOR_MODE); + + try { + const bookings = await inviteVisitorsViaUI(staffPage, staffApi, visitors, reason); + created_ids.push(...bookings.map((b) => b.id)); + + const containers = bookings.filter((b) => b.booking_type === 'group'); + const members = bookings.filter((b) => b.booking_type === 'visitor'); + + expect(containers, 'exactly one group container was created').toHaveLength(1); + expect(members, 'one visitor booking per invited visitor').toHaveLength( + visitors.length, + ); + + // Assert on `extension_data`, not `asset_name`. The UI sets + // `asset_name: 'Group Booking'` when it builds the container, but + // staff-api's Booking model has no such column and never echoes it + // back — asserting on it compares against `undefined` and passes or + // fails for the wrong reason. + const container = containers[0]; + expect( + container.extension_data?.group_resource_type, + 'the container records what kind of group it holds', + ).toBe('visitor'); + expect( + container.extension_data?.group, + 'the container carries the group reference its members share', + ).toBeTruthy(); + + // The link between members and container is the whole point of a + // group container — PPT-2638 is exactly this going wrong. + for (const member of members) { + expect( + member.parent_id, + `visitor booking ${member.id} must point at the group container`, + ).toBe(container.id); + } + + // Each visitor against THEIR OWN address, not the host's and not the + // first visitor's (PPT-2635). + expect( + members.map((m) => m.asset_id).sort(), + 'every visitor is stored against their own address', + ).toEqual(visitors.map((v) => v.email).sort()); + + // And a guest record each — the half reception actually reads. + for (const v of visitors) { + expect( + (await searchGuests(staffApi, v.email)).map((g) => g.email), + `a guest record was created for ${v.email}`, + ).toContain(v.email); + } + } finally { + for (const id of created_ids) await deleteBooking(staffApi, id); + for (const v of visitors) await deleteGuest(staffApi, v.email); + } + }); + + test('the visitor listing returns the members but not the container', async ({ + staffPage, + staffApi, + }, testInfo) => { + const visitors = [ + visitorFor(testInfo.parallelIndex, VISITOR_SLOTS.group.a), + visitorFor(testInfo.parallelIndex, VISITOR_SLOTS.group.b), + ]; + const reason = uniqueTitle('E2E Group Listing'); + const created_ids: number[] = []; + + await sweep(staffApi, visitors.map((v) => v.email)); + await useSettings(staffPage, GROUP_VISITOR_MODE); + + try { + const bookings = await inviteVisitorsViaUI(staffPage, staffApi, visitors, reason); + created_ids.push(...bookings.map((b) => b.id)); + + const [container] = bookings.filter((b) => b.booking_type === 'group'); + const members = bookings.filter((b) => b.booking_type === 'visitor'); + expect( + container, + 'precondition: the group invite produced a container booking', + ).toBeTruthy(); + + const listed = ( + await listBookings(staffApi, 'visitor', window_from(), window_to()) + ).map((b) => b.id); + + for (const member of members) { + expect(listed, `member booking ${member.id} is in the listing`).toContain( + member.id, + ); + } + expect( + listed, + 'the container is `booking_type: group`, so a visitor-typed listing must ' + + 'not include it — code that expects N+1 here is counting the ' + + 'container as a visitor', + ).not.toContain(container.id); + } finally { + for (const id of created_ids) await deleteBooking(staffApi, id); + for (const v of visitors) await deleteGuest(staffApi, v.email); + } + }); +}); diff --git a/apps/workplace/e2e/local/visitor-invite.spec.ts b/apps/workplace/e2e/local/visitor-invite.spec.ts new file mode 100644 index 0000000000..3cf1eccf66 --- /dev/null +++ b/apps/workplace/e2e/local/visitor-invite.spec.ts @@ -0,0 +1,163 @@ +/** + * VIS-01 / VIS-02 — invite a visitor through the full UI, as a NON-ADMIN, + * against a real local backend. + * + * Deliberately the same shape as `desk-booking.spec.ts`: two tests, one that + * creates through the real form and asserts hard against what the backend + * stored, one that proves teardown really tears down. Assertions are folded + * into the creating test rather than split across more tests, because what is + * being guarded is a single flow — splitting it would drive the same form three + * times to check three fields. + * + * Three things make visitors different from desks, each of which produced a + * wrong assumption before it was checked against the running stack: + * + * - A visitor is NOT an exclusive asset. The backend accepts the same visitor + * twice in the same slot (VIS-B1), so a leftover booking cannot block a + * later run the way a leaked all-day desk booking does. Per-worker, per-slot + * addresses are about keeping sweeps disjoint, not about avoiding a clash. + * - A GUEST record is created alongside the booking, from its `attendees`, and + * it OUTLIVES the booking. Teardown has to remove both, or "was a guest + * created?" starts passing on the last run's data. + * - The form has two modes and the app default is the MULTI-visitor one. This + * file covers single mode, so it pins the setting per test rather than + * trusting a default a settings change could flip underneath it. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { + currentUser, + deleteBooking, + getBooking, + listBookings, + uniqueTitle, +} from '../../../../e2e/support/api'; +import { VISITOR_SLOTS, visitorFor } from '../../../../e2e/support/visitor/visitor.env'; +import { + deleteGuest, + releaseVisitor, + searchGuests, +} from '../../../../e2e/support/visitor/visitor.api'; +import { + SINGLE_VISITOR_MODE, + useSettings, +} from '../../../../e2e/support/visitor/visitor.settings'; +import { inviteVisitorViaUI } from '../../../../e2e/support/visitor/visitor.flows'; + +/** Window wide enough to cover an invite made in any timezone. */ +const DAY = 86_400; +const window_from = () => Math.floor(Date.now() / 1000) - 2 * DAY; +const window_to = () => Math.floor(Date.now() / 1000) + 2 * DAY; + +test.describe('visitor invite (single)', () => { + test('a non-admin invites a visitor in the UI and the backend stores it', async ({ + staffPage, + staffApi, + }, testInfo) => { + const visitor = visitorFor(testInfo.parallelIndex, VISITOR_SLOTS.invite.stores); + const reason = uniqueTitle('E2E Visit'); + let booking_id: number | undefined; + + const swept = await releaseVisitor( + staffApi, + visitor.email, + window_from(), + window_to(), + ); + if (swept) console.log(` swept ${swept} stale invite(s) for ${visitor.email}`); + + // Prove the sweep worked before claiming the invite created anything — a + // guest left over from an earlier run would make the check below pass + // for free. + expect( + await searchGuests(staffApi, visitor.email), + 'precondition: no guest record exists before the invite', + ).toHaveLength(0); + + await useSettings(staffPage, SINGLE_VISITOR_MODE); + + try { + const created = await inviteVisitorViaUI(staffPage, staffApi, visitor, reason); + booking_id = created.id; + + expect(created.id, 'the API returned a booking id').toBeTruthy(); + + // Read it back independently of the response we just parsed. + const stored = await getBooking(staffApi, booking_id); + const me = await currentUser(staffApi); + expect(stored.booking_type, 'stored as a visitor booking').toBe('visitor'); + expect(stored.asset_id, 'against this worker’s visitor address').toBe( + visitor.email, + ); + expect(stored.title, 'the reason we typed reached the backend').toBe(reason); + expect(stored.deleted, 'not soft-deleted').toBeFalsy(); + expect(stored.rejected, 'not rejected').toBeFalsy(); + expect( + stored.zones.length, + 'carries its zone hierarchy (org/building/level)', + ).toBeGreaterThan(0); + // With no host chosen the signed-in user is both host and booker. + // `visitor-delegate.spec.ts` covers the case where they differ. + expect(stored.user_email, 'the signed-in user is the host').toBe(me.email); + expect(stored.booked_by_email, 'and also the booker').toBe(me.email); + + // A guest record is the half of this flow that lives outside the + // booking, and it is what reception actually reads. + const guests = await searchGuests(staffApi, visitor.email); + expect( + guests.map((g) => g.email), + 'a guest record was created for the visitor', + ).toContain(visitor.email); + expect(guests[0].name, 'carrying the name we typed').toBe(visitor.name); + + // ...and the booking is discoverable through the listing the app + // uses, not only by direct id lookup. + const listed = await listBookings( + staffApi, + 'visitor', + window_from(), + window_to(), + ); + expect( + listed.map((b) => b.id), + 'the new invite appears in the visitor listing', + ).toContain(booking_id); + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + await deleteGuest(staffApi, visitor.email); + } + }); + + test('a deleted visitor invite leaves the listing', async ({ + staffPage, + staffApi, + }, testInfo) => { + const visitor = visitorFor(testInfo.parallelIndex, VISITOR_SLOTS.invite.cleanup); + const reason = uniqueTitle('E2E Visit Cleanup'); + + await releaseVisitor(staffApi, visitor.email, window_from(), window_to()); + await useSettings(staffPage, SINGLE_VISITOR_MODE); + + const created = await inviteVisitorViaUI(staffPage, staffApi, visitor, reason); + try { + expect( + ( + await listBookings(staffApi, 'visitor', window_from(), window_to()) + ).map((b) => b.id), + 'precondition: the invite is in the listing before we delete it', + ).toContain(created.id); + + await deleteBooking(staffApi, created.id); + + expect( + ( + await listBookings(staffApi, 'visitor', window_from(), window_to()) + ).map((b) => b.id), + 'a deleted invite must not come back in the listing — otherwise every ' + + 'spec teardown silently leaks state into the next run', + ).not.toContain(created.id); + } finally { + await deleteBooking(staffApi, created.id); + await deleteGuest(staffApi, visitor.email); + } + }); +}); diff --git a/apps/workplace/e2e/local/visitor-scoping.spec.ts b/apps/workplace/e2e/local/visitor-scoping.spec.ts new file mode 100644 index 0000000000..881a38b2d5 --- /dev/null +++ b/apps/workplace/e2e/local/visitor-scoping.spec.ts @@ -0,0 +1,180 @@ +/** + * VIS-05 / VIS-06 — one user's visitor invites are not another user's business. + * + * The desk equivalent is `booking-scoping.spec.ts`, and this deliberately + * mirrors it, because the property it locks down is the same one and it was + * learned the hard way: `GET /bookings` is scoped to the CALLER. An early + * leak-check written as an admin reported zero bookings while the database + * plainly held one, because the admin was only ever being shown their own. + * + * Visitors raise the stakes over desks. A desk booking leaks where a colleague + * sits; a visitor invite leaks WHO IS COMING TO SEE THEM, plus that person's + * name, email and company. It is also the surface a receptionist-style + * integration is most likely to be built against, so a regression here would + * leak quietly rather than fail loudly. + * + * Everything is created through the API, not the form: the subject is + * authorisation, and driving the UI would only add a way for the test to fail + * for an unrelated reason. + */ +import { request } from '@playwright/test'; +import { test, expect } from '../../../../e2e/support/fixtures'; +import { APP_URL, BACKEND_URL, WORKERS, roleFor } from '../../../../e2e/support/env'; +import { mintToken } from '../../../../e2e/support/auth'; +import { + STAFF_API, + currentUser, + deleteBooking, + listBookings, + uniqueTitle, + zonesWithTag, +} from '../../../../e2e/support/api'; +import { VISITOR_SLOTS, visitorFor } from '../../../../e2e/support/visitor/visitor.env'; +import { + createBookingViaApi, + deleteGuest, + releaseVisitor, + searchGuests, +} from '../../../../e2e/support/visitor/visitor.api'; +import type { APIRequestContext } from '@playwright/test'; + +const DAY = 86_400; +// Window comfortably wider than any invite these specs create. Keep it that +// way: a booking placed exactly on the boundary is not returned by the listing, +// which reads as "scoping is broken" rather than "the window was too tight". +const from = () => Math.floor(Date.now() / 1000) - 3 * DAY; +const to = () => Math.floor(Date.now() / 1000) + 3 * DAY; + +/** The org/building/level zone ids an invite carries, as the UI sends them. */ +async function bookingZones(api: APIRequestContext): Promise { + const groups = await Promise.all( + ['org', 'building', 'level'].map((tag) => zonesWithTag(api, tag)), + ); + return groups.flat().map((z) => z.id); +} + +/** Create a visitor invite directly, returning the stored booking. */ +async function inviteViaApi( + api: APIRequestContext, + visitor: { email: string; name: string }, + title: string, +) { + const me = await currentUser(api); + const zones = await bookingZones(api); + const start = Math.floor(Date.now() / 1000) + DAY; + + // Via the REG-09-tolerant helper: the backend drops concurrent booking + // writes on this stack, and a scoping spec failing for that reason tells + // nobody anything about scoping. + return createBookingViaApi(api, { + 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 }], + }); +} + +test.describe('visitor invite visibility between users', () => { + test('another user cannot see or delete your visitor invite', async ({ + staffApi, + }, testInfo) => { + const mine = testInfo.parallelIndex; + // A genuinely different seeded user. With one worker there is nobody + // else to compare against, so the spec would be meaningless. + const theirs = (mine + 1) % WORKERS; + test.skip(theirs === mine, 'needs at least two workers to have two distinct users'); + + const visitor = visitorFor(mine, VISITOR_SLOTS.scoping.own); + const title = uniqueTitle('E2E Visitor Scoping'); + await releaseVisitor(staffApi, visitor.email, from(), to()); + + const me = await currentUser(staffApi); + const booking = await inviteViaApi(staffApi, visitor, title); + + const other_role = roleFor('staff', theirs); + const other_mint = await mintToken( + BACKEND_URL, + APP_URL, + other_role.email, + other_role.password, + ); + const other = await request.newContext({ + baseURL: BACKEND_URL, + ignoreHTTPSErrors: true, + extraHTTPHeaders: { Authorization: `Bearer ${other_mint.accessToken}` }, + }); + + try { + // Sanity: the two identities really are different, or everything + // below would pass for the wrong reason. + const them = await currentUser(other); + expect(them.email, 'the second user must be a different person').not.toBe( + me.email, + ); + + // The privacy boundary. + const their_view = await listBookings(other, 'visitor', from(), to()); + expect( + their_view.map((b) => b.id), + "another user's listing must not include your visitor invite", + ).not.toContain(booking.id); + + // And they cannot remove it. A 2xx here would mean anyone can cancel + // anyone's visitor, which is worse than merely being able to see it. + const their_delete = await other.delete(`${STAFF_API}/bookings/${booking.id}`); + expect( + their_delete.status(), + `another user must not be able to delete your invite ` + + `(got ${their_delete.status()})`, + ).toBeGreaterThanOrEqual(400); + + // Still there afterwards, from the owner's point of view. + const still_mine = await listBookings(staffApi, 'visitor', from(), to()); + expect( + still_mine.map((b) => b.id), + 'the invite should survive the other user attempting to delete it', + ).toContain(booking.id); + } finally { + await other.dispose(); + await deleteBooking(staffApi, booking.id); + await deleteGuest(staffApi, visitor.email); + } + }); + + test('you can see your own visitor invite and its guest record', async ({ + staffApi, + }, testInfo) => { + // The control for the test above. Without it, "they cannot see it" would + // also pass if nobody could see anything — which is exactly how an + // earlier leak-check managed to be reassuring and wrong. + const visitor = visitorFor(testInfo.parallelIndex, VISITOR_SLOTS.scoping.control); + const title = uniqueTitle('E2E Visitor Scoping Control'); + await releaseVisitor(staffApi, visitor.email, from(), to()); + + const booking = await inviteViaApi(staffApi, visitor, title); + + try { + const mine = await listBookings(staffApi, 'visitor', from(), to()); + expect( + mine.map((b) => b.id), + 'you must be able to see your own invite', + ).toContain(booking.id); + + expect( + (await searchGuests(staffApi, visitor.email)).map((g) => g.email), + 'and the guest record it created', + ).toContain(visitor.email); + } finally { + await deleteBooking(staffApi, booking.id); + await deleteGuest(staffApi, visitor.email); + } + }); +}); diff --git a/apps/workplace/e2e/local/visitor-settings.spec.ts b/apps/workplace/e2e/local/visitor-settings.spec.ts new file mode 100644 index 0000000000..67253d7bbf --- /dev/null +++ b/apps/workplace/e2e/local/visitor-settings.spec.ts @@ -0,0 +1,131 @@ +/** + * VIS-07 / VIS-08 — settings that change what the invite form demands. + * + * Both of these are off by default, and both are the kind of thing that only + * breaks for the customers who turned them on — which is exactly the class of + * bug an e2e suite is for. `visitors.reason_required` is PPT-2782. + * + * The settings are applied per BROWSER CONTEXT rather than seeded onto the org + * zone (see `visitor.settings.ts`). Two specs can therefore demand opposite + * configurations and still run in parallel; seeding them centrally would force + * the whole suite to serialise on shared state. + * + * Both tests drive the real form, because the point is the form's behaviour. + * The assertions are still made against what the backend stored. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { deleteBooking, getBooking, uniqueTitle } from '../../../../e2e/support/api'; +import { VISITOR_SLOTS, visitorFor } from '../../../../e2e/support/visitor/visitor.env'; +import { deleteGuest, releaseVisitor } from '../../../../e2e/support/visitor/visitor.api'; +import { + ALLOW_ALL_DAY, + REASON_REQUIRED, + SINGLE_VISITOR_MODE, + useSettings, +} from '../../../../e2e/support/visitor/visitor.settings'; +import { InviteVisitorForm } from '../../../../e2e/support/visitor/invite-form.page'; +import { inviteVisitorViaUI } from '../../../../e2e/support/visitor/visitor.flows'; + +const DAY = 86_400; +const window_from = () => Math.floor(Date.now() / 1000) - 2 * DAY; +const window_to = () => Math.floor(Date.now() / 1000) + 2 * DAY; + +test.describe('visitor invite settings', () => { + test('an all-day invite is stored as all-day', async ({ staffPage, staffApi }, testInfo) => { + const visitor = visitorFor(testInfo.parallelIndex, VISITOR_SLOTS.settings.allDay); + const reason = uniqueTitle('E2E Visit All Day'); + let booking_id: number | undefined; + + await releaseVisitor(staffApi, visitor.email, window_from(), window_to()); + await useSettings(staffPage, { ...SINGLE_VISITOR_MODE, ...ALLOW_ALL_DAY }); + + try { + const created = await inviteVisitorViaUI(staffPage, staffApi, visitor, reason, { + allDay: true, + }); + booking_id = created.id; + + const stored = await getBooking(staffApi, booking_id); + expect(stored.all_day, 'the backend recorded it as an all-day booking').toBe(true); + + // An all-day invite should span a real day, not the one-hour default + // the form starts with. Asserting the flag alone would pass even if + // the times never changed. + expect( + stored.booking_end - stored.booking_start, + 'an all-day invite covers far more than the default hour', + ).toBeGreaterThan(8 * 3600); + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + await deleteGuest(staffApi, visitor.email); + } + }); + + test('with a reason required, sending without one is refused', async ({ + staffPage, + staffApi, + }, testInfo) => { + const visitor = visitorFor(testInfo.parallelIndex, VISITOR_SLOTS.settings.reason); + + await releaseVisitor(staffApi, visitor.email, window_from(), window_to()); + await useSettings(staffPage, { ...SINGLE_VISITOR_MODE, ...REASON_REQUIRED }); + + const form = new InviteVisitorForm(staffPage); + await form.open(); + await form.expectSingleMode(); + + // With the setting on, the component starts the reason EMPTY. Without it + // the reason defaults to "Visit", so a spec that forgot to clear the + // field would send successfully and prove nothing. + await expect( + form.reason, + 'with reason_required the field must start empty, or this test is vacuous', + ).toHaveValue(''); + + const posts: number[] = []; + staffPage.on('response', (r) => { + if (r.url().includes('/api/staff/v1/bookings') && r.request().method() === 'POST') { + posts.push(r.status()); + } + }); + + await expect(async () => { + await form.visitorName.fill(visitor.name); + await form.visitorEmail.fill(visitor.email); + expect(await form.visitorEmail.inputValue()).toBe(visitor.email); + }).toPass({ timeout: 30_000 }); + + await form.sendButton.click(); + + // The form must refuse locally. Give it long enough that a slow POST + // would still have been seen — asserting "no request yet" immediately + // after a click would pass even if one were on its way. + await staffPage.waitForTimeout(5_000); + + expect( + posts, + 'a missing reason must be caught in the form — no booking POST should be made', + ).toHaveLength(0); + await expect( + form.successPanel, + 'and the invite must not reach its success screen', + ).toBeHidden(); + + // Control: the same form DOES send once a reason is supplied, so the + // assertion above is about the reason and not about a form that was + // broken for some unrelated reason. + let booking_id: number | undefined; + try { + const reason = uniqueTitle('E2E Visit Reason'); + const created = await inviteVisitorViaUI(staffPage, staffApi, visitor, reason); + booking_id = created.id; + expect( + (await getBooking(staffApi, booking_id)).title, + 'with a reason supplied the invite goes through and keeps it', + ).toBe(reason); + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + await deleteGuest(staffApi, visitor.email); + } + }); +}); diff --git a/apps/workplace/e2e/local/visitor-times.spec.ts b/apps/workplace/e2e/local/visitor-times.spec.ts new file mode 100644 index 0000000000..c71e4b6fb2 --- /dev/null +++ b/apps/workplace/e2e/local/visitor-times.spec.ts @@ -0,0 +1,136 @@ +/** + * VIS-22 / VIS-23 — when the visit is, and the limits on what may be chosen. + * + * Test 1 is the plain question nothing else in this suite asks: if a user picks + * a start time and a length, are THOSE the times that get stored? Every other + * spec takes the form's default hour, so a form that quietly ignored the picker + * would still have passed everywhere. + * + * Test 2 covers the two settings that fence the picker in — a maximum visit + * length, and the hours of the day a visit may start. Both are unset by default + * so nothing else here sees them, and both fail in the same quiet way: the + * option is simply absent rather than refused, so there is nothing to click and + * no error to read. That is why this asserts on the options OFFERED. Checking + * "a long visit 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 { VISITOR_SLOTS, visitorFor } from '../../../../e2e/support/visitor/visitor.env'; +import { deleteGuest, releaseVisitor } from '../../../../e2e/support/visitor/visitor.api'; +import { + LIMITED_HOURS, + SINGLE_VISITOR_MODE, + useSettings, +} from '../../../../e2e/support/visitor/visitor.settings'; +import { InviteVisitorForm } from '../../../../e2e/support/visitor/invite-form.page'; +import { inviteVisitorViaUI } from '../../../../e2e/support/visitor/visitor.flows'; + +const DAY = 86_400; +const window_from = () => Math.floor(Date.now() / 1000) - 2 * DAY; +const window_to = () => Math.floor(Date.now() / 1000) + 2 * DAY; + +const MAX_MINUTES = LIMITED_HOURS['app.visitors.max_duration']; +const HOURS = LIMITED_HOURS['app.visitors.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('visitor invite times', () => { + test('the start time and length chosen on the form are what get stored', async ({ + staffPage, + staffApi, + }, testInfo) => { + const visitor = visitorFor(testInfo.parallelIndex, VISITOR_SLOTS.times.chosen); + const reason = uniqueTitle('E2E Visit Times'); + let booking_id: number | undefined; + + await releaseVisitor(staffApi, visitor.email, window_from(), window_to()); + await useSettings(staffPage, SINGLE_VISITOR_MODE); + + try { + const form = new InviteVisitorForm(staffPage); + await form.open(); + await form.expectSingleMode(); + + // Pick from what the form itself offers rather than naming an hour: + // the options run on a fixed step from the current time, 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); + // Second option, not the first: the first is usually the default + // already selected, so choosing it would prove nothing. + const chosen_time = times[1]; + const lengths = await form.durationOptions(); + const chosen_length = lengths.find((m) => m >= 90) ?? lengths[lengths.length - 1]; + + // Set through the flow rather than here, so a REG-09 retry re-picks + // them instead of falling back to the defaults. + const created = await inviteVisitorViaUI(staffPage, staffApi, visitor, reason, { + 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); + const start_minutes = start.getHours() * 60 + start.getMinutes(); + expect( + start_minutes, + `the visit must start at the ${chosen_time} that was picked, not at the ` + + `form's default`, + ).toBe(minutesOfDay(chosen_time)); + expect( + (stored.booking_end - stored.booking_start) / 60, + `and run for the ${chosen_length} minutes that were picked`, + ).toBe(chosen_length); + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + await deleteGuest(staffApi, visitor.email); + } + }); + + test('a maximum length and bookable hours limit what can be chosen', async ({ + staffPage, + }) => { + await useSettings(staffPage, { ...SINGLE_VISITOR_MODE, ...LIMITED_HOURS }); + + const form = new InviteVisitorForm(staffPage); + await form.open(); + await form.expectSingleMode(); + + const lengths = await form.durationOptions(); + expect( + lengths.length, + 'the form offered no visit 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 will store 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); + const earliest = Math.min(...times.map(minutesOfDay)); + const latest = Math.max(...times.map(minutesOfDay)); + expect( + earliest, + `no visit may start before ${HOURS.start}:00 once bookable hours are set`, + ).toBeGreaterThanOrEqual(HOURS.start * 60); + expect( + latest, + `nor after ${HOURS.end}:00 — an option outside the window is one a user ` + + 'can pick and then be turned away at the door for', + ).toBeLessThanOrEqual(HOURS.end * 60); + }); +}); diff --git a/apps/workplace/e2e/local/visitor-validation.spec.ts b/apps/workplace/e2e/local/visitor-validation.spec.ts new file mode 100644 index 0000000000..4b00b0a9bd --- /dev/null +++ b/apps/workplace/e2e/local/visitor-validation.spec.ts @@ -0,0 +1,143 @@ +/** + * VIS-18 / VIS-19 — what the form refuses to send, and who it sends. + * + * Both tests are about the moment BEFORE the request: the invite form is the + * only thing standing between a typo and a visitor who never gets told they are + * expected. Neither can be judged from the screen, so both watch the network — + * the question is not "did a message appear" but "did anything leave". + * + * `asset_id` carries the visitor's address on a visitor booking and has an + * `email` validator on it (`booking.utilities.ts`), which is what test 1 + * exercises. The backend would happily store `not-an-email`, so if the form + * lets it through nothing else will stop it. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { deleteBooking, getBooking, listBookings, uniqueTitle } from '../../../../e2e/support/api'; +import { VISITOR_SLOTS, visitorFor } from '../../../../e2e/support/visitor/visitor.env'; +import { deleteGuest, releaseVisitor, releaseGroupContainers } from '../../../../e2e/support/visitor/visitor.api'; +import { + GROUP_VISITOR_MODE, + SINGLE_VISITOR_MODE, + useSettings, +} from '../../../../e2e/support/visitor/visitor.settings'; +import { + inviteVisitorViaUI, + inviteVisitorsViaUI, +} from '../../../../e2e/support/visitor/visitor.flows'; +import { InviteVisitorForm } from '../../../../e2e/support/visitor/invite-form.page'; + +const DAY = 86_400; +const window_from = () => Math.floor(Date.now() / 1000) - 2 * DAY; +const window_to = () => Math.floor(Date.now() / 1000) + 2 * DAY; + +test.describe('visitor invite validation', () => { + test('an address that is not an email is refused before anything is sent', async ({ + staffPage, + staffApi, + }, testInfo) => { + const visitor = visitorFor(testInfo.parallelIndex, VISITOR_SLOTS.validation.badEmail); + + await releaseVisitor(staffApi, visitor.email, window_from(), window_to()); + await useSettings(staffPage, SINGLE_VISITOR_MODE); + + const form = new InviteVisitorForm(staffPage); + await form.open(); + await form.expectSingleMode(); + + const posts: number[] = []; + staffPage.on('response', (r) => { + if (r.url().includes('/api/staff/v1/bookings') && r.request().method() === 'POST') { + posts.push(r.status()); + } + }); + + // Everything else valid, so a refusal can only be about the address. + await expect(async () => { + await form.visitorName.fill(visitor.name); + await form.visitorEmail.fill('not-an-email'); + await form.reason.fill(uniqueTitle('E2E Bad Email')); + expect(await form.visitorEmail.inputValue()).toBe('not-an-email'); + }).toPass({ timeout: 30_000 }); + + await form.sendButton.click(); + // Long enough that a slow POST would still have been seen. Asserting + // "nothing yet" straight after the click would pass either way. + await staffPage.waitForTimeout(5_000); + + expect( + posts, + 'a malformed address must be caught in the form — the backend stores ' + + 'whatever it is given, so nothing else will catch it', + ).toHaveLength(0); + await expect( + form.successPanel, + 'and the invite must not reach its success screen', + ).toBeHidden(); + + // Control: the same form sends once the address is a real one. Without + // this, a form broken for any other reason would pass the test above. + let booking_id: number | undefined; + try { + const reason = uniqueTitle('E2E Good Email'); + const created = await inviteVisitorViaUI(staffPage, staffApi, visitor, reason); + booking_id = created.id; + expect( + (await getBooking(staffApi, booking_id)).asset_id, + 'with a valid address the same invite goes through', + ).toBe(visitor.email); + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + await deleteGuest(staffApi, visitor.email); + } + }); + + test('a visitor removed before sending is not invited', async ({ + staffPage, + staffApi, + }, testInfo) => { + const kept = visitorFor(testInfo.parallelIndex, VISITOR_SLOTS.validation.keptA); + const dropped = visitorFor(testInfo.parallelIndex, VISITOR_SLOTS.validation.removedB); + const reason = uniqueTitle('E2E Visit Removed'); + const created_ids: number[] = []; + + for (const v of [kept, dropped]) { + await releaseVisitor(staffApi, v.email, window_from(), window_to()); + } + await releaseGroupContainers(staffApi, window_from(), window_to()); + await useSettings(staffPage, GROUP_VISITOR_MODE); + + try { + // Both go on the list, then one comes off — all inside the send + // flow, so a REG-09 retry repeats the correction instead of + // quietly inviting the person who was taken off. + const created = await inviteVisitorsViaUI( + staffPage, + staffApi, + [kept, dropped], + reason, + { remove: [dropped] }, + ); + created_ids.push(...created.map((b) => b.id)); + + const members = created.filter((b) => b.booking_type === 'visitor'); + expect( + members.map((b) => b.asset_id), + 'only the visitor left on the list is invited', + ).toEqual([kept.email]); + + // And nothing for the removed one anywhere in the window — a + // booking created and then orphaned would not show up above. + const live = ( + await listBookings(staffApi, 'visitor', window_from(), window_to()) + ).filter((b) => !b.deleted); + expect( + live.map((b) => b.asset_id), + 'the visitor taken off the list must have no booking at all', + ).not.toContain(dropped.email); + } finally { + for (const id of created_ids) await deleteBooking(staffApi, id).catch(() => null); + await releaseGroupContainers(staffApi, window_from(), window_to()); + for (const v of [kept, dropped]) await deleteGuest(staffApi, v.email).catch(() => null); + } + }); +}); diff --git a/e2e/README.md b/e2e/README.md index eabbe1560a..eee525b7be 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -81,6 +81,14 @@ e2e/ shared engine, all apps fixtures.ts worker-scoped auth fixtures — import test/expect from here api.ts engine/staff-api helpers + asset sweep flows.ts multi-step UI flows (bookDeskViaUI) + visitor/ visitor-invite coverage, kept out of the files above + visitor.env.ts per-worker visitor addresses + slot table + visitor.api.ts guest + visitor-booking helpers and sweeps + visitor.settings.ts per-context app settings presets + 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) + 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 seed.ts idempotent API-driven seeding diff --git a/e2e/VISITOR_E2E_HANDOVER.md b/e2e/VISITOR_E2E_HANDOVER.md new file mode 100644 index 0000000000..45939ad932 --- /dev/null +++ b/e2e/VISITOR_E2E_HANDOVER.md @@ -0,0 +1,197 @@ +# Visitor invite e2e — handover + +Working notes for the visitor-invite coverage added alongside the existing desk +specs. Written to be picked up cold. Coverage contract lives in +[`../E2E_USER_STORIES.md`](../E2E_USER_STORIES.md); harness conventions in +[`README.md`](README.md). + +**Status: 22 of 23 tests passing. 1 blocked on an app bug (VIS-B8).** +Best full suite run 2026-09-15: **36 passed, 1 skipped, 0 failed** in 1.7 min. +Two of four runs that afternoon also showed the PRE-EXISTING desk flake +(REG-10, "Require locker" — inside `bookDeskViaUI`, untouched here). It is +appearing more often as the suite grows, which is a CI problem waiting to happen: +more specs means more parallel load, and that race is won by whoever is faster. +Rows for all of them are in [`../E2E_USER_STORIES.md`](../E2E_USER_STORIES.md) (§1a), along +with the REG-09 correction. The check-in specs no longer depend on the time of day. +Nothing in the pre-existing suite was modified — `git diff` on +`e2e/support/{env,api,flows}.ts` is empty. + +### Known flake in the PRE-EXISTING desk suite (not introduced here) + +`desk-booking.spec.ts` "a deleted desk booking leaves the listing" failed once +on 2026-09-14 with: + +``` +Error: "Require locker" should be unchecked +expect(locator).not.toBeChecked() failed +``` + +That is inside the original `bookDeskViaUI` helper, which this work does not +touch. It is the REG-10 / PPT-2643 form-rebuild race the coverage doc already +describes — the rebuild restores "Require locker" to checked. Mentioned here only +so a red desk test after a visitor run is not misread as collateral damage. + +--- + +## How to run + +```bash +e2e/stack/up.sh # if the stack is not already up +export E2E_BACKEND_URL=https://localhost:9443 +bun run e2e:typecheck # NEW — nothing else type-checks the specs +bunx playwright test --config apps/workplace/playwright.config.ts +``` + +Visitor specs only: + +```bash +bunx playwright test --config apps/workplace/playwright.config.ts \ + --project=local apps/workplace/e2e/local/visitor-*.spec.ts +``` + +--- + +## What was built + +Eight new files, no existing file changed except one line in `package.json` +(the `e2e:typecheck` script) and `e2e/tsconfig.json` (new). + +### Support — `e2e/support/visitor/` + +| File | Holds | +|---|---| +| `calendar.ts` | driving the shared `date-calendar` widget (schedule sidebar AND the form's date pop-up) | +| `visitor.env.ts` | `visitorFor(worker, slot)` + the `VISITOR_SLOTS` table | +| `visitor.api.ts` | guest search/delete, sweeps, `createBookingViaApi`, `checkInViaApi`, `isReg09` | +| `visitor.settings.ts` | `useSettings()` + named mode presets | +| `invite-form.page.ts` | page object for `/#/book/visitor/form` | +| `your-bookings.page.ts` | page object for `/#/your-bookings` + details modal | +| `visitor.flows.ts` | `inviteVisitorViaUI`, `inviteVisitorsViaUI` | + +Kept out of the shared support files deliberately, so nothing visitor-shaped can +break the desk specs. + +### Specs — `apps/workplace/e2e/local/` + +| File | Tests | State | +|---|---|---| +| `visitor-invite.spec.ts` | 2 | passing | +| `visitor-group.spec.ts` | 2 | passing | +| `visitor-scoping.spec.ts` | 2 | passing | +| `visitor-settings.spec.ts` | 2 | passing | +| `visitor-delegate.spec.ts` | 2 | passing | +| `visitor-checkin.spec.ts` | 2 | passing, at any hour — test 1 now covers check-in **and** check-out | +| `visitor-edit.spec.ts` | 3 | 2 passing (multi-visitor mode, one of them future-dated); 1 **`fixme`** — single-visitor mode, VIS-B8 | +| `visitor-cancel.spec.ts` | 2 | passing — cancelling from the app, and declining the confirmation | +| `visitor-validation.spec.ts` | 2 | passing — a bad address is refused; a visitor removed before sending is not invited | +| `visitor-details.spec.ts` | 2 | passing — pass number + international flag; company + guest record name | +| `visitor-times.spec.ts` | 2 | passing — the chosen start/length are stored; limits control what is offered | + +Two tests per file, matching every existing spec file in the suite — except +`visitor-edit`, which carries a third as a placeholder for VIS-B8. Fold it away +when the bug is fixed; it is then the same assertion as test 1 with one setting +changed. + +### Reproducers — `e2e/support/repro/` + +- `vis-b1-duplicate-visitor.ts` — the same visitor booked twice into one slot +- `vis-b6-burst.ts` — concurrent booking POSTs, no browser; proves REG-09 + +Both need the suite to have run once first, so the worker token sidecars exist. + +--- + +## Findings — all confirmed against the running stack + +| ID | Finding | Evidence | +|---|---|---| +| **VIS-B8** | **In SINGLE-visitor mode, saving an edit throws `_.toJSON is not a function` and sends no request.** Multi-visitor mode — the shipped default — is fine. `Booking` declares `attendees` as `User[]` but assigns raw JSON to it; in single mode the edit path copies that into the form's `assets`, and saving calls `.toJSON()` on each entry. The multi paths rebuild people with `new User(...)` first, so they escape it. | all created and edited through the app: single/1 fails, multi/1 and multi/2 save cleanly; same on a future date. Screenshot: `reports/visitor-edit-probe/` | +| **VIS-B9** | **Two overlapping GROUP invites by one host are refused, whoever the visitors are.** The container is named `${host}[${date the invite was CREATED}]`, so all of a host's group invites that day share one asset id and the backend refuses the overlap. Same two visits booked on different days are accepted — which is what shows it is accidental. Do NOT confuse with VIS-B1. | `vis-b9-group-clash.ts`: 201 / **409 Conflicting booking** / 201 / 201 | +| **VIS-B1** | Same visitor bookable twice into one slot — backend returns 201 both times. Desks refuse this. Product says it should be refused. | `vis-b1-duplicate-visitor.ts` | +| **REG-09** | Group invites are the FIRST thing in this suite that bursts concurrent booking writes, so this suite now trips PPT-2642. `E2E_USER_STORIES.md` still says "nothing here bursts concurrent bookings" — **that line is now wrong and should be corrected.** | `vis-b6-burst.ts`: 4 concurrent POSTs x 12 rounds gave 13x 201, 29x 400, 6x 500, clean early then degrading | +| **Host flags** | `visitors.can_book_for_anyone` renders the host picker, but `sendInvite` keeps your choice only if `visitors.can_book_for_others` is ALSO set. Set one without the other and the picker silently discards the host. | `BOOK_FOR_ANYONE` sets both; documented there | +| **Listing limit** | `GET /bookings` defaults to limit 100 and the schedule passes `include_deleted: true`, so once a user has 100+ soft-deleted bookings, new ones stop appearing in their schedule. Broke the card-dependent specs until 428 stale rows were cleared. | counted in Postgres | +| **Deep link** | `?booking=` never opens the details modal: `booking-card.component.ts` checks `params.has('booking')` then compares the id to `params.get('event')` — a param that is not there. | read from source | + +None of these are test defects. + +--- + +## Decisions already taken (so they are not re-litigated) + +- **Workplace only.** The desk specs touch neither concierge nor the kiosk (zero + mentions in `e2e/`), so visitor coverage stays in workplace too. +- **Settings per browser context**, via `localStorage['PLACEOS.setting_overrides']`, + not seeded on the org zone. Single- and multi-visitor mode are the same setting + with two values; seeding it would force the whole suite to serialise. Trade-off + stated in `visitor.settings.ts`. +- **No `seed.ts` change.** A visitor "asset" is just an email, so there is + nothing to provision. This removes the one file where a mistake could have + broken the desk specs. +- **Slots, not shared addresses.** Every test owns a slot in `VISITOR_SLOTS`, + because `releaseVisitor` sweeps by address and two tests sharing one can delete + each other's data. +- **REG-09 retry lives in the flows, not the config.** Confined to visitor specs + and easy to delete after the staff-api upgrade. `submitWhenAccepted` rolls back + a partial group before retrying, since retrying after a partial create would + leave two containers. +- **Selectors never depend on visible text.** All labels are translated. See the + policy blocks at the top of both page objects. + +--- + +## Next steps + +1. ~~`E2E_USER_STORIES.md` rows~~ — **done 2026-09-15.** §1a holds VIS-01 … VIS-14 + and the six findings; the REG-09 row and the notes bullet now say the suite + DOES burst concurrent bookings, and point at the retry in + `visitor.flows.ts` as the thing to delete when the image is updated. +2. ~~`visitor-checkin` time fragility~~ — **done 2026-09-15.** `YourBookingsPage` + grew `showDayOf(timestamp_ms)`, which drives the sidebar calendar to whatever + day a booking falls on, so neither test cares what the clock says any more. + Both skips are gone. Test 2 now books a fixed **10am tomorrow** instead of + "3 hours from now"; test 1 still has to sit 10 minutes from now, because a + check-in only works next to its own start time, but it may now cross midnight + safely. Red-checked: with the `showDayOf` call removed, test 2 fails with + `no card for booking 568 ... 25 card(s) are rendered`. + How it works, since it is the non-obvious part: the calendar grid is 42 + consecutive day cells and only one of them is marked (the "today" ring, drawn + from a value captured at page load). Reading that cell's day-of-month and + comparing it against the browser clock says whether the ring means today or + yesterday, and every other cell is then just counting. That is what makes it + survive the clock crossing midnight mid-test. +3. **File the six findings above.** None are filed yet. Note VIS-B8's priority + depends on an answer nobody has yet: **does any real deployment run + single-visitor mode?** If not, it is low. +4. **Stale booking accumulation.** Each run adds soft-deleted rows; past 100 per + user the card-dependent specs (`delegate`, `checkin`) break again in a way + that looks nothing like the cause. A run on 2026-09-15 already rendered 25 + cards for one worker, so this is accumulating, not hypothetical. Needs either a backend change or a purge + step before this goes near CI. **This is an environment decision, not a spec + change.** + +--- + +## Traps that cost time — do not re-derive + +- **`asset_name` is not returned by staff-api.** The UI sets it on a group + container; the model has no such column. Asserting on it compares against + `undefined`. Assert on `extension_data` instead. +- **A group container is `booking_type: 'group'`**, so `GET /bookings?type=visitor` + returns N members, NOT N+1. `visitor-group` test 2 exists to stop that mistake. +- **Guest records outlive their booking** and are created from `attendees`, not + `asset_id`. Teardown must delete both. +- **Two different check-in gates.** The app hides the button until 15 MINUTES + before the start (`Booking.state` is `'future'` before that); the backend + accepts a check-in within 1 HOUR. Easy to conflate. +- **Playwright text selectors do not match the `` wrapper.** Both + `icon:text-is("edit")` and `filter({ hasText: /^edit$/ })` silently matched + nothing while the element was plainly in the DOM. `menuAction` reads trimmed + `textContent` instead. +- **Two wrong theories about the REG-09 400**, recorded in `visitor.flows.ts`: + "the app posts an empty body" (it sends ~2 KB) and "the form is rebuilt + mid-init and loses its model" (the browser-free burst reproduces it with no + form at all). +- **Add diagnostics before theorising.** Every cause here became obvious in one + run once the failure message reported what was actually present — rendered card + ids, menu contents, request sizes. Guessing at selectors cost far more. diff --git a/e2e/support/repro/vis-b1-duplicate-visitor.ts b/e2e/support/repro/vis-b1-duplicate-visitor.ts new file mode 100644 index 0000000000..4fadb13481 --- /dev/null +++ b/e2e/support/repro/vis-b1-duplicate-visitor.ts @@ -0,0 +1,88 @@ +/** + * VIS-B1 — the same visitor can be booked twice into the same slot. + * + * A desk is exclusive for a time range: a second booking of the same desk over + * the same period is refused, and `desk-clash.spec.ts` locks that in. A visitor + * is not, and as far as we can tell that is not intentional — product's + * expectation is that the backend rejects it. + * + * This script is the minimal reproduction, kept next to the finding the same way + * the REG-09 reproducers are. It creates two identical visitor bookings and + * reports what the backend did, then cleans up after itself. + * + * bunx tsx e2e/support/repro/vis-b1-duplicate-visitor.ts + * + * Expected today: both POSTs return 201. + * Expected after a fix: the second returns 409, as desks do. + */ +import { APIRequestContext } from '@playwright/test'; +import { BACKEND_URL, assertLocalOnly } from '../env'; +import { ENGINE_API, STAFF_API, apiFor, deleteBooking, zonesWithTag } from '../api'; +import { deleteGuest } from '../visitor/visitor.api'; + +const VISITOR = 'vis-b1-repro@example.com'; +const DAY = 86_400; + +/** + * Reuse the bearer the auth fixtures already minted for worker 0. + * + * Minting here directly does NOT work: ts-client derives `client_id` from the + * redirect_uri, and the only OAuth app `seed.ts` registers is the dev server's. + * Pointing a fresh mint at the backend origin asks for a client that was never + * registered and comes back 401. Run the suite once first so the sidecar exists. + */ +async function staffApi(): Promise { + return apiFor('staff', 0); +} + +async function main() { + assertLocalOnly(BACKEND_URL); + const api = await staffApi(); + const created: number[] = []; + try { + const me = await (await api.get(`${ENGINE_API}/users/current`)).json(); + const zones = ( + await Promise.all(['org', 'building', 'level'].map((t) => zonesWithTag(api, t))) + ) + .flat() + .map((z) => z.id); + + const start = Math.floor(Date.now() / 1000) + DAY; + const payload = { + booking_type: 'visitor', + asset_id: VISITOR, + asset_name: 'VIS-B1 Repro', + booking_start: start, + booking_end: start + 3600, + timezone: 'Etc/UTC', + user_email: me.email, + user_id: me.id, + user_name: me.name, + title: 'VIS-B1 duplicate visitor repro', + zones, + }; + + for (const attempt of [1, 2]) { + const res = await api.post(`${STAFF_API}/bookings`, { data: payload }); + const body = await res.text(); + console.log(` attempt ${attempt}: HTTP ${res.status()}`); + if (res.ok()) created.push(JSON.parse(body).id); + else console.log(` ${body}`); + } + + console.log( + created.length === 2 + ? '\nREPRODUCED: both bookings were accepted. A desk would have refused the second.' + : '\nNOT reproduced: the second booking was refused. VIS-B1 may be fixed.', + ); + } finally { + for (const id of created) await deleteBooking(api, id); + await deleteGuest(api, VISITOR); + await api.dispose(); + } +} + +main().catch((e) => { + console.error(e.message); + process.exit(1); +}); diff --git a/e2e/support/repro/vis-b6-burst.ts b/e2e/support/repro/vis-b6-burst.ts new file mode 100644 index 0000000000..352278bff7 --- /dev/null +++ b/e2e/support/repro/vis-b6-burst.ts @@ -0,0 +1,117 @@ +/** + * VIS-B6 probe — is the 400 a FRONTEND problem or a BACKEND one? + * + * Symptom seen through the UI: a `POST /bookings` carrying a real ~2 KB JSON + * body comes back + * + * HTTP 400 {"error":"Expected BeginObject but was EOF at line 1, column 1 + * parsing PlaceOS::Model::Booking at line 0, column 0"} + * + * "EOF at line 1, column 1" means the parser saw an empty input, yet the client + * demonstrably sent 2050 bytes. Two competing explanations: + * + * A. the app sends something malformed (wrong content-type, double encoding) + * B. staff-api loses the body under concurrent writes + * + * This script settles it by removing the browser entirely: it fires N identical, + * well-formed visitor bookings concurrently, straight at the API. If the 400 + * appears here, the app cannot be at fault and (B) is the answer. + * + * bunx tsx e2e/support/repro/vis-b6-burst.ts [concurrency] [rounds] + * + * Related: REG-09 (PPT-2642) is a different failure with the same trigger — + * concurrent booking POSTs against a staff-api image that predates pg-orm 2.2.4. + */ +import { APIRequestContext } from '@playwright/test'; +import { BACKEND_URL, assertLocalOnly } from '../env'; +import { ENGINE_API, STAFF_API, apiFor, deleteBooking, zonesWithTag } from '../api'; +import { deleteGuest } from '../visitor/visitor.api'; + +const CONCURRENCY = Number(process.argv[2] ?? 4); +const ROUNDS = Number(process.argv[3] ?? 10); +const DAY = 86_400; + +/** + * Reuse the bearer the auth fixtures already minted for worker 0. + * + * Minting here directly does NOT work: ts-client derives `client_id` from the + * redirect_uri, and the only OAuth app `seed.ts` registers is the dev server's. + * Pointing a fresh mint at the backend origin asks for a client that was never + * registered and comes back 401. Run the suite once first so the sidecar exists. + */ +async function staffApi(): Promise { + return apiFor('staff', 0); +} + +async function main() { + assertLocalOnly(BACKEND_URL); + const api = await staffApi(); + const created: number[] = []; + const emails: string[] = []; + const statuses = new Map(); + + try { + const me = await (await api.get(`${ENGINE_API}/users/current`)).json(); + const zones = ( + await Promise.all(['org', 'building', 'level'].map((t) => zonesWithTag(api, t))) + ) + .flat() + .map((z) => z.id); + + console.log(`bursting ${CONCURRENCY} concurrent POSTs x ${ROUNDS} rounds\n`); + + for (let round = 0; round < ROUNDS; round++) { + const start = Math.floor(Date.now() / 1000) + DAY + round * 3600; + const results = await Promise.all( + Array.from({ length: CONCURRENCY }, async (_, i) => { + const email = `vis-b6-burst-${round}-${i}@example.com`; + emails.push(email); + const res = await api.post(`${STAFF_API}/bookings`, { + data: { + booking_type: 'visitor', + asset_id: email, + asset_name: `Burst ${round}-${i}`, + booking_start: start, + booking_end: start + 3600, + timezone: 'Etc/UTC', + user_email: me.email, + user_id: me.id, + user_name: me.name, + title: `VIS-B6 burst ${round}-${i}`, + zones, + attendees: [{ name: `Burst ${round}-${i}`, email }], + }, + }); + const text = await res.text(); + if (res.ok()) created.push(JSON.parse(text).id); + return { status: res.status(), text }; + }), + ); + + for (const r of results) { + const key = `${r.status}`; + statuses.set(key, (statuses.get(key) ?? 0) + 1); + if (r.status < 200 || r.status >= 300) { + console.log(` round ${round}: HTTP ${r.status} ${r.text.slice(0, 160)}`); + } + } + } + + console.log('\nstatus counts:', Object.fromEntries(statuses)); + const bad = [...statuses.entries()].filter(([s]) => Number(s) >= 300); + console.log( + bad.length + ? '\nREPRODUCED without a browser — the app is not the cause.' + : '\nNot reproduced at the API level. The trigger involves the browser path.', + ); + } finally { + for (const id of created) await deleteBooking(api, id); + for (const email of emails) await deleteGuest(api, email); + await api.dispose(); + } +} + +main().catch((e) => { + console.error(e.message); + process.exit(1); +}); diff --git a/e2e/support/repro/vis-b9-group-clash.ts b/e2e/support/repro/vis-b9-group-clash.ts new file mode 100644 index 0000000000..9b04ed08e0 --- /dev/null +++ b/e2e/support/repro/vis-b9-group-clash.ts @@ -0,0 +1,110 @@ +/** + * VIS-B9 — a host cannot make two overlapping GROUP invites, even for + * completely different visitors. + * + * Found 2026-09-15 from a user's screenshot of `placeos-dev`: a second invite + * came back `409 {"error":"Conflicting booking","bookings":[{"booking_type": + * "group", ...}]}`. The conflicting row is a GROUP container, not a visitor — + * which is the whole story. + * + * The app names a group container after the HOST and the day the invite was + * CREATED: + * + * asset_id = `${currentUser().email}[${format(Date.now(), 'yyyy-MM-dd')}]` + * (booking-form.service.ts, `_groupName`) + * + * Every group invite that host creates today therefore shares one asset id, and + * the backend refuses overlapping bookings on one asset. So the second invite is + * rejected because of the first one's TIME, regardless of who is being invited. + * + * Note `Date.now()` — the day the invite is MADE, not the day of the visit. That + * is what makes the behaviour arbitrary from a user's point of view, and what + * marks it as accidental rather than a deliberate rule: the same two visits are + * accepted if they are booked on different days (case 4 below). + * + * bunx tsx e2e/support/repro/vis-b9-group-clash.ts + * + * Expected today: 1 -> 201, 2 -> 409, 3 -> 201, 4 -> 201. + * Expected after a fix: 2 -> 201 as well; two unrelated groups may overlap. + */ +import { BACKEND_URL, assertLocalOnly } from '../env'; +import { ENGINE_API, STAFF_API, apiFor, deleteBooking, zonesWithTag } from '../api'; + +async function main() { + assertLocalOnly(BACKEND_URL); + // Reuse the bearer the auth fixtures minted for worker 0 — see the note in + // `vis-b1-duplicate-visitor.ts`. Run the suite once first. + const api = await apiFor('staff', 0); + const created: number[] = []; + try { + const me = await (await api.get(`${ENGINE_API}/users/current`)).json(); + const zones = ( + await Promise.all(['org', 'building', 'level'].map((t) => zonesWithTag(api, t))) + ) + .flat() + .map((z) => z.id); + const today = new Date().toISOString().slice(0, 10); + const group_name = `${me.email}[${today}]`; + const slot = new Date(); + slot.setDate(slot.getDate() + 1); + slot.setHours(13, 30, 0, 0); + const start = Math.floor(slot.valueOf() / 1000); + + const container = (asset_id: string, from: number, to: number, title: string) => ({ + booking_type: 'group', + asset_id, + asset_name: 'Group Booking', + booking_start: from, + booking_end: to, + timezone: 'Etc/UTC', + user_email: me.email, + user_id: me.id, + user_name: me.name, + title, + zones, + }); + + const post = async (label: string, body: Record) => { + const res = await api.post(`${STAFF_API}/bookings`, { data: body }); + const text = await res.text(); + if (res.ok()) created.push(JSON.parse(text).id); + console.log( + ` ${label.padEnd(52)} -> ${res.status()} ${res.ok() ? '' : text.slice(0, 140)}`, + ); + return res.status(); + }; + + console.log(`\n container id the app would use: ${group_name}\n`); + const first = await post( + '1st group invite, 13:30-14:30', + container(group_name, start, start + 3600, 'first group'), + ); + const second = await post( + '2nd group invite, SAME time, different guests', + container(group_name, start, start + 3600, 'second group'), + ); + await post( + '3rd group invite, later slot', + container(group_name, start + 2.5 * 3600, start + 3.5 * 3600, 'third group'), + ); + await post( + '4th, same time but "created" on another day', + container(`${me.email}[2020-01-01]`, start, start + 3600, 'fourth group'), + ); + + console.log( + first === 201 && second === 409 + ? '\nREPRODUCED: the second overlapping group invite was refused, and the ' + + 'visitors had nothing to do with it.' + : '\nNOT reproduced: the second invite was not refused. VIS-B9 may be fixed.', + ); + } finally { + for (const id of created) await deleteBooking(api, id).catch(() => null); + await api.dispose(); + } +} + +main().catch((e) => { + console.error(e.message); + process.exit(1); +}); diff --git a/e2e/support/visitor/calendar.ts b/e2e/support/visitor/calendar.ts new file mode 100644 index 0000000000..4144c651f9 --- /dev/null +++ b/e2e/support/visitor/calendar.ts @@ -0,0 +1,150 @@ +/** + * Driving the `date-calendar` widget. + * + * The same widget appears twice in the flows this suite covers — the schedule's + * sidebar, and behind the date button on the invite form — so the awkward part + * is written once here. + * + * WHY IT IS AWKWARD: the grid is 42 day cells with nothing on them but a day + * number, and day numbers repeat (the 1st of next month sits in the same grid as + * the 1st of this one). The month label and the weekday headings are locale + * text, so they cannot be matched on either. + * + * What CAN be relied on: exactly one cell carries the "today" ring, drawn from a + * value the component captured when the page loaded, and the cells are + * consecutive days. So read that one cell, work out which real date it means, + * and count from there. + */ +import { Locator, Page, expect } from '@playwright/test'; + +const DAY_MS = 86_400_000; + +/** + * Whole days from one moment to another, counted in LOCAL calendar days. + * + * Rounded rather than truncated because a DST boundary makes a day 23 or 25 + * hours long, and the grid still advances by exactly one cell. + */ +export function daysApart(from_ms: number, to_ms: number): number { + const from = new Date(from_ms); + const to = new Date(to_ms); + from.setHours(0, 0, 0, 0); + to.setHours(0, 0, 0, 0); + return Math.round((to.valueOf() - from.valueOf()) / DAY_MS); +} + +/** + * Click the cell for `timestamp_ms` in an already-visible calendar. + * + * `calendar` must be the `date-calendar` element itself, so this works whether + * it is inline (the schedule) or inside an overlay (the invite form). + */ +export async function pickCalendarDay( + page: Page, + calendar: Locator, + timestamp_ms: number, + options: { + /** + * True for a calendar that lives in a pop-up and closes when a day is + * clicked — the invite form's. The clicked cell is then gone, so the + * pop-up closing is what proves the click landed. The schedule's + * calendar is permanent and marks the day instead. + */ + closes_on_pick?: boolean; + } = {}, +): Promise { + await expect( + calendar, + 'the calendar is not on screen — the schedule renders it only in `day` ' + + 'period and from the `sm` breakpoint up, and the invite form only ' + + 'once its date button has been clicked', + ).toBeVisible({ timeout: 30_000 }); + + const cells = calendar.locator('button[name="schedule-set-date"]'); + // The ring is the only child div carrying `border-secondary`; every cell + // also holds a plain ripple div, so the class matters. + 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 calendar, so there is nothing to count from. ' + + 'The grid only marks today while it is showing this month, and ' + + 'nothing here navigates months.', + ); + } + + const day_of_month = Number( + ((await cells.nth(today_index).textContent()) ?? '').trim(), + ); + const browser_now = await page.evaluate(() => Date.now()); + // The ring means the day the PAGE loaded, which is today unless the run has + // just crossed midnight, in which case it means yesterday. + 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() === day_of_month); + if (!anchor) { + throw new Error( + `the calendar marks day ${day_of_month} as today, which is neither ` + + `today nor yesterday by the browser clock ` + + `(${new Date(browser_now).toString()}). The two should never be more ` + + `than a midnight apart.`, + ); + } + + const index = today_index + daysApart(anchor.valueOf(), timestamp_ms); + const count = await cells.count(); + if (index < 0 || index >= count) { + throw new Error( + `${new Date(timestamp_ms).toDateString()} is outside the ${count} days the ` + + `calendar is showing. Only the displayed month and a few days either ` + + `side can be reached; anything further needs the ` + + `\`schedule-next-month\`/\`schedule-previous-month\` chevrons first ` + + `(whose names are the wrong way round — the "next" one goes back).`, + ); + } + + const cell = cells.nth(index); + await expect( + cell, + `the calendar will not accept ${new Date(timestamp_ms).toDateString()} — the ` + + 'cell is disabled, so a `from`/`to` bound is in the way', + ).toBeEnabled(); + await cell.click(); + if (options.closes_on_pick) { + await expect( + calendar, + 'the date pop-up stayed open, so the day was probably not accepted', + ).toBeHidden({ timeout: 10_000 }); + return index; + } + // `bg-secondary` is how the component marks the selected day. Waiting on it + // proves the click landed before anything reads the result. + await expect(cell, 'the calendar did not select the day').toHaveClass(/bg-secondary/); + return index; +} + +/** Which cell the calendar currently shows as selected, or -1. */ +export async function selectedCalendarIndex(calendar: Locator): Promise { + return calendar + .locator('button[name="schedule-set-date"]') + .evaluateAll((els) => els.findIndex((el) => el.className.includes('bg-secondary'))); +} + +/** The day number written on a cell, for diagnostics. */ +export async function calendarDayLabel( + calendar: Locator, + index: number, +): Promise { + if (index < 0) return 'none'; + const text = await calendar + .locator('button[name="schedule-set-date"]') + .nth(index) + .textContent(); + return (text ?? '').trim() || 'blank'; +} diff --git a/e2e/support/visitor/invite-form.page.ts b/e2e/support/visitor/invite-form.page.ts new file mode 100644 index 0000000000..775918f49a --- /dev/null +++ b/e2e/support/visitor/invite-form.page.ts @@ -0,0 +1,323 @@ +/** + * Page object for the Invite Visitor form (`/#/book/visitor/form`). + * + * SELECTOR POLICY, because this form has none of the `name` hooks the desk flow + * relies on: every locator below is a hardcoded attribute, a fixed id, a form + * control suffix, or a `data-` attribute. NOT ONE depends on visible text, so a + * translation change cannot break this file. Each was confirmed against the + * running app by dumping the DOM, not read off the template and hoped for. + * + * Send button `button[send]` bare attribute in the template + * Sending spinner `div[loading]` bare attribute + * Success panel `div[sent]` bare attribute + * Visitor name `#visitor-name` fixed id + * Visitor email `#visitor-email` fixed id + * Reason `#reason` fixed id + * Company etc. `input[name$=".company"]` signal-form control suffix + * Visitor chips `mat-chip-row[user]` bare attribute + * Chip entry `input[name="user_email"]` hardcoded name + * Building `mat-select[name="building"]` hardcoded name + * Time picker `button[time-field]` bare attribute + * Time option `button[data-time="HH:mm"]` real data- attribute + * Duration option `button[data-duration="mins"]` real data- attribute + * + * The form-control names carry a form index (`ng.form0.asset_name`), so match + * the SUFFIX rather than the whole name. Note the reason lives on a SECOND + * form (`ng.form1.title`) because it is validated separately — `#reason` is the + * unambiguous way to reach it. + */ +import { Locator, Page, expect } from '@playwright/test'; +import { pickCalendarDay } from './calendar'; + +export class InviteVisitorForm { + constructor(private readonly page: Page) {} + + get root(): Locator { + return this.page.locator('invite-visitor-form'); + } + get sendButton(): Locator { + return this.root.locator('button[send]'); + } + get successPanel(): Locator { + return this.page.locator('div[sent]'); + } + get visitorName(): Locator { + return this.page.locator('#visitor-name'); + } + get visitorEmail(): Locator { + return this.page.locator('#visitor-email'); + } + get company(): Locator { + return this.root.locator('input[name$=".company"]'); + } + get reason(): Locator { + return this.page.locator('#reason'); + } + get passNumber(): Locator { + return this.root.locator('input[name$=".pass_number"]'); + } + get allDay(): Locator { + return this.root.locator('input[name$=".all_day"]'); + } + get international(): Locator { + return this.root.locator('input[name$=".international"]'); + } + get chipEntry(): Locator { + return this.page.locator('input[name="user_email"]'); + } + get chips(): Locator { + return this.page.locator('mat-chip-row[user]'); + } + /** The button that opens the date picker. */ + get dateButton(): Locator { + return this.root.locator('a-date-field button').first(); + } + /** The picker itself, which lives in an overlay once opened. */ + get datePicker(): Locator { + return this.page.locator('.cdk-overlay-container date-calendar'); + } + + /** + * Put the visit on a different day. + * + * The form defaults to today, so anything testing a future visit has to say + * so. Same widget as the schedule's sidebar calendar, hence the shared + * helper — the only difference is that this one has to be opened first. + */ + async setDate(timestamp_ms: number): Promise { + await this.dateButton.click(); + // This picker is a pop-up and shuts itself when a day is clicked, which + // is also how we know the click landed. + await pickCalendarDay(this.page, this.datePicker, timestamp_ms, { + closes_on_pick: true, + }); + } + + /** + * The chip for one visitor, in multi-visitor mode. + * + * Matched on the part of the address BEFORE the @, because that is all the + * chip renders: the widget shows `name || email`, and a visitor added by + * email alone comes back named after the local part. Filtering on the full + * address matches nothing — silently, which is what an earlier version of + * the group edit spec did, and it read as a broken remove button. + * + * This is test data, not app copy, so matching on it does not break the + * "never depend on visible text" rule — nothing here is translated. + */ + async chipIndexFor(email: string): Promise { + const local = email.split('@')[0]; + const texts = await this.chips.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 visitor chip for ${email}. The form shows ` + + `${texts.length}: ${JSON.stringify(texts.map((t) => t.split('\n')[0].trim()))}`, + ); + } + return index; + } + + /** + * Add a visitor to the list, 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 + * next step operates on a list that is one person short. Seen for real, + * which is why this retries rather than pressing once and hoping. + */ + async addVisitorChip(email: string): Promise { + for (let attempt = 1; attempt <= 3; attempt++) { + await this.chipEntry.fill(email); + await this.chipEntry.press('Enter'); + const landed = await expect(async () => { + await this.chipIndexFor(email); + }) + .toPass({ timeout: 5_000 }) + .then(() => true) + .catch(() => false); + if (landed) return; + } + const texts = await this.chips.allInnerTexts(); + throw new Error( + `${email} could not be added to the visitor list after 3 tries. The list ` + + `holds ${texts.length}: ${JSON.stringify(texts.map((t) => t.split('\n')[0].trim()))}. ` + + `The autocomplete may not be offering "add external user" for this ` + + `address at all.`, + ); + } + + /** Take one visitor off the list before sending. */ + async removeVisitorChip(email: string): Promise { + const before = await this.chips.count(); + const index = await this.chipIndexFor(email); + await this.chips.nth(index).locator('button[remove]').click(); + await expect( + this.chips, + `removing ${email} did not shorten the visitor list`, + ).toHaveCount(before - 1, { timeout: 10_000 }); + } + + get buildingSelect(): Locator { + return this.page.locator('mat-select[name="building"]'); + } + get hostField(): Locator { + return this.page.locator('a-user-search-field'); + } + get hostInput(): Locator { + return this.hostField.locator('input').first(); + } + + /** + * Pick a different host — "book this visit on behalf of someone else". + * + * Needs `visitors.can_book_for_anyone` AND `basic_user_search`. The other + * host control (`host-select-field`, behind `can_book_for_others`) builds + * its list from `GET /calendars`, which is 500 on any stack without real + * Microsoft/Google credentials, so it can never list anyone but yourself + * here. Likewise the search field's default source is `/people`, also + * calendar backed; `basic_user_search` swings it to rest-api's `/users`. + * + * Once chosen the input shows the host's NAME, not their email + * (`displayFn` in user-search-field.component.ts) — which is why the name + * is needed to tell "already set" from "not set yet". + */ + async chooseHost(email: string, name: string): Promise { + await expect( + this.hostField, + 'the host field is missing — this flow needs ' + + '`app.visitors.can_book_for_anyone = true`', + ).toBeVisible({ timeout: 30_000 }); + + if ((await this.hostInput.inputValue()) === name) return; + + await this.hostInput.fill(email); + const option = this.page + .locator('mat-option') + .filter({ hasText: email }) + .first(); + await expect( + option, + `no user matching "${email}" was offered — is that user seeded, and is ` + + '`app.basic_user_search` on so the search hits rest-api rather than ' + + 'the calendar-backed /people endpoint?', + ).toBeVisible({ timeout: 20_000 }); + await option.click(); + await expect(this.hostInput).toHaveValue(name); + } + get startTimeTrigger(): Locator { + return this.page.locator('button[time-field]'); + } + + get durationTrigger(): Locator { + return this.page.locator('button[duration-field]'); + } + + /** + * Every start time the form is willing to offer, as "HH:mm". + * + * Read from the `data-time` attribute, not the rendered label, so a 12/24 + * hour setting or a locale cannot change the answer. Opens the menu and + * closes it again, leaving the form as it was found. + */ + async startTimeOptions(): Promise { + await this.startTimeTrigger.click(); + const options = this.page.locator('button[data-time]'); + await expect( + options.first(), + 'the start-time menu offered nothing at all', + ).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 visit length the form is willing to offer, in minutes. */ + async durationOptions(): Promise { + await this.durationTrigger.click(); + const options = this.page.locator('button[data-duration]'); + await expect( + options.first(), + 'the duration menu offered nothing at all', + ).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)); + } + + /** Choose how long the visit lasts, 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 — the form offers lengths on a fixed step and ` + + 'only up to `max_duration`, so pick one it actually lists', + ).toBeVisible({ timeout: 10_000 }); + await option.click(); + } + + /** Open the form and wait for it to render. */ + async open(): Promise { + await this.page.goto('/#/book/visitor/form'); + await expect( + this.root, + 'the visitor invite form never rendered — is the `visitor-invite` feature enabled?', + ).toBeVisible({ timeout: 30_000 }); + } + + /** Fail with a useful message if the form is not in single-visitor mode. */ + async expectSingleMode(): Promise { + await expect( + this.visitorEmail, + 'the single-visitor email field is missing — this flow needs ' + + '`app.bookings.multiple_visitors = false`; the app default is true, ' + + 'which renders the chip list instead', + ).toBeVisible({ timeout: 30_000 }); + } + + /** Fail with a useful message if the form is not in multi-visitor mode. */ + async expectGroupMode(): Promise { + await expect( + this.chipEntry, + 'the multi-visitor entry field is missing — this flow needs ' + + '`app.bookings.multiple_visitors = true`', + ).toBeVisible({ timeout: 30_000 }); + } + + /** + * Pick an exact start time. + * + * Uses the option's `data-time` attribute rather than its rendered label, + * which is formatted with the user's 12/24-hour setting and timezone. Needed + * by the check-in specs, which must land a booking inside the one-hour + * window the backend allows a check-in from. + */ + 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 within its bookable hours, so pick one it actually lists`, + ).toBeVisible({ timeout: 10_000 }); + await option.click(); + } + + /** Set a Material checkbox to a state, reading the real state off the input. */ + async setCheckbox(input: Locator, want: boolean): Promise { + if (!(await input.count())) return; + if ((await input.isChecked().catch(() => false)) === want) return; + // The visible mat-checkbox is the click target; the input holds `checked`. + await input.locator('xpath=ancestor::mat-checkbox').click(); + await (want ? expect(input).toBeChecked() : expect(input).not.toBeChecked()); + } +} diff --git a/e2e/support/visitor/visitor.api.ts b/e2e/support/visitor/visitor.api.ts new file mode 100644 index 0000000000..ad74b6ae9a --- /dev/null +++ b/e2e/support/visitor/visitor.api.ts @@ -0,0 +1,203 @@ +/** + * Backend helpers specific to visitor invites: guest records, and a sweep that + * understands them. + * + * Kept out of `e2e/support/api.ts` so the desk specs' helpers stay untouched. + * The generic pieces this builds on (`listBookings`, `deleteBooking`, + * `STAFF_API`) are imported from there rather than duplicated. + */ +import { APIRequestContext } from '@playwright/test'; +import { Booking, STAFF_API, deleteBooking, listBookings } from '../api'; + +/** + * A booking as the visitor flows deal with it. + * + * `Booking` in `../api` carries an index signature, so these fields are already + * reachable — but as `unknown`, which means a spec cannot read + * `extension_data.group` without a cast and a reader cannot tell which extra + * fields matter. Naming the ones the visitor specs rely on is the difference + * between a typed assertion and a hopeful one. + */ +export interface VisitorBooking extends Booking { + /** Set on a group member, pointing at its container booking. */ + parent_id?: string; + /** Who created the booking — differs from `user_email` on a delegated invite. */ + booked_by_email?: string; + booked_by_name?: string; + /** Carries `group`, `group_members`, `group_resource_type` on a container. */ + extension_data?: Record; + checked_in?: boolean; + all_day?: boolean; +} + +export interface Guest { + id: number; + email: string; + name?: string; + checked_in?: boolean; + visit_expected?: boolean; + [k: string]: unknown; +} + +/** + * Guests matching a search term. + * + * A guest row is created by staff-api from a booking's `attendees`, NOT from + * `asset_id` — an invite posted without attendees creates the booking and no + * guest. The UI always sends attendees, so a real invite does create one, but + * an API-only fixture will not unless it says so. + * + * `q` is the filter that works without a period; the windowed form + * (`period_start`/`period_end`) lists guests expected in that window instead. + */ +export async function searchGuests(api: APIRequestContext, q: string): Promise { + const res = await api.get(`${STAFF_API}/guests`, { params: { q } }); + if (!res.ok()) { + throw new Error(`GET /guests?q=${q} failed: HTTP ${res.status()} ${await res.text()}`); + } + const body = await res.json(); + return Array.isArray(body) ? body : (body.results ?? []); +} + +/** + * Is this response REG-09 (PPT-2642) rather than a real rejection? + * + * Two signatures, both produced by the same poisoned connection. The 400 is the + * confusing one — it claims the request body was empty when it was not. + * `e2e/support/repro/vis-b6-burst.ts` reproduces both with no browser involved. + * + * Deliberately narrow. Matching on the status alone would swallow genuine 400s + * and 500s, and a suite that retries real failures is worse than no suite. + */ +export function isReg09(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; + return false; +} + +/** + * Create a booking through the API, retrying past REG-09. + * + * Specs whose subject is authorisation or listing behaviour create their data + * this way rather than through the form. They still meet REG-09, because the + * bug is in the backend's connection handling and does not care how the request + * was produced — so without this a scoping spec fails for a reason that has + * nothing to do with scoping. + * + * Remove once the stack runs a staff-api built on pg-orm >= 2.2.4. + */ +export async function createBookingViaApi( + api: APIRequestContext, + data: Record, + attempts = 4, +): Promise { + let last = ''; + for (let attempt = 1; attempt <= attempts; attempt++) { + const res = await api.post(`${STAFF_API}/bookings`, { data }); + const body = await res.text(); + if (res.ok()) return JSON.parse(body) as VisitorBooking; + + last = `HTTP ${res.status()} ${body.slice(0, 200)}`; + if (!isReg09(res.status(), body)) { + throw new Error(`POST /bookings failed: ${last}`); + } + console.warn(` ! REG-09 on attempt ${attempt}/${attempts}, 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( + `POST /bookings still failing after ${attempts} attempts. If every attempt is ` + + `REG-09 (PPT-2642), the stack is running a staff-api older than pg-orm 2.2.4. ` + + `Last: ${last}`, + ); +} + +/** + * Attempt a check-in directly, returning the raw outcome rather than throwing. + * + * Specs use this to prove the BACKEND enforces the timing rule independently of + * the UI. The app hides its check-in button until a booking is within 15 minutes + * of starting (`Booking.state`), so a UI-only assertion would pass even if the + * server happily accepted a check-in a week early. + */ +export async function checkInViaApi( + api: APIRequestContext, + id: number, + state = true, +): Promise<{ status: number; body: string }> { + const res = await api.post(`${STAFF_API}/bookings/${id}/check_in?state=${state}`); + return { status: res.status(), body: await res.text() }; +} + +/** Best-effort guest removal — never throws, so teardown cannot fail a passing test. */ +export async function deleteGuest(api: APIRequestContext, email: string): Promise { + try { + await api.delete(`${STAFF_API}/guests/${encodeURIComponent(email)}`); + } catch { + /* swallow: teardown must not mask the actual result */ + } +} + +/** + * Free a visitor address before using it: delete its live bookings in the + * window AND its guest record. + * + * Two differences from the desks' `releaseAsset`, both found by probing the + * backend rather than assumed: + * + * - A guest row OUTLIVES its booking. Deleting the booking leaves the guest + * behind, so a sweep that only removes bookings lets guest state accumulate + * across runs and makes "was a guest created?" pass on last run's data. + * - A visitor is not exclusive, so a leftover booking does not BLOCK the next + * run the way a leaked all-day desk booking does. It still pollutes the + * listing, which is what turns a scoping assertion into a confusing failure. + */ +export async function releaseVisitor( + api: APIRequestContext, + email: string, + from: number, + to: number, +): Promise { + let removed = 0; + try { + const existing = await listBookings(api, 'visitor', from, to); + for (const b of existing) { + if (b.asset_id === email && !b.deleted) { + await deleteBooking(api, b.id); + removed++; + } + } + } catch { + /* a sweep that cannot run must not fail the test it is protecting */ + } + await deleteGuest(api, email); + return removed; +} + +/** + * Delete any group CONTAINER bookings this user owns in the window. + * + * Containers are `booking_type: 'group'`, so `releaseVisitor` — which lists + * `type=visitor` — cannot see them. A run that dies between creating the + * container and creating its members leaves an orphan behind (VIS-B7), and + * without this the orphans accumulate silently. + */ +export async function releaseGroupContainers( + api: APIRequestContext, + from: number, + to: number, +): Promise { + let removed = 0; + try { + const existing = await listBookings(api, 'group', from, to); + for (const b of existing) { + if (!b.deleted) { + await deleteBooking(api, b.id); + removed++; + } + } + } catch { + /* a sweep that cannot run must not fail the test it is protecting */ + } + return removed; +} diff --git a/e2e/support/visitor/visitor.env.ts b/e2e/support/visitor/visitor.env.ts new file mode 100644 index 0000000000..7b19a76d9c --- /dev/null +++ b/e2e/support/visitor/visitor.env.ts @@ -0,0 +1,67 @@ +/** + * Visitor identities for the e2e suite. + * + * Kept out of `e2e/support/env.ts` on purpose: the desk specs depend on that + * file, and nothing visitor-shaped should be able to break them. + * + * NOTHING seeds these addresses. A visitor "asset" is just an email, so unlike + * a desk there is no resource to provision — which is why the visitor specs + * need no `seed.ts` step at all. + */ +export const VISITOR_PREFIX = 'e2e-visitor-'; + +/** + * Slot allocation. Every spec FILE gets its own range, and every test within a + * file gets its own slot. + * + * This is not about booking clashes — a visitor is not exclusive, and the + * backend happily accepts the same one twice (VIS-B1). It is about the SWEEP: + * `releaseVisitor` deletes by address under the worker's own identity, so two + * tests sharing an address can delete each other's booking if they ever land on + * the same worker. Distinct slots make that impossible by construction rather + * than by relying on how Playwright happens to schedule. + */ +export const VISITOR_SLOTS = { + /** visitor-invite.spec.ts */ + invite: { stores: 0, cleanup: 1 }, + /** visitor-group.spec.ts — two visitors per test, so two slots each */ + group: { a: 2, b: 3 }, + /** visitor-scoping.spec.ts */ + scoping: { own: 4, control: 15 }, + /** visitor-delegate.spec.ts */ + delegate: { single: 5, groupA: 6, groupB: 7 }, + /** visitor-checkin.spec.ts */ + checkin: { checkIn: 8, tooEarly: 9 }, + /** visitor-edit.spec.ts */ + edit: { details: 10, memberA: 11, memberB: 12 }, + /** visitor-settings.spec.ts */ + settings: { allDay: 13, reason: 14 }, + /** visitor-cancel.spec.ts */ + cancel: { fromApp: 16, dismissed: 17 }, + /** visitor-validation.spec.ts */ + validation: { badEmail: 18, keptA: 19, removedB: 20 }, + /** visitor-details.spec.ts */ + details: { extras: 21, company: 22 }, + /** visitor-times.spec.ts */ + times: { chosen: 23, limits: 24 }, +} as const; + +export interface VisitorIdentity { + email: string; + name: string; + company: string; +} + +/** + * A visitor address owned by this worker and this slot. + * + * Per-worker keeps each parallel worker's listings and guest records disjoint; + * per-slot keeps tests within a worker from sweeping each other. + */ +export function visitorFor(workerIndex: number, slot: number): VisitorIdentity { + return { + email: `${VISITOR_PREFIX}${workerIndex}-${slot}@example.com`, + name: `E2E Visitor ${workerIndex}-${slot}`, + company: `E2E Co ${workerIndex}`, + }; +} diff --git a/e2e/support/visitor/visitor.flows.ts b/e2e/support/visitor/visitor.flows.ts new file mode 100644 index 0000000000..1831c1da8a --- /dev/null +++ b/e2e/support/visitor/visitor.flows.ts @@ -0,0 +1,378 @@ +/** + * Multi-step visitor flows, composed from `InviteVisitorForm`. + * + * These return what the BACKEND created, parsed from the real POST responses — + * never anything the page rendered. Same principle as `bookDeskViaUI`, and the + * reason this suite can tell "the screen looked right" apart from "the data is + * right". + * + * ## Why these flows retry: REG-09 / PPT-2642, not a form bug + * + * Under concurrent booking writes, `POST /bookings` intermittently answers + * + * HTTP 400 {"error":"Expected BeginObject but was EOF at line 1, column 1"} + * HTTP 500 {"error":"...could not serialize access due to read/write + * dependencies among transactions"} + * + * The 400 reads like the client sent nothing. It does not: the request carried + * ~2 KB of valid JSON. `e2e/support/repro/vis-b6-burst.ts` settles it by firing + * the same payloads concurrently with NO BROWSER INVOLVED — 4 concurrent POSTs + * over 12 rounds produced 13x 201, 29x 400 and 6x 500, with the early rounds + * clean and the failure rate climbing as the run went on. + * + * That degradation curve is the signature of REG-09: a failed COMMIT leaves a + * connection's transaction flag set, it returns to the pool with its `BEGIN` + * still open, and everything routed to it afterwards fails. Fixed in pg-orm + * v2.2.4 and staff-api #386, but `e2e/stack` pulls `placeos/staff-api:latest`, + * a release image that predates it. + * + * E2E_USER_STORIES.md says of REG-09: "harmless for now: the suite does not + * burst concurrent bookings, so nothing here trips it." A GROUP invite is the + * first thing in this suite that does — one send posts a container plus one + * booking per visitor, and several workers do that at once. + * + * So the retry below is NOT papering over a frontend defect. It absorbs a known + * backend bug whose fix is already merged upstream, the same way CI's + * `retries: 2` absorbs REG-09 today. Delete it once the stack runs a staff-api + * built on pg-orm >= 2.2.4, and the suite should still be green. + * + * Two earlier explanations were wrong and are recorded so nobody re-derives + * them: "the app posts an empty body" (it does not — the request is ~2 KB) and + * "the form is rebuilt mid-init and loses its model" (plausible, but the + * browser-free burst reproduces it without any form at all). + */ +import { APIRequestContext, Page, expect } from '@playwright/test'; +import { InviteVisitorForm } from './invite-form.page'; +import { VisitorIdentity } from './visitor.env'; +import { deleteBooking } from '../api'; +import { VisitorBooking } from './visitor.api'; + +/** How long the form's values must hold steady before we trust them. */ +const SETTLE_MS = 1_000; + +interface CapturedPost { + status: number; + body: string; + request: string; + headers: Record; +} + +/** + * Collect every `POST /bookings` the page makes, with its REQUEST body. + * + * Capturing the REQUEST body, not just the response, is what disproved the + * first theory about REG-09's 400: the body is ~2 KB, so "the app sent nothing" + * was never true however much the error message sounded like it. + * + * A single-visitor invite is one POST; a multi-visitor invite is N+1 — a group + * container plus one booking per visitor. `waitForResponse` resolves on the + * FIRST match, so using it for a group would assert against the container and + * silently ignore every member. + */ +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() ?? ''; + const headers = r.request().headers(); + // Read the body while the response is still live; resolving it later can + // fail with "No data found", which reads as a network flake rather than + // the assertion you meant to write. + pending.push( + r + .text() + .catch(() => '') + .then( + (body) => void posts.push({ status: r.status(), body, request, headers }), + ), + ); + }; + page.on('response', onResponse); + + const describe = () => + posts + .map((p) => { + const ok = p.status >= 200 && p.status < 300; + const base = + `HTTP ${p.status} req=${p.request.length ? `${p.request.length}b` : 'EMPTY'}`; + // Only dump the full picture for a FAILED post. On the happy path + // it is noise, and on a failure it is the only evidence there is — + // the app's error toast has faded before any screenshot is taken. + if (ok) return `${base} ok`; + return ( + `${base}\n content-type=${p.headers['content-type'] ?? '(none)'}` + + `\n content-length=${p.headers['content-length'] ?? '(none)'}` + + `\n req-head=${JSON.stringify(p.request.slice(0, 220))}` + + `\n resp=${p.body.slice(0, 220)}` + ); + }) + .join('\n | ') || '(no booking POST was made at all)'; + + return { + okCount: () => posts.filter((p) => p.status >= 200 && p.status < 300).length, + /** Ids of everything that really got created, for cleaning up a partial group. */ + createdIds: () => + posts + .filter((p) => p.status >= 200 && p.status < 300) + .map((p) => { + try { + return JSON.parse(p.body).id as number; + } catch { + return undefined; + } + }) + .filter((id): id is number => typeof id === 'number'), + describe, + /** Forget everything so far — used after a failed attempt is rolled back. */ + reset: async () => { + await Promise.all(pending); + posts.length = 0; + pending.length = 0; + }, + stop: async () => { + page.off('response', onResponse); + await Promise.all(pending); + return posts; + }, + }; +} + +/** + * Fill the form, then require the values to SURVIVE a settling window. + * + * Converging on the values is not enough on its own: the rebuild can land in + * the gap between the last check and the click. Holding them steady for a beat + * closes most of that gap; the retry in `submitWhenAccepted` closes the rest. + */ +async function fillAndSettle(page: Page, fill: () => Promise): Promise { + await expect(async () => { + await fill(); + await page.waitForTimeout(SETTLE_MS); + await fill(); + }).toPass({ timeout: 45_000 }); +} + +/** A send attempt that did not reach the success screen. */ +const MAX_ATTEMPTS = 3; + +/** + * Click send, and keep trying until the app confirms the invite. + * + * Two distinct failure shapes, and they need opposite handling: + * + * - NOTHING was created. Safe to settle and click again. + * - SOMETHING was created but the flow still failed. For a group invite this + * means the container landed and a member did not, leaving an orphaned + * `booking_type: 'group'` row with no members. Clicking again would add a + * SECOND container, so the partial result is deleted first and the whole + * invite starts over. + * + * Rolling back rather than giving up is deliberate. The cause is REG-09, a + * backend defect already fixed upstream (see the file header), so a failed + * attempt says nothing about the behaviour under test. Leaving the orphan + * behind instead would poison the next run's listing assertions. + */ +async function submitWhenAccepted( + page: Page, + api: APIRequestContext, + form: InviteVisitorForm, + posts: ReturnType, + refill: () => Promise, + what: string, +): Promise { + let last = ''; + + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + await form.sendButton.click(); + const ok = await form.successPanel + .waitFor({ state: 'visible', timeout: 20_000 }) + .then(() => true) + .catch(() => false); + if (ok) return; + + last = posts.describe(); + const partial = posts.createdIds(); + if (partial.length) { + console.warn( + ` ! REG-09: ${what} partially created ${partial.length} booking(s) then ` + + `failed — rolling them back and retrying (attempt ${attempt}/${MAX_ATTEMPTS})`, + ); + for (const id of partial) await deleteBooking(api, id); + } + await posts.reset(); + + if (attempt === MAX_ATTEMPTS) break; + // Start from a clean form: after a partial create the component has + // already advanced its own state, so re-filling the current DOM is not + // the same as re-opening. + await form.open(); + await fillAndSettle(page, refill); + } + + throw new Error( + `${what} never reached its success screen after ${MAX_ATTEMPTS} attempts. ` + + `If every attempt shows HTTP 400 "Expected BeginObject but was EOF" or HTTP 500 ` + + `"could not serialize access", this is REG-09 (PPT-2642) and the stack is ` + + `running a staff-api older than pg-orm 2.2.4. Last responses:\n ${last}`, + ); +} + +/** + * The bookings that were actually created. + * + * A failed attempt a retry recovered is NOT a test failure — the flow's job is + * to get the invite made. Recovered failures are still printed, because they + * are the visible symptom of VIS-B6; swallowing them would make the suite green + * and the defect invisible, which is how a suite stops being worth running. + */ +function createdBookings(posts: CapturedPost[]): VisitorBooking[] { + const ok = posts.filter((p) => p.status >= 200 && p.status < 300); + for (const f of posts.filter((p) => p.status < 200 || p.status >= 300)) { + console.warn( + ` ! REG-09: a booking POST was rejected and recovered on retry — ` + + `HTTP ${f.status} (request carried ${f.request.length} bytes, so the body ` + + `was not the problem)`, + ); + } + expect( + ok.length, + `no booking was created. ` + + posts + .map((p) => `HTTP ${p.status} resp=${p.body.slice(0, 200)}`) + .join(' | '), + ).toBeGreaterThan(0); + return ok.map((p) => JSON.parse(p.body) as VisitorBooking); +} + +/** + * Invite ONE visitor through the full UI, in single-visitor mode. + * Returns the booking the API created. + */ +export async function inviteVisitorViaUI( + page: Page, + api: APIRequestContext, + visitor: VisitorIdentity, + reason: string, + options: { + allDay?: boolean; + passNumber?: string; + international?: boolean; + /** Book on behalf of somebody else. Needs `can_book_for_anyone`. */ + host?: { email: string; name: string }; + /** Put the visit on another day. Defaults to whatever the form offers. */ + date?: number; + /** Start the visit at "HH:mm". Must be an option the form lists. */ + startTime?: string; + /** How long the visit runs, in minutes. Must be an option the form lists. */ + duration?: number; + } = {}, +): Promise { + const form = new InviteVisitorForm(page); + await form.open(); + await form.expectSingleMode(); + + const fill = async () => { + // Host first: choosing one can reset parts of the form, so anything + // typed before it would be lost. + if (options.host) await form.chooseHost(options.host.email, options.host.name); + await form.visitorName.fill(visitor.name); + await form.visitorEmail.fill(visitor.email); + if (visitor.company && (await form.company.count())) { + await form.company.fill(visitor.company); + } + if (options.date !== undefined) await form.setDate(options.date); + // Times go in before the rest: changing the start can shift which + // lengths are on offer, so setting them the other way round can leave a + // duration that no longer exists. + if (options.startTime) await form.setStartTime(options.startTime); + if (options.duration !== undefined) await form.setDuration(options.duration); + await form.reason.fill(reason); + if (options.passNumber && (await form.passNumber.count())) { + await form.passNumber.fill(options.passNumber); + } + if (options.allDay !== undefined) { + await form.setCheckbox(form.allDay, options.allDay); + } + if (options.international !== undefined) { + await form.setCheckbox(form.international, options.international); + } + expect(await form.visitorEmail.inputValue()).toBe(visitor.email); + expect(await form.visitorName.inputValue()).toBe(visitor.name); + expect(await form.reason.inputValue()).toBe(reason); + }; + + await fillAndSettle(page, fill); + + const posts = collectBookingPosts(page); + await submitWhenAccepted(page, api, form, posts, fill, 'the invite'); + + const bookings = createdBookings(await posts.stop()); + expect(bookings, 'a single-visitor invite creates exactly one booking').toHaveLength(1); + return bookings[0]; +} + +/** + * Invite SEVERAL visitors through the full UI, in multi-visitor mode. + * Returns every booking created: the group container and one per visitor. + */ +export async function inviteVisitorsViaUI( + page: Page, + api: APIRequestContext, + visitors: VisitorIdentity[], + reason: string, + options: { + host?: { email: string; name: string }; + /** Put the visit on another day. Defaults to whatever the form offers. */ + date?: number; + /** + * Add these, then take them off again before sending. Modelling the + * everyday "wrong person from the lookup" correction — and it has to + * happen INSIDE the fill, because a retry re-runs the fill and would + * otherwise send the removed visitor after all. + */ + remove?: VisitorIdentity[]; + } = {}, +): Promise { + const removing = options.remove ?? []; + const expected = visitors.filter( + (v) => !removing.some((r) => r.email === v.email), + ); + const form = new InviteVisitorForm(page); + await form.open(); + await form.expectGroupMode(); + + const fill = async () => { + if (options.host) await form.chooseHost(options.host.email, options.host.name); + for (const visitor of visitors) { + // "Already added?" has to go through `chipIndexFor`: a chip shows + // only the part before the @, so a `hasText` on the full address + // never matches and a retry would add everyone twice. + const already = await form + .chipIndexFor(visitor.email) + .then(() => true) + .catch(() => false); + if (already) continue; + await form.addVisitorChip(visitor.email); + } + for (const visitor of removing) await form.removeVisitorChip(visitor.email); + if (options.date !== undefined) await form.setDate(options.date); + await form.reason.fill(reason); + expect(await form.chips.count()).toBe(expected.length); + expect(await form.reason.inputValue()).toBe(reason); + }; + + await fillAndSettle(page, fill); + + const posts = collectBookingPosts(page); + await submitWhenAccepted(page, api, form, posts, fill, 'the group invite'); + + const bookings = createdBookings(await posts.stop()); + expect( + bookings.length, + `a group invite creates one container plus one booking per visitor ` + + `(${expected.length + 1} total), got ${bookings.length}`, + ).toBe(expected.length + 1); + return bookings; +} diff --git a/e2e/support/visitor/visitor.settings.ts b/e2e/support/visitor/visitor.settings.ts new file mode 100644 index 0000000000..2d5786b2b5 --- /dev/null +++ b/e2e/support/visitor/visitor.settings.ts @@ -0,0 +1,121 @@ +/** + * Per-test app settings for the visitor specs. + * + * The app reads a flat, highest-priority override map out of + * `localStorage['PLACEOS.setting_overrides']` + * (libs/common/src/lib/settings.service.ts, `loadDebugOverrides`). Because it + * lives in the browser context rather than on a zone, two specs can demand + * opposite settings and still run in parallel — which matters here, since + * single- and multi-visitor mode are the same setting with two values, and + * seeding it on the org zone would force the whole suite to serialise. + * + * The trade-off, stated plainly because a future reader deserves it: this is a + * debug hook, so these specs run with an override layer a real user does not + * have. That is acceptable for exercising form behaviour and would not be for + * asserting how settings themselves are resolved from zone metadata. + */ +import { Page } from '@playwright/test'; + +/** + * Force app settings for ONE page, before its first navigation. + * + * Two rules the implementation imposes: + * - Keys MUST start with `app.` — anything else is dropped on load. + * - Matching is EXACT. `app.visitors` will not satisfy a read of + * `app.visitors.allow_all_day`; spell out the full key. + * + * Call BEFORE `page.goto`. `addInitScript` runs before the bundle evaluates, so + * the override is already in localStorage when the service reads it once at + * module scope. + */ +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, which would silently leave the default in place and make the ` + + `spec assert against the wrong mode. Got: ${bad.join(', ')}`, + ); + } + await page.addInitScript((value) => { + localStorage.setItem('PLACEOS.setting_overrides', JSON.stringify(value)); + }, overrides); +} + +/** + * Settings every visitor spec needs. + * + * `basic_user_search` points the host field at rest-api's `/users`. Its default + * (`/people`) and the dropdown host field (`/calendars`) are both calendar + * backed and return 500 on a stack without real Microsoft/Google credentials, + * so without this the host field silently finds nobody. + */ +export const VISITOR_BASE_SETTINGS = { + 'app.basic_user_search': true, +}; + +/** Single-visitor mode: name/email/company fields. NOT the app default. */ +export const SINGLE_VISITOR_MODE = { + ...VISITOR_BASE_SETTINGS, + 'app.bookings.multiple_visitors': false, +}; + +/** Multi-visitor mode: the chip list. This IS the app default. */ +export const GROUP_VISITOR_MODE = { + ...VISITOR_BASE_SETTINGS, + 'app.bookings.multiple_visitors': true, +}; + +/** + * Let the invite be booked on behalf of any user the search can find. + * + * BOTH keys are required, and that is not obvious. `can_book_for_anyone` is + * what renders the searchable host field, but `sendInvite` decides whether to + * KEEP the chosen host by checking `can_book_for_others` + * (invite-visitor-form.component.ts, "fall back to the signed-in user for a new + * booking with no host"). Enable only `can_book_for_anyone` and you get a host + * picker that works right up until you press send, at which point the choice is + * silently replaced by whoever is signed in — no error, no hint. + * + * Worth raising with the dev team: a deployment that sets one flag and not the + * other gets exactly that silent failure. + */ +export const BOOK_FOR_ANYONE = { + 'app.visitors.can_book_for_anyone': true, + 'app.visitors.can_book_for_others': true, +}; + +/** Show the All Day checkbox on the invite form. */ +export const ALLOW_ALL_DAY = { + 'app.visitors.allow_all_day': true, +}; + +/** Show the pass-number and international-visitor controls. */ +export const ALLOW_EXTRAS = { + 'app.visitors.allow_pass_number': true, + 'app.visitors.allow_international': true, +}; + +/** Make the reason mandatory (PPT-2782). */ +export const REASON_REQUIRED = { + 'app.visitors.reason_required': true, +}; + +/** + * Cap how long a visit may be, in minutes, and confine it to office hours. + * + * Both are unset by default (`null`), which is why nothing else in this suite + * sees them. `bookable_hours` is `{ start, end }` in 24-hour decimal hours. + */ +export const LIMITED_HOURS = { + 'app.visitors.max_duration': 60, + 'app.visitors.bookable_hours': { start: 9, end: 17 }, +}; + +/** Allow an existing invite to be edited — off by default, so the Edit button hides. */ +export const ALLOW_EDITING = { + 'app.visitors.allow_editing': true, +}; diff --git a/e2e/support/visitor/your-bookings.page.ts b/e2e/support/visitor/your-bookings.page.ts new file mode 100644 index 0000000000..eb81f6a58e --- /dev/null +++ b/e2e/support/visitor/your-bookings.page.ts @@ -0,0 +1,332 @@ +/** + * Page object for `/your-bookings` — the schedule list and the booking details + * modal it opens. + * + * SELECTOR POLICY, same as `invite-form.page.ts`: nothing here depends on + * visible text, because all of it is translated. The awkward one is the + * "Associate" badge, which is the only marker on a card with no attribute of + * its own: + * + *
Associate
+ * + * Its sibling above it DOES have one (`checked-in-badge`), and the only other + * element that could match the class shape is the `group-event` badge — which a + * visitor booking can never be, since that is a different `booking_type`. So + * `.absolute.top-2.right-2:not([checked-in-badge])`, scoped to a card we have + * already identified by booking id, is unambiguous here even though it would + * not be in general. If a third badge is ever added to that corner this breaks, + * which is why the locator carries a failure message saying so. + * + * Cards are found by booking id rather than by title: the card anchor renders + * `routerLink` query params into its href, so `?booking=123` is real data in + * the DOM and cannot be knocked out by a copy change. + */ +import { Locator, Page, expect } from '@playwright/test'; +import { + calendarDayLabel, + pickCalendarDay, + selectedCalendarIndex, +} from './calendar'; + +export class YourBookingsPage { + constructor(private readonly page: Page) {} + + /** Open the list and wait for the schedule to render. */ + async open(): Promise { + await this.page.goto('/#/your-bookings'); + await expect( + this.page.locator('button[name="schedule-toggle-visitor-filter"]').first(), + 'the schedule filters never rendered — is the `visitor-invite` feature enabled?', + ).toBeVisible({ timeout: 30_000 }); + } + + /** Make sure visitor bookings are among the types being shown. */ + async showVisitors(): Promise { + const toggle = this.page + .locator('button[name="schedule-toggle-visitor-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(); + } + } + + /** + * Show the day a given moment falls on, rather than trusting the default. + * + * The schedule opens on whatever "today" was when the page loaded and never + * moves again, so a spec whose booking is not on that day sees an empty + * list. That is not only a tomorrow problem: a run that starts at 23:58 and + * loads the page at 00:01 is looking at the NEW day while its booking sits + * on the old one. The symptom is a missing card, which looks nothing like a + * clock problem. + * + * Driving the sidebar calendar is the only stable way to move the view — + * there is no date in the URL, and `schedule-mobile-calendar` is `sm:hidden` + * so it does not exist at the desktop viewport the suite runs at. + * + * The grid is 42 consecutive day cells, so ONE known cell fixes every other + * one by counting. The known cell is the one carrying the "today" ring, + * which the component renders from its own `today`, captured when the page + * loaded. Its day-of-month is read back and matched against the clock to + * work out which real date the ring means — today, or yesterday if the run + * has just crossed midnight. Counting from there is immune to the clock + * moving underneath us afterwards. + */ + async showDayOf(timestamp_ms: number): Promise { + const calendar = this.page.locator('schedule-sidebar date-calendar'); + // Twice, if needed. Under a loaded parallel run the schedule has been + // seen back on today after the click — the component rebinds its date + // from the service while the first query is still in flight, and the + // selection goes with it. Re-picking is cheap; a missing card that + // actually means "wrong day" costs a 30s timeout and looks like a bug + // in the app. + for (let attempt = 1; attempt <= 2; attempt++) { + const wanted = await pickCalendarDay(this.page, calendar, timestamp_ms); + // The list re-queries on a 300ms debounce and blanks itself while + // loading, so settle before judging anything. + await this.page.waitForTimeout(500); + await expect( + this.page.locator('mat-progress-bar'), + 'the schedule never finished reloading after the day changed', + ).toHaveCount(0, { timeout: 30_000 }); + + if ((await selectedCalendarIndex(calendar)) === wanted) return; + } + const selected = await selectedCalendarIndex(calendar); + throw new Error( + `the schedule would not stay on ${new Date(timestamp_ms).toDateString()}. ` + + `After two attempts it is showing day ` + + `"${await calendarDayLabel(calendar, selected)}" instead.`, + ); + } + + /** + * Accept the app's confirmation dialog ("are you sure?"). + * + * Used by check-out, which asks before releasing the booking. The dialog is + * the shared `confirm-modal`, whose accept button carries `name="accept"` — + * the one control in this flow with a stable attribute, and the reason this + * does not have to match the translated "Check out" label. + */ + async acceptConfirm(): Promise { + const accept = this.page.locator( + '.cdk-overlay-container button[name="accept"]', + ); + await expect( + accept, + 'the confirmation dialog did not open, so there was nothing to accept', + ).toBeVisible({ timeout: 15_000 }); + await accept.click(); + await expect(accept, 'the confirmation dialog did not close').toBeHidden({ + timeout: 15_000, + }); + } + + /** The card for a specific booking, matched on the id in its href. */ + card(bookingId: number): Locator { + return this.page.locator(`a[name="view-booking-details"][href*="booking=${bookingId}"]`); + } + + /** + * The "Associate" badge — shown when the booking's HOST is not the signed-in + * user, which for a visitor invite means somebody booked it on their behalf. + */ + associateBadge(bookingId: number): Locator { + return this.card(bookingId).locator( + 'div.absolute.top-2.right-2:not([checked-in-badge])', + ); + } + + /** The green badge that replaces the Associate one once checked in. */ + checkedInBadge(bookingId: number): Locator { + return this.card(bookingId).locator('div[checked-in-badge]'); + } + + /** The "For {name}" line, shown only when the host is somebody else. */ + bookedForLine(bookingId: number): Locator { + return this.card(bookingId).locator('div[booked-for]'); + } + + /** The booking ids of every card currently rendered, for failure messages. */ + async renderedBookingIds(): Promise { + const hrefs = await this.page + .locator('a[name="view-booking-details"]') + .evaluateAll((els) => els.map((e) => e.getAttribute('href') ?? '')); + return hrefs + .map((h) => Number(/booking=(\d+)/.exec(h)?.[1])) + .filter((n) => Number.isFinite(n)); + } + + /** Open a booking's details modal and wait for it. */ + 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) { + // Say what IS on the page. "Element not found" alone cannot + // distinguish "wrong day", "filter off" and "the list is so full of + // old bookings that this one is paged out". + const rendered = await this.renderedBookingIds(); + const calendar = this.page.locator('schedule-sidebar date-calendar'); + const showing = await calendarDayLabel( + calendar, + await selectedCalendarIndex(calendar), + ); + throw new Error( + `no card for booking ${bookingId} on /your-bookings, which is showing ` + + `day "${showing}" of the month. ` + + `${rendered.length} card(s) are rendered: [${rendered.join(', ')}]. ` + + `Check the day being shown is the booking's own day (\`showDayOf\` ` + + `moves it), that the visitor filter is on, and that the list is not ` + + `dominated by leftovers from earlier runs.`, + ); + } + await card.click(); + const modal = this.page.locator('booking-details-modal'); + await expect(modal, 'the booking details modal did not open').toBeVisible({ + timeout: 20_000, + }); + return modal; + } + + /** + * The check-in button inside an open details modal. + * + * `div[actions]` holds exactly two buttons: this one, which carries `btn`, + * and the overflow menu, which carries `icon`. Selecting on `btn` avoids the + * translated label. The button is only rendered when check-in is available + * at all, so a spec should assert on its visibility rather than assume it. + */ + checkInButton(modal: Locator): Locator { + // Note: this is ALSO the check-out button. The app flips the same + // control once you are checked in, so a spec that checks in and then out + // uses this twice rather than looking for a second button. + return modal.locator('div[actions] button[btn]'); + } + + /** + * Open the modal's overflow menu — the "..." next to check-in. + * + * It is the `icon` button in `div[actions]`; check-in is the `btn` one. The + * two are told apart by attribute rather than position, so adding a third + * control would not silently repoint this at the wrong thing. + */ + async openOverflowMenu(modal: Locator): Promise { + await modal.locator('div[actions] button[icon]').click(); + await expect( + this.page.locator('.cdk-overlay-container [mat-menu-item]').first(), + 'the booking overflow menu did not open', + ).toBeVisible({ timeout: 10_000 }); + } + + /** + * A menu action, identified by its MATERIAL ICON rather than its label. + * + * The menu items carry no attributes and their labels are translated, but + * each renders an icon ligature — `edit`, `delete` — which is an icon name + * in the source, not copy. That is the only stable handle here. + * + * Read explicitly rather than matched with a text selector. Two attempts at + * the latter both silently matched nothing while the item was plainly in the + * DOM: `icon:text-is("edit")` binds to the innermost element holding the + * text, so it never matches the wrapper, and `hasText` with an + * anchored regex did not match either. Comparing trimmed `textContent` is + * duller and it works. + */ + async menuAction(icon: string): Promise { + const items = this.page.locator('.cdk-overlay-container button[mat-menu-item]'); + const count = await items.count(); + for (let i = 0; i < count; i++) { + const item = items.nth(i); + const label = (await item.locator('icon').first().textContent()) ?? ''; + if (label.trim() === icon) return item; + } + return null; + } + + /** Dismiss a confirmation dialog without accepting it. */ + async dismissConfirm(): Promise { + // The decline button is the footer button that is NOT `accept`. Its + // label is translated, so it cannot be matched on text. + const decline = this.page + .locator('.cdk-overlay-container footer button:not([name="accept"])') + .first(); + await expect( + decline, + 'the confirmation dialog did not open, so there was nothing to dismiss', + ).toBeVisible({ timeout: 15_000 }); + await decline.click(); + await expect(decline, 'the confirmation dialog did not close').toBeHidden({ + timeout: 15_000, + }); + } + + /** + * Start cancelling a booking from the app: details -> overflow -> Cancel. + * + * Stops at the confirmation dialog, so a caller can accept it or walk away. + * The menu item is found by its `delete` icon; every other item in that menu + * uses a different one. There can be more than one delete-ish action on a + * booking (remove this one, remove the whole series, remove the group), so + * this fails loudly rather than picking the first of several — cancelling + * the wrong thing would look like a passing test. + */ + async startCancel(bookingId: number): Promise { + const modal = await this.openDetails(bookingId); + await this.openOverflowMenu(modal); + const icons = await this.page + .locator('.cdk-overlay-container button[mat-menu-item] icon') + .allTextContents(); + const deletes = icons.filter((i) => i.trim() === 'delete').length; + if (deletes !== 1) { + throw new Error( + `expected exactly one Cancel action in the booking menu, found ` + + `${deletes}. The menu holds: ${JSON.stringify(icons.map((i) => i.trim()))}. ` + + `More than one means this booking also offers "remove series" or ` + + `"remove group", and picking blindly could cancel the wrong thing.`, + ); + } + const remove = await this.menuAction('delete'); + if (!remove) throw new Error('no Cancel action in the booking menu'); + await remove.click(); + } + + /** + * Open a booking for editing and land on the pre-filled invite form. + * + * 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. + */ + async startEdit(bookingId: number): Promise { + const modal = await this.openDetails(bookingId); + await this.openOverflowMenu(modal); + const edit = await this.menuAction('edit'); + if (!edit) { + // Say what IS in the menu. "Edit is missing" and "my locator cannot + // see Edit" look identical as a selector timeout, and only one of + // them is a problem with the app's configuration. + const items = await this.page + .locator('.cdk-overlay-container button[mat-menu-item]') + .evaluateAll((els) => + els.map((e) => (e.textContent ?? '').replace(/\s+/g, ' ').trim()), + ); + 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.`, + ); + } + await edit.click(); + await expect( + this.page.locator('invite-visitor-form'), + 'editing did not land on the visitor form', + ).toBeVisible({ timeout: 30_000 }); + } +} diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 0000000000..55d79c5f9b --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,26 @@ +{ + // Type-checking for the e2e suite. Nothing else covered these files: + // Playwright transpiles specs without checking them, and no project in the + // workspace includes `e2e/support` or `apps/*/e2e`. Real errors sat in the + // tree while every test passed green. + // + // Run with: bun run e2e:typecheck + "extends": "../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "skipLibCheck": true, + "target": "es2022", + "module": "preserve", + "moduleResolution": "bundler", + "esModuleInterop": true, + "lib": ["dom", "es2022"], + "types": ["node"], + // ON here even though the workspace base has it off. This is the setting + // that catches the mistake this file exists for: a `let id: number | + // undefined` assigned from an untyped helper and then passed somewhere + // expecting a number. Without it the check compiles and finds nothing. + // Scoped to the e2e project so the app build is unaffected. + "strictNullChecks": true + }, + "include": ["support/**/*.ts", "../apps/workplace/e2e/**/*.ts"] +} diff --git a/package.json b/package.json index c766696c0a..de1fc318de 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "test": "ng test", "lint": "nx workspace-lint && ng lint", "e2e": "ng e2e", + "e2e:typecheck": "tsc --noEmit -p e2e/tsconfig.json", "affected:apps": "nx affected:apps", "affected:libs": "nx affected:libs", "affected:build": "nx affected:build", From d44c2270d9a81329162a86a1f3773256636b4e2f Mon Sep 17 00:00:00 2001 From: sharmilaseenivasan17 Date: Wed, 16 Sep 2026 09:44:08 +1000 Subject: [PATCH 02/10] test(e2e): add room booking coverage for workplace 14 tests across 6 spec files, in the same shape as the desk and visitor coverage: book through the UI, teardown, double-booking (same slot and partial overlap, as a second user, with a control), visibility between users, times and the limits on what may be chosen, attendees, and cancelling from the app. 11 pass; 3 are fixme, blocked on app bugs - a room booking is stored with no zones (ROOM-B2), the meeting length picked is not the length booked (ROOM-B3), and cancelling from the schedule fires DELETE /events and 500s while the room stays held (ROOM-B4). Rooms only run locally in one mode. By default the meeting flow calls Microsoft/Google through /events and /calendars, which 500 on the local stack; with app.events.use_bookings = true the same form saves an ordinary PlaceOS booking of type room, with no outbound call. So a green run proves the PlaceOS-native room path works and says nothing about the calendar path - E2E_USER_STORIES.md WP-E2E-15 is now partial rather than out of scope, split along that line, and rooms have their own section 1b. Room support code lives in e2e/support/room/ so neither the desk nor the visitor specs can be affected, and rooms are seeded there rather than in the shared seed.ts - a room is an engine System and must genuinely be created. The one existing file touched is your-bookings.page.ts, where the constructor argument becomes protected so the room schedule page can inherit it instead of copying it. Co-Authored-By: Claude Opus 5 (1M context) --- E2E_USER_STORIES.md | 93 +++++- .../e2e/local/room-attendees.spec.ts | 135 +++++++++ apps/workplace/e2e/local/room-booking.spec.ts | 188 ++++++++++++ apps/workplace/e2e/local/room-cancel.spec.ts | 152 ++++++++++ apps/workplace/e2e/local/room-clash.spec.ts | 200 +++++++++++++ apps/workplace/e2e/local/room-scoping.spec.ts | 124 ++++++++ apps/workplace/e2e/local/room-times.spec.ts | 188 ++++++++++++ e2e/README.md | 27 +- e2e/ROOM_E2E_HANDOVER.md | 179 ++++++++++++ e2e/support/room/meeting-form.page.ts | 274 ++++++++++++++++++ e2e/support/room/room.api.ts | 106 +++++++ e2e/support/room/room.env.ts | 79 +++++ e2e/support/room/room.flows.ts | 240 +++++++++++++++ e2e/support/room/room.seed.ts | 121 ++++++++ e2e/support/room/room.settings.ts | 116 ++++++++ e2e/support/room/schedule.page.ts | 84 ++++++ e2e/support/visitor/your-bookings.page.ts | 7 +- 17 files changed, 2297 insertions(+), 16 deletions(-) create mode 100644 apps/workplace/e2e/local/room-attendees.spec.ts create mode 100644 apps/workplace/e2e/local/room-booking.spec.ts create mode 100644 apps/workplace/e2e/local/room-cancel.spec.ts create mode 100644 apps/workplace/e2e/local/room-clash.spec.ts create mode 100644 apps/workplace/e2e/local/room-scoping.spec.ts create mode 100644 apps/workplace/e2e/local/room-times.spec.ts create mode 100644 e2e/ROOM_E2E_HANDOVER.md create mode 100644 e2e/support/room/meeting-form.page.ts create mode 100644 e2e/support/room/room.api.ts create mode 100644 e2e/support/room/room.env.ts create mode 100644 e2e/support/room/room.flows.ts create mode 100644 e2e/support/room/room.seed.ts create mode 100644 e2e/support/room/room.settings.ts create mode 100644 e2e/support/room/schedule.page.ts diff --git a/E2E_USER_STORIES.md b/E2E_USER_STORIES.md index 5d503904d4..a873faa809 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-14). 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,68 @@ 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 + +Fourteen tests in six 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` — one room 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. + +**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. + +| 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. | + +### Findings from building this coverage + +Four, all reproduced and minimised, **none filed**. Three have a `fixme` row above waiting on +them; ROOM-B1 has no row at all, because the preset that triggers it is deliberately kept out +of the base settings. + +| ID | Finding | +|----|---------| +| **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 and is currently unused. | + +### Still to write + +Seven scenarios, in rough order of value: edit a room booking's **time**; edit its **room**; +the **capacity warning** when attendees exceed the room; the **approval** flow (**blocked by +ROOM-B1**); **check in** to a room booking; **favourite** rooms; **catering and equipment**. + ## 2. Auth & session Grounded in the auth.cr work (PPT-2536), where every production failure was an @@ -233,13 +295,28 @@ 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", fourteen + green tests guard a path those customers never take. This needs a product answer, not more + specs. +- **Seven rows are blocked on product fixes, not on test effort** (REG-08, REG-09, REG-10, + VIS-15, ROOM-11, ROOM-13, ROOM-14). All were found by this suite. Leaving them visible here is the point — a blocked row is coverage information, a deleted row is not. +- **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/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-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-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/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..3c0ac86aee --- /dev/null +++ b/e2e/ROOM_E2E_HANDOVER.md @@ -0,0 +1,179 @@ +# 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: 11 of 14 tests passing, 3 blocked on app bugs (ROOM-B2, ROOM-B3, ROOM-B4).** +Six spec files, ~55 seconds. Nothing in the pre-existing suite was changed; the only +edit to an existing file is **one word** in `e2e/support/visitor/your-bookings.page.ts` +(`private` → `protected`), so the room schedule page can inherit it instead of copying it. + +## 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** (one engine System per worker) and needs the +admin identity from `seed.ts` to be present. They are left in place between runs. + +--- + +## 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` | one room per worker (`roomFor`), one time slot per spec file (`ROOM_SLOTS`), `slotFor(hour, daysAhead, minutes)` | +| `room.seed.ts` | creates the rooms as engine **Systems**, on demand, idempotent, cached per process. Needs ADMIN | +| `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 | + +Two tests per file, matching every other spec file in the suite. The two files carrying a +third carry it as a `fixme` placeholder for a specific bug; fold each one into its sibling +test when the bug is fixed. + +**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-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. | reached from the app via `app.bookings.no_approval = true`, which is why `NO_APPROVAL` is not in the base settings. No spec: the approval scenario is blocked on it | + +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. +- **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-14 and + the four 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. **Commit and push.** Nothing is committed on `automated-testing` yet — visitor work + first, then rooms. +3. **File the four findings.** Needs a project and an owner. ROOM-B3 and ROOM-B4 first. +4. **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. +5. **Seven room scenarios still to write:** edit the time · edit the room · capacity + warning · approval (**blocked by ROOM-B1**) · check in · favourite rooms · catering and + equipment. + +**Cannot be tested here at all:** real Outlook/Google invites, free/busy, attendee +availability · email of any kind · room panels and signage · recurring meetings. + +--- + +## 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. diff --git a/e2e/support/room/meeting-form.page.ts b/e2e/support/room/meeting-form.page.ts new file mode 100644 index 0000000000..bdb22c7fba --- /dev/null +++ b/e2e/support/room/meeting-form.page.ts @@ -0,0 +1,274 @@ +/** + * 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 }); + } + + /** + * 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 }); + } + + /** 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..66d4a079cf --- /dev/null +++ b/e2e/support/room/room.api.ts @@ -0,0 +1,106 @@ +/** + * 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, 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); +} + +/** + * 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..40e2e03b07 --- /dev/null +++ b/e2e/support/room/room.env.ts @@ -0,0 +1,79 @@ +/** + * 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; + +export interface RoomIdentity { + /** Engine system id, filled in by the seeder — not known up front. */ + id: string; + name: string; + email: string; + capacity: number; +} + +/** The name and address of the room this worker owns. */ +export function roomFor(workerIndex: number): Omit { + 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..935a350f5e --- /dev/null +++ b/e2e/support/room/room.flows.ts @@ -0,0 +1,240 @@ +/** + * 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[]; + } = {}, +): 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); + // 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); + + // 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); + } + + 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. + const reached_success = await form.successPanel + .waitFor({ state: 'visible', timeout: 30_000 }) + .then(() => true) + .catch(() => false); + const captured = await posts.stop(); + const 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..8f7e9ff31d --- /dev/null +++ b/e2e/support/room/room.seed.ts @@ -0,0 +1,121 @@ +/** + * 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 { RoomIdentity, 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 ?? []); +} + +/** + * Make sure every worker has a bookable room, and return them. + * + * 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: RoomIdentity[] = []; + + for (let i = 0; i < WORKERS; i++) { + const want = roomFor(i); + const found = existing.find( + (s) => `${s.email}`.toLowerCase() === want.email.toLowerCase(), + ); + if (found) { + rooms.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.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): 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[workerIndex]; + if (!room) { + throw new Error( + `no room seeded for worker ${workerIndex}. ${rooms.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..c645d560eb --- /dev/null +++ b/e2e/support/room/room.settings.ts @@ -0,0 +1,116 @@ +/** + * 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 }, +}; + +/** + * 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..4b515d9035 --- /dev/null +++ b/e2e/support/room/schedule.page.ts @@ -0,0 +1,84 @@ +/** + * 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(); + } + } + + /** 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/your-bookings.page.ts b/e2e/support/visitor/your-bookings.page.ts index eb81f6a58e..5f676ca6cc 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 { From 2edaa7d52c267024acbf91e76d68a13bc62d4ced Mon Sep 17 00:00:00 2001 From: sharmilaseenivasan17 Date: Wed, 16 Sep 2026 10:47:16 +1000 Subject: [PATCH 03/10] test(e2e): cover editing, capacity, favourites, approval and catering for rooms Seven more room tests, taking the room files from 14 tests to 22 - 17 passing, 5 blocked, 0 failing. Four new spec files: room-edit change the start time; move the booking to another room room-capacity strict mode refuses before anything is sent; the default only warns and still books room-favourites a favourite is saved to the user's own settings, and the Favorites Only filter narrows the picker to it room-approval the default stores the booking unapproved room-catering fixme, see ROOM-B5 below The capacity pair needs a room too small to book, and capacity belongs to the engine System rather than to settings, so room.seed.ts now creates three rooms per worker: a normal one, an alt one to move a booking into, and a capacity-1 one. catering.seed.ts does the same for a catering menu, which is made of assets - a hidden _CATERING_ category, a CATERING: asset type and one asset on the building - all on the engine api. ROOM-B5, a new finding: catering cannot be ordered with a PlaceOS-native room booking at all. The meeting saves (201) and the order that follows is refused 422 "error linking booking to event", because orders are linked to a calendar event by id and in use_bookings mode that id is a booking id. The room booking is then left behind undeleted and no order exists, while the user is shown an error and has every reason to believe nothing was booked. ROOM-B1 was re-measured through the app and still 500s. Checking in to a room is blocked by the stack, not by effort, and has no spec on purpose: the control needs a live Bookings driver module on the room's System, and this stack has one driver (spec_helper) and one module (PrivateHelper). ROOM-23 records that, and what would unblock it. E2E_USER_STORIES.md carries ROOM-15 ... ROOM-23 and the fifth finding. your-bookings.page.ts gains two protected hooks so the room schedule page inherits startEdit rather than copying it - the form Edit lands on is the only part that differs by booking type. Co-Authored-By: Claude Opus 5 (1M context) --- E2E_USER_STORIES.md | 59 +++-- .../workplace/e2e/local/room-approval.spec.ts | 125 +++++++++++ .../workplace/e2e/local/room-capacity.spec.ts | 210 ++++++++++++++++++ .../workplace/e2e/local/room-catering.spec.ts | 132 +++++++++++ apps/workplace/e2e/local/room-edit.spec.ts | 195 ++++++++++++++++ .../e2e/local/room-favourites.spec.ts | 96 ++++++++ e2e/ROOM_E2E_HANDOVER.md | 139 ++++++++++-- e2e/support/room/catering.seed.ts | 160 +++++++++++++ e2e/support/room/meeting-form.page.ts | 146 ++++++++++++ e2e/support/room/room.api.ts | 58 ++++- e2e/support/room/room.env.ts | 77 ++++++- e2e/support/room/room.flows.ts | 12 + e2e/support/room/room.seed.ts | 88 +++++--- e2e/support/room/room.settings.ts | 18 ++ e2e/support/room/schedule.page.ts | 20 ++ e2e/support/visitor/your-bookings.page.ts | 34 ++- 16 files changed, 1478 insertions(+), 91 deletions(-) create mode 100644 apps/workplace/e2e/local/room-approval.spec.ts create mode 100644 apps/workplace/e2e/local/room-capacity.spec.ts create mode 100644 apps/workplace/e2e/local/room-catering.spec.ts create mode 100644 apps/workplace/e2e/local/room-edit.spec.ts create mode 100644 apps/workplace/e2e/local/room-favourites.spec.ts create mode 100644 e2e/support/room/catering.seed.ts diff --git a/E2E_USER_STORIES.md b/E2E_USER_STORIES.md index a873faa809..62b732b5ed 100644 --- a/E2E_USER_STORIES.md +++ b/E2E_USER_STORIES.md @@ -122,7 +122,7 @@ the PR gate. | 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. | **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-14). 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-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 @@ -185,7 +185,7 @@ defects. Listed here so the coverage record is honest about what the suite knows ## 1b. Workplace — room bookings -Fourteen tests in six files under `apps/workplace/e2e/local/room-*.spec.ts`, with their own +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). @@ -200,14 +200,19 @@ other or both is [open question 3](#notes--blockers) and it decides what this co **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` — one room 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. +`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. +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 | |----|---|-------|--------| @@ -225,25 +230,42 @@ building cannot be exercised — the stack seeds one org, one building, one leve | 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 -Four, all reproduced and minimised, **none filed**. Three have a `fixme` row above waiting on -them; ROOM-B1 has no row at all, because the preset that triggers it is deliberately kept out -of the base settings. +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 and is currently unused. | +| **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 -Seven scenarios, in rough order of value: edit a room booking's **time**; edit its **room**; -the **capacity warning** when attendees exceed the room; the **approval** flow (**blocked by -ROOM-B1**); **check in** to a room booking; **favourite** rooms; **catering and equipment**. +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 @@ -302,12 +324,15 @@ Config gaps caused several production incidents, and they are invisible to UI sp 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", fourteen - green tests guard a path those customers never take. This needs a product answer, not more + 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. -- **Seven rows are blocked on product fixes, not on test effort** (REG-08, REG-09, REG-10, - VIS-15, ROOM-11, ROOM-13, ROOM-14). All were found by this suite. Leaving them visible here is the point — a blocked row is coverage +- **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 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-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-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/e2e/ROOM_E2E_HANDOVER.md b/e2e/ROOM_E2E_HANDOVER.md index 3c0ac86aee..f2ecf2faa0 100644 --- a/e2e/ROOM_E2E_HANDOVER.md +++ b/e2e/ROOM_E2E_HANDOVER.md @@ -6,10 +6,17 @@ same shape. Written to be picked up cold. The coverage contract lives in conventions in [`README.md`](README.md); the visitor equivalent of this file is [`VISITOR_E2E_HANDOVER.md`](VISITOR_E2E_HANDOVER.md). -**Status: 11 of 14 tests passing, 3 blocked on app bugs (ROOM-B2, ROOM-B3, ROOM-B4).** -Six spec files, ~55 seconds. Nothing in the pre-existing suite was changed; the only -edit to an existing file is **one word** in `e2e/support/visitor/your-bookings.page.ts` -(`private` → `protected`), so the room schedule page can inherit it instead of copying it. +**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 @@ -45,8 +52,15 @@ 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** (one engine System per worker) and needs the -admin identity from `seed.ts` to be present. They are left in place between runs. +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. --- @@ -58,8 +72,9 @@ Seven files, ~1,000 lines, sharing nothing with the desk or visitor support code | File | Holds | |---|---| -| `room.env.ts` | one room per worker (`roomFor`), one time slot per spec file (`ROOM_SLOTS`), `slotFor(hour, daysAhead, minutes)` | +| `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 | @@ -76,10 +91,15 @@ Seven files, ~1,000 lines, sharing nothing with the desk or visitor support code | `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. The two files carrying a -third carry it as a `fixme` placeholder for a specific bug; fold each one into its sibling -test when the bug is fixed. +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. @@ -90,10 +110,11 @@ genuinely different second user). Run with the default worker count. | 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. | reached from the app via `app.bookings.no_approval = true`, which is why `NO_APPROVAL` is not in the base settings. No spec: the approval scenario is blocked on it | +| **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 @@ -114,6 +135,13 @@ something different. - **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. @@ -124,22 +152,49 @@ something different. ## Next steps -1. ~~`E2E_USER_STORIES.md` rows~~ — **done 2026-09-16.** §1b holds ROOM-01 … ROOM-14 and - the four findings; WP-E2E-15 is now **partial** rather than out of scope, split into +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. **Commit and push.** Nothing is committed on `automated-testing` yet — visitor work - first, then rooms. -3. **File the four findings.** Needs a project and an owner. ROOM-B3 and ROOM-B4 first. -4. **Stale booking accumulation** — shared with the visitor work and bigger than either. +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. -5. **Seven room scenarios still to write:** edit the time · edit the room · capacity - warning · approval (**blocked by ROOM-B1**) · check in · favourite rooms · catering and - equipment. +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. +availability · email of any kind · room panels and signage · recurring meetings on the +calendar path · checking in to a room (above). --- @@ -177,3 +232,45 @@ availability · email of any kind · room panels and signage · recurring meetin - **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/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 index bdb22c7fba..c0437e600b 100644 --- a/e2e/support/room/meeting-form.page.ts +++ b/e2e/support/room/meeting-form.page.ts @@ -160,6 +160,77 @@ export class MeetingForm { ).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()); + } + + /** + * 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. * @@ -209,6 +280,81 @@ export class MeetingForm { ).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 }); + } + /** Set the start time from the options the form offers, as "HH:mm". */ async setStartTime(hhmm: string): Promise { await this.startTimeTrigger.click(); diff --git a/e2e/support/room/room.api.ts b/e2e/support/room/room.api.ts index 66d4a079cf..e82cebdf60 100644 --- a/e2e/support/room/room.api.ts +++ b/e2e/support/room/room.api.ts @@ -15,7 +15,14 @@ * and the backend — not the UI — is what enforces it. */ import { APIRequestContext } from '@playwright/test'; -import { Booking, STAFF_API, currentUser, releaseAsset, zonesWithTag } from '../api'; +import { + Booking, + ENGINE_API, + STAFF_API, + currentUser, + releaseAsset, + zonesWithTag, +} from '../api'; export interface RoomBooking extends Booking { attendees?: { name?: string; email: string }[]; @@ -85,6 +92,55 @@ export async function createRoomBookingViaApi( 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. * diff --git a/e2e/support/room/room.env.ts b/e2e/support/room/room.env.ts index 40e2e03b07..a07e4df9b2 100644 --- a/e2e/support/room/room.env.ts +++ b/e2e/support/room/room.env.ts @@ -42,6 +42,30 @@ export const ROOM_SLOTS = { 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 }, +} as const; + export interface RoomIdentity { /** Engine system id, filled in by the seeder — not known up front. */ id: string; @@ -50,13 +74,52 @@ export interface RoomIdentity { capacity: number; } -/** The name and address of the room this worker owns. */ -export function roomFor(workerIndex: number): Omit { - return { - name: `E2E Room ${workerIndex}`, - email: `${ROOM_PREFIX}${workerIndex}@place.tech`, - capacity: 8, - }; +/** + * 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, + }; + } } /** diff --git a/e2e/support/room/room.flows.ts b/e2e/support/room/room.flows.ts index 935a350f5e..a498915e65 100644 --- a/e2e/support/room/room.flows.ts +++ b/e2e/support/room/room.flows.ts @@ -109,6 +109,12 @@ export async function bookRoomViaUI( * 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[]; } = {}, ): Promise { const form = new MeetingForm(page); @@ -150,6 +156,12 @@ export async function bookRoomViaUI( 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. diff --git a/e2e/support/room/room.seed.ts b/e2e/support/room/room.seed.ts index 8f7e9ff31d..07605f67a6 100644 --- a/e2e/support/room/room.seed.ts +++ b/e2e/support/room/room.seed.ts @@ -17,7 +17,7 @@ import { APIRequestContext } from '@playwright/test'; import { ENGINE_API, apiFor, zonesWithTag } from '../api'; import { WORKERS } from '../env'; -import { RoomIdentity, roomFor } from './room.env'; +import { ROOM_VARIANTS, RoomIdentity, RoomVariant, roomFor } from './room.env'; /** * `GET /systems` answers with a BARE ARRAY, not `{ results: [] }`. @@ -36,14 +36,22 @@ async function listSystems(api: APIRequestContext): Promise { 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 a bookable room, and return them. + * 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 { +export async function ensureRooms(): Promise { const admin = await apiFor('admin', 0); try { const [building] = await zonesWithTag(admin, 'building'); @@ -56,38 +64,40 @@ export async function ensureRooms(): Promise { } const zones = [building.id, level?.id].filter(Boolean) as string[]; const existing = await listSystems(admin); - const rooms: RoomIdentity[] = []; + const rooms = { main: [], alt: [], small: [] } as RoomSet; - for (let i = 0; i < WORKERS; i++) { - const want = roomFor(i); - const found = existing.find( - (s) => `${s.email}`.toLowerCase() === want.email.toLowerCase(), - ); - if (found) { - rooms.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()}`, + 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 }); } - rooms.push({ ...want, id: (await res.json()).id }); } return rooms; } finally { @@ -101,8 +111,11 @@ export async function ensureRooms(): Promise { * 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): Promise { +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 @@ -110,11 +123,12 @@ export async function roomForWorker(workerIndex: number): Promise cache = null; throw error; }); - const room = rooms[workerIndex]; + const room = rooms[variant][workerIndex]; if (!room) { throw new Error( - `no room seeded for worker ${workerIndex}. ${rooms.length} exist, one per ` + - `worker, so E2E_WORKERS is smaller than the number Playwright is running.`, + `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 index c645d560eb..d66da23ca4 100644 --- a/e2e/support/room/room.settings.ts +++ b/e2e/support/room/room.settings.ts @@ -104,6 +104,24 @@ export const LIMITED_HOURS = { '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, +}; + /** * Settings shared by every room spec. * diff --git a/e2e/support/room/schedule.page.ts b/e2e/support/room/schedule.page.ts index 4b515d9035..657a51dad9 100644 --- a/e2e/support/room/schedule.page.ts +++ b/e2e/support/room/schedule.page.ts @@ -56,6 +56,26 @@ export class RoomSchedulePage extends YourBookingsPage { } } + /** + * 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); diff --git a/e2e/support/visitor/your-bookings.page.ts b/e2e/support/visitor/your-bookings.page.ts index 5f676ca6cc..e1e0dfda10 100644 --- a/e2e/support/visitor/your-bookings.page.ts +++ b/e2e/support/visitor/your-bookings.page.ts @@ -302,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); @@ -324,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 }); } } From 5d0ac4fe04f3c57bcce1b7988f93d9ac50620d7c Mon Sep 17 00:00:00 2001 From: sharmilaseenivasan17 Date: Wed, 16 Sep 2026 17:35:14 +1000 Subject: [PATCH 04/10] test(e2e): cover Your Bookings and the desk gaps 24 tests in 15 new spec files, all passing: 14 for the Your Bookings page, which had no coverage of its own, and 10 filling the desk gaps. Your Bookings (e2e/support/bookings/, bookings-*.spec.ts) the listing, per type and per day, plus what it asks the backend for the five type filters, and the chips beside them cancelling from the list, and declining the confirmation Edit routing to the right form per booking type checking in to a desk booking and back out a finished booking offers no check-in an empty day shows its empty state another user's bookings never appear, with a control Desks (e2e/support/desk/, desk-*.spec.ts) the day, start time and length chosen are the ones stored a maximum length and bookable hours limit what is offered editing the time, and moving the booking to another desk an all-day booking is stored as all-day booking for a colleague stores them as user and you as booker a favourite desk is saved against the user a bad booking request is refused 4xx, never 5xx a clash check uses the current booking_end (REG-03, now covered) the checked-in badge appears only once checked in (REG-04) Both areas keep their own support folder and share nothing with each other or with visitor/ and room/. The schedule page object still belongs to visitor/your-bookings.page.ts, which introduced it; bookings/ and desk/ inherit it, and the form Edit lands on is now a settable hook because on that page it depends on the booking rather than the page. Three things measured along the way, recorded where they cost time: - The schedule sends include_deleted=true and renders cancelled bookings for ever, so the number of cards on a day grows with every run. Card counts are therefore never asserted; the specs compare against what the backend says is live. - POST /bookings can return 201 with an id for a booking that does not exist: GET on that id then 404s. That is REG-09 doing more damage than its row describes, and every create here reads the row back. - The desk host field searches /api/staff/v1/people, the calendar directory, which 500s on this stack. app.basic_user_search switches it to the local user list, so DESK-14 covers the PlaceOS path only. Also: the desk form's date picker needs a converging open (the form is rebuilt underneath the click), and the schedule's sidebar calendar only reaches the displayed month - so desk slots stay within four days of today and separate by hour instead. Co-Authored-By: Claude Opus 5 (1M context) --- .../e2e/local/bookings-cancel.spec.ts | 198 ++++++++ .../e2e/local/bookings-checkin.spec.ts | 129 +++++ .../workplace/e2e/local/bookings-edit.spec.ts | 160 ++++++ .../e2e/local/bookings-empty.spec.ts | 91 ++++ .../e2e/local/bookings-filters.spec.ts | 211 ++++++++ .../workplace/e2e/local/bookings-list.spec.ts | 279 +++++++++++ .../workplace/e2e/local/bookings-past.spec.ts | 93 ++++ .../e2e/local/bookings-scoping.spec.ts | 152 ++++++ apps/workplace/e2e/local/desk-api.spec.ts | 229 +++++++++ .../workplace/e2e/local/desk-delegate.spec.ts | 126 +++++ apps/workplace/e2e/local/desk-edit.spec.ts | 192 ++++++++ .../e2e/local/desk-favourites.spec.ts | 101 ++++ .../workplace/e2e/local/desk-settings.spec.ts | 110 +++++ apps/workplace/e2e/local/desk-status.spec.ts | 113 +++++ apps/workplace/e2e/local/desk-times.spec.ts | 167 +++++++ e2e/support/bookings/bookings.api.ts | 228 +++++++++ e2e/support/bookings/bookings.env.ts | 115 +++++ e2e/support/bookings/bookings.settings.ts | 49 ++ e2e/support/bookings/schedule.page.ts | 270 +++++++++++ e2e/support/desk/desk-form.page.ts | 455 ++++++++++++++++++ e2e/support/desk/desk-schedule.page.ts | 50 ++ e2e/support/desk/desk.api.ts | 168 +++++++ e2e/support/desk/desk.env.ts | 113 +++++ e2e/support/desk/desk.settings.ts | 78 +++ 24 files changed, 3877 insertions(+) create mode 100644 apps/workplace/e2e/local/bookings-cancel.spec.ts create mode 100644 apps/workplace/e2e/local/bookings-checkin.spec.ts create mode 100644 apps/workplace/e2e/local/bookings-edit.spec.ts create mode 100644 apps/workplace/e2e/local/bookings-empty.spec.ts create mode 100644 apps/workplace/e2e/local/bookings-filters.spec.ts create mode 100644 apps/workplace/e2e/local/bookings-list.spec.ts create mode 100644 apps/workplace/e2e/local/bookings-past.spec.ts create mode 100644 apps/workplace/e2e/local/bookings-scoping.spec.ts create mode 100644 apps/workplace/e2e/local/desk-api.spec.ts create mode 100644 apps/workplace/e2e/local/desk-delegate.spec.ts create mode 100644 apps/workplace/e2e/local/desk-edit.spec.ts create mode 100644 apps/workplace/e2e/local/desk-favourites.spec.ts create mode 100644 apps/workplace/e2e/local/desk-settings.spec.ts create mode 100644 apps/workplace/e2e/local/desk-status.spec.ts create mode 100644 apps/workplace/e2e/local/desk-times.spec.ts create mode 100644 e2e/support/bookings/bookings.api.ts create mode 100644 e2e/support/bookings/bookings.env.ts create mode 100644 e2e/support/bookings/bookings.settings.ts create mode 100644 e2e/support/bookings/schedule.page.ts create mode 100644 e2e/support/desk/desk-form.page.ts create mode 100644 e2e/support/desk/desk-schedule.page.ts create mode 100644 e2e/support/desk/desk.api.ts create mode 100644 e2e/support/desk/desk.env.ts create mode 100644 e2e/support/desk/desk.settings.ts 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/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, +}; From b2dfe8d66ea9c198e568e12b9668ee5ee8a498db Mon Sep 17 00:00:00 2001 From: sharmilaseenivasan17 Date: Wed, 16 Sep 2026 17:55:41 +1000 Subject: [PATCH 05/10] test(e2e): add parking booking coverage for workplace 14 tests in 8 spec files: 13 passing, 1 blocked. Parking was the last booking type in workplace with no coverage at all. parking-booking book through the full UI; teardown really tears down parking-clash same window and partial overlap refused 409 as a second user, with a control; the space frees up again parking-scoping another user cannot see or delete yours, with a control parking-times the day, start and length chosen are the ones stored; limits control what is offered parking-cancel cancelling from the app really removes it; declining the confirmation does not parking-edit change the time; move the booking to another space parking-favourites a favourite space is saved against the user parking-api a bad request is refused 4xx, never 5xx parking-restrictions fixme - PARK-B1 below Parking needed the most seeding of any resource here, and none of it is in the shared seed.ts: parking.seed.ts creates a level zone tagged `parking`, the hidden _PARKING_ asset category, the _PARKING_SPACES_ asset type, and one space per worker plus a spare. A NEW level zone rather than a tag on the seeded one, so the desks' data is untouched. PARK-B1, a new finding: with `parking.require_space_restriction` on, the ordinary parking booking form cannot be submitted at all. The validator lives in the shared booking form and fires for any parking booking, while the parking booking form never renders a `space_restrictions` control - so Confirm Reservation answers "Some fields are invalid. [space_restrictions]" naming a field that is not on screen. The setting's own schema describes it as belonging to the parking REQUEST flow, which does render one. Measured on this stack with no overrides, which blocked the whole area until the setting was turned off in PARKING_BASE_SETTINGS. Also recorded where it cost time: the parking confirm step is a BOTTOM SHEET in the CDK overlay, not a routed view like the desk and meeting flows; the picker's favourite control is a bare `fav` attribute; and the favourites key is `favourite_parking_spaces`, not the `favourite_parking` constant in libs/common that nothing reads. The backend accepts a booking against an asset id that does not exist, for parking and for desks alike. Recorded in both API specs as a warning rather than a failure - it may be worth a bug of its own. Co-Authored-By: Claude Opus 5 (1M context) --- apps/workplace/e2e/local/parking-api.spec.ts | 94 ++++++ .../e2e/local/parking-booking.spec.ts | 151 +++++++++ .../e2e/local/parking-cancel.spec.ts | 119 +++++++ .../workplace/e2e/local/parking-clash.spec.ts | 197 +++++++++++ apps/workplace/e2e/local/parking-edit.spec.ts | 174 ++++++++++ .../e2e/local/parking-favourites.spec.ts | 92 ++++++ .../e2e/local/parking-restrictions.spec.ts | 96 ++++++ .../e2e/local/parking-scoping.spec.ts | 134 ++++++++ .../workplace/e2e/local/parking-times.spec.ts | 149 +++++++++ e2e/support/parking/parking-form.page.ts | 311 ++++++++++++++++++ e2e/support/parking/parking-schedule.page.ts | 42 +++ e2e/support/parking/parking.api.ts | 170 ++++++++++ e2e/support/parking/parking.env.ts | 97 ++++++ e2e/support/parking/parking.seed.ts | 197 +++++++++++ e2e/support/parking/parking.settings.ts | 77 +++++ 15 files changed, 2100 insertions(+) create mode 100644 apps/workplace/e2e/local/parking-api.spec.ts create mode 100644 apps/workplace/e2e/local/parking-booking.spec.ts create mode 100644 apps/workplace/e2e/local/parking-cancel.spec.ts create mode 100644 apps/workplace/e2e/local/parking-clash.spec.ts create mode 100644 apps/workplace/e2e/local/parking-edit.spec.ts create mode 100644 apps/workplace/e2e/local/parking-favourites.spec.ts create mode 100644 apps/workplace/e2e/local/parking-restrictions.spec.ts create mode 100644 apps/workplace/e2e/local/parking-scoping.spec.ts create mode 100644 apps/workplace/e2e/local/parking-times.spec.ts create mode 100644 e2e/support/parking/parking-form.page.ts create mode 100644 e2e/support/parking/parking-schedule.page.ts create mode 100644 e2e/support/parking/parking.api.ts create mode 100644 e2e/support/parking/parking.env.ts create mode 100644 e2e/support/parking/parking.seed.ts create mode 100644 e2e/support/parking/parking.settings.ts 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-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/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, +}; From 107909de49f925b0be40ef2f101cbfe023b1b2e1 Mon Sep 17 00:00:00 2001 From: sharmilaseenivasan17 Date: Wed, 16 Sep 2026 19:24:02 +1000 Subject: [PATCH 06/10] test(e2e): cover the workplace home page 11 tests in 6 spec files: 8 passing, 3 blocked. The home page had nothing of its own - boot.spec.ts proves the shell renders, which says nothing about whether the panels show the right data. home-upcoming a booking later today is listed; View all hands off to Your Bookings with the booking findable there home-colleagues a colleague added from the sidebar is saved; removing one clears it home-favourites a favourite desk is listed on the Favourites tab; removing it there clears the saved setting home-scoping another user's booking never appears, with a control home-quick-book fixme - HOME-B2 below home-availability fixme - blocked by the stack, below Three things measured that shaped the tests: - "Upcoming" means TODAY and nothing else. The panel is built from the schedule's own list filtered to `isSameDay(date, now)` and sliced to five. A first draft booked sixteen days out and spent three failures finding that out. - HOME-B1, a new finding: the panel keeps showing bookings you cancelled. It inherits the schedule's include_deleted=true query and only hides what was cancelled in the current session. Measured: five cards, every one cancelled, while a live booking for the same user and day was absent - the junk had crowded it out of the five slots. So it both tells users they have a desk they cancelled AND can hide a real one. - HOME-B2, a new finding: the one-click quick-book tile spins for ever and books nothing. GET /calendars 500s here (the calendar surface this suite does not cover), and `book()` awaits listAvailableResources outside any try/catch - so the rejection kills the handler after the loading flag is set, leaving a permanent spinner and no message. A misconfigured tenant would look like this in production. The availability panel is blocked by the stack rather than by a bug: it lists only rooms a live driver binding reports as free, and this stack has one driver (spec_helper) and one module (PrivateHelper). Same blocker as room check-in (ROOM-23). Also recorded where each cost a run: the colleague list is its own `contacts` metadata document, NOT the favourite_team_members key that libs/common advertises; the sidebar tabs are reached by their material ligatures (people, favorite) because neither button has a name; and colleague rows are labelled with display names, so lookups normalise both sides before comparing. Co-Authored-By: Claude Opus 5 (1M context) --- .../e2e/local/home-availability.spec.ts | 81 +++++++ .../e2e/local/home-colleagues.spec.ts | 124 ++++++++++ .../e2e/local/home-favourites.spec.ts | 104 ++++++++ .../e2e/local/home-quick-book.spec.ts | 142 +++++++++++ apps/workplace/e2e/local/home-scoping.spec.ts | 145 +++++++++++ .../workplace/e2e/local/home-upcoming.spec.ts | 227 ++++++++++++++++++ e2e/support/home/home.api.ts | 189 +++++++++++++++ e2e/support/home/home.env.ts | 58 +++++ e2e/support/home/home.settings.ts | 42 ++++ e2e/support/home/landing.page.ts | 218 +++++++++++++++++ 10 files changed, 1330 insertions(+) create mode 100644 apps/workplace/e2e/local/home-availability.spec.ts create mode 100644 apps/workplace/e2e/local/home-colleagues.spec.ts create mode 100644 apps/workplace/e2e/local/home-favourites.spec.ts create mode 100644 apps/workplace/e2e/local/home-quick-book.spec.ts create mode 100644 apps/workplace/e2e/local/home-scoping.spec.ts create mode 100644 apps/workplace/e2e/local/home-upcoming.spec.ts create mode 100644 e2e/support/home/home.api.ts create mode 100644 e2e/support/home/home.env.ts create mode 100644 e2e/support/home/home.settings.ts create mode 100644 e2e/support/home/landing.page.ts 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..71006253ca --- /dev/null +++ b/apps/workplace/e2e/local/home-scoping.spec.ts @@ -0,0 +1,145 @@ +/** + * 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'; + +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(); + } + }); + + test('control: your own booking today IS on your panel', 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 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) { + const showing = await home.upcomingIds(); + throw new Error( + `your own booking ${booking_id} should be on the panel, without ` + + `which "nobody sees anything" would pass as success. The panel ` + + `is showing ${showing.length}: [${showing.join(', ')}] — and it ` + + `holds only five, with cancelled bookings taking slots (HOME-B1).`, + ); + } + } 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..586a6c6ca0 --- /dev/null +++ b/apps/workplace/e2e/local/home-upcoming.spec.ts @@ -0,0 +1,227 @@ +/** + * 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', () => { + test('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/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; + } +} From 5958d64c681ebaa68437cc2dcffa0ec81a8313bb Mon Sep 17 00:00:00 2001 From: sharmilaseenivasan17 Date: Wed, 16 Sep 2026 19:56:45 +1000 Subject: [PATCH 07/10] test(e2e): room, visitor and parking gaps, and three more findings Nine more tests: 3 passing, 6 written and skipped against defects they guard. Plus a REG-09 retry for the room booking flow, which was the last place in the suite without one. Passing parking-levels a space on a level that is not tagged `parking` is not offered, with a control that the properly-placed one is Guards for findings, written and skipped room-multi ROOM-B6 (new): a two-room meeting books ONE room. Both rooms show on the form, the meeting is accepted, and only the first is held - so the second stays bookable and everyone sent to it finds it occupied. room-allday ROOM-B7 (new): the all-day flag is ignored. With the checkbox proven still ticked at send time, the stored booking is one hour long. room-delegate ROOM-B8 (new): the chosen host is discarded. With the field proven to still show the colleague at send time, the booking comes back owned by the booker. Desks get this right (DESK-14 is green), which is the useful comparison. visitor-duplicate VIS-B1's guard. Red-checked: the second identical invite returns 201. visitor-group-clash VIS-B9's guard. Red-checked: 409 Conflicting booking on a `group` row named `${host}[${creation date}]`, which is the mechanism itself, visible in the response. home-upcoming HOME-B1's second facet: a live booking is crowded off the five-slot panel by cancelled ones. All three new room findings were proven to be the app discarding input rather than the form reverting it: `bookRoomViaUI` now asserts the All Day checkbox and the host field still hold their values immediately before the meeting is confirmed. Without that, "the booking came back wrong" has two indistinguishable causes and neither could be reported honestly. Two things that shaped the tests rather than the app: - The meeting form's all-day control is `events.allow_all_day`, in meeting-form-details.component.ts - NOT `allow_multiday`, which only widens the picker's dates. A first attempt looked for a checkbox that was never going to be there. - Multi-select rooms confirm with `space-return` and the add button starts DISABLED, enabling only once a row click registers. Checking isEnabled immediately silently adds one room instead of two. The home page's Upcoming panel cannot be relied on to show a live booking while HOME-B1 stands, so the scoping control proves the same property on Your Bookings instead of losing the control altogether. Co-Authored-By: Claude Opus 5 (1M context) --- apps/workplace/e2e/local/home-scoping.spec.ts | 40 ++-- .../workplace/e2e/local/home-upcoming.spec.ts | 16 +- .../e2e/local/parking-levels.spec.ts | 102 +++++++++++ apps/workplace/e2e/local/room-allday.spec.ts | 88 +++++++++ .../workplace/e2e/local/room-delegate.spec.ts | 100 ++++++++++ apps/workplace/e2e/local/room-multi.spec.ts | 129 +++++++++++++ .../e2e/local/visitor-duplicate.spec.ts | 107 +++++++++++ .../e2e/local/visitor-group-clash.spec.ts | 136 ++++++++++++++ e2e/support/room/meeting-form.page.ts | 172 ++++++++++++++++++ e2e/support/room/room.env.ts | 8 + e2e/support/room/room.flows.ts | 84 ++++++++- e2e/support/room/room.settings.ts | 45 +++++ e2e/support/visitor/visitor.env.ts | 6 + 13 files changed, 1012 insertions(+), 21 deletions(-) create mode 100644 apps/workplace/e2e/local/parking-levels.spec.ts create mode 100644 apps/workplace/e2e/local/room-allday.spec.ts create mode 100644 apps/workplace/e2e/local/room-delegate.spec.ts create mode 100644 apps/workplace/e2e/local/room-multi.spec.ts create mode 100644 apps/workplace/e2e/local/visitor-duplicate.spec.ts create mode 100644 apps/workplace/e2e/local/visitor-group-clash.spec.ts diff --git a/apps/workplace/e2e/local/home-scoping.spec.ts b/apps/workplace/e2e/local/home-scoping.spec.ts index 71006253ca..9b2571cfaf 100644 --- a/apps/workplace/e2e/local/home-scoping.spec.ts +++ b/apps/workplace/e2e/local/home-scoping.spec.ts @@ -26,6 +26,7 @@ import { 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; @@ -95,7 +96,20 @@ test.describe('home page — visibility between users', () => { } }); - test('control: your own booking today IS on your panel', async ({ + /** + * 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) => { @@ -121,22 +135,14 @@ test.describe('home page — visibility between users', () => { }); 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) { - const showing = await home.upcomingIds(); - throw new Error( - `your own booking ${booking_id} should be on the panel, without ` + - `which "nobody sees anything" would pass as success. The panel ` + - `is showing ${showing.length}: [${showing.join(', ')}] — and it ` + - `holds only five, with cancelled bookings taking slots (HOME-B1).`, - ); - } + 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 index 586a6c6ca0..626cb4acd1 100644 --- a/apps/workplace/e2e/local/home-upcoming.spec.ts +++ b/apps/workplace/e2e/local/home-upcoming.spec.ts @@ -51,7 +51,21 @@ function stillToday(unix_seconds: number): boolean { } test.describe('home page — the Upcoming panel', () => { - test('a booking later today is listed on the panel', async ({ + /** + * 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) => { 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/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-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-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/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/support/room/meeting-form.page.ts b/e2e/support/room/meeting-form.page.ts index c0437e600b..3287cb9223 100644 --- a/e2e/support/room/meeting-form.page.ts +++ b/e2e/support/room/meeting-form.page.ts @@ -174,6 +174,65 @@ export class MeetingForm { 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. * @@ -355,6 +414,119 @@ export class MeetingForm { ).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(); diff --git a/e2e/support/room/room.env.ts b/e2e/support/room/room.env.ts index a07e4df9b2..3c04dbc7b0 100644 --- a/e2e/support/room/room.env.ts +++ b/e2e/support/room/room.env.ts @@ -64,6 +64,14 @@ export const ROOM_SLOTS_2 = { 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 { diff --git a/e2e/support/room/room.flows.ts b/e2e/support/room/room.flows.ts index a498915e65..9190d11d83 100644 --- a/e2e/support/room/room.flows.ts +++ b/e2e/support/room/room.flows.ts @@ -115,6 +115,17 @@ export async function bookRoomViaUI( * 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); @@ -138,6 +149,10 @@ export async function bookRoomViaUI( 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); @@ -171,6 +186,30 @@ export async function bookRoomViaUI( 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(); @@ -178,12 +217,51 @@ export async function bookRoomViaUI( // 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. - const reached_success = await form.successPanel + let reached_success = await form.successPanel .waitFor({ state: 'visible', timeout: 30_000 }) .then(() => true) .catch(() => false); - const captured = await posts.stop(); - const created = captured.filter((p) => p.status >= 200 && p.status < 300); + 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 diff --git a/e2e/support/room/room.settings.ts b/e2e/support/room/room.settings.ts index d66da23ca4..1aa12a01c0 100644 --- a/e2e/support/room/room.settings.ts +++ b/e2e/support/room/room.settings.ts @@ -122,6 +122,51 @@ 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. * 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; From b0229a60bc160a9a0bf09667e0b6956d9ed8c99e Mon Sep 17 00:00:00 2001 From: sharmilaseenivasan17 Date: Wed, 16 Sep 2026 23:16:54 +1000 Subject: [PATCH 08/10] test(e2e): parking access and requests, desk level scoping, room features Five more workplace tests: 4 passing, 1 parked. parking-access a user marked `deny` cannot book, and can again once the flag is cleared. The flag is an asset, and its value is the STRING 'true' - a real boolean never registers. parking-request a submitted request is stored as a parking booking against an `unallocated-*` asset, the only parking booking that holds no real space. desk-scoping DESK-15, which was blocked on a seeding change. The extra level is created by the spec and deleted afterwards, so the shared seed.ts stays untouched. room-features the picker's facilities filter narrows to the room that has the feature. room.seed.ts now puts one feature on the `alt` room, because the filter section is not rendered at all when no room carries anything. home-meeting-with the colleague shortcut opens the meeting form with that person already invited. home-errors fixme: the home page raises an unhandled rejection on load from the calendar 500s (HOME-B2). room-rules fixme, and UNRESOLVED: a `hidden` booking rule has no effect, and I have not established whether the ruleset shape is wrong or the picker ignores it. The docblock records what was measured and the one-run way to settle it (window.debug_booking_rules). Two API details learned and written down: a System PATCH needs `version` in the QUERY STRING (in the body it is ignored and the request 422s), and booking rules are read from the BUILDING's own metadata document even though the request reads like a query about its children. Co-Authored-By: Claude Opus 5 (1M context) --- apps/workplace/e2e/local/desk-scoping.spec.ts | 113 ++++++++++++++ apps/workplace/e2e/local/home-errors.spec.ts | 59 +++++++ .../e2e/local/home-meeting-with.spec.ts | 88 +++++++++++ .../e2e/local/parking-access.spec.ts | 144 ++++++++++++++++++ .../e2e/local/parking-request.spec.ts | 104 +++++++++++++ .../workplace/e2e/local/room-features.spec.ts | 107 +++++++++++++ apps/workplace/e2e/local/room-rules.spec.ts | 120 +++++++++++++++ e2e/support/desk/desk.zones.ts | 114 ++++++++++++++ e2e/support/parking/parking.env.ts | 4 + e2e/support/parking/parking.seed.ts | 81 ++++++++++ e2e/support/room/room.env.ts | 11 ++ e2e/support/room/room.seed.ts | 118 +++++++++++++- 12 files changed, 1062 insertions(+), 1 deletion(-) create mode 100644 apps/workplace/e2e/local/desk-scoping.spec.ts create mode 100644 apps/workplace/e2e/local/home-errors.spec.ts create mode 100644 apps/workplace/e2e/local/home-meeting-with.spec.ts create mode 100644 apps/workplace/e2e/local/parking-access.spec.ts create mode 100644 apps/workplace/e2e/local/parking-request.spec.ts create mode 100644 apps/workplace/e2e/local/room-features.spec.ts create mode 100644 apps/workplace/e2e/local/room-rules.spec.ts create mode 100644 e2e/support/desk/desk.zones.ts diff --git a/apps/workplace/e2e/local/desk-scoping.spec.ts b/apps/workplace/e2e/local/desk-scoping.spec.ts new file mode 100644 index 0000000000..5840a53bed --- /dev/null +++ b/apps/workplace/e2e/local/desk-scoping.spec.ts @@ -0,0 +1,113 @@ +/** + * DESK-15 — the level selector re-scopes which desks are offered. + * + * The row nobody could write. The stack seeds one org, one building and one + * level, so the selector has always had a list of one and a test would have + * asserted nothing. That is why it sat as "todo — needs a seeding change" in the + * coverage doc. + * + * ## It does not need a seeding change after all + * + * The level is created by this spec and deleted afterwards + * (`desk/desk.zones.ts`), which leaves the shared `seed.ts` untouched — the file + * both sessions have deliberately avoided, because a mistake in it breaks the + * dev's original desk specs. + * + * This is the only place in the suite that adds to the org hierarchy. It is + * per-worker, reused if an earlier run left it behind, and removed in teardown. + * + * ## What is being guarded + * + * A picker that ignored the selector would offer every desk in the building + * whatever the user chose — so somebody would book a desk on a floor they never + * meant, and nothing about the screen would look wrong. Hence both halves: the + * extra level's desk appears when it is selected, and the ordinary level's desks + * do not. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { deskFor } from '../../../../e2e/support/env'; +import { + createSecondLevel, + removeSecondLevel, +} from '../../../../e2e/support/desk/desk.zones'; +import { DeskForm } from '../../../../e2e/support/desk/desk-form.page'; + +test.describe('desk level scoping', () => { + test('choosing a level changes which desks the picker offers', async ({ + staffPage, + }, testInfo) => { + const ordinary_desk = deskFor(testInfo.parallelIndex); + const extra = await createSecondLevel(testInfo.parallelIndex); + + try { + const form = new DeskForm(staffPage); + await form.open(); + await form.setChecked(form.requireLocker, false); + await form.addDeskButton.click(); + + const offered = async () => { + const texts = await staffPage + .locator('button[name="select-desk"]') + .allInnerTexts(); + return texts.map((t) => t.split('\n')[0].trim()); + }; + await expect( + staffPage.locator('button[name="select-desk"]').first(), + 'the desk picker did not open', + ).toBeVisible({ timeout: 20_000 }); + + const before = await offered(); + expect( + before, + `precondition: the ordinary level's desk is offered to begin with. ` + + `Offered: ${JSON.stringify(before)}`, + ).toContain(ordinary_desk.name); + + // Switch the level. The selector is a `mat-select` named `location` + // in the picker's filters; its options are the building's levels. + const selector = staffPage.locator('mat-select[name="location"]').first(); + const present = await selector + .waitFor({ state: 'visible', timeout: 10_000 }) + .then(() => true) + .catch(() => false); + if (!present) { + throw new Error( + `no level selector in the desk picker. It is only rendered when the ` + + `building has levels to choose between — this spec creates a ` + + `second one, so check createSecondLevel ran (zone ` + + `${extra.zone_id}).`, + ); + } + await selector.click(); + const option = staffPage + .locator('.cdk-overlay-container mat-option') + .filter({ hasText: 'E2E Extra Level' }) + .first(); + await expect( + option, + 'the extra level is not among the options the selector offers', + ).toBeVisible({ timeout: 10_000 }); + await option.click(); + + // The desk on the extra level appears, and the ordinary level's does + // not. The second half is what makes this about scoping rather than + // about the list simply growing. + await expect(async () => { + const after = await offered(); + expect( + after, + `the extra level's desk should be offered once it is selected. ` + + `Offered: ${JSON.stringify(after)}`, + ).toContain(extra.desk.name); + expect( + after, + `and the other level's desks must NOT be — otherwise the picker is ` + + `ignoring the selector, and somebody can book a desk on a floor ` + + `they never chose`, + ).not.toContain(ordinary_desk.name); + }).toPass({ timeout: 30_000 }); + } finally { + await removeSecondLevel(extra.zone_id); + } + }); +}); diff --git a/apps/workplace/e2e/local/home-errors.spec.ts b/apps/workplace/e2e/local/home-errors.spec.ts new file mode 100644 index 0000000000..b2dfa1f9e7 --- /dev/null +++ b/apps/workplace/e2e/local/home-errors.spec.ts @@ -0,0 +1,59 @@ +/** + * HOME-15 — the home page loads without throwing. + * + * The cheapest guard on the app's first screen, and the one that catches the + * failure users describe as "it's broken": an uncaught exception during load + * leaves panels blank with no message. Nothing else in the suite watches for it, + * because every other test is busy asserting on data. + * + * ## `fixme` — it cannot pass while HOME-B2 stands + * + * Measured on this stack: loading the home page raises an unhandled rejection + * whose value is a `Response`, because `GET /api/staff/v1/calendars` returns + * **500** and nothing catches it. That is the same defect the quick-book tile + * trips over (HOME-B2) — the calendar surface needs real tenant credentials, and + * the app does not handle its absence. + * + * The 500 itself is expected here and is not what this test objects to: the + * assertion excludes `/calendars` and `/events` by name. What it objects to is + * the exception escaping, which would happen on a misconfigured tenant in + * production exactly as it does here. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { LandingPage } from '../../../../e2e/support/home/landing.page'; + +test.describe('home page — loading cleanly', () => { + test.fixme('the home page loads with no uncaught exception', async ({ staffPage }) => { + const thrown: string[] = []; + const server_errors: string[] = []; + staffPage.on('pageerror', (error) => thrown.push(error.message)); + staffPage.on('response', (r) => { + if (r.status() < 500) return; + server_errors.push(`${r.status()} ${new URL(r.url()).pathname}`); + }); + + const home = new LandingPage(staffPage); + await home.open(); + // Long enough for the panels to finish their own requests: an exception + // raised by a late response is still an exception. + await staffPage.waitForTimeout(6_000); + + expect( + thrown, + `the home page must not raise an uncaught exception while loading. Got:\n` + + `${thrown.join('\n')}\nServer errors seen: ${JSON.stringify(server_errors)}`, + ).toEqual([]); + + // The calendar endpoints are expected to fail on this stack and are + // excluded BY NAME rather than by silence. Any other 5xx is the page's + // problem. + const unexpected = server_errors.filter( + (e) => !/\/(events|calendars)(\/|$)/.test(e), + ); + expect( + unexpected, + `no server error other than the known calendar ones should be provoked. ` + + `All 5xx seen: ${JSON.stringify(server_errors)}`, + ).toEqual([]); + }); +}); diff --git a/apps/workplace/e2e/local/home-meeting-with.spec.ts b/apps/workplace/e2e/local/home-meeting-with.spec.ts new file mode 100644 index 0000000000..c409364fff --- /dev/null +++ b/apps/workplace/e2e/local/home-meeting-with.spec.ts @@ -0,0 +1,88 @@ +/** + * HOME-12 — "meeting with" a colleague from the home page. + * + * A shortcut with a real risk: it opens the meeting form with somebody already + * invited. If it opened the form with the WRONG person, or with nobody, the user + * would send an invitation to a meeting the intended colleague never hears + * about — and the form looks perfectly normal either way. + * + * What is asserted is the handoff: the right person arrives on the form as an + * attendee. Deliberately NOT asserted: sending the meeting. What happens to + * attendees once a meeting is booked is ROOM-09's job, and the host handling on + * that form is a known defect (ROOM-B8), so carrying this through to a booking + * would fail for a reason that has nothing to do with this shortcut. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { WORKERS, staffEmail } from '../../../../e2e/support/env'; +import { readContacts, setContacts } from '../../../../e2e/support/home/home.api'; +import { LandingPage } from '../../../../e2e/support/home/landing.page'; + +test.describe('home page — meeting with a colleague', () => { + test('the shortcut opens the meeting form with that colleague invited', async ({ + staffPage, + staffApi, + }, testInfo) => { + const mine = testInfo.parallelIndex; + const theirs = (mine + 1) % WORKERS; + test.skip(theirs === mine, 'needs at least two workers to have a colleague'); + const colleague = staffEmail(theirs); + + // Seeded through the API: adding a colleague through the UI is + // HOME-11's subject, and this test starts from having one. + await setContacts(staffApi, [ + { email: colleague, name: colleague.split('@')[0] }, + ]); + + try { + const home = new LandingPage(staffPage); + await home.open(); + await home.showTab('people'); + await expect( + home.colleagueRows, + 'the seeded colleague should be listed before the shortcut is used', + ).toHaveCount(1, { timeout: 20_000 }); + + const index = await home.colleagueIndex(colleague); + expect(index, `${colleague} is not findable in the sidebar`).toBeGreaterThanOrEqual(0); + await home.colleagueRows.nth(index).click({ timeout: 10_000 }); + + const action = staffPage + .locator('.cdk-overlay-container button[name="meeting-with-colleague"]') + .first(); + await expect( + action, + 'the colleague menu has no "meeting with" action', + ).toBeVisible({ timeout: 10_000 }); + await action.click(); + + await expect( + staffPage, + 'the shortcut should open the meeting form', + ).toHaveURL(/#\/book\/(meeting|spaces)/, { timeout: 30_000 }); + + const form = staffPage.locator('meeting-flow-form'); + await expect(form, 'the meeting form never rendered').toBeVisible({ + timeout: 30_000, + }); + + // The chip shows the person's NAME or the part before the @, never + // the whole address — the same labelling the attendee list uses + // everywhere else in this app. + const chips = staffPage.locator('a-user-list-field mat-chip-row[user]'); + await expect( + chips, + `the meeting form should open with ${colleague} already invited — an ` + + `empty attendee list means the shortcut lost the person it was ` + + `started from`, + ).toHaveCount(1, { timeout: 30_000 }); + const flat = (value: string) => value.toLowerCase().replace(/[^a-z0-9]/g, ''); + const label = await chips.first().innerText(); + expect( + flat(label), + `the attendee should be ${colleague}; the chip reads "${label.trim()}"`, + ).toContain(flat(colleague.split('@')[0])); + } finally { + await setContacts(staffApi, []); + } + }); +}); diff --git a/apps/workplace/e2e/local/parking-access.spec.ts b/apps/workplace/e2e/local/parking-access.spec.ts new file mode 100644 index 0000000000..cd304bd123 --- /dev/null +++ b/apps/workplace/e2e/local/parking-access.spec.ts @@ -0,0 +1,144 @@ +/** + * PARK-14 — a user denied parking access. + * + * Parking is the one booking type with a per-person permission: a + * `_PARKING_USERS_` asset carries a `deny` flag, and `ParkingService` exposes it + * as `deny_parking_access`. The flag is how a site says "this person does not + * get a space", and nothing tests it — so a change that stopped reading it would + * quietly let denied users book, which is a policy failure rather than a bug + * anybody would notice from a screenshot. + * + * Both halves matter and the second is the control: with the flag cleared the + * same user can book again, so "the form is broken" cannot pass as "the user was + * denied". + * + * ## The flag is a STRING + * + * `toParkingUser` compares `String(data.deny) === 'true'`, so a real boolean + * never registers. `setParkingDenied` writes the strings, and that is worth + * knowing before debugging a test that seems to have no effect. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { currentUser, deleteBooking, releaseAsset, uniqueTitle } from '../../../../e2e/support/api'; +import { + PARKING_SLOTS, + dayBoundsOn, + hhmm, + slotOn, +} from '../../../../e2e/support/parking/parking.env'; +import { + clearParkingUser, + setParkingDenied, + spaceForWorker, +} 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 access', () => { + test('a user marked as denied cannot book, and can again once cleared', async ({ + staffPage, + staffApi, + }, testInfo) => { + const space = await spaceForWorker(testInfo.parallelIndex); + const slot = slotOn(PARKING_SLOTS.access.day, PARKING_SLOTS.access.hour); + const { from, to } = dayBoundsOn(PARKING_SLOTS.access.day); + const me = await currentUser(staffApi); + let record_id: string | undefined; + let booking_id: number | undefined; + + await releaseAsset(staffApi, 'parking', space.id, from, to); + await useSettings(staffPage, PARKING_BASE_SETTINGS); + + try { + record_id = await setParkingDenied(me.email, true); + + const form = new ParkingForm(staffPage); + await form.open(); + await form.pickDate(slot.date_ms); + + // Either the space is not offered at all, or the form refuses to + // send. Both are acceptable ways to deny access; what must NOT + // happen is a stored booking. + const chosen = await (async () => { + try { + await form.setChecked(form.allDay, false); + await form.title.fill(uniqueTitle('E2E Parking Denied')); + await form.chooseSpace(space.name); + return true; + } catch { + return false; + } + })(); + + if (chosen) { + await form.setStartTime(hhmm(slot.start)); + await staffPage.waitForTimeout(2_000); + const sent = staffPage + .waitForResponse( + (r) => + r.url().includes('/api/staff/v1/bookings') && + r.request().method() === 'POST', + { timeout: 15_000 }, + ) + .catch(() => null); + await form.confirmViewButton.click(); + const response = await sent; + if (response) { + const body = await response.text(); + if (response.status() < 300) booking_id = JSON.parse(body).id; + expect( + response.status(), + `a denied user's booking must not be accepted. Got ` + + `${response.status()}: ${body.slice(0, 200)}`, + ).toBeGreaterThanOrEqual(400); + } + } + expect( + booking_id, + 'a user marked `deny` must not end up with a parking booking', + ).toBeUndefined(); + + // CONTROL: clear the flag and the same user can book. Without this, + // a form that simply never works would pass the half above. + await setParkingDenied(me.email, false); + await staffPage.reload(); + const allowed = new ParkingForm(staffPage); + await allowed.open(); + await allowed.pickDate(slot.date_ms); + await expect(async () => { + await allowed.setChecked(allowed.allDay, false); + await allowed.title.fill(uniqueTitle('E2E Parking Allowed')); + if ((await allowed.chosenSpaces.count()) === 0) { + await allowed.chooseSpace(space.name); + } + expect(await allowed.chosenSpaces.count()).toBe(1); + }).toPass({ timeout: 60_000 }); + await allowed.setStartTime(hhmm(slot.start)); + await staffPage.waitForTimeout(2_000); + + const [ok_response] = await Promise.all([ + staffPage.waitForResponse( + (r) => + r.url().includes('/api/staff/v1/bookings') && + r.request().method() === 'POST', + { timeout: 30_000 }, + ), + allowed.confirmAndSend(), + ]); + const ok_body = await ok_response.text(); + expect( + ok_response.status(), + `with the deny flag cleared the same user must be able to book: ` + + `${ok_body.slice(0, 200)}`, + ).toBeLessThan(300); + booking_id = JSON.parse(ok_body).id; + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + if (record_id) await clearParkingUser(record_id); + await releaseAsset(staffApi, 'parking', space.id, from, to); + } + }); +}); diff --git a/apps/workplace/e2e/local/parking-request.spec.ts b/apps/workplace/e2e/local/parking-request.spec.ts new file mode 100644 index 0000000000..5551e76cd1 --- /dev/null +++ b/apps/workplace/e2e/local/parking-request.spec.ts @@ -0,0 +1,104 @@ +/** + * PARK-15 — the parking REQUEST flow. + * + * A different route and a different intent from booking a space: + * `/book/parking-request/form` does not pick a space at all. `submitRequest()` + * stores a parking booking against **`unallocated-`** with the name + * "Parking Request", which somebody allocates a real space to later. + * + * That makes it worth its own test for a reason the booking specs cannot cover: + * an unallocated booking is the one kind that holds NO asset, so a change that + * started validating `asset_id` against real spaces would break requests while + * every other parking test stayed green. + * + * Asserted on the stored booking, not on the success screen. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { + STAFF_API, + currentUser, + deleteBooking, + getBooking, + uniqueTitle, +} from '../../../../e2e/support/api'; +import { + PARKING_BASE_SETTINGS, + useSettings, +} from '../../../../e2e/support/parking/parking.settings'; + +test.describe('parking requests', () => { + test('a submitted request is stored as an unallocated parking booking', async ({ + staffPage, + staffApi, + }) => { + const title = uniqueTitle('E2E Parking Request'); + const plate = `E2E-${Date.now() % 10000}`; + let booking_id: number | undefined; + + await useSettings(staffPage, PARKING_BASE_SETTINGS); + + try { + const me = await currentUser(staffApi); + await staffPage.goto('/#/book/parking-request/form'); + const form = staffPage.locator('parking-request-form'); + await expect( + form, + 'the parking request form never rendered — is the `parking` feature ' + + 'enabled for this org?', + ).toBeVisible({ timeout: 30_000 }); + + // Fill what the form offers. Both fields are optional depending on + // settings, so each is set only if it is there. + const title_input = staffPage.locator('input[name$=".title"]').first(); + if (await title_input.count()) await title_input.fill(title); + const plate_input = staffPage.locator('input[name$=".plate_number"]').first(); + if (await plate_input.count()) await plate_input.fill(plate); + await staffPage.waitForTimeout(1_500); + + const sent = staffPage + .waitForResponse( + (r) => + r.url().includes(`${STAFF_API}/bookings`) && + r.request().method() === 'POST', + { timeout: 30_000 }, + ) + .catch(() => null); + await form.locator('button[confirm]').first().click({ timeout: 10_000 }); + const response = await sent; + if (!response) { + const message = await staffPage + .locator('.cdk-overlay-container') + .innerText() + .catch(() => ''); + throw new Error( + `the request was submitted and nothing was sent. On screen: ` + + `"${message.replace(/\s+/g, ' ').trim().slice(0, 250)}"`, + ); + } + const body = await response.text(); + expect( + response.status(), + `submitting the request failed: ${body.slice(0, 250)}`, + ).toBeLessThan(300); + booking_id = JSON.parse(body).id; + + const stored: any = await getBooking(staffApi, booking_id!); + expect( + stored.booking_type, + 'a request is stored as a parking booking', + ).toBe('parking'); + expect( + `${stored.asset_id}`, + 'and against an UNALLOCATED asset — that is what makes it a request ' + + 'rather than a booking, and the only parking booking that holds no ' + + 'real space', + ).toContain('unallocated'); + expect( + `${stored.user_email}`.toLowerCase(), + 'for the person who asked', + ).toBe(me.email.toLowerCase()); + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + } + }); +}); diff --git a/apps/workplace/e2e/local/room-features.spec.ts b/apps/workplace/e2e/local/room-features.spec.ts new file mode 100644 index 0000000000..5bf36a1aac --- /dev/null +++ b/apps/workplace/e2e/local/room-features.spec.ts @@ -0,0 +1,107 @@ +/** + * ROOM-29 — the picker's facilities filter. + * + * A user looking for "a room with a projector" uses this, and it is the only + * filter in the room picker driven by data on the ROOM rather than by the + * meeting. If it stopped narrowing the list, the picker would look perfectly + * healthy and quietly offer rooms without the equipment somebody needs. + * + * ## The feature has to exist before the filter can + * + * The filter section is built from whatever features the loaded rooms carry + * (`space-filters.component.ts`), so with no room carrying anything it is not + * rendered at all. `room.seed.ts` therefore puts one feature — `E2E Projector` — + * on the `alt` room and nothing else, which gives filtering by it a right + * answer: one room in, the rest out. + * + * Two things learned putting it there, both recorded in the seeder: a System + * PATCH needs `version` in the QUERY STRING (in the body it is ignored and the + * request 422s), and `features` is an array — a space-separated string is a 400 + * from the JSON parser. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { ALT_ROOM_FEATURE } from '../../../../e2e/support/room/room.env'; +import { roomForWorker } from '../../../../e2e/support/room/room.seed'; +import { ROOM_BASE_SETTINGS, useSettings } from '../../../../e2e/support/room/room.settings'; +import { MeetingForm } from '../../../../e2e/support/room/meeting-form.page'; + +test.describe('the room picker facilities filter', () => { + test('filtering by a feature narrows the list to the room that has it', async ({ + staffPage, + }, testInfo) => { + const featured = await roomForWorker(testInfo.parallelIndex, 'alt'); + const plain = await roomForWorker(testInfo.parallelIndex); + + await useSettings(staffPage, ROOM_BASE_SETTINGS); + + const form = new MeetingForm(staffPage); + await form.open(); + // The small rooms are only offered at the smallest size, and more rooms + // on screen makes the "before" count below more meaningful. + await form.setRoomSize(1); + await form.addSpaceButton.click(); + await expect( + staffPage.locator('button[name="select-space"]').first(), + 'the room picker did not open', + ).toBeVisible({ timeout: 20_000 }); + + const offered = async () => { + const texts = await staffPage + .locator('button[name="select-space"]') + .allInnerTexts(); + return texts.map((t) => t.split('\n')[0].trim()); + }; + + const before = await offered(); + expect( + before.length, + 'the picker must offer more than one room, or narrowing to one proves ' + + 'nothing', + ).toBeGreaterThan(1); + expect(before, 'precondition: both rooms are offered to begin with').toContain( + featured.name, + ); + expect(before).toContain(plain.name); + + // The filter lives in the picker's filters panel, in a `features` + // section, as a toggle per feature. Matched through the section + // attribute and the feature's own name — which is test data we seeded, + // not app copy. + const section = staffPage.locator('section[features]'); + const present = await section + .first() + .waitFor({ state: 'visible', timeout: 10_000 }) + .then(() => true) + .catch(() => false); + if (!present) { + throw new Error( + `no facilities section in the picker. It is only rendered when the ` + + `loaded rooms carry features — check room.seed.ts put ` + + `"${ALT_ROOM_FEATURE}" on ${featured.name}. Rooms offered: ` + + `${JSON.stringify(before)}`, + ); + } + const toggle = section + .locator('settings-toggle') + .filter({ hasText: ALT_ROOM_FEATURE }) + .first(); + await expect( + toggle, + `no toggle for "${ALT_ROOM_FEATURE}" in the facilities section`, + ).toBeVisible({ timeout: 10_000 }); + await toggle.locator('button').first().click({ timeout: 10_000 }); + + await expect(async () => { + const after = await offered(); + expect( + after, + `with "${ALT_ROOM_FEATURE}" required, only ${featured.name} should be ` + + `offered. Got ${JSON.stringify(after)}`, + ).toContain(featured.name); + expect( + after, + `and ${plain.name}, which has no features, must be filtered out`, + ).not.toContain(plain.name); + }).toPass({ timeout: 20_000 }); + }); +}); diff --git a/apps/workplace/e2e/local/room-rules.spec.ts b/apps/workplace/e2e/local/room-rules.spec.ts new file mode 100644 index 0000000000..61dd5d57fb --- /dev/null +++ b/apps/workplace/e2e/local/room-rules.spec.ts @@ -0,0 +1,120 @@ +/** + * ROOM-30 — a zone booking rule hides a room. + * + * Booking rules are how a site says "this room is not for general booking" — + * a boardroom, a room under repair, a floor reserved for an event. They live in + * `booking_rules` metadata on the zone and are applied client-side + * (`filterResourcesFromRules`), so a change that stopped reading them would + * offer restricted rooms to everybody, and the picker would look completely + * normal doing it. + * + * Nothing has ever tested them, for the same reason as several other rows: the + * stack seeds no rules, so there was nothing to observe. This spec writes one, + * asserts its effect, and clears it. + * + * The condition is `resource_ids`, the narrowest one available, so exactly one + * room is hidden and the others are the control. Both halves matter: the hidden + * room disappears AND the rest stay, or "the picker is empty" would pass. + * + * ## `fixme`, and UNRESOLVED — read this before trusting either answer + * + * The rule has no effect, and I have not established why. What is known: + * + * - the app fetches `GET /metadata/booking_rules/bulk?parent_ids=`, + * and the response comes back keyed by the BUILDING's id carrying the + * building's own document — despite `parent_ids` reading like a query about + * children. `event-form.service.ts` looks them up as `rules[building.id]`. + * - `setRoomBookingRules` writes the document to the building AND every level, + * so the app is definitely being served a ruleset. + * - the ruleset is `{ zone: '*', conditions: { resource_ids: [] }, + * rules: { hidden: true } }`. On paper that satisfies both + * `rulesForResource` (a `*` zone matches any resource) and + * `checkRulesMatch` (one condition, one match). + * - the room is still offered. + * + * So it is one of two things, and they need different owners: + * + * 1. the ruleset shape is wrong — most likely `resource_ids` wants something + * other than the system id (the room's email is the obvious candidate), or + * `zone` must name a real zone rather than `*`; + * 2. the picker does not apply `hidden` at all, which would be a real defect. + * + * The next step is to set `window.debug_booking_rules = true` in the page before + * opening the picker: `rulesForResource` then logs every ruleset it matched or + * rejected, per resource, which answers this in one run. I have not done that, + * so this test is parked rather than reported as a bug. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { + roomForWorker, + setRoomBookingRules, +} from '../../../../e2e/support/room/room.seed'; +import { ROOM_BASE_SETTINGS, useSettings } from '../../../../e2e/support/room/room.settings'; +import { MeetingForm } from '../../../../e2e/support/room/meeting-form.page'; + +test.describe('zone booking rules', () => { + test.fixme('a room hidden by a booking rule is not offered, and the others still are', async ({ + staffPage, + }, testInfo) => { + const hidden = await roomForWorker(testInfo.parallelIndex, 'alt'); + const visible = await roomForWorker(testInfo.parallelIndex); + + await useSettings(staffPage, ROOM_BASE_SETTINGS); + + try { + // No rules yet: both rooms are offered. This is the baseline the + // assertion below is measured against. + const form = new MeetingForm(staffPage); + await form.open(); + await form.setRoomSize(1); + await form.addSpaceButton.click(); + await expect( + staffPage.locator('button[name="select-space"]').first(), + 'the room picker did not open', + ).toBeVisible({ timeout: 20_000 }); + + const offered = async () => { + const texts = await staffPage + .locator('button[name="select-space"]') + .allInnerTexts(); + return texts.map((t) => t.split('\n')[0].trim()); + }; + const before = await offered(); + expect( + before, + `precondition: ${hidden.name} is offered before any rule exists. ` + + `Offered: ${JSON.stringify(before)}`, + ).toContain(hidden.name); + + // Now hide it, and reload so the app reads the new metadata. + await setRoomBookingRules([hidden.id]); + await staffPage.reload(); + const after_form = new MeetingForm(staffPage); + await after_form.open(); + await after_form.setRoomSize(1); + await after_form.addSpaceButton.click(); + await expect( + staffPage.locator('button[name="select-space"]').first(), + 'the room picker did not open after the rule was written', + ).toBeVisible({ timeout: 20_000 }); + + await expect(async () => { + const after = await offered(); + expect( + after, + `${hidden.name} is hidden by a booking rule and must NOT be ` + + `offered. Offered: ${JSON.stringify(after)}`, + ).not.toContain(hidden.name); + expect( + after, + `and ${visible.name}, which no rule mentions, must still be — ` + + `otherwise this is "the picker went empty" rather than a rule ` + + `being applied`, + ).toContain(visible.name); + }).toPass({ timeout: 30_000 }); + } finally { + // Always: a rule left behind would hide a room from every later run. + await setRoomBookingRules([]); + } + }); +}); diff --git a/e2e/support/desk/desk.zones.ts b/e2e/support/desk/desk.zones.ts new file mode 100644 index 0000000000..82b92e039c --- /dev/null +++ b/e2e/support/desk/desk.zones.ts @@ -0,0 +1,114 @@ +/** + * A second level, created on the fly, so the desk form's level selector has + * something to switch between. + * + * ## Why this is not a change to `seed.ts` + * + * DESK-15 — "changing the building or level re-scopes which desks are offered" — + * has been the one desk row nobody could write, because the stack seeds ONE + * org, ONE building and ONE level, so the selector has a list of one. The + * obvious fix is to seed a second level, and that means editing the shared + * `seed.ts` — the file both sessions have deliberately left alone, because a + * mistake in it breaks the dev's original desk specs. + * + * So the level is created by the spec that needs it and deleted afterwards. That + * keeps the shared fixture untouched and makes the cost visible: this is the + * only place in the suite that adds to the org hierarchy, and it cleans up after + * itself. + * + * ## What a desk actually is + * + * Zone METADATA, not an asset: the app reads `listChildMetadata(building, + * {name: 'desks'})`, so a desk on the new level means a `desks` metadata + * document on the new zone. That is why this file writes metadata rather than + * creating anything desk-shaped. + * + * Needs ADMIN. + */ +import { ENGINE_API, apiFor, zonesWithTag } from '../api'; + +export interface SecondLevel { + zone_id: string; + desk: { id: string; name: string }; +} + +/** The name the extra level is created under, so it can be found and removed. */ +const LEVEL_NAME_PREFIX = 'E2E Extra Level'; + +/** + * Create a second level under the seeded building, with one desk on it. + * + * Named per worker, so parallel workers do not share a zone: each one's spec + * switches the selector to its own level and expects exactly its own desk. + */ +export async function createSecondLevel(workerIndex: number): Promise { + const admin = await apiFor('admin', 0); + try { + const [building] = await zonesWithTag(admin, 'building'); + if (!building?.id) { + throw new Error('no building zone to hang a level off — the stack is not seeded'); + } + const name = `${LEVEL_NAME_PREFIX} ${workerIndex}`; + const desk = { + id: `e2e-extra-desk-${workerIndex}`, + name: `E2E Extra Desk ${workerIndex}`, + }; + + // Reuse the zone if an earlier run left it behind, so a crashed run does + // not accumulate levels. + const existing = (await zonesWithTag(admin, 'level')).find((z) => z.name === name); + const zone = + existing ?? + (await (async () => { + const res = await admin.post(`${ENGINE_API}/zones`, { + data: { + name, + display_name: name, + description: 'Extra level owned by the e2e suite. Safe to delete.', + tags: ['level'], + parent_id: building.id, + }, + }); + if (!res.ok()) { + throw new Error( + `create level failed: HTTP ${res.status()} ${await res.text()}`, + ); + } + return res.json(); + })()); + + // One desk, as zone metadata — the shape `seed.ts` uses for the others. + const meta = await admin.put(`${ENGINE_API}/metadata/${zone.id}`, { + data: { + name: 'desks', + description: 'e2e bookable desks on the extra level', + details: [ + { + id: desk.id, + name: desk.name, + bookable: true, + zone: zone.id, + }, + ], + }, + }); + if (!meta.ok()) { + throw new Error( + `write desks metadata failed: HTTP ${meta.status()} ${await meta.text()}`, + ); + } + return { zone_id: zone.id, desk }; + } finally { + await admin.dispose(); + } +} + +/** Remove the extra level. Best effort: teardown must not mask a result. */ +export async function removeSecondLevel(zone_id: string): Promise { + const admin = await apiFor('admin', 0); + try { + await admin.delete(`${ENGINE_API}/zones/${zone_id}`).catch(() => null); + } finally { + await admin.dispose(); + } +} diff --git a/e2e/support/parking/parking.env.ts b/e2e/support/parking/parking.env.ts index 12f13f8083..7dc515f7cc 100644 --- a/e2e/support/parking/parking.env.ts +++ b/e2e/support/parking/parking.env.ts @@ -62,6 +62,10 @@ export const PARKING_SLOTS = { edit: { day: 3, hour: 16, moved_to: 18 }, /** parking-api.spec.ts */ api: { day: 4, hour: 20 }, + /** parking-access.spec.ts — the deny flag */ + access: { day: 3, hour: 11 }, + /** parking-request.spec.ts — the separate request flow */ + request: { day: 4, hour: 12 }, } as const; export function slotOn(dayOffset: number, hour: number, minutes = 60) { diff --git a/e2e/support/parking/parking.seed.ts b/e2e/support/parking/parking.seed.ts index 7ca64b19ff..3e24313857 100644 --- a/e2e/support/parking/parking.seed.ts +++ b/e2e/support/parking/parking.seed.ts @@ -39,6 +39,8 @@ 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_'; +/** The asset type parking USER records live under, for the deny flag. */ +const PARKING_USER_TYPE = '_PARKING_USERS_'; export interface ParkingSpaceIdentity { id: string; @@ -160,6 +162,85 @@ export async function ensureParking(): Promise { } } +/** + * Mark a user as denied parking, or clear it. + * + * A user's parking permissions are an ASSET, not a user field: + * `parking-assets.fn.ts` keeps one `_PARKING_USERS_` asset per person, with the + * flags in `other_data` as the STRINGS 'true' and 'false' — `toParkingUser` + * compares `String(data.deny) === 'true'`, so a real boolean does not register. + * + * Returns the asset id so a spec can delete it in teardown. Needs ADMIN, like + * everything else asset-shaped. + */ +export async function setParkingDenied( + email: string, + denied: boolean, +): Promise { + const admin = await apiFor('admin', 0); + try { + const seed = await parkingSeed(); + const categories = await listAll(admin, 'asset_categories', { hidden: 'true' }); + let category = categories.find((c) => c.name === PARKING_CATEGORY); + category = + category ?? + (await create(admin, 'asset_categories', { name: PARKING_CATEGORY, hidden: true })); + const types = await listAll(admin, 'asset_types', { category_id: category.id }); + let type = types.find((t) => t.name === PARKING_USER_TYPE); + type = + type ?? + (await create(admin, 'asset_types', { + name: PARKING_USER_TYPE, + category_id: category.id, + brand: 'E2E', + description: 'Parking user records owned by the e2e suite.', + })); + + const existing = await listAll(admin, 'assets', { + zone_id: seed.zone_id, + type_id: type.id, + }); + const found = existing.find( + (a) => `${a.other_data?.email ?? ''}`.toLowerCase() === email.toLowerCase(), + ); + const data = { + identifier: email, + zone_id: seed.zone_id, + asset_type_id: type.id, + other_data: { + name: email, + email, + // STRINGS, deliberately: see the note above. + deny: denied ? 'true' : 'false', + special_needs: 'false', + }, + }; + if (found) { + const res = await admin.patch(`${ENGINE_API}/assets/${found.id}`, { data }); + if (!res.ok()) { + throw new Error( + `update parking user failed: HTTP ${res.status()} ${await res.text()}`, + ); + } + return found.id; + } + const created = await create(admin, 'assets', data); + return created.id; + } finally { + await admin.dispose(); + } +} + +/** Remove a parking user record, by asset id. */ +export async function clearParkingUser(id: string): Promise { + const admin = await apiFor('admin', 0); + try { + await admin.delete(`${ENGINE_API}/assets/${id}`).catch(() => null); + } finally { + await admin.dispose(); + } +} + let cache: Promise | null = null; /** The parking space this worker owns, creating the set on first use. */ diff --git a/e2e/support/room/room.env.ts b/e2e/support/room/room.env.ts index 3c04dbc7b0..da28ae326d 100644 --- a/e2e/support/room/room.env.ts +++ b/e2e/support/room/room.env.ts @@ -101,6 +101,17 @@ export interface RoomIdentity { */ export type RoomVariant = 'main' | 'alt' | 'small'; +/** + * A feature only the `alt` room has. + * + * Space features come from the engine System's own `features` list, and the + * picker's facilities filter is built from whatever the loaded rooms carry + * (`space-filters.component.ts`). With no room carrying anything, the filter + * section is not rendered at all — so testing it needs exactly this: one + * feature, on one room, so filtering by it has a right answer. + */ +export const ALT_ROOM_FEATURE = 'E2E Projector'; + export const ROOM_VARIANTS: RoomVariant[] = ['main', 'alt', 'small']; /** The name, address and capacity of a room this worker owns. */ diff --git a/e2e/support/room/room.seed.ts b/e2e/support/room/room.seed.ts index 07605f67a6..aa552b1cc6 100644 --- a/e2e/support/room/room.seed.ts +++ b/e2e/support/room/room.seed.ts @@ -17,7 +17,13 @@ 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'; +import { + ALT_ROOM_FEATURE, + ROOM_VARIANTS, + RoomIdentity, + RoomVariant, + roomFor, +} from './room.env'; /** * `GET /systems` answers with a BARE ARRAY, not `{ results: [] }`. @@ -74,6 +80,38 @@ export async function ensureRooms(): Promise { ); if (found) { rooms[variant].push({ ...want, id: found.id }); + // The `alt` room carries a feature nothing else has, so the + // picker's facilities filter has something to filter on. + // Patched rather than only set at creation, because the + // rooms are left in place between runs and were created + // before this existed. + if ( + variant === 'alt' && + !(found.features || []).includes(ALT_ROOM_FEATURE) + ) { + // `version` is REQUIRED on a System PATCH — without it + // the request is a 422 "missing required parameter + // 'version'" and the feature silently never lands. + // `features` is an ARRAY; a space-separated string is a + // 400 from the JSON parser. + const patched = await admin.patch( + `${ENGINE_API}/systems/${found.id}`, + { + // `version` goes in the QUERY STRING, not the + // body — in the body it is ignored and the + // request is still a 422 "missing required + // parameter 'version'". + params: { version: String(found.version ?? 0) }, + data: { features: [ALT_ROOM_FEATURE] }, + }, + ); + if (!patched.ok()) { + throw new Error( + `adding the feature to ${want.name} failed: HTTP ` + + `${patched.status()} ${await patched.text()}`, + ); + } + } continue; } const res = await admin.post(`${ENGINE_API}/systems`, { @@ -88,6 +126,7 @@ export async function ensureRooms(): Promise { // invisible in the picker while existing perfectly well. signage: false, zones, + ...(variant === 'alt' ? { features: [ALT_ROOM_FEATURE] } : {}), description: 'Room owned by the e2e suite. Safe to delete.', }, }); @@ -133,3 +172,80 @@ export async function roomForWorker( } return room; } + +/** + * Hide a room with a ZONE BOOKING RULE, and remove the rule again. + * + * Booking rules live in `booking_rules` metadata on a zone, as a list of + * rulesets (`libs/common/src/lib/booking-rules.ts`). Each ruleset names the zone + * it applies to, a set of CONDITIONS, and the RULES that result — and `hidden` + * is the one that takes a resource out of the picker entirely. + * + * The condition used here is `resource_ids`, which is the narrowest available: + * it matches one room by id, so the rest of the picker is unaffected and the + * test has a control. + * + * Writes the WHOLE document, which is safe because nothing else in this suite + * writes booking rules — unlike the user `settings` blob, which is shared and + * has to be read-modify-written. + * + * Needs ADMIN. Pass an empty list to clear. + */ +export async function setRoomBookingRules( + hidden_room_ids: string[], +): Promise { + const admin = await apiFor('admin', 0); + try { + // WHERE THE APP LOOKS, measured rather than guessed. + // + // The app fetches `GET /metadata/booking_rules/bulk?parent_ids=` + // and the response comes back keyed by the BUILDING's own id, carrying + // the building's document — despite `parent_ids` reading like a query + // about children. `event-form.service.ts` then looks the rules up as + // `rules[building.id]`. + // + // So the document has to be on the building. It is written to the levels + // as well, because nothing else writes booking rules and a stale + // document on a level would be one more thing to explain later. + const [building] = await zonesWithTag(admin, 'building'); + if (!building?.id) throw new Error('no building zone — the stack is not seeded'); + const levels = await zonesWithTag(admin, 'level'); + const targets = levels.filter((z) => (z.parent_id ?? building.id) === building.id); + if (!targets.length) { + throw new Error('no level zone under the building to write booking rules on'); + } + + const details = hidden_room_ids.length + ? [ + { + id: 'e2e-hidden-rooms', + name: 'E2E hidden rooms', + // `*` matches any resource the ruleset is considered for; + // the `resource_ids` condition below is what narrows it to + // one room. + zone: '*', + conditions: { resource_ids: hidden_room_ids }, + rules: { hidden: true }, + }, + ] + : []; + + for (const zone of [building, ...targets]) { + const res = await admin.put(`${ENGINE_API}/metadata/${zone.id}`, { + data: { + name: 'booking_rules', + description: 'Booking rules owned by the e2e suite', + details, + }, + }); + if (!res.ok()) { + throw new Error( + `write booking_rules on ${zone.name} failed: HTTP ${res.status()} ` + + `${await res.text()}`, + ); + } + } + } finally { + await admin.dispose(); + } +} From 97a79ac54d209d91360ed6bb2aa0d4b4f24503e7 Mon Sep 17 00:00:00 2001 From: sharmilaseenivasan17 Date: Wed, 16 Sep 2026 23:24:20 +1000 Subject: [PATCH 09/10] test(e2e): moving a group invite moves every member (VIS-26) Passing. A group invite is a container plus one booking per person, so an edit that updated only the container, or only the member it was opened from, would leave half a party expected at the old time and reception turning people away. Three things this cost, all now written into the spec: - inviteVisitorsViaUI returns the CONTAINER as well as the members - three bookings for two visitors - so the members are picked out by asset address. - The container has to be swept in teardown as well as the members. It is not returned by the flow, and because every group invite a host makes in a day shares one asset id (VIS-B9), one leftover container makes the next three runs of this test impossible. - The flow's `date` option chooses a DAY, not a time, so the starting hour is read back rather than assumed. Also corrected visitor-group-clash to build its container id the way the app does - `${host}[${YYYY-MM-DD}]`, measured from a real invite - rather than with toDateString(). Co-Authored-By: Claude Opus 5 (1M context) --- .../e2e/local/visitor-group-clash.spec.ts | 11 +- .../e2e/local/visitor-group-time.spec.ts | 190 ++++++++++++++++++ 2 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 apps/workplace/e2e/local/visitor-group-time.spec.ts diff --git a/apps/workplace/e2e/local/visitor-group-clash.spec.ts b/apps/workplace/e2e/local/visitor-group-clash.spec.ts index 32aa1e24dc..ed2385a055 100644 --- a/apps/workplace/e2e/local/visitor-group-clash.spec.ts +++ b/apps/workplace/e2e/local/visitor-group-clash.spec.ts @@ -83,7 +83,16 @@ test.describe('two group invites on one day', () => { start: number, label: string, ) => { - const container_asset = `${me.email}[${new Date().toDateString()}]`; + // The app's own format, measured from a real group invite: + // `${host}[${YYYY-MM-DD}]`. An earlier draft used + // `Date.toDateString()` ("Wed Sep 16 2026"), which demonstrated the + // same clash but did not mirror what the app writes. + const today = new Date(); + const iso = + `${today.getFullYear()}-` + + `${`${today.getMonth() + 1}`.padStart(2, '0')}-` + + `${`${today.getDate()}`.padStart(2, '0')}`; + const container_asset = `${me.email}[${iso}]`; const container = await staffApi.post(`${STAFF_API}/bookings`, { data: { booking_type: 'group', diff --git a/apps/workplace/e2e/local/visitor-group-time.spec.ts b/apps/workplace/e2e/local/visitor-group-time.spec.ts new file mode 100644 index 0000000000..7f7963e53d --- /dev/null +++ b/apps/workplace/e2e/local/visitor-group-time.spec.ts @@ -0,0 +1,190 @@ +/** + * VIS-26 — moving a group invite moves every member. + * + * A group invite is not one booking: it is a `group` container plus one + * `visitor` booking per person, linked by `parent_id` (VIS-03). That shape is + * exactly why this needs its own test — an edit that updated the container, or + * only the first member, would look completely correct on screen while leaving + * half a group expected at the old time. Reception would then turn people away. + * + * `visitor-edit.spec.ts` covers editing the REASON of a delegated invite and + * REMOVING a member. Neither touches the time, and nothing else in the suite + * moves a multi-person booking. + * + * ## Created through the UI, on purpose + * + * The container's naming and the member links are the app's own doing, and an + * API-made group would be my reconstruction of them rather than the real thing + * (the room edit specs learned that lesson the other way round). So the invite + * is made with the real form, and only the edit is the subject. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { + currentUser, + deleteBooking, + getBooking, + listBookings, +} from '../../../../e2e/support/api'; +import { VISITOR_SLOTS, visitorFor } from '../../../../e2e/support/visitor/visitor.env'; +import { deleteGuest, releaseVisitor } from '../../../../e2e/support/visitor/visitor.api'; +import { + ALLOW_EDITING, + useSettings, +} from '../../../../e2e/support/visitor/visitor.settings'; +import { inviteVisitorsViaUI } from '../../../../e2e/support/visitor/visitor.flows'; +import { InviteVisitorForm } from '../../../../e2e/support/visitor/invite-form.page'; +import { YourBookingsPage } from '../../../../e2e/support/visitor/your-bookings.page'; + +const DAY = 86_400; +const window_from = () => Math.floor(Date.now() / 1000) - 2 * DAY; +const window_to = () => Math.floor(Date.now() / 1000) + 4 * DAY; + +/** A fixed hour tomorrow, local, as a ms timestamp. */ +function tomorrowAt(hour: number): number { + const day = new Date(); + day.setDate(day.getDate() + 1); + day.setHours(hour, 0, 0, 0); + return day.valueOf(); +} + +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}`; +} + +test.describe('moving a group invite', () => { + test('changing the time moves every member of the group', async ({ + staffPage, + staffApi, + }, testInfo) => { + const worker = testInfo.parallelIndex; + const visitors = [ + visitorFor(worker, VISITOR_SLOTS.group_time.memberA), + visitorFor(worker, VISITOR_SLOTS.group_time.memberB), + ]; + // 13:00, not 9:00, and this is VIS-B9 shaping a test rather than a + // finding: a group container is named `${host}[${creation date}]`, so + // EVERY group invite this host makes today shares one asset id and any + // overlap is refused. `visitor-group-clash.spec.ts` owns the 9 o'clock + // hour for the same host, so this one has to sit elsewhere. + const visit_day = tomorrowAt(13); + const moved_to = tomorrowAt(16); + const ids: number[] = []; + + for (const visitor of visitors) { + await releaseVisitor(staffApi, visitor.email, window_from(), window_to()); + } + // Sweep this host's GROUP CONTAINERS too, before and after. + // + // `inviteVisitorsViaUI` returns the member bookings, not the container, + // so a test that only deletes what it was handed leaves the container + // behind — and because every group invite this host makes today shares + // one asset id (VIS-B9), that leftover blocks the NEXT run of this test + // with a 409. Learned the hard way: a failed run made the next three + // attempts impossible. + const sweepContainers = async () => { + const me = await currentUser(staffApi); + const containers = await listBookings( + staffApi, + 'group', + window_from(), + window_to(), + ); + for (const container of containers) { + if (container.deleted) continue; + if (!`${container.asset_id}`.startsWith(me.email)) continue; + await deleteBooking(staffApi, container.id); + } + }; + await sweepContainers(); + await useSettings(staffPage, ALLOW_EDITING); + + try { + const created = await inviteVisitorsViaUI( + staffPage, + staffApi, + visitors, + 'E2E group time', + { date: visit_day }, + ); + // The flow hands back the CONTAINER as well as the members — three + // bookings for two visitors. The members are the ones whose asset is + // a visitor's address; the container's is + // `${host}[${creation date}]`. + for (const booking of created) ids.push(booking.id); + const addresses = visitors.map((v) => v.email.toLowerCase()); + const members = created.filter((b) => + addresses.includes(`${b.asset_id}`.toLowerCase()), + ); + expect( + members.length, + `precondition: one booking per visitor, got ${members.length} from ` + + `${JSON.stringify( + created.map((m) => ({ id: m.id, asset: m.asset_id })), + )}. A 409 while creating means another group invite by this host ` + + `overlaps — see VIS-B9 and the note above.`, + ).toBe(2); + const member_ids = members.map((m) => m.id); + + // The invite's HOUR is whatever the form offered — the flow's `date` + // option chooses a day, not a time. So the starting hour is read + // back rather than assumed, and what matters is that it CHANGES for + // every member. + const before = await Promise.all( + member_ids.map((id) => getBooking(staffApi, id)), + ); + const starts = before.map((b) => hhmm(b.booking_start)); + expect( + new Set(starts).size, + `precondition: every member of a group starts at the same time, got ` + + `${JSON.stringify(starts)}`, + ).toBe(1); + expect( + starts[0], + 'precondition: the invite does not already start at the time it will ' + + 'be moved to', + ).not.toBe(hhmm(Math.floor(moved_to / 1000))); + + // Re-open the invite from the schedule and change only the time. + const schedule = new YourBookingsPage(staffPage); + await schedule.open(); + await schedule.showVisitors(); + await schedule.showDayOf(visit_day); + await schedule.startEdit(member_ids[0]); + + const form = new InviteVisitorForm(staffPage); + await expect( + form.chips, + 'the edit form should open with both visitors listed', + ).toHaveCount(2, { timeout: 30_000 }); + await form.setStartTime(hhmm(Math.floor(moved_to / 1000))); + await staffPage.waitForTimeout(3_000); + await form.sendButton.click(); + + // EVERY member, not just the one the edit was opened from. + await expect(async () => { + const after = await Promise.all( + member_ids.map((id) => getBooking(staffApi, id)), + ); + for (const booking of after) { + expect( + hhmm(booking.booking_start), + `every member of the group must move: booking ${booking.id} is ` + + `still at ${hhmm(booking.booking_start)}. A group left ` + + `half-moved means reception expects some of the party at the ` + + `old time`, + ).toBe(hhmm(Math.floor(moved_to / 1000))); + } + }).toPass({ timeout: 45_000 }); + } finally { + for (const id of ids) await deleteBooking(staffApi, id); + await sweepContainers(); + for (const visitor of visitors) { + await deleteGuest(staffApi, visitor.email); + await releaseVisitor(staffApi, visitor.email, window_from(), window_to()); + } + } + }); +}); From 3cc08bdb3e7d73e821ccf4f9530324bd5d30799c Mon Sep 17 00:00:00 2001 From: sharmilaseenivasan17 Date: Fri, 18 Sep 2026 10:14:03 +1000 Subject: [PATCH 10/10] test(e2e): extend workplace and concierge app coverage --- .../e2e/local/concierge-access.spec.ts | 233 ++++++++++ .../e2e/local/concierge-assets.spec.ts | 89 ++++ .../e2e/local/concierge-catering.spec.ts | 81 ++++ .../e2e/local/concierge-dayview.spec.ts | 181 ++++++++ .../e2e/local/concierge-desks.spec.ts | 305 +++++++++++++ .../e2e/local/concierge-lockers.spec.ts | 219 ++++++++++ .../e2e/local/concierge-parking.spec.ts | 149 +++++++ .../e2e/local/concierge-reports.spec.ts | 146 +++++++ .../e2e/local/concierge-rooms.spec.ts | 330 +++++++++++++++ .../e2e/local/concierge-staff.spec.ts | 174 ++++++++ .../e2e/local/concierge-visitors.spec.ts | 340 +++++++++++++++ .../e2e/local/concierge-zones.spec.ts | 152 +++++++ apps/concierge/playwright.config.ts | 127 ++++-- apps/workplace/e2e/local/room-assets.spec.ts | 317 ++++++++++++++ .../e2e/local/room-recurring.spec.ts | 339 +++++++++++++++ .../e2e/local/visitor-concurrency.spec.ts | 399 ++++++++++++++++++ e2e/support/concierge/concierge.api.ts | 335 +++++++++++++++ e2e/support/concierge/concierge.env.ts | 89 ++++ e2e/support/concierge/concierge.seed.ts | 127 ++++++ e2e/support/concierge/concierge.settings.ts | 51 +++ e2e/support/concierge/desks.page.ts | 81 ++++ e2e/support/concierge/fixtures.ts | 186 ++++++++ e2e/support/concierge/locker.seed.ts | 206 +++++++++ e2e/support/concierge/simple-table.page.ts | 336 +++++++++++++++ e2e/support/room/asset.seed.ts | 166 ++++++++ e2e/support/room/meeting-form.page.ts | 90 ++++ e2e/support/room/room.env.ts | 21 + e2e/support/room/room.flows.ts | 22 +- e2e/support/room/room.settings.ts | 25 ++ e2e/support/visitor/visitor.env.ts | 8 + e2e/tsconfig.json | 6 +- 31 files changed, 5290 insertions(+), 40 deletions(-) create mode 100644 apps/concierge/e2e/local/concierge-access.spec.ts create mode 100644 apps/concierge/e2e/local/concierge-assets.spec.ts create mode 100644 apps/concierge/e2e/local/concierge-catering.spec.ts create mode 100644 apps/concierge/e2e/local/concierge-dayview.spec.ts create mode 100644 apps/concierge/e2e/local/concierge-desks.spec.ts create mode 100644 apps/concierge/e2e/local/concierge-lockers.spec.ts create mode 100644 apps/concierge/e2e/local/concierge-parking.spec.ts create mode 100644 apps/concierge/e2e/local/concierge-reports.spec.ts create mode 100644 apps/concierge/e2e/local/concierge-rooms.spec.ts create mode 100644 apps/concierge/e2e/local/concierge-staff.spec.ts create mode 100644 apps/concierge/e2e/local/concierge-visitors.spec.ts create mode 100644 apps/concierge/e2e/local/concierge-zones.spec.ts create mode 100644 apps/workplace/e2e/local/room-assets.spec.ts create mode 100644 apps/workplace/e2e/local/room-recurring.spec.ts create mode 100644 apps/workplace/e2e/local/visitor-concurrency.spec.ts create mode 100644 e2e/support/concierge/concierge.api.ts create mode 100644 e2e/support/concierge/concierge.env.ts create mode 100644 e2e/support/concierge/concierge.seed.ts create mode 100644 e2e/support/concierge/concierge.settings.ts create mode 100644 e2e/support/concierge/desks.page.ts create mode 100644 e2e/support/concierge/fixtures.ts create mode 100644 e2e/support/concierge/locker.seed.ts create mode 100644 e2e/support/concierge/simple-table.page.ts create mode 100644 e2e/support/room/asset.seed.ts diff --git a/apps/concierge/e2e/local/concierge-access.spec.ts b/apps/concierge/e2e/local/concierge-access.spec.ts new file mode 100644 index 0000000000..bb62f4f015 --- /dev/null +++ b/apps/concierge/e2e/local/concierge-access.spec.ts @@ -0,0 +1,233 @@ +/** + * CON-AUTH-01 / 02 / 03 — who may open concierge at all. + * + * The most important file in this app, and the reason it is the first one + * written. Concierge reads and writes EVERYBODY's bookings and the org + * hierarchy: who is allowed in is the whole security boundary, and every other + * concierge spec signs in as an admin — so without these, the boundary would be + * the one thing never exercised. + * + * ## Rewritten 2026-09-17, because the first version was a false pass + * + * The original CON-AUTH-02 asserted `refused || !shell || noManagementLinks` + * and went green on its first run. It was measured afterwards, and it passed on + * the third clause only — a plain staff user was NOT refused. They were given + * the concierge app: + * + * admin -> 21 sidebar links, zone-management present + * staff -> 13 sidebar links, zone-management absent + * + * The 13 they keep include everybody's desk and parking bookings, the visitor + * list, catering orders, the staff directory and all three reports — including + * contact tracing. Losing the eight admin-group links is not a refusal, so the + * old assertion was reporting a pass over a genuine hole. That hole is CON-B1 + * below. + * + * So the file now separates two different questions that the first version had + * collapsed into one: + * + * CON-AUTH-02 — does the guard work AT ALL, when it is switched on? + * CON-B1 — what happens on the default configuration, where it is off? + */ +import { test, expect } from '../../../../e2e/support/concierge/fixtures'; +import { CONCIERGE_URL } from '../../../../e2e/support/concierge/concierge.env'; +import { + useSettings, + REQUIRE_ACCESS_GROUP, +} from '../../../../e2e/support/concierge/concierge.settings'; + +/** Sidebar links that only an admin should ever be offered. */ +const ADMIN_ONLY = 'a[href*="zone-management"], a[href*="room-management"]'; + +test.describe('concierge access', () => { + test('CON-AUTH-01: an admin reaches the app and the shell renders', async ({ + adminPage, + }) => { + await adminPage.goto('/#/'); + // `app-topbar` and `app-sidebar` are the shell the existing mock specs + // already rely on, so they are the safest handles in this app. + await expect( + adminPage.locator('app-topbar'), + 'the concierge shell never rendered for an admin. If this fails first, ' + + 'nothing else in this app can be trusted. Check three things in order: ' + + `the app is served on ${CONCIERGE_URL}; the dev server's proxy is ` + + 'pointed at the LOCAL stack via PLACE_PROXY_DOMAIN (see the webServer ' + + 'block in playwright.config.ts — without it the app authenticates ' + + 'against the shared dev deployment and is bounced to real Microsoft); ' + + 'and the storage state was minted for THAT origin', + ).toBeVisible({ timeout: 45_000 }); + await expect( + adminPage.locator('app-sidebar'), + 'the sidebar is what carries the navigation between concierge areas', + ).toBeVisible({ timeout: 30_000 }); + await expect( + adminPage, + 'and an authorised admin must not be bounced to /unauthorised', + ).not.toHaveURL(/unauthorised/, { timeout: 10_000 }); + // The positive half of CON-B1's comparison: an admin really does get the + // management surfaces, so their absence for staff means something. + await expect( + adminPage.locator(ADMIN_ONLY).first(), + 'an admin should be offered the org-management pages', + ).toBeVisible({ timeout: 20_000 }); + }); + + test('CON-AUTH-02: with an access group set, a user outside it is refused', async ({ + browser, + conciergeStaffState, + }) => { + // This is the guard's real test, and it only means anything with the + // group configured — see REQUIRE_ACCESS_GROUP for why. Applied to this + // context alone: the override would refuse the admin too, because the + // group branch has no sys_admin bypass. + const context = await browser.newContext({ + storageState: conciergeStaffState, + ignoreHTTPSErrors: true, + baseURL: CONCIERGE_URL, + }); + try { + const page = await context.newPage(); + await useSettings(page, REQUIRE_ACCESS_GROUP); + await page.goto('/#/'); + + // Now there IS one expected outcome to converge on, so this + // asserts rather than sampling: the guard calls + // `router.navigate(['/unauthorised'])`. + await expect( + page, + 'with app.allow_access_groups set to a group this user is not in, ' + + 'AuthorisedUserGuard must send them to /unauthorised. If this ' + + 'fails, the guard is not refusing anyone under ANY configuration, ' + + 'which is a much bigger problem than CON-B1', + ).toHaveURL(/unauthorised/, { timeout: 45_000 }); + await expect( + page.locator(ADMIN_ONLY), + 'and a refused user must not be offered the management pages', + ).toHaveCount(0); + } finally { + await context.close(); + } + }); + + test('CON-AUTH-03: an unauthenticated visit is sent to the authority login', async ({ + browser, + }) => { + // `storageState: undefined` IS LOAD-BEARING. Do not remove it. + // + // Playwright merges the test's context options into + // `browser.newContext()`, and this suite's fixtures set `storageState` + // to the ADMIN state for every test. So a context created without + // saying otherwise silently carries admin credentials. + // + // Measured 2026-09-17: the first version of this test omitted it, and + // the "unauthenticated" context came up holding + // `_access_token` and walked straight into `#/book/rooms`. + // The test then failed for the right-looking reason — "it stayed on the + // app" — while actually exercising the admin. A false FAILURE rather + // than a false pass, which is luckier than it deserved. + // + // The other two tests here pass `conciergeStaffState` explicitly, so an + // explicit value wins and they were never affected. + const context = await browser.newContext({ + storageState: undefined, + ignoreHTTPSErrors: true, + baseURL: CONCIERGE_URL, + }); + try { + const page = await context.newPage(); + await page.goto('/#/'); + // ts-client finds no token, fails to refresh, and hands over to the + // authority's `login_url`. On this stack that is the local backend + // (`https://localhost:9443/login?continue=…`). + // + // Asserting "left the app" rather than one exact URL: where it lands + // depends on the authority's configured login_url and its auth + // source, which is a deployment choice, not a product guarantee. What + // must NOT happen is the concierge UI rendering for someone with no + // credentials at all. + await expect + .poll(() => page.url(), { + message: + 'an unauthenticated visit must not stay on the concierge app. ' + + 'If it does, check whether a token was left in this origin\'s ' + + 'localStorage by another test — this context is deliberately ' + + 'created without storage state', + timeout: 45_000, + }) + .not.toMatch(/localhost:4215/); + + await expect( + page.locator(ADMIN_ONLY), + 'and no management surface may be rendered on the way out', + ).toHaveCount(0); + } finally { + await context.close(); + } + }); + + /** + * CON-B1 — the finding, written as the test that will pass once it is fixed. + * + * `fixme`, in the same style as the ROOM-B* specs: the assertion below + * states the behaviour we believe is correct, so this turns green the day + * the default changes and stays a standing record until then. + * + * What was measured on 2026-09-17, on the default configuration + * (`app.allow_access_groups` unset): + * + * - `e2e-staff-0@place.tech` — `sys_admin: false`, `support: false`, + * `groups: []` — loaded concierge and got the full shell and 13 sidebar + * links, among them everyone's desk and parking bookings, the visitor + * list, catering orders, the staff directory and the contact-tracing + * report. + * - It is not only the nav. With that user's own token: + * GET /api/engine/v2/users -> 200, 5 rows + * GET /bookings?type=desk&zones= -> 200, and it + * returned a desk booking belonging to support@place.tech + * GET /bookings?type=desk&email=support@place.tech -> 200, same + * A plain `?type=desk` with no filter returns 0 rows, so the backend does + * scope by default — but concierge lists by zone, which is exactly the + * shape that reads straight through. + * + * Cause, in one line: `authorised-user.guard.ts:111-113` takes the + * `!groups.length` branch and sets `can_activate = true` for everybody when + * no access group is configured, and no group is configured by default. + * + * Worth saying plainly in review: a deployment that never sets + * `app.allow_access_groups` gives every authenticated staff member the + * concierge front desk. + */ + test.fixme( + 'CON-B1: by default a plain staff user is still given the concierge app', + async ({ browser, conciergeStaffState }) => { + const context = await browser.newContext({ + storageState: conciergeStaffState, + ignoreHTTPSErrors: true, + baseURL: CONCIERGE_URL, + }); + try { + const page = await context.newPage(); + // No settings override on purpose. This is the DEFAULT. + await page.goto('/#/'); + await expect( + page.locator('app-topbar').or(page.locator('app-sidebar')).first(), + ).toBeVisible({ timeout: 45_000 }); + + const url = page.url(); + const refused = /unauthorised|unauthorized|misconfigured/.test(url); + const links = await page.locator('app-sidebar a').count(); + + expect( + refused || links === 0, + `a plain non-admin staff user must not be given the concierge app ` + + `on the default configuration. They landed on "${url}" with ` + + `${links} sidebar link(s). Fix is either a shipped default for ` + + `app.allow_access_groups or a guard that refuses when no group ` + + `is configured (fail closed), rather than admitting everyone`, + ).toBe(true); + } finally { + await context.close(); + } + }, + ); +}); diff --git a/apps/concierge/e2e/local/concierge-assets.spec.ts b/apps/concierge/e2e/local/concierge-assets.spec.ts new file mode 100644 index 0000000000..b6c705634d --- /dev/null +++ b/apps/concierge/e2e/local/concierge-assets.spec.ts @@ -0,0 +1,89 @@ +/** + * CON-ASSET-01 — concierge's asset list. + * + * ## This page does NOT use `simple-table` + * + * Measured 2026-09-17: `/#/book/assets/list/items` renders `asset-listing` and + * `app-asset-item-list`, and there is no `[role=table]` anywhere on it. So the + * shared `simple-table.page.ts` helper is useless here and the assertions read + * the component's own text instead. That difference is why the plan had this + * area down as needing a page object of its own. + * + * It groups by CATEGORY and then lists asset TYPES inside it — "E2E Equipment + * (1 items)" with "E2E AV Equipment" under it — rather than listing individual + * assets. So the thing to assert is that a seeded category and its type appear, + * not that three projector units do. + * + * ## It reuses the equipment fixture + * + * `asset.seed.ts` (from the workplace ROOM-24 work) creates a VISIBLE category + * `E2E Equipment` with type `E2E AV Equipment` and three units. That is exactly + * what this page should show, so there is no second seeder — and the fact that + * a fixture created for the workplace suite shows up correctly in concierge is + * itself worth one assertion. + */ +import { expect, test } from '../../../../e2e/support/concierge/fixtures'; +import { + ASSET_CATEGORY, + ASSET_TYPE, + ensureEquipment, +} from '../../../../e2e/support/room/asset.seed'; + +const ASSET_LIST_ROUTE = '/#/book/assets/list/items'; + +test.describe('concierge assets', () => { + test('CON-ASSET-01: the asset list shows the seeded category and its type', async ({ + adminPage, + adminApi, + }) => { + // `ensureEquipment` mints its own admin context from the WORKPLACE token + // file, which the concierge fixtures do not write — so the catalogue is + // read through this suite's own admin instead, and only created if it is + // genuinely absent. + const existing = await adminApi.get('/api/engine/v2/asset_types', { + params: { limit: '500' }, + }); + const body = await existing.json(); + const types = (Array.isArray(body) ? body : (body.data ?? body.results ?? [])) as Array< + Record + >; + if (!types.some((type) => type.name === ASSET_TYPE)) { + await ensureEquipment(); + } + + const crashes: string[] = []; + adminPage.on('pageerror', (error) => crashes.push(error.message)); + + await adminPage.goto(ASSET_LIST_ROUTE); + + const listing = adminPage.locator('app-asset-item-list'); + await expect( + listing, + 'the asset list never rendered. Note this page uses `asset-listing` / ' + + '`app-asset-item-list`, NOT simple-table, so the shared table helper ' + + 'does not apply', + ).toBeVisible({ timeout: 45_000 }); + + await expect + .poll(async () => (await listing.innerText().catch(() => '')).replace(/\s+/g, ' '), { + message: + `the asset list should show the seeded category "${ASSET_CATEGORY}" ` + + `and the type "${ASSET_TYPE}" inside it. The list groups by ` + + `CATEGORY and lists TYPES, not individual units, so it will not ` + + `name the three projector assets`, + timeout: 30_000, + }) + .toContain(ASSET_CATEGORY); + + const text = (await listing.innerText()).replace(/\s+/g, ' '); + expect( + text, + `and the type inside that category. Full text: ${text.slice(0, 200)}`, + ).toContain(ASSET_TYPE); + + expect( + crashes, + 'the asset list must not throw while rendering', + ).toEqual([]); + }); +}); diff --git a/apps/concierge/e2e/local/concierge-catering.spec.ts b/apps/concierge/e2e/local/concierge-catering.spec.ts new file mode 100644 index 0000000000..3ff48b380a --- /dev/null +++ b/apps/concierge/e2e/local/concierge-catering.spec.ts @@ -0,0 +1,81 @@ +/** + * CON-CAT-01 — concierge's catering menu, `/#/book/catering/menu`. + * + * Small, and deliberately so. Of the plan's three catering rows this is the one + * that can be tested here: + * + * CON-CAT-01 the menu lists the seeded items -> this file + * CON-CAT-02 an order attached to a booking is stored -> blocked, ROOM-B5 + * CON-CAT-03 an item created here is offered by workplace -> not yet written + * + * CON-CAT-02 is blocked by a finding the workplace room work already proved: + * **ROOM-B5** — a catering order cannot be linked to a native PlaceOS room + * booking at all. The order is refused `422 error linking booking to event`, + * the room booking survives anyway, and the food is never ordered. Concierge's + * ordering screens hang off the same link, and concierge's own room bookings + * come from the calendar, which is a placeholder here — so there is no path to + * a working order on this stack. Recorded rather than attempted. + * + * ## What a catering item is + * + * An asset, like a parking space rather than like a desk. The workplace + * catering seeder creates a hidden category, an `CATERING:` asset type + * and an item inside it — so the item's presence in `/assets` is what this test + * compares the screen against. + */ +import { expect, test } from '../../../../e2e/support/concierge/fixtures'; +import { + columnIndex, + waitForAnyRow, +} from '../../../../e2e/support/concierge/simple-table.page'; + +const CATERING_MENU_ROUTE = '/#/book/catering/menu'; + +test.describe('concierge catering', () => { + test('CON-CAT-01: the catering menu lists the seeded items', async ({ + adminPage, + adminApi, + }) => { + // The catering item is an asset whose TYPE name starts with `CATERING:`. + const types = await ( + await adminApi.get('/api/engine/v2/asset_types', { + params: { limit: '200' }, + }) + ).json(); + const catering_types = ( + Array.isArray(types) ? types : (types.results ?? []) + ).filter((t: { name?: string }) => (t.name ?? '').startsWith('CATERING:')); + + test.skip( + catering_types.length === 0, + 'no CATERING: asset type on this stack — the workplace catering seeder ' + + 'has not run, so there is no menu to assert against. Skipped rather ' + + 'than failed, because that is a missing fixture, not a defect.', + ); + + const assets = await ( + await adminApi.get('/api/engine/v2/assets', { params: { limit: '200' } }) + ).json(); + const type_ids = new Set(catering_types.map((t: { id: string }) => t.id)); + const items = (Array.isArray(assets) ? assets : (assets.results ?? [])).filter( + (a: { asset_type_id?: string }) => type_ids.has(a.asset_type_id ?? ''), + ); + + expect( + items.length, + 'there should be at least one seeded catering item to look for', + ).toBeGreaterThan(0); + + await adminPage.goto(CATERING_MENU_ROUTE); + const snapshot = await waitForAnyRow(adminPage); + const name_col = columnIndex(snapshot, 'name'); + const listed = snapshot.rows.map((r) => r[name_col]).join(' | '); + + for (const item of items as Array<{ name: string }>) { + expect( + listed, + `the catering menu should list "${item.name}". It showed: ${listed}`, + ).toContain(item.name); + } + }); +}); diff --git a/apps/concierge/e2e/local/concierge-dayview.spec.ts b/apps/concierge/e2e/local/concierge-dayview.spec.ts new file mode 100644 index 0000000000..592c2c907d --- /dev/null +++ b/apps/concierge/e2e/local/concierge-dayview.spec.ts @@ -0,0 +1,181 @@ +/** + * CON-DAY-01 … CON-DAY-08 — concierge's Room Bookings day view, `/#/`. + * + * This is concierge's DEFAULT page and its headline screen, and on this stack + * almost none of it can be tested. That is a deliberate, recorded scope cut + * rather than an oversight, so this file is mostly an explanation. + * + * ## Why six of the eight rows are `fixme` + * + * The day view reads room bookings from `GET /api/staff/v1/events`, which is + * the Microsoft/Google calendar surface. `e2e/support/seed.ts` creates an + * **office365 tenant with placeholder credentials on purpose** + * (`tenant: 'e2e-local-placeholder'`), so every calendar call leaves the stack + * and dies at Microsoft: + * + * POST /api/staff/v1/events status=500 + * AADSTS900023: Specified tenant identifier 'e2e-local-placeholder' is + * neither a valid DNS name, nor a valid external domain + * + * Re-measured on 2026-09-17 from the browser: loading `/#/` fires + * `GET /api/staff/v1/events` and it answers **500**. + * + * The workplace room specs dodge this with `app.events.use_bookings = true`, + * which switches room booking to native PlaceOS bookings. **Concierge has no + * such switch** — `events-state.service.ts` calls `queryEvents()` + * unconditionally, and `apps/concierge/src/environments/settings.ts` sets only + * `catering.use_bookings`, never `events.use_bookings`. So no booking can ever + * appear on this timeline here. The page renders; it is permanently empty. + * + * **Decision taken 2026-09-17:** leave the concierge room-booking screens out + * of scope on this stack, and keep these rows as failing-by-default `fixme`s so + * they turn green the day a switch or real credentials arrive. The alternatives + * considered were asking the developers for the same `use_bookings` switch, or + * getting real Microsoft test credentials for the local stack. + * + * ## What IS tested here + * + * The two rows that do not need a booking: the shell renders (CON-DAY-01), and + * the empty timeline is a clean empty state rather than a crash (CON-DAY-08). + * CON-DAY-08 is the odd one out — normally the hardest state to arrange, and + * here the only one available. + */ +import { expect, test } from '../../../../e2e/support/concierge/fixtures'; + +/** The calendar surface every blocked test below is waiting on. */ +const CALENDAR_ENDPOINT = '/api/staff/v1/events'; + +test.describe('concierge day view', () => { + test('CON-DAY-01: the day view renders for an authorised concierge user', async ({ + adminPage, + }) => { + await adminPage.goto('/#/'); + + // The same three handles the existing mock spec uses, so this and + // `dayview.spec.ts` cannot disagree about what "rendered" means. + await expect( + adminPage.locator('app-topbar'), + 'the concierge shell never rendered on the default route', + ).toBeVisible({ timeout: 45_000 }); + await expect( + adminPage.locator('app-sidebar'), + 'the sidebar carries the navigation between concierge areas', + ).toBeVisible({ timeout: 30_000 }); + // `room-bookings > div`, NOT `room-bookings`. + // + // The custom element itself is unstyled and measures 1024x0, so + // `toBeVisible()` on it is always false — measured 2026-09-17. Its inner + // div is the thing with a box (1024x655). This is also the handle the + // existing mock spec uses, so the two cannot disagree. + await expect( + adminPage.locator('room-bookings > div'), + 'the room-bookings timeline is the day view itself. It will be EMPTY on ' + + 'this stack (see the file header) — but it must still render', + ).toBeVisible({ timeout: 30_000 }); + await expect( + adminPage.locator('room-bookings-inverted-timeline'), + 'and the timeline body itself should be laid out, empty or not', + ).toBeVisible({ timeout: 30_000 }); + + // `/#/` is the app's default_route, so landing anywhere else means the + // routing or the guard redirected, which is worth knowing about here + // rather than in whichever spec happens to run next. + await expect(adminPage).toHaveURL(/book\/rooms/, { timeout: 15_000 }); + }); + + test('CON-DAY-08: the empty timeline renders cleanly, with no uncaught exception', async ({ + adminPage, + }) => { + // Uncaught exceptions do NOT fail a Playwright test on their own — the + // page keeps going and the assertions below would happily pass over a + // broken component. Collecting them is the only way this row means + // anything. + const crashes: string[] = []; + adminPage.on('pageerror', (error) => crashes.push(error.message)); + + let calendar_status: number | null = null; + adminPage.on('response', (response) => { + if (response.url().includes(CALENDAR_ENDPOINT)) { + calendar_status = response.status(); + } + }); + + await adminPage.goto('/#/'); + await expect(adminPage.locator('room-bookings > div')).toBeVisible({ + timeout: 45_000, + }); + // Give the failing calendar request time to land and the component time + // to react to it. This is the state under test, so it must be reached. + await expect + .poll(() => calendar_status, { + message: + `the day view never called ${CALENDAR_ENDPOINT}. If that is now ` + + `true, this whole file's premise has changed — re-read the header ` + + `and un-fixme the blocked rows`, + timeout: 30_000, + }) + .not.toBeNull(); + + // Recording rather than asserting the 500: this test is about the app + // surviving it, and pinning the status would make the row fail on the + // good day the stack gets real credentials. + // eslint-disable-next-line no-console + console.log( + `CON-DAY-08: ${CALENDAR_ENDPOINT} answered ${calendar_status} ` + + `(500 is expected on this stack — see the file header)`, + ); + + expect( + crashes, + 'the day view must survive its calendar query failing. An empty timeline ' + + 'is the correct outcome here; an uncaught exception is not', + ).toEqual([]); + + await expect( + adminPage.locator('app-topbar'), + 'and the shell must still be standing afterwards', + ).toBeVisible(); + }); + + /** + * CON-DAY-02 … CON-DAY-07 — blocked by the placeholder calendar. + * + * Written as one `fixme` rather than six near-identical stubs: they all fail + * at the same first step, for the same reason, and six copies of that + * explanation would rot independently. The scenarios they stand for: + * + * CON-DAY-02 a room booking made through the API appears on the timeline + * CON-DAY-03 changing the day moves the timeline with it + * CON-DAY-04 a booking made by ANOTHER user is visible here + * CON-DAY-05 booking a room from the day view stores it correctly + * CON-DAY-06 a tentative booking can be APPROVED from the approvals list + * CON-DAY-07 rejecting from the approvals list records the rejection + * + * CON-DAY-04 is the one worth regretting: "another user's booking is + * visible" is the entire difference between a concierge view and the + * workplace one, and it is the single most valuable assertion in this area. + * + * To revive these: give concierge an `app.events.use_bookings` switch (then + * these become ordinary native-booking tests, like the workplace room + * specs), or point the stack at a real Microsoft tenant. + */ + test.fixme( + 'CON-DAY-02..07: bookings on the timeline — blocked by the placeholder calendar', + async ({ adminPage, adminApi }) => { + await adminPage.goto('/#/'); + const res = await adminApi.get(`${CALENDAR_ENDPOINT}`, { + params: { + period_start: String(Math.floor(Date.now() / 1000)), + period_end: String(Math.floor(Date.now() / 1000) + 86_400), + }, + }); + // The precondition for every scenario listed above. While this is a + // 500 none of them can be written honestly. + expect( + res.status(), + `${CALENDAR_ENDPOINT} must answer before any booking can appear on ` + + `the concierge timeline`, + ).toBe(200); + }, + ); +}); diff --git a/apps/concierge/e2e/local/concierge-desks.spec.ts b/apps/concierge/e2e/local/concierge-desks.spec.ts new file mode 100644 index 0000000000..cb9aa6a7de --- /dev/null +++ b/apps/concierge/e2e/local/concierge-desks.spec.ts @@ -0,0 +1,305 @@ +/** + * CON-DESK-01 / 05 / 07 / 08 / 09 — the concierge desk-bookings and + * desk-management screens. + * + * The first area written after the access specs, and the one that best shows + * what concierge is FOR: every test here acts on a booking belonging to + * somebody else. The workplace desk specs assert the opposite rule — that one + * user cannot see or touch another's booking — so these two suites together + * describe the boundary from both sides. + * + * ## Two things measured on 2026-09-17 that shaped these tests + * + * 1. **There is no "cancel" on this screen, so CON-DESK-03 cannot exist.** That + * row read "cancelling somebody else's desk booking from concierge removes + * it on the backend". The actions column holds exactly one control, "View + * Booking History". What the page actually offers against another person's + * booking is approve/reject and check-in/check-out. + * + * Those three are NEW scenarios, so they carry new ids — CON-DESK-07, 08 and + * 09 — rather than being filed under CON-DESK-03. Re-using that id would + * have left the plan claiming a cancel test exists when it does not. + * CON-DESK-03 is marked *not offered by the UI* in the plan instead. + * + * 2. **The listing shows one day, and `?date=` does not work on this route.** + * `DesksComponent` reads only `zone_ids` from the query string and returns + * early without it; the `date` parameter is handled by + * `desks-topbar.component.ts`, which is referenced only by unit tests and is + * not rendered here. So these specs book for TODAY. + * + * ## Why every assertion ends at the backend + * + * A row changing to "Approved" proves the component re-rendered, nothing more. + * Concierge acts on other people's bookings, so "the screen said so" is exactly + * the failure mode that matters — each test re-reads the booking through the + * API and asserts on the stored record. + */ +import { expect, test } from '../../../../e2e/support/concierge/fixtures'; +import { + createBookingFor, + cleanUpBookings, + seededDesks, +} from '../../../../e2e/support/concierge/concierge.api'; +import { + chooseRowAction, + columnIndex, + openDeskBookings, + openDeskManagement, + readTable, + waitForAnyRow, + waitForRow, +} from '../../../../e2e/support/concierge/desks.page'; +import { getBooking, uniqueTitle } from '../../../../e2e/support/api'; +import { roleFor } from '../../../../e2e/support/env'; + +/** + * Today, at a fixed hour. + * + * The listing only shows today (see the header), so these cannot use the + * day-offset slots the other areas use. 16:00 is chosen to sit clear of the + * workplace desk specs, which work in today +1 to +4 hours from whenever they + * run. The hour may already have passed on a late run; that is fine, because + * nothing here depends on the booking being in the future — a past booking is + * still listed, approved and checked in. + */ +function todayAt(hour: number, minutes = 60) { + const start = new Date(); + start.setHours(hour, 0, 0, 0); + const seconds = Math.floor(start.valueOf() / 1000); + return { start: seconds, end: seconds + minutes * 60 }; +} + +/** + * A window that CONTAINS the present moment. + * + * Check-in needs this. Measured 2026-09-17: choosing "Check-in" against a + * FUTURE booking opens the menu and accepts the click, but fires no request at + * all and the booking stays checked out — the app will only check somebody in + * while their booking is actually running. A test that booked a tidy future + * hour therefore failed for a reason that had nothing to do with concierge. + */ +function windowAroundNow(minutes = 30) { + const now = Math.floor(Date.now() / 1000); + return { start: now - minutes * 60, end: now + minutes * 60 }; +} + +/** The holder for these bookings: a plain staff user, never the concierge. */ +const HOLDER = roleFor('staff', 0); + +test.describe('concierge desk bookings', () => { + const created: number[] = []; + + test.afterEach(async ({ adminApi }) => { + await cleanUpBookings(adminApi, created.splice(0)); + }); + + test('CON-DESK-01: the listing shows another user\'s booking, naming them and the booker', async ({ + adminPage, + adminApi, + }) => { + const desks = await seededDesks(adminApi); + const desk = desks[desks.length - 1]; + const title = uniqueTitle('CON-DESK-01'); + const { start, end } = todayAt(16); + + const booking = await createBookingFor(adminApi, { + type: 'desk', + asset_id: desk.id, + asset_name: desk.name, + title, + start, + end, + holder: { email: HOLDER.email }, + }); + created.push(booking.id); + + await openDeskBookings(adminPage); + const row = await waitForRow(adminPage, title); + const snapshot = await readTable(adminPage); + + // The whole point of a concierge view: the booking belongs to somebody + // else and is still shown, with BOTH identities on the row. + expect( + row[columnIndex(snapshot, 'user_email')], + 'the "Person" column must name the person the desk is booked FOR, not the concierge', + ).toContain(HOLDER.email); + expect( + row[columnIndex(snapshot, 'booked_by_email')], + 'the "Booked By" column must name the concierge who made it', + ).toContain('support@place.tech'); + expect( + row[columnIndex(snapshot, 'asset_name')], + 'and the desk it is against', + ).toContain(desk.name); + }); + + test('CON-DESK-07: approving another user\'s desk booking is stored on the backend', async ({ + adminPage, + adminApi, + }) => { + const desks = await seededDesks(adminApi); + const desk = desks[desks.length - 1]; + const title = uniqueTitle('CON-DESK-07'); + const { start, end } = todayAt(17); + + const booking = await createBookingFor(adminApi, { + type: 'desk', + asset_id: desk.id, + asset_name: desk.name, + title, + start, + end, + holder: { email: HOLDER.email }, + }); + created.push(booking.id); + + // It starts unapproved — asserted, so a booking that arrived approved + // could not make the rest of this test pass for free. + expect( + (await getBooking(adminApi, booking.id)).approved, + 'a new desk booking should not already be approved', + ).toBe(false); + + await openDeskBookings(adminPage); + await waitForRow(adminPage, title); + await chooseRowAction(adminPage, title, 'status', 'Approve Desk'); + + await expect + .poll( + async () => { + const after = await getBooking(adminApi, booking.id); + return { approved: after.approved, rejected: after.rejected }; + }, + { + message: + 'approving from the concierge listing must store the approval. ' + + 'The row re-rendering as "Approved" is not enough — this reads ' + + 'the booking back through the API', + timeout: 20_000, + }, + ) + .toEqual({ approved: true, rejected: false }); + + expect( + (await getBooking(adminApi, booking.id)).approver_email, + 'and it should record WHO approved it — the concierge, not the holder', + ).toBe('support@place.tech'); + }); + + test('CON-DESK-08: rejecting another user\'s desk booking is stored on the backend', async ({ + adminPage, + adminApi, + }) => { + const desks = await seededDesks(adminApi); + const desk = desks[desks.length - 1]; + const title = uniqueTitle('CON-DESK-08'); + const { start, end } = todayAt(18); + + const booking = await createBookingFor(adminApi, { + type: 'desk', + asset_id: desk.id, + asset_name: desk.name, + title, + start, + end, + holder: { email: HOLDER.email }, + }); + created.push(booking.id); + + await openDeskBookings(adminPage); + await waitForRow(adminPage, title); + await chooseRowAction(adminPage, title, 'status', 'Reject Desk'); + + await expect + .poll( + async () => (await getBooking(adminApi, booking.id)).rejected, + { + message: + 'rejecting from the concierge listing must store the rejection. ' + + 'This is the closest thing the screen offers to the plan\'s ' + + '"cancel somebody else\'s booking" — there is no cancel control here', + timeout: 20_000, + }, + ) + .toBe(true); + }); + + test('CON-DESK-09: checking another user in from concierge is stored', async ({ + adminPage, + adminApi, + }) => { + const desks = await seededDesks(adminApi); + // A desk of its OWN, unlike the other tests here. This booking spans the + // present moment, so on a late run it could otherwise overlap the fixed + // hours the approve/reject tests use, and a desk is exclusive. + const desk = desks[desks.length - 2] ?? desks[desks.length - 1]; + const title = uniqueTitle('CON-DESK-09'); + const { start, end } = windowAroundNow(); + + const booking = await createBookingFor(adminApi, { + type: 'desk', + asset_id: desk.id, + asset_name: desk.name, + title, + start, + end, + holder: { email: HOLDER.email }, + }); + created.push(booking.id); + + expect( + (await getBooking(adminApi, booking.id)).checked_in, + 'a new booking should not already be checked in', + ).toBe(false); + + await openDeskBookings(adminPage); + await waitForRow(adminPage, title); + await chooseRowAction(adminPage, title, 'checked_in', 'Check-in'); + + await expect + .poll( + async () => (await getBooking(adminApi, booking.id)).checked_in, + { + message: + 'a concierge checking somebody in at the front desk is the core ' + + 'of this app, and it must reach the backend. If this fails, check ' + + 'the booking window FIRST: check-in silently does nothing for a ' + + 'booking that is not currently running (no request is sent at ' + + 'all), so the window must contain now. Worth noting this is ' + + 'coverage the workplace suite could not get for rooms, where ' + + 'check-in needs a live Bookings driver module', + timeout: 20_000, + }, + ) + .toBe(true); + }); +}); + +test.describe('concierge desk management', () => { + test('CON-DESK-05: desk management lists the seeded desks', async ({ + adminPage, + adminApi, + }) => { + const desks = await seededDesks(adminApi); + + await openDeskManagement(adminPage); + // Rows arrive after the header on this page — see waitForAnyRow. + const snapshot = await waitForAnyRow(adminPage); + + expect( + snapshot.rows.length, + `desk management showed ${snapshot.rows.length} row(s) but the stack seeds ` + + `${desks.length} desks. Columns rendered: ${snapshot.column_ids.join(', ')}`, + ).toBeGreaterThanOrEqual(desks.length); + + // Asserting on the seeded desks by name rather than on a count alone: + // a page listing the right NUMBER of the wrong things would pass a count. + const listed = snapshot.rows.flat().join(' | '); + for (const desk of desks) { + expect( + listed, + `desk management should list the seeded desk "${desk.name}"`, + ).toContain(desk.name); + } + }); +}); diff --git a/apps/concierge/e2e/local/concierge-lockers.spec.ts b/apps/concierge/e2e/local/concierge-lockers.spec.ts new file mode 100644 index 0000000000..6330c6b26d --- /dev/null +++ b/apps/concierge/e2e/local/concierge-lockers.spec.ts @@ -0,0 +1,219 @@ +/** + * CON-LOCK-01 / CON-LOCK-03 — concierge's locker management and allocation. + * + * The area the plan called out as the biggest remaining piece, because a locker + * is TWO levels of seeding rather than one: a bank asset, then locker assets + * whose `parent_id` points at it. `locker.seed.ts` does that and explains the + * shape; the same gap has kept workplace's WP-E2E-09 untested. + * + * ## The mock spec stays + * + * `apps/concierge/e2e/lockers.spec.ts` already tests this area in `?mock=true` + * against English button text. It is left alone: it costs no infrastructure and + * catches pure render breakage, and CON-LOCK-02 ("is it replaced or kept?") is + * still an open decision rather than something to settle here quietly. + * + * ## What is asserted + * + * The management page showing a bank proves the listing renders. What matters + * for CON-LOCK-03 is that allocating a locker to somebody is STORED against + * that person — so the allocation is asserted on the booking record, not on the + * row. + */ +import { expect, test } from '../../../../e2e/support/concierge/fixtures'; +import { ensureLockers } from '../../../../e2e/support/concierge/locker.seed'; +import { + cleanUpBookings, + createBookingFor, +} from '../../../../e2e/support/concierge/concierge.api'; +import { + columnIndex, + readTable, + waitForAnyRow, +} from '../../../../e2e/support/concierge/simple-table.page'; +import { getBooking, uniqueTitle } from '../../../../e2e/support/api'; +import { roleFor } from '../../../../e2e/support/env'; + +const LOCKER_MANAGE_ROUTE = '/#/book/lockers/manage'; +const LOCKER_BOOKINGS_ROUTE = '/#/book/lockers/events'; + +/** The person a locker is allocated to: a plain staff user. */ +const HOLDER = roleFor('staff', 3); + +function windowAroundNow(minutes = 45) { + const now = Math.floor(Date.now() / 1000); + return { start: now - minutes * 60, end: now + minutes * 60 }; +} + +test.describe('concierge lockers', () => { + const created: number[] = []; + + test.afterEach(async ({ adminApi }) => { + await cleanUpBookings(adminApi, created.splice(0)); + }); + + test('CON-LOCK-01: locker banks are listed from the backend', async ({ + adminPage, + adminApi, + }) => { + const fixture = await ensureLockers(adminApi); + + await adminPage.goto(LOCKER_MANAGE_ROUTE); + const snapshot = await waitForAnyRow(adminPage); + + const name_col = columnIndex(snapshot, 'name'); + const listed = snapshot.rows.map((row) => row[name_col]); + expect( + listed.join(' | '), + `locker management should list the seeded bank "${fixture.bank.name}". ` + + `It showed ${snapshot.rows.length} row(s): ${listed.join(', ')}. ` + + `If the bank is there but NAMELESS, the seeder wrote the asset ` + + `columns without the matching other_data copy — the UI reads ` + + `other_data (see locker.seed.ts)`, + ).toContain(fixture.bank.name); + }); + + test('CON-LOCK-01b: the lockers inside a bank are listed, and belong to it', async ({ + adminApi, + }) => { + const fixture = await ensureLockers(adminApi); + + // Asserted at the API rather than by expanding the row. The value here + // is the PARENT LINK — a locker whose `parent_id` is wrong exists, is + // returned, and simply never appears under any bank. That is invisible + // on screen and obvious in the record. + const res = await adminApi.get('/api/engine/v2/assets', { + params: { zone_id: fixture.zone_id, limit: '500' }, + }); + expect(res.ok(), `GET /assets failed: HTTP ${res.status()}`).toBe(true); + const body = await res.json(); + const assets = (Array.isArray(body) ? body : (body.data ?? body.results ?? [])) as Array< + Record + >; + + for (const locker of fixture.lockers) { + const stored = assets.find((asset) => asset.id === locker.id); + expect( + stored, + `the seeded locker "${locker.name}" is not on zone ${fixture.zone_id}`, + ).toBeTruthy(); + expect( + String((stored as Record).parent_id ?? ''), + `"${locker.name}" must point at its bank, or it belongs to no bank ` + + `and never shows up under one`, + ).toBe(fixture.bank.id); + } + }); + + test('CON-LOCK-03: a locker allocated to a user is stored against them', async ({ + adminPage, + adminApi, + }) => { + const fixture = await ensureLockers(adminApi); + const locker = fixture.lockers[0]; + const title = uniqueTitle('CON-LOCK-03'); + const { start, end } = windowAroundNow(); + + const booking = await createBookingFor(adminApi, { + type: 'locker', + asset_id: locker.id, + asset_name: locker.name, + title, + start, + end, + holder: { email: HOLDER.email }, + }); + created.push(booking.id); + + // The record is the assertion: a locker allocated to the wrong person is + // the failure that matters, and the screen cannot tell you that. + const stored = await getBooking(adminApi, booking.id); + expect( + stored.user_email, + 'the locker must be stored against the person it was allocated to', + ).toBe(HOLDER.email); + expect( + stored.asset_id, + 'and against the locker that was allocated', + ).toBe(locker.id); + + // Then prove the concierge listing actually surfaces it, because an + // allocation nobody at the front desk can see is not much use. + // + // Matched on the HOLDER, not the locker name — see CON-B3 below: the + // Locker column renders a raw asset id, so `locker.name` is not on the + // row at all. + await adminPage.goto(LOCKER_BOOKINGS_ROUTE); + const snapshot = await waitForAnyRow(adminPage); + const person_col = columnIndex(snapshot, 'user_name'); + const asset_col = columnIndex(snapshot, 'asset_name'); + const row = snapshot.rows.find((r) => r[person_col].includes(HOLDER.email)); + expect( + row, + `the locker bookings listing should show the allocation to ` + + `${HOLDER.email}. It showed ${snapshot.rows.length} row(s). Note ` + + `this listing shows ONE DAY and defaults to today, which is why the ` + + `booking window straddles now`, + ).toBeTruthy(); + expect( + (row as string[])[asset_col], + 'and the row must identify the locker somehow — by id today, by name ' + + 'once CON-B3 is fixed', + ).toContain(locker.id); + }); + + /** + * CON-B3 — the locker bookings listing shows a raw asset id, not the locker's + * name. + * + * Found 2026-09-17 while writing CON-LOCK-03. Low severity, but it lands on + * the screen a concierge actually works from: the Locker column renders + * `asset-KpaBMkGOPR` where it should say "E2E Locker 0". + * + * Cause, as far as it was measured: `asset_name` is **not stored** on a + * locker booking. It was sent in the POST and comes back absent from + * `GET /bookings/`, so the column falls back to the id. Desk bookings + * DO keep theirs — CON-DESK-01 asserts on `asset_name` and passes — so this + * is specific to the locker type rather than a general rule. + * + * Not chased further: whether staff-api drops the field for this type, or + * the app never really sends it, was not established. Either way the screen + * is unusable for its purpose, which is what the test records. + */ + test.fixme('CON-B3: the locker listing names the locker rather than showing its id', async ({ + adminPage, + adminApi, + }) => { + const fixture = await ensureLockers(adminApi); + const locker = fixture.lockers[0]; + const { start, end } = windowAroundNow(); + + const booking = await createBookingFor(adminApi, { + type: 'locker', + asset_id: locker.id, + asset_name: locker.name, + title: uniqueTitle('CON-B3'), + start, + end, + holder: { email: HOLDER.email }, + }); + created.push(booking.id); + + expect( + (await getBooking(adminApi, booking.id)).asset_name, + 'a locker booking should keep the locker name it was given, as a desk ' + + 'booking does', + ).toBe(locker.name); + + await adminPage.goto(LOCKER_BOOKINGS_ROUTE); + const snapshot = await waitForAnyRow(adminPage); + const asset_col = columnIndex(snapshot, 'asset_name'); + const person_col = columnIndex(snapshot, 'user_name'); + const row = snapshot.rows.find((r) => r[person_col].includes(HOLDER.email)); + expect( + (row as string[])[asset_col], + 'the Locker column must name the locker. A front desk cannot work from ' + + 'an asset id', + ).toContain(locker.name); + }); +}); diff --git a/apps/concierge/e2e/local/concierge-parking.spec.ts b/apps/concierge/e2e/local/concierge-parking.spec.ts new file mode 100644 index 0000000000..11a7e8f40c --- /dev/null +++ b/apps/concierge/e2e/local/concierge-parking.spec.ts @@ -0,0 +1,149 @@ +/** + * CON-PARK-01 / 05 — the concierge parking screens. + * + * CON-PARK-05 is a NEW id, not the plan's CON-PARK-04. That row is "parking + * users and fleet vehicles can be listed and one created", which is a different + * screen and is still unwritten; listing the SPACES is its own scenario. + * + * Same shape as the desk specs, and the same point: every booking here belongs + * to somebody other than the signed-in concierge, and every assertion ends at + * the backend rather than at the row. + * + * ## The parking listing has no Title column + * + * Measured 2026-09-17. Its columns are: + * + * state · date · asset_id · user_name · booked_by_name · plate_number · + * status · actions + * + * Every other area in this suite finds its row with `uniqueTitle()`. That does + * not work here, because the title is never rendered. The only per-test handle + * left is the SPACE, so these specs book against parking spaces of their own — + * `E2E Concierge Parking 0…` — created by `ensureParkingSpaces` under a + * concierge-specific prefix. Sharing `E2E Parking 0..4` with the workplace + * parking specs would make "the row for my booking" ambiguous the moment both + * suites had data on the same day. + * + * ## Spaces are assets, on a zone tagged BOTH `level` and `parking` + * + * `level` is what the org hierarchy reads and `parking` is what + * `ParkingService.levels` filters on; a zone with one but not the other is + * invisible to one of them. `ensureParkingSpaces` handles that and is + * idempotent, so it reuses whatever the workplace parking seeder already built. + */ +import { expect, test } from '../../../../e2e/support/concierge/fixtures'; +import { + cleanUpBookings, + createBookingFor, + ensureParkingSpaces, +} from '../../../../e2e/support/concierge/concierge.api'; +import { + columnIndex, + LIVE_ONLY, + readTable, + waitForAnyRow, + waitForRow, +} from '../../../../e2e/support/concierge/simple-table.page'; +import { roleFor } from '../../../../e2e/support/env'; + +const PARKING_BOOKINGS_ROUTE = '/#/book/parking/events'; +const PARKING_MANAGE_ROUTE = '/#/book/parking/manage'; + +/** Spaces that belong to the concierge specs alone — see the file header. */ +const CONCIERGE_SPACE_PREFIX = 'E2E Concierge Parking'; + +/** The holder: a plain staff user, never the concierge. */ +const HOLDER = roleFor('staff', 1); + +/** + * A window containing now. + * + * The listing shows a single day and defaults to today, exactly as the desk one + * does, so a booking has to be today to be seen at all. + */ +function windowAroundNow(minutes = 45) { + const now = Math.floor(Date.now() / 1000); + return { start: now - minutes * 60, end: now + minutes * 60 }; +} + +test.describe('concierge parking', () => { + const created: number[] = []; + + test.afterEach(async ({ adminApi }) => { + await cleanUpBookings(adminApi, created.splice(0)); + }); + + test('CON-PARK-01: the parking listing shows another user\'s booking, naming them and the booker', async ({ + adminPage, + adminApi, + }) => { + const { spaces } = await ensureParkingSpaces( + adminApi, + 1, + CONCIERGE_SPACE_PREFIX, + ); + const space = spaces[0]; + const { start, end } = windowAroundNow(); + + const booking = await createBookingFor(adminApi, { + type: 'parking', + asset_id: space.id, + asset_name: space.name, + title: 'CON-PARK-01', + start, + end, + holder: { email: HOLDER.email }, + }); + created.push(booking.id); + + await adminPage.goto(PARKING_BOOKINGS_ROUTE); + // The space name, not a title — see the file header. `LIVE_ONLY` + // because cancelled bookings stay on this listing forever, so by the + // second run the space name matches this booking AND last run's + // cancelled one, and `waitForRow` refuses to guess between them. + const row = await waitForRow(adminPage, space.name, { where: LIVE_ONLY }); + const snapshot = await readTable(adminPage); + + expect( + row[columnIndex(snapshot, 'user_name')], + 'the parking row must name the person the space is booked FOR', + ).toContain(HOLDER.email); + expect( + row[columnIndex(snapshot, 'booked_by_name')], + 'and the concierge who booked it', + ).toContain('support@place.tech'); + + // The row is proof the screen shows it; this is proof it is the booking + // we made, and that the listing is not showing a stale copy. + const stored = await adminApi.get(`/api/staff/v1/bookings/${booking.id}`); + expect(stored.ok(), 'the booking should still exist on the backend').toBe( + true, + ); + expect((await stored.json()).user_email).toBe(HOLDER.email); + }); + + test('CON-PARK-05: parking management lists the seeded spaces', async ({ + adminPage, + adminApi, + }) => { + const { spaces } = await ensureParkingSpaces( + adminApi, + 2, + CONCIERGE_SPACE_PREFIX, + ); + + await adminPage.goto(PARKING_MANAGE_ROUTE); + const snapshot = await waitForAnyRow(adminPage); + + // Asserting on the names rather than a count: a page listing the right + // NUMBER of the wrong things would pass a count check. + const listed = snapshot.rows.flat().join(' | '); + for (const space of spaces) { + expect( + listed, + `parking management should list the space "${space.name}". ` + + `Columns rendered: ${snapshot.column_ids.join(', ')}`, + ).toContain(space.name); + } + }); +}); diff --git a/apps/concierge/e2e/local/concierge-reports.spec.ts b/apps/concierge/e2e/local/concierge-reports.spec.ts new file mode 100644 index 0000000000..e045c2d5b1 --- /dev/null +++ b/apps/concierge/e2e/local/concierge-reports.spec.ts @@ -0,0 +1,146 @@ +/** + * CON-REP-01 / CON-REP-02 — concierge's reports. + * + * ## Two of the three reports are calendar-backed, so they cannot be tested here + * + * Measured 2026-09-17 by loading each page and pressing Generate Report: + * + * /#/reports/bookings "Rooms Report" -> 500 /api/staff/v1/events + * /#/reports/attendance "Site Attendance Report" -> 500 /api/staff/v1/events + * /#/reports/contact-tracing "Contact Tracing Report" -> no failed call + * + * The route named `bookings` is a ROOMS report, which is the surprise here — it + * reads the Microsoft calendar, not PlaceOS bookings, so it is blocked by the + * same placeholder tenant as the concierge day view (see + * `concierge-dayview.spec.ts`). Attendance is the same. Anyone reading the route + * name and assuming "bookings report = /bookings" would waste an afternoon, so + * it is written down here. + * + * ## What is tested + * + * CON-REP-01, as the plan framed it — "a report renders for a seeded window + * without an uncaught exception" — is worth having even while the data is + * unavailable, and it is genuinely testable: each page must render its controls + * and its prompt, and must not throw when its query fails. That is the guard + * that catches a report page going blank, which is indistinguishable from a + * page that failed to load. + * + * CON-REP-02 — "a report's totals match what the API returns for the same + * window" — is the one with teeth and it is `fixme`. Two of the three reports + * have no numbers to compare, and the third needs a user chosen from a + * directory-backed picker. + */ +import { expect, test } from '../../../../e2e/support/concierge/fixtures'; + +const REPORTS = [ + { + route: '/#/reports/bookings', + heading: /Rooms Report/i, + calendar_backed: true, + }, + { + route: '/#/reports/attendance', + heading: /Attendance Report/i, + calendar_backed: true, + }, + { + route: '/#/reports/contact-tracing', + heading: /Contact Tracing Report/i, + calendar_backed: false, + }, +] as const; + +test.describe('concierge reports', () => { + for (const report of REPORTS) { + test(`CON-REP-01: ${report.route} renders and survives its own query`, async ({ + adminPage, + }) => { + // Uncaught exceptions do not fail a Playwright test by themselves — + // the page carries on and the assertions below would pass over a + // broken component. Collecting them is what makes this row mean + // something. + const crashes: string[] = []; + adminPage.on('pageerror', (error) => crashes.push(error.message)); + + await adminPage.goto(report.route); + + // `date-range-field`, not `reports-options`. + // + // The three reports do NOT share a control set: rooms and + // attendance render `reports-options` with a Generate Report + // button, while contact tracing is driven from the topbar (a user + // search) and has no `reports-options` at all — measured, after + // asserting on it and watching contact tracing fail. The date range + // is the one control all three do have. + await expect( + adminPage.locator('date-range-field'), + 'the report controls never rendered', + ).toBeVisible({ timeout: 45_000 }); + await expect( + adminPage.getByText(report.heading), + 'and the report must name itself, so a blank panel can be told ' + + 'apart from a page that failed to load', + ).toBeVisible({ timeout: 30_000 }); + + // The generate button only exists on the two that take a range; + // contact tracing is driven by choosing a user instead. + const generate = adminPage.getByRole('button', { + name: /Generate Report/i, + }); + if (await generate.count()) { + await generate.first().click(); + // Long enough for the query to fail and the component to react. + await adminPage.waitForTimeout(8_000); + } + + expect( + crashes, + `${report.route} must not throw when its query fails. On this stack ` + + `the rooms and attendance reports both get a 500 from ` + + `/api/staff/v1/events (the placeholder Microsoft tenant), and an ` + + `empty report is the correct outcome — an uncaught exception is not`, + ).toEqual([]); + + await expect( + adminPage.locator('app-topbar'), + 'and the shell must still be standing afterwards', + ).toBeVisible(); + }); + } + + /** + * CON-REP-02 — blocked. + * + * "A report's totals match what the API returns for the same window" is the + * assertion with teeth: a report that renders the WRONG number looks + * perfectly healthy, and nothing else in this suite would catch it. + * + * It cannot be written here: + * + * - the rooms and attendance reports read `/api/staff/v1/events`, which is + * a 500 on this stack, so there is no total to compare against; + * - the contact-tracing report does not 500, but it needs a user chosen + * from the topbar, and the staff/user pickers in this app are + * directory-backed (`/api/staff/v1/people`, also a 500 — see + * `concierge-staff.spec.ts`). + * + * To revive it: the same two fixes that unblock everything else here — a + * local-bookings path for the reports, or real Microsoft credentials. + */ + test.fixme( + 'CON-REP-02: a report total matches the API for the same window — blocked by the placeholder calendar', + async ({ adminApi }) => { + const now = Math.floor(Date.now() / 1000); + const res = await adminApi.get('/api/staff/v1/events', { + params: { + period_start: String(now), + period_end: String(now + 86_400), + }, + }); + expect( + res.status(), + 'the reports cannot be compared against an API that does not answer', + ).toBe(200); + }, + ); +}); diff --git a/apps/concierge/e2e/local/concierge-rooms.spec.ts b/apps/concierge/e2e/local/concierge-rooms.spec.ts new file mode 100644 index 0000000000..2a927de5f9 --- /dev/null +++ b/apps/concierge/e2e/local/concierge-rooms.spec.ts @@ -0,0 +1,330 @@ +/** + * CON-ROOM-01 / 02 — concierge's Room Management page, `/#/room-management`. + * + * ## This is NOT the blocked room area + * + * Concierge has two room surfaces and only one of them is blocked. The **day + * view** (`/#/`) reads bookings from the Microsoft calendar and can never show + * anything on this stack — that is CON-DAY-02..07, documented in + * `concierge-dayview.spec.ts`. **Room management** is different: it reads + * PlaceOS `systems` directly, which answer 200 here, so it is fully testable. + * + * Keeping that distinction visible matters, because "concierge rooms are out of + * scope" is true of the calendar screens and false of this one. + * + * ## Why editing capacity is the test worth having + * + * A room's capacity is read by the WORKPLACE room picker when it decides + * whether a meeting is too big for a room — the behaviour covered by the + * workplace specs ROOM-19/20. So CON-ROOM-02 is a genuine cross-app assertion: + * a number changed on the concierge admin screen has to be the number the + * booking app later enforces. It is asserted against the backend record, which + * is the thing both apps actually read. + */ +import { expect, test } from '../../../../e2e/support/concierge/fixtures'; +import { + columnIndex, + readTable, + waitForAnyRow, +} from '../../../../e2e/support/concierge/simple-table.page'; + +const ROOM_MANAGEMENT_ROUTE = '/#/room-management'; + +interface RoomSystem { + id: string; + name: string; + display_name?: string; + capacity?: number; +} + +/** The rooms PlaceOS knows about, straight from the API. */ +async function seededRooms( + api: Parameters extends never ? never : any, +): Promise { + const res = await api.get('/api/engine/v2/systems', { + params: { limit: '200' }, + }); + expect(res.ok(), 'GET /systems should answer — concierge room management ' + + 'reads PlaceOS systems, not the calendar').toBe(true); + const body = await res.json(); + const rows = Array.isArray(body) ? body : (body.results ?? []); + return rows as RoomSystem[]; +} + + +/** + * Put a room's capacity back, and FAIL if it cannot be. + * + * ## `version` is a QUERY parameter, not a body field + * + * This was got wrong first time and it silently corrupted the fixture. A PUT to + * `/systems/` without it is refused: + * + * 422 {"error":"missing required parameter 'version'", + * "parameter":"version","restriction":"Int32"} + * + * and putting `version` in the JSON body does NOT satisfy it — it has to be + * `?version=`, optimistic locking on the record's current version. The + * original teardown sent it in the body, swallowed the 422 with a `.catch`, and + * so every run left the capacity one higher than it found it. Measured after six + * runs: `E2E Alt Room 0` had drifted from 8 to 14, version 7. + * + * ## Why this throws instead of staying quiet + * + * Teardown normally must not mask a test's real result — but this fixture is + * SHARED, and the workplace capacity specs (ROOM-19/20) assert against a room of + * 8. A restore that fails quietly breaks a different suite, hours later, in a + * way nobody would trace back here. A loud failure in the test that caused it is + * much cheaper than that. + */ +async function restoreCapacity( + api: any, + system_id: string, + capacity: number, +): Promise { + const current = await (await api.get(`/api/engine/v2/systems/${system_id}`)).json(); + if (current.capacity === capacity) return; + const res = await api.put( + `/api/engine/v2/systems/${system_id}?version=${current.version ?? 0}`, + { data: { capacity } }, + ); + const after = await (await api.get(`/api/engine/v2/systems/${system_id}`)).json(); + expect( + after.capacity, + `could not restore ${system_id} to capacity ${capacity} (PUT answered ` + + `${res.status()}). It is now ${after.capacity}, and it is a SHARED ` + + `fixture: the workplace capacity specs ROOM-19/20 expect 8. Reset it by ` + + `hand before running either suite again`, + ).toBe(capacity); +} + +test.describe('concierge room management', () => { + test('CON-ROOM-01: the room list shows the seeded rooms with their capacity', async ({ + adminPage, + adminApi, + }) => { + const rooms = await seededRooms(adminApi); + expect( + rooms.length, + 'the stack should have seeded at least one room system', + ).toBeGreaterThan(0); + + await adminPage.goto(ROOM_MANAGEMENT_ROUTE); + const snapshot = await waitForAnyRow(adminPage); + + const name_col = columnIndex(snapshot, 'display_name'); + const capacity_col = columnIndex(snapshot, 'capacity'); + + // Check a room the API knows about is really on the screen, with the + // capacity the API reports — rather than just counting rows. + const sample = rooms.find((r) => (r.capacity ?? 0) > 0) ?? rooms[0]; + const label = sample.display_name || sample.name; + const row = snapshot.rows.find((r) => r[name_col].includes(label)); + + expect( + row, + `room management should list "${label}". It rendered ` + + `${snapshot.rows.length} row(s): ` + + `${snapshot.rows.map((r) => r[name_col]).join(', ')}`, + ).toBeDefined(); + + if (sample.capacity != null) { + expect( + (row as string[])[capacity_col], + `and show the capacity the backend holds for "${label}"`, + ).toContain(String(sample.capacity)); + } + }); + + test('CON-ROOM-02: a capacity change made in the edit form is stored on the room', async ({ + adminPage, + adminApi, + }) => { + const rooms = await seededRooms(adminApi); + const room = rooms[0]; + const before = (await (await adminApi.get( + `/api/engine/v2/systems/${room.id}`, + )).json()) as RoomSystem; + const original = before.capacity ?? 0; + + await adminPage.goto(ROOM_MANAGEMENT_ROUTE); + const snapshot = await waitForAnyRow(adminPage); + const actions = columnIndex(snapshot, 'actions'); + + try { + await adminPage + .locator('simple-table [role=table] > *:not([header])') + .nth(actions) + .locator('button, [matripple]') + .first() + .click(); + // Filtered by text on the menu ITEM rather than `getByText` on the + // page: a bare text match here hits the row's tooltip instead and + // waits on an element that never becomes visible. + await adminPage + .locator('.mat-mdc-menu-panel button, [role=menuitem]') + .filter({ hasText: 'Edit Room' }) + .first() + .click(); + + // `room-form-modal` inside the CDK overlay, NOT `mat-dialog-container` + // — measured 2026-09-17, the plain dialog locator resolves to a + // hidden container and reports the form as absent. + const modal = adminPage.locator('room-form-modal'); + // Waiting on the SAVE BUTTON, not on `room-form-modal` itself. + // + // The host custom element is unstyled and measures zero, exactly + // like `room-bookings` on the day view, so `toBeVisible()` on it is + // permanently false even with the form fully on screen. Anchoring on + // a control inside it is both true and more useful — it means the + // form is actually usable. + await expect( + modal.getByRole('button', { name: 'Save', exact: false }), + 'the Edit Room form never opened', + ).toBeVisible({ timeout: 30_000 }); + + // Driving the stepper rather than typing into the capacity input: + // that input has no formcontrolname, no placeholder and no test id, + // so the only way to address it is by position among fourteen + // inputs — which would break the first time a field is added. The + // `add` button is unambiguous. + await modal.getByRole('button', { name: 'add', exact: true }).click(); + await modal.getByRole('button', { name: 'Save', exact: false }).click(); + await expect( + modal.getByRole('button', { name: 'Save', exact: false }), + 'the form should close once the room is saved', + ).toBeHidden({ timeout: 30_000 }); + + await expect + .poll( + async () => + ( + await ( + await adminApi.get(`/api/engine/v2/systems/${room.id}`) + ).json() + ).capacity, + { + message: + 'the capacity saved on the concierge admin screen is the ' + + 'number the WORKPLACE room picker later reads when it ' + + 'decides a meeting is too big for a room (workplace ' + + 'ROOM-19/20). If it does not persist, the two apps disagree', + timeout: 20_000, + }, + ) + .toBe(original + 1); + } finally { + await restoreCapacity(adminApi, room.id, original); + } + }); + + test('CON-ROOM-03: a feature added in the edit form is stored on the room', async ({ + adminPage, + adminApi, + }) => { + const rooms = await seededRooms(adminApi); + // A room with NO features to begin with, so "the feature was added" is + // not satisfied by one that was already there. E2E Alt Room 0 ships with + // "E2E Projector", which is exactly the trap. + const room = + rooms.find((r) => !((r as any).features ?? []).length) ?? rooms[0]; + const before = (await ( + await adminApi.get(`/api/engine/v2/systems/${room.id}`) + ).json()) as Record; + const original: string[] = before.features ?? []; + const feature = `E2E Whiteboard ${Date.now().toString(36)}`; + + await adminPage.goto(ROOM_MANAGEMENT_ROUTE); + const snapshot = await waitForAnyRow(adminPage); + const name_col = columnIndex(snapshot, 'display_name'); + const actions = columnIndex(snapshot, 'actions'); + const row = snapshot.rows.findIndex((r) => + r[name_col].includes(room.display_name || room.name), + ); + expect( + row, + `"${room.display_name || room.name}" is not in the room list`, + ).toBeGreaterThanOrEqual(0); + + try { + const width = snapshot.column_ids.length; + await adminPage + .locator('simple-table [role=table] > *:not([header])') + .nth(row * width + actions) + .locator('button, [matripple]') + .first() + .click(); + await adminPage + .locator('.mat-mdc-menu-panel button, [role=menuitem]') + .filter({ hasText: 'Edit Room' }) + .first() + .click(); + + const modal = adminPage.locator('room-form-modal'); + await expect( + modal.getByRole('button', { name: 'Save', exact: false }), + 'the Edit Room form never opened', + ).toBeVisible({ timeout: 30_000 }); + + // The features field is a chip input: type the value and commit it + // with Enter. Matched by placeholder because it has no test id. + const features = modal.getByPlaceholder('Features').first(); + await expect( + features, + 'the Edit Room form has no Features field', + ).toBeVisible({ timeout: 15_000 }); + await features.fill(feature); + await features.press('Enter'); + await expect( + modal.getByText(feature, { exact: false }).first(), + 'the feature was typed but never became a chip on the form, so ' + + 'nothing would be sent', + ).toBeVisible({ timeout: 15_000 }); + + await modal.getByRole('button', { name: 'Save', exact: false }).click(); + await expect( + modal.getByRole('button', { name: 'Save', exact: false }), + 'the form should close once the room is saved', + ).toBeHidden({ timeout: 30_000 }); + + await expect + .poll( + async () => + ( + await ( + await adminApi.get(`/api/engine/v2/systems/${room.id}`) + ).json() + ).features ?? [], + { + message: + 'the feature must be stored on the room system. This is ' + + 'also what unblocks the workplace picker\'s features ' + + 'filter (ROOM-29), which has been untestable because no ' + + 'room had a feature to filter on', + timeout: 20_000, + }, + ) + .toContain(feature); + } finally { + // Put the feature list back exactly as it was — this is a shared + // fixture, and the same `version` rule applies as for capacity. + const current = await ( + await adminApi.get(`/api/engine/v2/systems/${room.id}`) + ).json(); + if (JSON.stringify(current.features ?? []) !== JSON.stringify(original)) { + await adminApi.put( + `/api/engine/v2/systems/${room.id}?version=${current.version ?? 0}`, + { data: { features: original } }, + ); + const after = await ( + await adminApi.get(`/api/engine/v2/systems/${room.id}`) + ).json(); + expect( + after.features ?? [], + `could not restore the features of ${room.id}. It is a SHARED ` + + `fixture — reset it by hand before running either suite again`, + ).toEqual(original); + } + } + }); +}); \ No newline at end of file diff --git a/apps/concierge/e2e/local/concierge-staff.spec.ts b/apps/concierge/e2e/local/concierge-staff.spec.ts new file mode 100644 index 0000000000..38417bdf56 --- /dev/null +++ b/apps/concierge/e2e/local/concierge-staff.spec.ts @@ -0,0 +1,174 @@ +/** + * CON-STAFF-01 / 02 — concierge's staff directory, `/#/users/staff`. + * + * Blocked, and this file exists to say so with evidence rather than leaving the + * area silently untested. + * + * ## The cause is the same placeholder tenant as the calendar + * + * The staff listing reads `GET /api/staff/v1/people`, which is the calendar + * DIRECTORY surface — Microsoft/Google backed. `e2e/support/seed.ts` creates an + * office365 tenant with placeholder credentials on purpose, so the call leaves + * the stack and dies at Microsoft. Measured from the browser on 2026-09-17: + * loading `/#/users/staff` fires `/api/staff/v1/people` and it answers **500**. + * + * This is the same root cause as CON-DAY-02..07 (see `concierge-dayview.spec.ts`) + * and it is why the workplace desk specs force `app.basic_user_search`, which + * switches the host picker to `GET /api/engine/v2/users` — the local PlaceOS + * user list — instead of the directory. There is no equivalent switch for this + * page. + * + * ## What IS proven below + * + * Two things worth having even while the screen cannot be tested: + * + * 1. The local user list DOES answer, so the data concierge would need exists — + * the gap is the directory call, not the seeding. + * 2. The page fails without taking the app down: it still renders its shell + * and raises no uncaught exception. That is a real regression guard. + * + * The listing assertion itself stays `fixme`. + */ +import { expect, test } from '../../../../e2e/support/concierge/fixtures'; + +const STAFF_ROUTE = '/#/users/staff'; +const DIRECTORY_ENDPOINT = '/api/staff/v1/people'; + +test.describe('concierge staff directory', () => { + test('CON-STAFF-00: the seeded users exist locally, and the page survives the directory failing', async ({ + adminPage, + adminApi, + }) => { + // 1. The data exists in PlaceOS itself. + const local = await adminApi.get('/api/engine/v2/users', { + params: { limit: '200' }, + }); + expect( + local.ok(), + 'the local PlaceOS user list must answer — if this fails the problem is ' + + 'the seeding, not the directory', + ).toBe(true); + const users = await local.json(); + expect( + (Array.isArray(users) ? users : (users.results ?? [])).length, + 'the stack seeds an admin plus four staff users', + ).toBeGreaterThan(1); + + // 2. The page survives the directory call failing. + const crashes: string[] = []; + adminPage.on('pageerror', (error) => crashes.push(error.message)); + let directory_status: number | null = null; + adminPage.on('response', (response) => { + if (response.url().includes(DIRECTORY_ENDPOINT)) { + directory_status = response.status(); + } + }); + + await adminPage.goto(STAFF_ROUTE); + await expect( + adminPage.locator('staff-listings'), + 'the staff page should still render its listing component', + ).toBeAttached({ timeout: 45_000 }); + + await expect + .poll(() => directory_status, { + message: + `the staff page never called ${DIRECTORY_ENDPOINT}. If that is ` + + `now true, re-read this file's header — the premise has changed`, + timeout: 30_000, + }) + .not.toBeNull(); + + // eslint-disable-next-line no-console + console.log( + `CON-STAFF-00: ${DIRECTORY_ENDPOINT} answered ${directory_status} ` + + `(500 is expected on this stack — placeholder office365 tenant)`, + ); + + // Recorded, not asserted — see CON-B2 below. The failure this page DOES + // have is an unhandled rejection, and pinning it here would leave the + // suite red over a known finding instead of guarding the thing that + // matters: that the shell survives. + // eslint-disable-next-line no-console + console.log( + `CON-STAFF-00: ${crashes.length} uncaught page error(s): ` + + `${JSON.stringify(crashes)} — see CON-B2`, + ); + + await expect( + adminPage.locator('app-topbar'), + 'the shell must still be standing after the directory call fails', + ).toBeVisible(); + await expect( + adminPage.locator('app-sidebar'), + 'and the navigation must still work, so the user can leave the page', + ).toBeVisible(); + }); + + /** + * CON-B2 — the staff page leaves its failed directory call as an UNHANDLED + * REJECTION. + * + * Found 2026-09-17 while writing CON-STAFF-00, and worth separating from + * the environment blocker above, because this part is a code issue rather + * than a credentials issue. + * + * When `GET /api/staff/v1/people` fails, the rejected promise is never + * caught: the browser reports one uncaught error whose message is just + * `"Response"` — the raw failed HTTP response, thrown and unhandled. + * + * **The comparison is what makes this a finding rather than a shrug.** The + * day view makes the same kind of failing call to the same kind of + * calendar-backed endpoint (`/api/staff/v1/events`, also 500 here) and + * produces NO uncaught error at all — measured in + * `concierge-dayview.spec.ts`, CON-DAY-08. So this is not "the stack is + * broken so everything throws"; one page handles it and this one does not. + * + * Severity is low on its own — the page still renders and the shell + * survives — but an unhandled rejection means there is no error path here, + * so a real deployment whose directory is briefly unavailable shows the user + * nothing at all rather than a message. + * + * Remove the `.fixme` once the directory call has a `catch`. + */ + test.fixme( + 'CON-B2: the staff page handles a failing directory call without an unhandled rejection', + async ({ adminPage }) => { + const crashes: string[] = []; + adminPage.on('pageerror', (error) => crashes.push(error.message)); + await adminPage.goto(STAFF_ROUTE); + await expect(adminPage.locator('staff-listings')).toBeAttached({ + timeout: 45_000, + }); + // Long enough for the directory call to fail and the rejection to + // surface, which is the whole point. + await adminPage.waitForTimeout(8_000); + expect( + crashes, + 'a failing directory call should be handled, not left to surface as ' + + 'an uncaught "Response". The day view manages this (CON-DAY-08)', + ).toEqual([]); + }, + ); + + /** + * CON-STAFF-01/02 — blocked by the placeholder directory. + * + * CON-STAFF-01 the staff list shows the seeded users + * CON-STAFF-02 opening a user shows their bookings (depends on 01) + * + * To revive these: give this page a local-user-list fallback in the shape of + * `app.basic_user_search`, or point the stack at a real Microsoft tenant. + */ + test.fixme( + 'CON-STAFF-01/02: the staff list shows the seeded users — blocked by the placeholder directory', + async ({ adminApi }) => { + const res = await adminApi.get(DIRECTORY_ENDPOINT); + expect( + res.status(), + `${DIRECTORY_ENDPOINT} must answer before the staff directory can be ` + + `tested through the UI`, + ).toBe(200); + }, + ); +}); diff --git a/apps/concierge/e2e/local/concierge-visitors.spec.ts b/apps/concierge/e2e/local/concierge-visitors.spec.ts new file mode 100644 index 0000000000..eb217fa743 --- /dev/null +++ b/apps/concierge/e2e/local/concierge-visitors.spec.ts @@ -0,0 +1,340 @@ +/** + * CON-VIS-01 / 03 / 07 / 09 — the concierge visitor (guest) listing. + * + * CON-VIS-09 is a NEW id. The plan's CON-VIS-02 is "inviting a visitor FROM + * concierge stores the booking AND creates the guest record" — driving the + * invite form, which is still unwritten. Approving an invite is a different + * scenario and gets its own id rather than borrowing that one. + * + * The front desk's busiest screen, and the one place in this suite where + * checking a guest in can actually be tested end to end. + * + * ## How a visitor booking is shaped, which is not like the others + * + * For a desk or a parking space, `asset_id` is the thing being booked and + * `user_*` is the person. For a visitor it is inverted: + * + * asset_id / asset_name -> the VISITOR (their email, their name) + * user_* -> the HOST, the staff member being visited + * attendees: [{name, email}] -> creates the GUEST record + * + * `attendees` is not optional. Measured 2026-09-17: a visitor booking created + * without it is stored (201) and never appears on this screen, because the + * listing is built from guest records. That is a silent, confusing failure and + * the reason `inviteVisitor` below always sends it. + * + * ## Rows are found by EMAIL, and the email must be unique per test + * + * The listing renders the visitor as `email~email` — the name is never shown, + * even when the booking carries one. So the email is the only handle, and + * because cancelled invites stay on the screen indefinitely, re-using a fixed + * address makes "the row for my visitor" ambiguous the second time a test runs. + * Every test here mints its own address. + * + * ## CON-VIS-01 and CON-VIS-07 are the same test + * + * The plan lists "the listing shows an invite" and "the listing shows visitors + * invited by OTHER hosts" separately. Here they collapse: the concierge is + * signed in as the admin and every invite these specs make is hosted by a plain + * staff user, so the first test already proves the second. Recorded rather than + * quietly dropped. + */ +import { expect, test } from '../../../../e2e/support/concierge/fixtures'; +import { + cleanUpBookings, + createBookingFor, +} from '../../../../e2e/support/concierge/concierge.api'; +import { + chooseRowMenuItem, + columnIndex, + confirmDialog, + readTable, + waitForRow, +} from '../../../../e2e/support/concierge/simple-table.page'; +import { getBooking } from '../../../../e2e/support/api'; +import { roleFor } from '../../../../e2e/support/env'; + +const VISITORS_ROUTE = '/#/book/visitors'; + +/** The HOST: a plain staff member, never the concierge doing the looking. */ +const HOST = roleFor('staff', 2); + +/** A visitor address nobody else will be using — see the file header. */ +function uniqueVisitor(tag: string) { + const stamp = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`; + return { + email: `con-${tag}-${stamp}@example.com`.toLowerCase(), + name: `CON ${tag} Visitor ${stamp}`, + }; +} + +/** + * A window containing now. + * + * The listing shows a single day and defaults to today, and check-in is only + * offered while the visit is actually running. + */ +function windowAroundNow(minutes = 45) { + const now = Math.floor(Date.now() / 1000); + return { start: now - minutes * 60, end: now + minutes * 60 }; +} + +test.describe('concierge visitors', () => { + const created: number[] = []; + const guests: string[] = []; + + test.afterEach(async ({ adminApi }) => { + await cleanUpBookings(adminApi, created.splice(0)); + // The guest record OUTLIVES the booking, so both have to go or the + // listing fills up with orphans across runs. + for (const email of guests.splice(0)) { + await adminApi + .delete(`/api/staff/v1/guests/${encodeURIComponent(email)}`) + .catch(() => undefined); + } + }); + + async function inviteVisitor( + adminApi: Parameters[0], + tag: string, + ) { + const visitor = uniqueVisitor(tag); + const { start, end } = windowAroundNow(); + const booking = await createBookingFor(adminApi, { + type: 'visitor', + asset_id: visitor.email, + asset_name: visitor.name, + title: `CON-${tag}`, + start, + end, + holder: { email: HOST.email }, + // Without this the booking is stored and never listed — see header. + extra: { + attendees: [{ name: visitor.name, email: visitor.email }], + }, + }); + created.push(booking.id); + guests.push(visitor.email); + return { visitor, booking }; + } + + test('CON-VIS-01/07: the listing shows an invite hosted by another user', async ({ + adminPage, + adminApi, + }) => { + const { visitor } = await inviteVisitor(adminApi, 'VIS-01'); + + await adminPage.goto(VISITORS_ROUTE); + const row = await waitForRow(adminPage, visitor.email); + const snapshot = await readTable(adminPage); + + expect( + row[columnIndex(snapshot, 'asset_name')], + 'the visitor column must identify the guest', + ).toContain(visitor.email); + expect( + row[columnIndex(snapshot, 'user_name')], + 'and the host column must name the staff member being visited — which ' + + 'is NOT the concierge. This is the whole difference between this ' + + 'screen and the workplace one', + ).toContain(HOST.email); + }); + + test('CON-VIS-09: approving a visitor from the listing is stored on the backend', async ({ + adminPage, + adminApi, + }) => { + const { visitor, booking } = await inviteVisitor(adminApi, 'VIS-09'); + + expect( + (await getBooking(adminApi, booking.id)).approved, + 'a new invite should not already be approved', + ).toBe(false); + + await adminPage.goto(VISITORS_ROUTE); + await waitForRow(adminPage, visitor.email); + await chooseRowMenuItem( + adminPage, + visitor.email, + 'status', + 'Approve Visitor', + ); + // UNLIKE THE DESK EQUIVALENT, this opens a confirmation dialog + // ("Approve attendance of to their meeting?"). Without + // accepting it, no request is sent at all and the assertion below fails + // while looking like a backend problem. `required` so that the dialog + // quietly disappearing in a future release fails here, loudly, instead + // of silently weakening the test. + await confirmDialog(adminPage, { required: true }); + + await expect + .poll( + async () => { + const after = await getBooking(adminApi, booking.id); + return { approved: after.approved, rejected: after.rejected }; + }, + { + message: + 'approving a visitor from the front desk must reach the ' + + 'backend. The row re-rendering is not evidence', + timeout: 20_000, + }, + ) + .toEqual({ approved: true, rejected: false }); + }); + + test('CON-VIS-03: checking a visitor in from the listing is recorded', async ({ + adminPage, + adminApi, + }) => { + const { visitor, booking } = await inviteVisitor(adminApi, 'VIS-03'); + + expect( + (await getBooking(adminApi, booking.id)).checked_in, + 'a new invite should not already be checked in', + ).toBe(false); + + await adminPage.goto(VISITORS_ROUTE); + await waitForRow(adminPage, visitor.email); + // "Check-in Guest" lives in the row's `actions` overflow menu, alongside + // View Attachments / Set visitor as Remote / Email Guest / Set Pass + // Number — not in a column of its own. + await chooseRowMenuItem( + adminPage, + visitor.email, + 'actions', + 'Check-in Guest', + ); + + await expect + .poll( + async () => (await getBooking(adminApi, booking.id)).checked_in, + { + message: + 'checking a guest in at the front desk is what this app is ' + + 'for, and it must be recorded. If this fails, check the visit ' + + 'window first — check-in is only offered while the booking is ' + + 'actually running', + timeout: 20_000, + }, + ) + .toBe(true); + }); + + test('CON-VIS-04: a checked-in visitor can be checked out, and not checked out twice', async ({ + adminPage, + adminApi, + }) => { + const { visitor, booking } = await inviteVisitor(adminApi, 'VIS-04'); + + await adminPage.goto(VISITORS_ROUTE); + await waitForRow(adminPage, visitor.email); + await chooseRowMenuItem(adminPage, visitor.email, 'actions', 'Check-in Guest'); + await expect + .poll(async () => (await getBooking(adminApi, booking.id)).checked_in, { + message: 'the visitor has to be checked IN before checking out means anything', + timeout: 20_000, + }) + .toBe(true); + + // Now out again. The menu item only appears once they are in, which is + // itself the app refusing to check out somebody who never arrived. + await chooseRowMenuItem(adminPage, visitor.email, 'actions', 'Check-out Guest'); + await expect + .poll(async () => (await getBooking(adminApi, booking.id)).checked_in, { + message: + 'checking a guest out at the front desk must reach the backend. ' + + 'A visitor left checked in is counted as on-site, which is what ' + + 'the contact-tracing report is built from', + timeout: 20_000, + }) + .toBe(false); + + // And it must not be offered again. `checked_in` is already false, so a + // second check-out is meaningless — the control should be gone rather + // than silently doing nothing. + await adminPage.reload(); + await waitForRow(adminPage, visitor.email); + const snapshot = await readTable(adminPage); + const width = snapshot.column_ids.length; + const row = snapshot.rows.findIndex((r) => + r.some((cell) => cell.includes(visitor.email)), + ); + await adminPage + .locator('simple-table [role=table] > *:not([header])') + .nth(row * width + columnIndex(snapshot, 'actions')) + .locator('button, [matripple]') + .first() + .click(); + const menu = adminPage.locator('.mat-mdc-menu-panel, [role="menu"]'); + await expect(menu, 'the actions menu did not open').toBeVisible({ + timeout: 15_000, + }); + await expect( + menu.getByText('Check-out Guest', { exact: false }), + 'a visitor who is already checked out must not be offered check-out ' + + 'again. If it is still there, the menu is built from something other ' + + 'than the booking\'s actual state', + ).toHaveCount(0); + }); + + test('CON-VIS-08: searching the listing narrows it to the matching visitor', async ({ + adminPage, + adminApi, + }) => { + // TWO invites, so "narrowed" can be told apart from "happened to show + // one row". A search test with a single record in the listing proves + // nothing at all. + const first = await inviteVisitor(adminApi, 'VIS-08a'); + const second = await inviteVisitor(adminApi, 'VIS-08b'); + + await adminPage.goto(VISITORS_ROUTE); + await waitForRow(adminPage, first.visitor.email); + await waitForRow(adminPage, second.visitor.email); + + const search = adminPage.locator('searchbar input').first(); + await expect(search, 'the visitor listing has no search box').toBeVisible({ + timeout: 20_000, + }); + await search.fill(first.visitor.email); + + await expect + .poll( + async () => { + const snapshot = await readTable(adminPage); + const flat = snapshot.rows.map((r) => r.join(' ')); + return { + kept: flat.filter((r) => r.includes(first.visitor.email)).length, + other: flat.filter((r) => r.includes(second.visitor.email)).length, + }; + }, + { + message: + 'searching for one visitor must keep their row and drop the ' + + 'other. Note these listings carry dozens of cancelled rows from ' + + 'earlier runs, so this asserts on the two addresses this test ' + + 'created rather than on the total row count', + timeout: 25_000, + }, + ) + .toEqual({ kept: 1, other: 0 }); + + // Clearing it brings the other one back — otherwise a search that simply + // broke the listing would pass the half above. + await search.fill(''); + await expect + .poll( + async () => { + const snapshot = await readTable(adminPage); + return snapshot.rows + .map((r) => r.join(' ')) + .filter((r) => r.includes(second.visitor.email)).length; + }, + { + message: 'clearing the search must restore the rows it hid', + timeout: 25_000, + }, + ) + .toBe(1); + }); +}); diff --git a/apps/concierge/e2e/local/concierge-zones.spec.ts b/apps/concierge/e2e/local/concierge-zones.spec.ts new file mode 100644 index 0000000000..ea476b226b --- /dev/null +++ b/apps/concierge/e2e/local/concierge-zones.spec.ts @@ -0,0 +1,152 @@ +/** + * CON-ZONE-01 — concierge's zone management, `/#/zone-management`. + * + * ## There is ONE page, with three tabs — and the default tab is empty + * + * `level-management`, `building-management` and `region-management` are all + * redirects to `zone-management` (`app.routes.ts`), which renders three tabs: + * **Regions · Buildings · Levels**. + * + * The default tab is Regions, and the stack seeds **no region zone at all** + * (measured: `GET /zones?tags=region` returns 0, against 1 org, 1 building and + * 2 levels). So the page a reader lands on says "no regions" and looks broken, + * while the hierarchy that IS seeded sits one tab across. That cost a first + * look, and it is why this file asserts per tab rather than on "the page". + * + * ## The `display_name` column shows the zone ID + * + * Also measured: the Buildings and Levels tabs render the zone **id** + * (`zone-Kof5bAwbrl`) in their "Name" column, because `seed.ts` creates zones + * with a `name` and no `display_name`, and the column falls back to the id + * rather than to the name. Not raised as a finding — the underlying data + * genuinely has no display name, so this is arguably correct — but it does mean + * a test must not assert on a friendly name here. The assertions below use the + * relationship columns (`building`, `level_count`) instead, which are the part + * that carries real meaning. + * + * ## CON-ZONE-02/03/04 are not written + * + * Creating a level, renaming a zone and deleting a zone all WRITE to the org + * hierarchy, and a failed teardown there leaves a broken org behind for every + * other spec in both suites. The plan flagged that as a decision rather than a + * detail and it is still open — see the Questions tab of the workbook. + */ +import { expect, test } from '../../../../e2e/support/concierge/fixtures'; +import { + columnIndex, + readTable, + waitForAnyRow, +} from '../../../../e2e/support/concierge/simple-table.page'; +import { zonesWithTag } from '../../../../e2e/support/api'; + +const ZONE_ROUTE = '/#/zone-management'; + +/** Switch tab and wait for its grid to carry rows. */ +async function openTab(page: any, name: RegExp) { + const tab = page.getByRole('tab', { name }).first(); + await expect(tab, `no "${name}" tab on the zone management page`).toBeVisible({ + timeout: 30_000, + }); + await tab.click(); +} + +test.describe('concierge zone management', () => { + test('CON-ZONE-01: the Buildings tab shows the seeded building and its level count', async ({ + adminPage, + adminApi, + }) => { + const buildings = await zonesWithTag(adminApi, 'building'); + const levels = await zonesWithTag(adminApi, 'level'); + expect( + buildings.length, + 'the stack should have seeded exactly one building', + ).toBeGreaterThan(0); + + await adminPage.goto(ZONE_ROUTE); + await openTab(adminPage, /Buildings/i); + const snapshot = await waitForAnyRow(adminPage); + + expect( + snapshot.rows.length, + `the Buildings tab should list ${buildings.length} building(s)`, + ).toBe(buildings.length); + + // The level COUNT is the assertion worth having: it proves the page is + // walking the hierarchy rather than just listing zones. + const count_col = columnIndex(snapshot, 'level_count'); + expect( + snapshot.rows[0][count_col], + `the building should report ${levels.length} level(s), matching ` + + `GET /zones?tags=level. If this is 0 the page is not resolving ` + + `children, which is the whole point of the column`, + ).toContain(String(levels.length)); + }); + + test('CON-ZONE-01b: the Levels tab shows every seeded level, under its building', async ({ + adminPage, + adminApi, + }) => { + const [building] = await zonesWithTag(adminApi, 'building'); + const levels = await zonesWithTag(adminApi, 'level'); + + await adminPage.goto(ZONE_ROUTE); + await openTab(adminPage, /Levels/i); + const snapshot = await waitForAnyRow(adminPage); + + expect( + snapshot.rows.length, + `the Levels tab should list all ${levels.length} seeded level(s). ` + + `Note the stack seeds TWO — the ordinary level and the parking ` + + `level the workplace parking specs created`, + ).toBe(levels.length); + + // Every row must name the building it hangs off, or the hierarchy is + // being flattened. + const building_col = columnIndex(snapshot, 'building'); + for (const row of snapshot.rows) { + expect( + row[building_col], + `each level must name its parent building (${building.name}). A ` + + `blank here means the level is orphaned or the page is not ` + + `resolving the parent`, + ).toContain(building.name); + } + }); + + test('CON-ZONE-01c: the Regions tab is empty on this stack, and says so cleanly', async ({ + adminPage, + adminApi, + }) => { + const regions = await zonesWithTag(adminApi, 'region'); + // Guard the premise. If somebody seeds a region later this test should + // start failing loudly rather than quietly asserting the wrong thing. + expect( + regions.length, + 'this test documents the EMPTY case. A region now exists, so rewrite ' + + 'it to assert the region is listed instead', + ).toBe(0); + + const crashes: string[] = []; + adminPage.on('pageerror', (error) => crashes.push(error.message)); + + await adminPage.goto(ZONE_ROUTE); + await openTab(adminPage, /Regions/i); + + // The header renders even with no rows, so this asserts the table is + // there and the body is empty — an empty state, not a broken page. + const snapshot = await readTable(adminPage); + expect( + snapshot.column_ids.length, + 'the Regions tab should still render its columns with no data', + ).toBeGreaterThan(0); + expect( + snapshot.rows.length, + 'and no rows, because no region zone is seeded', + ).toBe(0); + expect( + crashes, + 'an empty Regions tab must not throw. This is the tab the page OPENS ' + + 'on, so a crash here is the first thing a user would see', + ).toEqual([]); + }); +}); diff --git a/apps/concierge/playwright.config.ts b/apps/concierge/playwright.config.ts index 2f14ce7023..e0475fc700 100644 --- a/apps/concierge/playwright.config.ts +++ b/apps/concierge/playwright.config.ts @@ -3,62 +3,113 @@ import { defineConfig, devices } from '@playwright/test'; import { workspaceRoot } from '@nx/devkit'; -const baseURL = process.env['BASE_URL'] || 'http://localhost:4215'; +// `concierge.env.ts` pulls in `e2e/support/env.ts`, which self-loads e2e/.env +// before reading any E2E_* var — so importing it here is enough to apply it. +import { + CONCIERGE_URL, + assertConciergeLocal, +} from '../../e2e/support/concierge/concierge.env'; +import { BACKEND_URL } from '../../e2e/support/env'; + +// Fail fast, loudly, before a browser launches, if anything points at a backend +// that is not local. These specs create and delete real data, and concierge can +// write other people's bookings and the org hierarchy — so the guard matters +// more here than anywhere else in the suite. +assertConciergeLocal(); + +const CI = !!process.env.CI; +const PORT = new URL(CONCIERGE_URL).port || '4215'; export default defineConfig({ ...nxE2EPreset(__filename, { testDir: './e2e' }), + timeout: 90_000, + expect: { timeout: 15_000 }, + fullyParallel: true, + // ONE worker, unlike workplace. + // + // Concierge is an admin surface: every spec signs in as the same single + // admin identity, so parallel workers would share one user and one set of + // data rather than being isolated by it. The workplace suite parallelises + // safely because seed.ts gives each worker its own staff user and its own + // desk; there is no equivalent here, and inventing one would mean seeding + // several admins. + workers: 1, + forbidOnly: CI, + retries: CI ? 2 : 0, reporter: [ ['list'], - [ - 'html', - { outputFolder: '../../reports/e2e/concierge', open: 'never' }, - ], + ['html', { outputFolder: '../../reports/e2e/concierge', open: 'never' }], + ...(CI + ? ([ + ['json', { outputFile: '../../reports/e2e/concierge-results.json' }], + ['github'], + ] as const) + : []), ], use: { - baseURL, + baseURL: CONCIERGE_URL, + // The local stack terminates TLS with a self-signed cert. + ignoreHTTPSErrors: true, trace: 'on-first-retry', - }, - webServer: { - command: 'bunx nx serve concierge --port=4215', - url: 'http://localhost:4215', - reuseExistingServer: !process.env.CI, - cwd: workspaceRoot, - timeout: 120000, + screenshot: 'only-on-failure', + video: 'retain-on-failure', + actionTimeout: 20_000, + navigationTimeout: 45_000, }, projects: [ + // --- preflight: prove the stack is up, for `local` only ------------- + // A setup project rather than `globalSetup`, so the mock project below + // does not need a backend. Shared with the workplace suite: one stack, + // one check. { - name: 'chromium', + name: 'preflight', + testDir: '../../e2e/support', + testMatch: /preflight\.setup\.ts$/, use: { ...devices['Desktop Chrome'] }, }, + // --- real local backend, no mocks ---------------------------------- + // Auth comes from e2e/support/concierge/fixtures.ts, which mints a + // storage state bound to THIS origin — a workplace session is useless + // here, because the token lives in the other origin's localStorage. { - name: 'firefox', - use: { ...devices['Desktop Firefox'] }, + name: 'local', + testDir: './e2e/local', + dependencies: ['preflight'], + use: { ...devices['Desktop Chrome'] }, }, + // --- mock mode: no backend at all ---------------------------------- + // The two specs that were already here (dayview, lockers) run in + // `?mock=true` and match on English button text. Kept as they are: they + // cost no infrastructure and catch pure render breakage. Nothing new + // should be added to them — see the plan in notes/. { - name: 'webkit', - use: { ...devices['Desktop Safari'] }, + name: 'mock', + testDir: './e2e', + testIgnore: ['**/local/**'], + use: { ...devices['Desktop Chrome'] }, }, - - // Uncomment for mobile browsers support - /* { - name: 'Mobile Chrome', - use: { ...devices['Pixel 5'] }, - }, - { - name: 'Mobile Safari', - use: { ...devices['iPhone 12'] }, - }, */ - - // Uncomment for branded browsers - /* { - name: 'Microsoft Edge', - use: { ...devices['Desktop Edge'], channel: 'msedge' }, - }, - { - name: 'Google Chrome', - use: { ...devices['Desktop Chrome'], channel: 'chrome' }, - } */ ], + webServer: { + command: `bunx nx serve concierge --port=${PORT}`, + url: CONCIERGE_URL, + reuseExistingServer: !CI, + cwd: workspaceRoot, + timeout: 180_000, + // MUST be set, exactly as the workplace config does it. Without these + // the dev server's /api + /auth proxy keeps its default target, which + // is the SHARED DEV DEPLOYMENT (config/proxy.conf.js) — so the app + // authenticates against a real authority, the local token is refused + // 401, and the browser is redirected out to real Microsoft. Worse, the + // specs write data, and concierge writes other people's bookings and + // the org hierarchy: pointing it at a live deployment is exactly what + // `assertConciergeLocal()` exists to prevent, and that guard only + // checks E2E_BACKEND_URL, never the proxy target. + env: { + PLACE_PROXY_DOMAIN: new URL(BACKEND_URL).host, + PLACE_PROXY_SECURE: String(new URL(BACKEND_URL).protocol === 'https:'), + PLACE_PROXY_VALID_SSL: 'false', + }, + }, }); diff --git a/apps/workplace/e2e/local/room-assets.spec.ts b/apps/workplace/e2e/local/room-assets.spec.ts new file mode 100644 index 0000000000..08815e81d8 --- /dev/null +++ b/apps/workplace/e2e/local/room-assets.spec.ts @@ -0,0 +1,317 @@ +/** + * ROOM-24 — equipment requested on a meeting. + * + * ## The headline: it cannot be done on a PlaceOS-native room booking + * + * Measured 2026-09-17, and it is a NEW finding — **ROOM-B9**. I expected this to + * work from reading the code and it does not, which is worth saying plainly + * because the code genuinely looks like it should. + * + * `validateAssetRequestsForResource` (`libs/assets/src/lib/assets.fn.ts`) has a + * branch for exactly this case: + * + * createBooking(new Booking(asset_data), { + * ical_uid, + * event_id: from_booking ? '' : id, + * }); + * + * So when the parent is a booking rather than a calendar event, `event_id` is + * meant to be left empty and `parent_id` used instead. **That branch is not + * reached** — `from_booking` is falsy on the object the meeting form hands over, + * so `event_id` is sent as the room BOOKING's id and the backend refuses: + * + * POST /api/staff/v1/bookings (booking_type: asset-request) + * -> 422 {"error":"error linking booking to event", + * "failures":[{"field":"event_id", + * "reason":"Could not find metadata for event ARRAY['2008']"}]} + * + * The request payload proves the client half was right: it carried + * `extension_data.parent_id: 2008` and `request.event_id: ""`. Only the + * top-level `event_id` option was wrong. + * + * ## Same cause as ROOM-B5, better outcome + * + * ROOM-B5 is the catering version of this: the order is refused with the + * identical "error linking booking to event" 422. So the two findings share a + * root cause — sub-bookings of a native room booking are linked by `event_id`, + * and in this mode there is no event. + * + * What is left behind afterwards is different, and better: + * + * ROOM-B5 (catering) -> recorded as leaving the room booking UNDELETED and + * held, with no order placed. + * ROOM-B9 (equipment) -> **the room booking is rolled back.** Measured: the + * meeting came back `deleted: true`, and no + * `asset-request` booking exists. The user is told it + * failed and nothing is left held. + * + * So ROOM-B9 is a feature that cannot be used, not a booking left in a lying + * state — lower severity than ROOM-B5. + * + * **One thing for whoever picks this up:** both flows call the same + * `_removeBookingAfterError(!event.id, created_event, …)`, and with `is_new` + * true that helper calls `removeEvent` and rolls back regardless of which flow + * failed. That does not square with ROOM-B5's recorded "the room survives", + * so either something else differs between the two paths or **ROOM-B5's + * rollback observation is worth re-measuring.** Not resolved here, and not + * guessed at. + * + * ## What still passes, and it is worth having + * + * ROOM-24b below is active and green: equipment ALLOCATION works even though + * requesting it through a meeting does not. With two of three units held by + * existing `asset-request` bookings, the picker offers exactly one — so + * `queryGroupAvailability` really does read existing requests, and two meetings + * cannot be handed the same projector. That is the half of ROOM-24 that can be + * guarded today. + * + * ## Two preconditions, neither of which fails loudly + * + * - `app.events.has_assets` (ROOM_ASSETS_MODE) or the form has no equipment + * section at all. + * - requestable assets seeded (`asset.seed.ts`) or the modal opens empty. + * + * The group offered is the asset TYPE, not an individual unit: the app allocates + * specific units itself. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { + deleteBooking, + listBookings, + uniqueTitle, +} from '../../../../e2e/support/api'; +import { + ROOM_SLOTS_3, + THIRD_DAY, + slotFor, +} from '../../../../e2e/support/room/room.env'; +import { roomForWorker } from '../../../../e2e/support/room/room.seed'; +import { equipmentCatalogue } from '../../../../e2e/support/room/asset.seed'; +import { releaseRoom } from '../../../../e2e/support/room/room.api'; +import { + ROOM_ASSETS_MODE, + ROOM_BASE_SETTINGS, + useSettings, +} from '../../../../e2e/support/room/room.settings'; +import { MeetingForm } from '../../../../e2e/support/room/meeting-form.page'; +import { bookRoomViaUI, setDate } 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) + 21 * DAY; + +/** Every `asset-request` booking in the window that names one of our units. */ +async function ourAssetRequests( + api: Parameters[0], + unit_ids: string[], +) { + const all = await listBookings(api, 'asset-request', window_from(), window_to()); + return all.filter((booking) => { + const ids = [ + booking.asset_id, + ...((booking.asset_ids as string[] | undefined) ?? []), + ].filter(Boolean); + return ids.some((id) => unit_ids.includes(id as string)); + }); +} + +test.describe('room booking equipment requests', () => { + // `fixme` on ROOM-B9 — see the file header. The assertions below are what + // SHOULD happen; drop the `fixme` once an asset request can be linked to a + // booking rather than only to a calendar event. + test.fixme('ROOM-24: equipment requested on a meeting is stored as its own booking, linked to it', async ({ + staffPage, + staffApi, + }) => { + const kit = await equipmentCatalogue(); + const room = await roomForWorker(test.info().parallelIndex); + const slot = slotFor(ROOM_SLOTS_3.assets.hour, THIRD_DAY); + const title = uniqueTitle('E2E Assets'); + const unit_ids = kit.items.map((item) => item.id); + + // Clear both the room and any leftover request on our units, so a + // previous run cannot make this pass or fail for the wrong reason. + await releaseRoom(staffApi, room.id, window_from(), window_to()); + for (const stale of await ourAssetRequests(staffApi, unit_ids)) { + await deleteBooking(staffApi, stale.id); + } + + await useSettings(staffPage, { ...ROOM_BASE_SETTINGS, ...ROOM_ASSETS_MODE }); + + try { + // Through the shared flow rather than the form directly. It owns the + // load-bearing order — the DATE first and exactly once, then title + // and room through the REG-10 retry, then the times last — and + // getting that wrong is what made the first attempt at this spec + // fail on a start time the form had never been asked to offer. + const meeting = await bookRoomViaUI(staffPage, staffApi, room, title, { + date: slot.date_ms, + startTime: `${String(ROOM_SLOTS_3.assets.hour).padStart(2, '0')}:00`, + assets: [kit.type_name], + }); + expect( + meeting?.id, + 'the meeting itself was not stored, so nothing can be said about its ' + + 'equipment. Check the room specs first — this is their subject', + ).toBeTruthy(); + + // 2. The request reached the backend as its own booking. + await expect + .poll(async () => (await ourAssetRequests(staffApi, unit_ids)).length, { + message: + 'no `asset-request` booking was stored. This is the quiet ' + + 'failure the test exists for: the meeting is booked, the screen ' + + 'says it worked, and the equipment was never requested. If the ' + + 'response was 422 "error linking booking to event", equipment ' + + 'has regressed onto the calendar-only path that breaks catering ' + + '(ROOM-B5)', + timeout: 25_000, + }) + .toBe(1); + + const [request] = await ourAssetRequests(staffApi, unit_ids); + + // 3. It carries a real unit from our catalogue. + const allocated = [ + request.asset_id, + ...((request.asset_ids as string[] | undefined) ?? []), + ].filter(Boolean); + expect( + allocated.some((id) => unit_ids.includes(id as string)), + `the request was stored but against none of our units. It holds ` + + `${JSON.stringify(allocated)}; the catalogue is ` + + `${JSON.stringify(unit_ids)}`, + ).toBe(true); + + // 4. It belongs to the person who asked for it — the signed-in + // staff user, not the room and not the admin. + const me = await ( + await staffApi.get('/api/engine/v2/users/current') + ).json(); + expect( + String(request.user_email || '').toLowerCase(), + 'the request must be stored against the requester. If it comes back ' + + 'as the room or as nobody, the equipment cannot be traced to who ' + + 'asked for it', + ).toBe(String(me.email).toLowerCase()); + + // 5. It is tied back to the meeting rather than floating free. The + // native path links by `parent_id` (assets.fn.ts) rather than by + // `event_id`, so accept either and say which was found. + const extension = (request.extension_data ?? {}) as Record; + const link = + (request as Record).parent_id ?? + extension.parent_id ?? + (request as Record).event_id; + expect( + link, + 'the request is not linked to anything. An orphaned asset-request ' + + 'cannot be cancelled with its meeting, so the equipment stays held ' + + 'after the meeting is gone. Checked parent_id, ' + + 'extension_data.parent_id and event_id; the request holds ' + + `${JSON.stringify(extension)}`, + ).toBeTruthy(); + } finally { + for (const stale of await ourAssetRequests(staffApi, unit_ids)) { + await deleteBooking(staffApi, stale.id); + } + await releaseRoom(staffApi, room.id, window_from(), window_to()); + } + }); + + test('ROOM-24b: a unit already requested is not offered to an overlapping meeting', async ({ + staffPage, + staffApi, + }) => { + const kit = await equipmentCatalogue(); + const room = await roomForWorker(test.info().parallelIndex); + const slot = slotFor(ROOM_SLOTS_3.assets.second, THIRD_DAY); + const unit_ids = kit.items.map((item) => item.id); + + await releaseRoom(staffApi, room.id, window_from(), window_to()); + for (const stale of await ourAssetRequests(staffApi, unit_ids)) { + await deleteBooking(staffApi, stale.id); + } + + // Hold every unit but one, straight through the API — the subject here is + // what the PICKER offers, not the booking form. + const held: number[] = []; + try { + for (const item of kit.items.slice(0, kit.items.length - 1)) { + const res = await staffApi.post('/api/staff/v1/bookings', { + data: { + booking_type: 'asset-request', + asset_id: item.id, + asset_ids: [item.id], + asset_name: item.name, + title: uniqueTitle('E2E Held Asset'), + booking_start: slot.start, + booking_end: slot.end, + timezone: 'Etc/UTC', + zones: [kit.zone_id], + }, + }); + expect( + res.ok(), + `could not hold ${item.name} for the test: HTTP ${res.status()} ` + + `${(await res.text()).slice(0, 200)}`, + ).toBe(true); + held.push((await res.json()).id); + } + + await useSettings(staffPage, { + ...ROOM_BASE_SETTINGS, + ...ROOM_ASSETS_MODE, + }); + // No booking is made here, so this drives the form directly rather + // than going through `bookRoomViaUI` — but the DATE still has to be + // set before the times, or the form offers today's hours and the + // slot's hour is simply absent. + const form = new MeetingForm(staffPage); + await form.open(); + await setDate(form, staffPage, slot.date_ms); + await staffPage.waitForTimeout(2_000); + await form.chooseRoom(room.name); + await form.setStartTime( + `${String(ROOM_SLOTS_3.assets.second).padStart(2, '0')}:00`, + ); + await staffPage.waitForTimeout(3_000); + + await staffPage + .locator('asset-list-field') + .getByRole('button', { name: /Request Assets/i }) + .first() + .click(); + const modal = staffPage.locator('asset-select-modal'); + await expect(modal, 'the asset modal did not open').toBeVisible({ + timeout: 20_000, + }); + + // The count the modal shows is the assertion. `queryGroupAvailability` + // removes any unit covered by a live `asset-request`, so with all but + // one held it must offer exactly one. + const listing = modal.locator('asset-list'); + await expect( + listing.getByText(kit.type_name, { exact: false }).first(), + 'the equipment group should still be listed while one unit is free', + ).toBeVisible({ timeout: 20_000 }); + await expect + .poll(async () => (await listing.innerText()).replace(/\s+/g, ' '), { + message: + `with ${held.length} of ${kit.items.length} units held, the ` + + `modal must offer exactly ${kit.items.length - held.length}. ` + + `If it still offers all of them, availability is not reading ` + + `existing asset-request bookings and two meetings can be given ` + + `the same projector`, + timeout: 25_000, + }) + .toContain(`${kit.items.length - held.length} available`); + } finally { + for (const id of held) await deleteBooking(staffApi, id); + for (const stale of await ourAssetRequests(staffApi, unit_ids)) { + await deleteBooking(staffApi, stale.id); + } + await releaseRoom(staffApi, room.id, window_from(), window_to()); + } + }); +}); diff --git a/apps/workplace/e2e/local/room-recurring.spec.ts b/apps/workplace/e2e/local/room-recurring.spec.ts new file mode 100644 index 0000000000..53429ef9eb --- /dev/null +++ b/apps/workplace/e2e/local/room-recurring.spec.ts @@ -0,0 +1,339 @@ +/** + * ROOM-25 — a recurring room booking stores its pattern, and the instances exist. + * + * ## The headline: a recurring meeting booked through the app disappears + * + * Measured 2026-09-17, and it is a NEW finding — **ROOM-B10**. It is the worst + * of the room findings by outcome, because the user is shown a success screen + * and then cannot find the booking anywhere. + * + * Choosing "Weekly on Tuesday" on the form and confirming gives: + * + * POST /api/staff/v1/bookings -> 201 + * { booking_type: 'room', recurrence_type: 'daily', + * recurrence_days: 0, recurrence_interval: 1 } <- no day, and no end + * + * `recurrence_type: 'daily'` is CORRECT and not the bug: `toBookingRecurrence` + * deliberately encodes a weekly pattern as daily-plus-a-weekday-bitmask + * (`libs/common/src/lib/recurrence.ts`). The bug is that the bitmask is **0** — + * no weekday at all — so the pattern matches no day, and: + * + * GET /bookings/ -> 200, the row is there + * GET /bookings?type=room&period_start=… -> the booking is NOWHERE + * + * Tried with five different query shapes, including an eleven-day window with + * `limit=500`, `include_deleted`, `include_checked_out`, an `email` filter and a + * `zones` filter. Only a direct fetch by id finds it. So the meeting is stored, + * the user is told it worked, and no listing, schedule or cancel screen can ever + * show it to them. + * + * ## Why the cause is certainly client-side, and that is what the control proves + * + * The second test below is ACTIVE and passing. It creates the same recurrence + * **correctly formed** straight through the API — `recurrence_days: 8` + * (Wednesday), a `recurrence_end`, and `timezone`, which is mandatory for a + * recurring booking and is a 422 without it — and the backend then behaves + * perfectly: + * + * Wed +6d -> listed + * Thu +7d -> NOT listed (correct: weekly on Wednesday) + * Wed +13d -> listed + * Wed +20d -> listed + * + * So recurrence expansion works. The app's own payload is what is malformed. + * Keeping that control in the file is the difference between "recurring bookings + * are broken" and a finding a developer can act on in one sitting. + * + * Cause, in one line: `toBookingRecurrence` builds the bitmask from + * `r.weekdays`, and the form's model reaches it with `weekdays` empty — the UI + * derives the label "Weekly on Tuesday" from the chosen date, but never puts + * that day in the model. + * + * ## Preconditions + * + * `app.events.allow_recurrence` (ROOM_RECURRENCE_MODE) or the control is not + * rendered. The component also hides it for meetings longer than 24 hours, so a + * recurrence test cannot also be an all-day test. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { STAFF_API, deleteBooking, uniqueTitle } from '../../../../e2e/support/api'; +import { + ROOM_SLOTS_3, + THIRD_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, + ROOM_RECURRENCE_MODE, + useSettings, +} from '../../../../e2e/support/room/room.settings'; +import { MeetingForm } from '../../../../e2e/support/room/meeting-form.page'; +import { setDate } from '../../../../e2e/support/room/room.flows'; + +const DAY = 86_400; + +/** Midnight-to-midnight, local, for a day this many days out. */ +function dayBounds(days_ahead: number) { + const start = new Date(); + start.setDate(start.getDate() + days_ahead); + start.setHours(0, 0, 0, 0); + const from = Math.floor(start.valueOf() / 1000); + return { from, to: from + DAY }; +} + +/** Room bookings on one local day. */ +async function roomBookingsOn( + api: Parameters[0], + days_ahead: number, +) { + const { from, to } = dayBounds(days_ahead); + const res = await api.get(`${STAFF_API}/bookings`, { + params: { + type: 'room', + period_start: String(from), + period_end: String(to), + limit: '500', + }, + }); + expect(res.ok(), `GET /bookings failed: HTTP ${res.status()}`).toBe(true); + const body = await res.json(); + return (Array.isArray(body) ? body : (body.results ?? [])) as Array< + Record + >; +} + +test.describe('recurring room bookings', () => { + /** + * `fixme` on ROOM-B10. The assertions are what SHOULD happen; drop the + * `fixme` once the form puts the chosen weekday into the model. + */ + test.fixme('ROOM-25: a recurring meeting stores its pattern and its instances appear', async ({ + staffPage, + staffApi, + }) => { + const room = await roomForWorker(test.info().parallelIndex); + const slot = slotFor(ROOM_SLOTS_3.recurring.hour, THIRD_DAY); + const title = uniqueTitle('E2E Recurring'); + const created: number[] = []; + + await releaseRoom( + staffApi, + room.id, + Math.floor(Date.now() / 1000) - 2 * DAY, + Math.floor(Date.now() / 1000) + 30 * DAY, + ); + + await useSettings(staffPage, { + ...ROOM_BASE_SETTINGS, + ...ROOM_RECURRENCE_MODE, + }); + const form = new MeetingForm(staffPage); + await form.open(); + // The DATE first: the recurrence options are named after the weekday of + // the chosen date ("Weekly on Tuesday"), so they are wrong before it is + // set, and the start-time list is for the wrong day too. + await setDate(form, staffPage, slot.date_ms); + await staffPage.waitForTimeout(2_000); + + const recurrence = staffPage.locator('recurrence-field mat-select'); + await expect( + recurrence, + 'the recurrence control is missing — is `app.events.allow_recurrence` ' + + 'set? See ROOM_RECURRENCE_MODE', + ).toBeVisible({ timeout: 20_000 }); + await recurrence.click(); + const weekly = staffPage.locator('mat-option').filter({ hasText: /^Weekly on/ }); + await expect( + weekly.first(), + 'the recurrence menu offered no "Weekly on " option', + ).toBeVisible({ timeout: 15_000 }); + await weekly.first().click(); + await staffPage.waitForTimeout(2_000); + + await form.title.fill(title); + await form.chooseRoom(room.name); + await staffPage.waitForTimeout(2_000); + await form.setStartTime( + `${String(ROOM_SLOTS_3.recurring.hour).padStart(2, '0')}:00`, + ); + await staffPage.waitForTimeout(4_000); + + // PROVE the form still holds the pattern immediately before sending, so + // "the app threw the choice away" can be told apart from "the form + // reverted itself" — the same guard the rest of the room specs carry. + await expect( + recurrence, + 'the recurrence field must still show a weekly pattern at send time', + ).toContainText(/Weekly on/, { timeout: 10_000 }); + + try { + await form.confirmAndSend(); + await expect( + form.successPanel, + 'the meeting was refused outright', + ).toBeVisible({ timeout: 30_000 }); + + // 1. The FIRST instance must be findable on its own day. This is the + // assertion that fails today: the booking exists but no listing + // returns it, because its weekday bitmask is empty. + await expect + .poll( + async () => { + const rows = await roomBookingsOn(staffApi, THIRD_DAY); + return rows.filter( + (booking) => booking.asset_id === room.id, + ).length; + }, + { + message: + 'the recurring meeting does not appear in the bookings ' + + 'listing for its own day. It IS stored — a direct GET ' + + '/bookings/ returns it — but it carries ' + + 'recurrence_days: 0, so it matches no day and no listing, ' + + 'schedule or cancel screen can show it. The user was shown ' + + 'a success screen for a meeting they can never find again', + timeout: 30_000, + }, + ) + .toBeGreaterThan(0); + + const [first] = (await roomBookingsOn(staffApi, THIRD_DAY)).filter( + (booking) => booking.asset_id === room.id, + ); + created.push(first.id); + + // 2. The pattern must name the weekday it was booked on. + expect( + first.recurrence_days, + 'the stored pattern must include the chosen weekday as a bit flag. ' + + '0 means "repeats, on no day" — see recurrence.ts, ' + + 'toBookingRecurrence. Note recurrence_type "daily" is CORRECT ' + + 'here: a weekly pattern is deliberately encoded as daily plus a ' + + 'weekday bitmask', + ).toBeGreaterThan(0); + + const expected_bit = 1 << new Date(slot.date_ms).getDay(); + expect( + Number(first.recurrence_days) & expected_bit, + `the pattern must include the weekday of the booked date ` + + `(bit ${expected_bit}), and nothing else about it matters`, + ).toBe(expected_bit); + + // 3. And the later instances must exist, a week apart. + for (const week of [1, 2]) { + const rows = await roomBookingsOn(staffApi, THIRD_DAY + week * 7); + expect( + rows.filter((booking) => booking.asset_id === room.id).length, + `a weekly meeting must also be held ${week} week(s) later. ` + + `Without this a "recurring" booking is a single meeting`, + ).toBeGreaterThan(0); + } + } finally { + for (const id of created) await deleteBooking(staffApi, id); + await releaseRoom( + staffApi, + room.id, + Math.floor(Date.now() / 1000) - 2 * DAY, + Math.floor(Date.now() / 1000) + 30 * DAY, + ); + } + }); + + /** + * The control that makes ROOM-B10 actionable. + * + * Active and passing. It sends the recurrence the app SHOULD have sent — + * straight through the API, no form — and proves the backend expands it + * correctly. Without this, ROOM-B10 reads as "recurring room bookings do not + * work", which would send somebody looking in the wrong place. + */ + test('ROOM-25b: a correctly formed weekly recurrence is expanded by the backend', async ({ + staffApi, + }) => { + const room = await roomForWorker(test.info().parallelIndex); + // Two days clear of the fixme test's slot, so the two cannot contend for + // the same room. + const days_ahead = THIRD_DAY + 1; + const start = new Date(); + start.setDate(start.getDate() + days_ahead); + start.setHours(14, 0, 0, 0); + const booking_start = Math.floor(start.valueOf() / 1000); + const weekday_bit = 1 << start.getDay(); + const title = uniqueTitle('E2E Recurring API'); + + await releaseRoom( + staffApi, + room.id, + booking_start - DAY, + booking_start + 30 * DAY, + ); + + let id: number | undefined; + try { + const res = await staffApi.post(`${STAFF_API}/bookings`, { + data: { + booking_type: 'room', + asset_id: room.id, + asset_name: room.name, + booking_start, + booking_end: booking_start + 3600, + title, + // MANDATORY for a recurring booking. Without it the POST is a + // 422 `timezone: required for recurring bookings`, which is a + // much better error than the app's silent success. + timezone: 'Australia/Sydney', + recurrence_type: 'daily', + recurrence_days: weekday_bit, + recurrence_interval: 1, + recurrence_end: booking_start + 30 * DAY, + }, + }); + expect( + res.ok(), + `creating a well-formed recurring booking failed: HTTP ` + + `${res.status()} ${(await res.text()).slice(0, 300)}`, + ).toBe(true); + const stored = await res.json(); + id = stored.id; + + expect( + stored.recurrence_days, + 'the backend stored the weekday bitmask it was given', + ).toBe(weekday_bit); + + // The same day, one and two weeks on: present. This is the behaviour + // the app's own booking never gets. + for (const week of [0, 1, 2]) { + const rows = await roomBookingsOn( + staffApi, + days_ahead + week * 7, + ); + expect( + rows.some((booking) => booking.id === id), + `a weekly recurrence must be returned for week ${week}. If this ` + + `fails, recurrence expansion itself is broken and ROOM-B10 is ` + + `not only a client-side problem`, + ).toBe(true); + } + + // And NOT on the following day, or the pattern is really daily. + const next_day = await roomBookingsOn(staffApi, days_ahead + 1); + expect( + next_day.some((booking) => booking.id === id), + 'a weekly recurrence must NOT be returned for the day after. If it ' + + 'is, the daily-plus-bitmask encoding is being ignored and every ' + + 'weekly meeting silently holds the room every day', + ).toBe(false); + } finally { + if (id) await deleteBooking(staffApi, id); + await releaseRoom( + staffApi, + room.id, + booking_start - DAY, + booking_start + 30 * DAY, + ); + } + }); +}); diff --git a/apps/workplace/e2e/local/visitor-concurrency.spec.ts b/apps/workplace/e2e/local/visitor-concurrency.spec.ts new file mode 100644 index 0000000000..688654f52a --- /dev/null +++ b/apps/workplace/e2e/local/visitor-concurrency.spec.ts @@ -0,0 +1,399 @@ +/** + * VIS-28 — concurrent visitor writes are either stored or not, never "both". + * + * ## What this asserts, and why it is not a flakiness test + * + * The plan's note on this row warned that "a burst spec would encode REG-09 + * flakiness", and it was right about the obvious version of this test. Firing N + * writes and asserting all N succeed would go red whenever the stack is under + * load, teaching everyone to ignore it. + * + * So this asserts the INVARIANT instead, which is what REG-09 actually breaks: + * + * a 2xx response => the row MUST be readable afterwards + * a non-2xx => no row must be left behind + * + * Failures are allowed and counted. Only *disagreement between the answer and + * the stored state* fails the test. That is deterministic whatever the load, and + * it is exactly the property the REG-09 retry in the visitor, desk and parking + * helpers works around: + * + * REG-09 / PPT-2642 — under concurrent booking writes on a staff-api image + * predating pg-orm 2.2.4, `POST /bookings` has answered **201 with an id** + * whose row was never committed, so a later `GET /bookings/` 404s. The + * COMMIT fails after the response has been written. + * + * A phantom 201 is the worst shape of this: the app believes it booked, tells + * the user so, and nothing exists. Every "the app lost my invite" report starts + * here, which is why the row is P1 rather than P2. + * + * ## Group invites, and why the burst is of members rather than whole groups + * + * A group invite is one `group` container plus one `visitor` booking per person, + * each carrying `parent_id`. Building whole groups concurrently would make the + * TEST responsible for rolling a half-built group back — and then a failure + * would be measuring this file's error handling rather than the stack's. So the + * burst is of the individual member writes, which is where the atomicity + * question really lives, and the group invariant is checked separately in the + * second test: a container must never be left holding fewer members than were + * written for it. + * + * ## No retry here, deliberately + * + * `createBookingViaApi` retries on REG-09 shapes, which is right for every other + * spec and wrong for this one — a retry would hide the very thing being + * measured. These writes go straight at the API. + * + * ## Both are `fixme`, and the plan's own caution was correct + * + * The plan's note on this row said "a burst spec would encode REG-09 flakiness; + * the reproducer stays the record until the image is fixed". I tried to design + * around that by asserting the invariant rather than success — and then measured + * it, four consecutive runs on 2026-09-17: + * + * run 1 2/8 accepted, 6 refused (400) invariant HELD + * run 2 2/8 accepted, 6 refused (400) invariant HELD + * run 3 3/8 accepted, 5 refused (400) **PHANTOM WRITE** — invariant broke + * run 4 2/8 accepted, 6 refused (400) invariant HELD + * + * So the invariant itself fails intermittently — roughly one run in four. The + * note was right: left active, this file would be red about 25% of the time, + * which is how a real finding gets trained out of a team's attention. + * + * Both tests are therefore `test.fixme`, in the same style as the ROOM-B* and + * VIS-B* specs: written, disabled, and turning green the day the defect is + * fixed. Two things worth taking from the measurement even so: + * + * - Only **2 or 3 of 8** concurrent writes are accepted at all. The other five + * or six are refused `400 Expected BeginObject but was EOF` — staff-api + * losing the request body, which is VIS-B6's shape. That is a far higher + * failure rate than the retry's four attempts imply, and is worth knowing + * before anyone tunes that retry. + * - When a write IS accepted, it is usually real. The phantom 201 is the rarer + * and more dangerous case. + * + * **This is the row that decides when the REG-09 retry can be deleted.** Drop + * both `fixme`s and run this file repeatedly: once it is reliably green, the + * retries in the visitor, desk and parking helpers are no longer earning their + * keep. Until then `e2e/support/repro/reg09-concurrent-bookings.ts` and + * `vis-b6-burst.ts` remain the record. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { STAFF_API, deleteBooking, uniqueTitle } from '../../../../e2e/support/api'; +import { + VISITOR_SLOTS, + visitorFor, +} from '../../../../e2e/support/visitor/visitor.env'; +import { + deleteGuest, + releaseGroupContainers, + releaseVisitor, +} from '../../../../e2e/support/visitor/visitor.api'; +import { zonesWithTag } from '../../../../e2e/support/api'; + +const DAY = 86_400; +const window_from = () => Math.floor(Date.now() / 1000) - 2 * DAY; +const window_to = () => Math.floor(Date.now() / 1000) + 60 * DAY; + +/** How many writes go out at once. */ +const BURST = VISITOR_SLOTS.concurrency.count; + +interface Attempt { + status: number; + id?: number; + email: string; + body: string; +} + +test.describe('concurrent visitor writes', () => { + test.fixme('VIS-28: every accepted write is stored, and every refused one leaves nothing', async ({ + staffApi, + }) => { + const me = await (await staffApi.get('/api/engine/v2/users/current')).json(); + const zones = ( + await Promise.all( + ['org', 'building', 'level'].map((tag) => zonesWithTag(staffApi, tag)), + ) + ) + .flat() + .map((zone) => zone.id); + + const worker = test.info().parallelIndex; + const visitors = Array.from({ length: BURST }, (_, index) => + visitorFor(worker, VISITOR_SLOTS.concurrency.base + index), + ); + const start = Math.floor(Date.now() / 1000) + 30 * DAY; + + // Clean first: a leftover booking on one of these addresses would make + // "a row exists" true for the wrong reason. + for (const visitor of visitors) { + await releaseVisitor(staffApi, visitor.email, window_from(), window_to()); + await deleteGuest(staffApi, visitor.email); + } + + const created: number[] = []; + try { + // All at once, no retry, no stagger. The whole point is contention. + const attempts: Attempt[] = await Promise.all( + visitors.map(async (visitor, index): Promise => { + const res = await staffApi.post(`${STAFF_API}/bookings`, { + data: { + booking_type: 'visitor', + asset_id: visitor.email, + asset_name: visitor.name, + booking_start: start + index * 3600, + booking_end: start + index * 3600 + 1800, + timezone: 'Etc/UTC', + user_email: me.email, + user_id: me.id, + user_name: me.name, + title: uniqueTitle('E2E Burst'), + zones, + attendees: [ + { name: visitor.name, email: visitor.email }, + ], + }, + }); + const body = await res.text(); + let id: number | undefined; + try { + id = JSON.parse(body)?.id; + } catch { + /* a non-JSON error body is itself the evidence */ + } + if (typeof id === 'number') created.push(id); + return { status: res.status(), id, email: visitor.email, body }; + }), + ); + + const accepted = attempts.filter( + (attempt) => attempt.status >= 200 && attempt.status < 300, + ); + const refused = attempts.filter( + (attempt) => attempt.status < 200 || attempt.status >= 300, + ); + + // Recorded, NOT asserted. How many got through depends on the load and + // on the staff-api image, and pinning it is what would make this test + // a flake detector instead of a correctness test. + // eslint-disable-next-line no-console + console.log( + `VIS-28: ${accepted.length}/${BURST} accepted, ${refused.length} refused. ` + + `Refusal statuses: ${JSON.stringify(refused.map((a) => a.status))}`, + ); + + // INVARIANT 1 — a 2xx must mean the row is there. This is the phantom + // 201 that REG-09 produces. + const phantoms: Attempt[] = []; + for (const attempt of accepted) { + expect( + attempt.id, + `a ${attempt.status} response carried no booking id: ` + + `${attempt.body.slice(0, 200)}`, + ).toBeTruthy(); + const read = await staffApi.get(`${STAFF_API}/bookings/${attempt.id}`); + if (!read.ok()) phantoms.push(attempt); + } + expect( + phantoms.map((attempt) => ({ + id: attempt.id, + status: attempt.status, + })), + `PHANTOM WRITES. These POSTs answered 2xx with an id, and the row ` + + `cannot be read back — so the app has been told it booked ` + + `something that does not exist. This is REG-09 / PPT-2642: the ` + + `COMMIT fails after the response is written. It is the reason the ` + + `visitor, desk and parking helpers all carry a retry, and it must ` + + `be fixed in staff-api (pg-orm 2.2.4+) before that retry can be ` + + `removed`, + ).toEqual([]); + + // INVARIANT 2 — a refusal must leave nothing behind. + const ghosts: string[] = []; + for (const attempt of refused) { + const res = await staffApi.get(`${STAFF_API}/bookings`, { + params: { + type: 'visitor', + period_start: String(window_from()), + period_end: String(window_to()), + limit: '500', + }, + }); + if (!res.ok()) continue; + const rows = await res.json(); + const list = (Array.isArray(rows) ? rows : (rows.results ?? [])) as Array< + Record + >; + if (list.some((row) => row.asset_id === attempt.email && !row.deleted)) { + ghosts.push(attempt.email); + } + } + expect( + ghosts, + `GHOST WRITES. These POSTs were REFUSED and a live booking exists for ` + + `them anyway, so the user was told their invite failed while the ` + + `visitor is expected at the door`, + ).toEqual([]); + } finally { + for (const id of created) await deleteBooking(staffApi, id); + for (const visitor of visitors) { + await releaseVisitor( + staffApi, + visitor.email, + window_from(), + window_to(), + ); + await deleteGuest(staffApi, visitor.email); + } + await releaseGroupContainers(staffApi, window_from(), window_to()); + } + }); + + test.fixme('VIS-28b: a group container is never left holding fewer members than were written', async ({ + staffApi, + }) => { + const me = await (await staffApi.get('/api/engine/v2/users/current')).json(); + const zones = ( + await Promise.all( + ['org', 'building', 'level'].map((tag) => zonesWithTag(staffApi, tag)), + ) + ) + .flat() + .map((zone) => zone.id); + + const worker = test.info().parallelIndex; + const members = 3; + const visitors = Array.from({ length: members }, (_, index) => + visitorFor(worker, VISITOR_SLOTS.concurrency.base + BURST + index), + ); + const start = Math.floor(Date.now() / 1000) + 45 * DAY; + + for (const visitor of visitors) { + await releaseVisitor(staffApi, visitor.email, window_from(), window_to()); + await deleteGuest(staffApi, visitor.email); + } + + const created: number[] = []; + try { + // The container first, as the app does. + const container_res = await staffApi.post(`${STAFF_API}/bookings`, { + data: { + booking_type: 'group', + asset_id: `e2e-group-${worker}-${start}`, + asset_name: 'E2E Burst Group', + booking_start: start, + booking_end: start + 1800, + timezone: 'Etc/UTC', + user_email: me.email, + user_id: me.id, + user_name: me.name, + title: uniqueTitle('E2E Burst Group'), + zones, + }, + }); + expect( + container_res.ok(), + `the group container could not be created: HTTP ` + + `${container_res.status()} ${(await container_res.text()).slice(0, 200)}`, + ).toBe(true); + const container = await container_res.json(); + created.push(container.id); + + // Then every member AT ONCE, which is the part that can half-fail. + const results = await Promise.all( + visitors.map(async (visitor, index) => { + const res = await staffApi.post(`${STAFF_API}/bookings`, { + data: { + booking_type: 'visitor', + asset_id: visitor.email, + asset_name: visitor.name, + booking_start: start, + booking_end: start + 1800, + timezone: 'Etc/UTC', + user_email: me.email, + user_id: me.id, + user_name: me.name, + title: uniqueTitle('E2E Burst Member'), + zones, + parent_id: container.id, + attendees: [{ name: visitor.name, email: visitor.email }], + }, + }); + const body = await res.text(); + let id: number | undefined; + try { + id = JSON.parse(body)?.id; + } catch { + /* the body is the evidence */ + } + if (typeof id === 'number') created.push(id); + return { ok: res.ok(), status: res.status(), id, email: visitor.email }; + }), + ); + + const written = results.filter((result) => result.ok); + // eslint-disable-next-line no-console + console.log( + `VIS-28b: ${written.length}/${members} members written to container ` + + `${container.id}`, + ); + + // Every member the backend ACCEPTED must be readable and must still + // point at its container. A member that lost its `parent_id` is + // worse than one that failed: it becomes an invisible invite that no + // group screen will ever show or cancel. + for (const result of written) { + const read = await staffApi.get(`${STAFF_API}/bookings/${result.id}`); + expect( + read.ok(), + `member ${result.email} answered ${result.status} with id ` + + `${result.id}, and the row cannot be read back — a phantom ` + + `write inside a group (REG-09)`, + ).toBe(true); + const stored = await read.json(); + expect( + String(stored.parent_id ?? ''), + `member ${result.email} was stored without a link to its group ` + + `container. It is then an invite no group screen can show, ` + + `move or cancel — the failure PPT-2638 was about`, + ).toBe(String(container.id)); + } + + // And the container must not claim more members than exist. + const listed = await staffApi.get(`${STAFF_API}/bookings`, { + params: { + type: 'visitor', + period_start: String(start - DAY), + period_end: String(start + DAY), + limit: '500', + }, + }); + expect(listed.ok()).toBe(true); + const rows = await listed.json(); + const list = (Array.isArray(rows) ? rows : (rows.results ?? [])) as Array< + Record + >; + const attached = list.filter( + (row) => String(row.parent_id ?? '') === String(container.id) && !row.deleted, + ); + expect( + attached.length, + `the container holds ${attached.length} live member(s) but ` + + `${written.length} were accepted. A container and its members ` + + `must agree, or the group is partially written`, + ).toBe(written.length); + } finally { + for (const id of created) await deleteBooking(staffApi, id); + for (const visitor of visitors) { + await releaseVisitor( + staffApi, + visitor.email, + window_from(), + window_to(), + ); + await deleteGuest(staffApi, visitor.email); + } + await releaseGroupContainers(staffApi, window_from(), window_to()); + } + }); +}); diff --git a/e2e/support/concierge/concierge.api.ts b/e2e/support/concierge/concierge.api.ts new file mode 100644 index 0000000000..a3fbe29026 --- /dev/null +++ b/e2e/support/concierge/concierge.api.ts @@ -0,0 +1,335 @@ +/** + * Backend helpers for the CONCIERGE specs. + * + * ## What this file is for that the shared `../api.ts` is not + * + * Concierge's whole purpose is acting on OTHER people's data, so nearly every + * spec needs a booking that belongs to somebody who is not the signed-in user. + * The workplace helpers create bookings for the caller; these create them for a + * named holder, with the concierge admin recorded as the booker. That + * holder/booker split is the thing most concierge screens display, so getting it + * right in setup is most of the work. + * + * ## The zone chain is not optional — this cost a session to find + * + * Concierge's listings filter by the **building** zone, not the level. Measured + * on 2026-09-17: a desk booking created with `zones: []` alone is stored + * happily (201) and is then INVISIBLE on `/#/book/desks/events`, because the + * page queries `zones=`. A booking must carry the whole org → + * building → level chain to be seen. `withZoneChain` is what guarantees that, + * and no concierge spec should build a `zones` array by hand. + * + * ## The REG-09 retry + * + * Same retry, for the same reason, as the desk/visitor/parking helpers + * (REG-09 / PPT-2642: a burst of booking writes poisons a staff-api connection + * and an unrelated request fails on it). 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, + CurrentUser, + STAFF_API, + currentUser, + deleteBooking, + zonesWithTag, +} from '../api'; + +/** The three zone tags a booking must carry to be visible in concierge. */ +const ZONE_TAGS = ['org', 'building', 'level'] as const; + +/** Every seeded zone id in the org → building → level chain. */ +export async function withZoneChain(api: APIRequestContext): Promise { + const groups = await Promise.all(ZONE_TAGS.map((t) => zonesWithTag(api, t))); + const ids = groups.flat().map((z) => z.id); + if (!ids.length) { + throw new Error( + `No org/building/level zones found. The stack is not seeded — run e2e/stack/up.sh.`, + ); + } + return [...new Set(ids)]; +} + +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 load, `POST /bookings` has answered 201 + * with an id that `GET /bookings/` then 404'd, because the COMMIT failed + * after the response was written (REG-09). 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(); +} + +export interface BookingForOptions { + /** `desk`, `parking`, `locker`, … */ + type: string; + asset_id: string; + asset_name?: string; + title: string; + start: number; + end: number; + /** Who the booking is FOR. Defaults to the caller, but rarely should. */ + holder?: { email: string; id?: string; name?: string }; + /** Extra fields for types that need them. */ + extra?: Record; +} + +/** + * Create a booking FOR somebody else, as the concierge would. + * + * `user_*` is the holder; `booked_by_*` is the signed-in concierge. Concierge + * screens show both, and a spec that sets only one cannot tell the "Person" and + * "Booked By" columns apart. + */ +export async function createBookingFor( + api: APIRequestContext, + options: BookingForOptions, +): Promise { + const me: CurrentUser = await currentUser(api); + const holder = options.holder ?? me; + const zones = await withZoneChain(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: 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 ?? me.email, + title: options.title, + zones, + ...(options.extra ?? {}), + }; + + 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 booking = JSON.parse(body) as Booking; + if (await persisted(api, booking.id)) return booking; + // 201 but not committed — REG-09. Treat as a retryable failure. + last = `201 for id ${booking.id}, but GET /bookings/${booking.id} then 404'd (REG-09)`; + } else { + last = `HTTP ${res.status()} ${body.slice(0, 300)}`; + if (!isConcurrentWriteFailure(res.status(), body)) break; + } + await new Promise((r) => setTimeout(r, 400 * attempt)); + } + throw new Error( + `Could not create a ${options.type} booking for ` + + `${(options.holder ?? me).email}: ${last}`, + ); +} + +/** Delete a list of bookings, best effort. Teardown must never fail a test. */ +export async function cleanUpBookings( + api: APIRequestContext, + ids: Array, +): Promise { + for (const id of ids) if (typeof id === 'number') await deleteBooking(api, id); +} + +/** + * A seeded desk to book, as an `{id, name}` pair. + * + * Desks are zone METADATA rather than assets (`listChildMetadata(building, + * {name: 'desks'})`), which is why this reads metadata instead of asking for + * something desk-shaped. Returns them in the order the stack seeded them, so + * `seededDesks(api)[1]` is stable across runs. + */ +export async function seededDesks( + api: APIRequestContext, +): Promise> { + const [building] = await zonesWithTag(api, 'building'); + if (!building) throw new Error('No building zone — the stack is not seeded.'); + const res = await api.get(`/api/engine/v2/metadata/${building.id}/children`, { + params: { name: 'desks' }, + }); + if (!res.ok()) { + throw new Error( + `Could not read desk metadata for ${building.id}: HTTP ${res.status()}`, + ); + } + const body = (await res.json()) as Array<{ + metadata?: { desks?: { details?: Array<{ id: string; name: string }> } }; + }>; + // DEDUPE BY ID, and do not remove this. + // + // The stack seeds TWO level zones under the building, and the same five + // desks are listed on both — so this endpoint returns ten entries with five + // distinct ids (measured 2026-09-17). Booking "the second desk" off a + // non-deduped list would silently pick a duplicate of the first. + const seen = new Set(); + const desks = body + .flatMap((entry) => entry?.metadata?.desks?.details ?? []) + .filter((d) => d && d.id) + .filter((d) => (seen.has(d.id) ? false : (seen.add(d.id), true))); + if (!desks.length) { + throw new Error( + `No desks in the "desks" metadata under ${building.id}. The stack seeds ` + + `five (\`e2e-desk-0\` … \`e2e-desk-4\`) — run e2e/stack/up.sh.`, + ); + } + return desks.map((d) => ({ id: d.id, name: d.name ?? d.id })); +} + +/* ------------------------------------------------------------------ parking */ + +/** + * Parking spaces to book against, found or created. + * + * ## Why this does not call the workplace `ensureParking()` + * + * `e2e/support/parking/parking.seed.ts` mints its own admin context with + * `apiFor('admin', 0)`, which reads the TOKEN FILE the workplace fixtures + * write (`e2e/.auth/admin-0.token.json`). The concierge fixtures do not write + * that file — they write a storage state bound to the concierge origin — so a + * concierge spec calling it would either fail outright or, worse, quietly use + * yesterday's expired token. This takes the caller's context instead. + * + * Idempotent, and it reuses whatever the workplace parking seeder already made: + * the zone is matched by name, so the two never create competing hierarchies. + * + * ## The shape of a parking space + * + * A space is an ASSET, not zone metadata (unlike a desk), living on a zone + * tagged BOTH `level` and `parking` — `level` is what the org hierarchy reads + * and `parking` is what `ParkingService.levels` filters on, and one without the + * other leaves the level invisible to one of them. + */ +const PARKING_LEVEL_NAME = 'E2E Parking Level'; +const PARKING_CATEGORY = '_PARKING_'; +// These three MUST match `e2e/support/parking/parking.seed.ts` exactly, or this +// seeder creates a parallel set of fixtures instead of reusing the workplace +// ones — and spaces hung off a second type are invisible to the +// parking-management page, which filters on the original. `_PARKING_SPACES_` is +// the real type name; it is not `PARKING:SPACE`. +const PARKING_TYPE = '_PARKING_SPACES_'; + +async function findOrCreate( + api: APIRequestContext, + collection: string, + match: (row: Record) => boolean, + body: Record, + query: Record = {}, +): Promise> { + const list = await api.get(`/api/engine/v2/${collection}`, { + params: { limit: '200', ...query }, + }); + if (list.ok()) { + const parsed = await list.json(); + const rows = Array.isArray(parsed) ? parsed : (parsed.results ?? []); + const found = rows.find(match); + if (found) return found; + } + const res = await api.post(`/api/engine/v2/${collection}`, { data: body }); + if (!res.ok()) { + throw new Error( + `Could not create ${collection}: HTTP ${res.status()} ` + + `${(await res.text()).slice(0, 300)}`, + ); + } + return res.json(); +} + +/** + * `name_prefix` defaults to the workplace suite's spaces, but the concierge + * specs pass their OWN prefix. + * + * The concierge parking listing has **no title column** (measured: state, date, + * asset_id, user_name, booked_by_name, plate_number, status, actions), so a row + * cannot be found by the unique title trick every other area uses. The space + * name is the only per-test handle available — which means each test needs a + * space nobody else is using, rather than sharing `E2E Parking 0..4` with the + * workplace parking specs. + */ +export async function ensureParkingSpaces( + api: APIRequestContext, + count = 2, + name_prefix = 'E2E Parking', +): Promise<{ zone_id: string; spaces: Array<{ id: string; name: string }> }> { + const [building] = await zonesWithTag(api, 'building'); + if (!building) throw new Error('No building zone — the stack is not seeded.'); + + const zone = await findOrCreate( + api, + 'zones', + (z) => z.name === PARKING_LEVEL_NAME, + { + name: PARKING_LEVEL_NAME, + display_name: PARKING_LEVEL_NAME, + description: 'Parking level owned by the e2e suite. Safe to delete.', + tags: ['level', 'parking'], + parent_id: building.id, + }, + { tags: 'parking' }, + ); + + // MATCH BY NAME ACROSS EVERYTHING, with no narrowing query params. + // + // This was got wrong once and the failure is worth recording. Looking the + // category up with `?hidden=true` and the type up with + // `?category_id=` did NOT find the rows the + // workplace parking seeder had already made, so this created a SECOND + // `_PARKING_` category and a SECOND `PARKING:SPACE` type — and the spaces + // then hung off a type the parking-management page does not filter on, so + // they existed and were invisible. Matching on the name alone is what keeps + // this seeder and the workplace one pointing at the same fixtures. + const category = await findOrCreate( + api, + 'asset_categories', + (c) => c.name === PARKING_CATEGORY, + { name: PARKING_CATEGORY, hidden: true }, + ); + const type = await findOrCreate( + api, + 'asset_types', + (t) => t.name === PARKING_TYPE, + { + 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.', + }, + ); + + const spaces: Array<{ id: string; name: string }> = []; + for (let i = 0; i < count; i++) { + const name = `${name_prefix} ${i}`; + const asset = await findOrCreate( + api, + 'assets', + (a) => a.name === name, + { + name, + identifier: name, + zone_id: zone.id, + asset_type_id: type.id, + description: `${name_prefix.toLowerCase().replace(/\s+/g, '-')}-${i}`, + }, + ); + spaces.push({ id: String(asset.id), name }); + } + return { zone_id: String(zone.id), spaces }; +} diff --git a/e2e/support/concierge/concierge.env.ts b/e2e/support/concierge/concierge.env.ts new file mode 100644 index 0000000000..9cc6076593 --- /dev/null +++ b/e2e/support/concierge/concierge.env.ts @@ -0,0 +1,89 @@ +/** + * Config and identities for the CONCIERGE app's e2e specs. + * + * ## Concierge is a second app, not a second area + * + * Everything else in this suite tests `workplace` on port 4214. Concierge is its + * own Angular app on **4215**, with its own Playwright config, so it needs its + * own app URL and its own storage state: a browser session minted for one origin + * is useless to the other, because the token lives in that origin's + * `localStorage`. + * + * What IS shared is the stack and the seeded identities — one backend, one org, + * the same users. Nothing else. + * + * ## It runs as ADMIN, deliberately + * + * Concierge is a front-desk and management app: it reads and writes every user's + * bookings, and its manager pages (zones, levels, buildings, rooms) are + * administrative. The seeded non-admin staff users cannot do that, so the specs + * sign in as the admin identity. + * + * That choice is exactly why `concierge-access.spec.ts` exists: if concierge is + * tested as an admin, then "a plain staff user cannot get in" has to be asserted + * separately, or the whole permission boundary goes untested. + */ +import { APP_URL, BACKEND_URL, assertLocalOnly } from '../env'; + +/** + * Where the concierge app is served. + * + * Overridable for the same reason `APP_URL` is: a developer running the app on + * another port should not have to edit a spec. Defaults to what the app's own + * Playwright config serves. + */ +export const CONCIERGE_URL = + process.env.E2E_CONCIERGE_URL ?? 'http://localhost:4215'; + +/** Refuse to run against anything that is not local. Same guard as workplace. */ +export function assertConciergeLocal(): void { + assertLocalOnly(BACKEND_URL, APP_URL, CONCIERGE_URL); +} + +/** + * A day and an hour per spec file. + * + * Concierge reads the same bookings the workplace specs create, so its slots + * have to stay clear of theirs: desks own today and +1 to +4, rooms +3 and +4, + * Your Bookings +5 to +13, the home page +16 to +20. Concierge takes +22 + * onwards. + * + * The day view is the exception — it shows TODAY and cannot be moved off it for + * the tests that are about today. + */ +export const CONCIERGE_SLOTS = { + /** concierge-dayview.spec.ts */ + dayview: { day: 22, hour: 9 }, + /** concierge-dayview.spec.ts, the other-user test */ + dayview_other: { day: 22, hour: 14 }, + /** concierge-visitors.spec.ts */ + visitors: { day: 23, hour: 9 }, + /** concierge-visitors.spec.ts, the other-host test */ + visitors_other: { day: 23, hour: 14 }, + /** concierge-desks.spec.ts */ + desks: { day: 24, 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/concierge/concierge.seed.ts b/e2e/support/concierge/concierge.seed.ts new file mode 100644 index 0000000000..630d74752a --- /dev/null +++ b/e2e/support/concierge/concierge.seed.ts @@ -0,0 +1,127 @@ +/** + * Seeding the CONCIERGE origin. + * + * ## The one thing concierge needs that workplace already has + * + * ts-client derives `client_id = Md5(redirect_uri)` at runtime, and + * `redirect_uri` is built from the app's own origin. So an OAuth application is + * per-ORIGIN, not per-deployment: the row the workplace suite seeds is for + * `http://localhost:4214/oauth-resp.html` and is useless to a page on 4215. + * + * Measured before this file existed — minting an admin token for the concierge + * origin against a stack seeded only for workplace: + * + * HTTP 401 authorize did not return a code for support@place.tech + * + * That is the whole of the blocker. No missing feature, no permissions problem: + * the authorize endpoint simply had no application whose `uid` matched the hash + * of the concierge redirect URI. Registering one is enough. + * + * ## Why this is not a change to `e2e/support/seed.ts` + * + * The shared seed is what the desk, visitor, room, parking and home specs all + * depend on to boot, and it is the dev's file. Adding concierge's needs to it + * would mean every one of those suites now fails to seed if anything concierge + * wants is unavailable. This file is owned by the concierge specs, is called + * from the concierge fixtures, and is idempotent — so the shared seed keeps + * exactly the blast radius it had. + */ +import { APIRequestContext, request as pwRequest } from '@playwright/test'; +import { BACKEND_URL, roleFor } from '../env'; +import { ENGINE_API } from '../api'; +import { clientId, mintToken, redirectUriFor } from '../auth'; +import { CONCIERGE_URL, assertConciergeLocal } from './concierge.env'; + +/** + * The app `init` always creates. The bootstrap problem is the same one the + * shared seed has: registering an OAuth application needs a token, and minting + * a token needs a registered application — so come in through `backoffice`. + */ +const BOOTSTRAP_APP_URL = `${BACKEND_URL}/backoffice`; + +/** Does this error body mean "the row is already there"? */ +function alreadyExists(body: string): boolean { + return /already (exists|taken)|has already been taken|must be unique|should be unique|duplicate/i.test( + body, + ); +} + +async function bootstrapApi(): Promise { + const admin = roleFor('admin'); + const mint = await mintToken( + BACKEND_URL, + BOOTSTRAP_APP_URL, + admin.email, + admin.password, + ); + return pwRequest.newContext({ + baseURL: BACKEND_URL, + ignoreHTTPSErrors: true, + extraHTTPHeaders: { Authorization: `Bearer ${mint.accessToken}` }, + }); +} + +/** + * Register the OAuth application for the concierge dev-server origin, if it is + * not there already. Returns what it found or made, for the log line. + * + * Idempotent twice over: it checks first, and it also treats a uniqueness + * rejection as success — because the check reads from Elasticsearch, which lags + * Postgres by a second or two, so two workers racing here is normal and not an + * error. + */ +export async function ensureConciergeOAuthApp(): Promise<{ + uid: string; + redirect_uri: string; + created: boolean; +}> { + assertConciergeLocal(); + const redirect_uri = redirectUriFor(CONCIERGE_URL); + const uid = clientId(redirect_uri); + const api = await bootstrapApi(); + try { + const listed = await api.get(`${ENGINE_API}/oauth_apps`, { + params: { limit: '500' }, + }); + if (listed.ok()) { + const body = await listed.json(); + const list = Array.isArray(body) ? body : (body.results ?? []); + if (list.some((a: { uid?: string }) => a.uid === uid)) { + return { uid, redirect_uri, created: false }; + } + } + + const domains = await api.get(`${ENGINE_API}/domains`); + const domain_body = await domains.json(); + const domain_list = Array.isArray(domain_body) + ? domain_body + : (domain_body.results ?? []); + if (!domain_list.length) { + throw new Error( + `no authority visible via ${ENGINE_API}/domains, so an OAuth app ` + + `cannot be given an owner. The row is created by \`init start\` but ` + + `served from Elasticsearch — check search-ingest is running.`, + ); + } + + const res = await api.post(`${ENGINE_API}/oauth_apps`, { + data: { + name: 'concierge (e2e dev server)', + redirect_uri, + scopes: 'public', + owner_id: domain_list[0].id, + confidential: false, + }, + }); + if (!res.ok()) { + const body = await res.text(); + if (alreadyExists(body)) return { uid, redirect_uri, created: false }; + throw new Error( + `create oauth_app for the concierge origin failed: HTTP ${res.status()} ${body}`, + ); + } + return { uid, redirect_uri, created: true }; + } finally { + await api.dispose(); + } +} diff --git a/e2e/support/concierge/concierge.settings.ts b/e2e/support/concierge/concierge.settings.ts new file mode 100644 index 0000000000..5966f5ea97 --- /dev/null +++ b/e2e/support/concierge/concierge.settings.ts @@ -0,0 +1,51 @@ +/** + * Per-test app settings for the CONCIERGE specs. + * + * The sixth copy of this small helper — `visitor/`, `room/`, `bookings/`, + * `desk/`, `parking/`, `home/` and now here. Deliberate, and for the same + * reason as the others: a single shared settings file is the one thing that + * could break every area at once, and separate folders are what stops that. + * + * Overrides are written to `localStorage['PLACEOS.setting_overrides']` on the + * CONCIERGE origin, which `SettingsService` merges over the app defaults + * (`libs/common/src/lib/settings.service.ts`, `DEBUG_OVERRIDES_KEY`). They are + * per-browser-context, so one test configuring an access group cannot leak into + * the next. + */ +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); +} + +/** + * Turn the access guard ON by naming a group. + * + * This is the setting that makes `AuthorisedUserGuard` actually check anything. + * Read `libs/components/src/lib/authorised-user.guard.ts:103-120`: when + * `app.allow_access_groups` is EMPTY — the default, and the default on this + * stack — the guard takes the `!groups.length` branch and sets + * `can_activate = true` for everybody. Only once a group is named does it + * compare against `user.groups`. + * + * Measured on this stack (2026-09-17): neither seeded identity is in any group + * at all — `support@place.tech` (sys_admin) and `e2e-staff-0@place.tech` both + * report `groups: []`. And the group branch has **no sys_admin bypass**, so with + * this override in place the admin is refused too. That is why it is applied to + * one context in one test rather than to the whole suite. + */ +export const REQUIRE_ACCESS_GROUP = { + 'app.allow_access_groups': ['concierge_admins'], +}; diff --git a/e2e/support/concierge/desks.page.ts b/e2e/support/concierge/desks.page.ts new file mode 100644 index 0000000000..c732599141 --- /dev/null +++ b/e2e/support/concierge/desks.page.ts @@ -0,0 +1,81 @@ +/** + * The concierge desk-bookings screen — `/#/book/desks/events`. + * + * ## What this page can and cannot do, measured 2026-09-17 + * + * The row actions are **not** what the test plan assumed. The actions column + * holds one control, "View Booking History". There is **no cancel** on this + * screen. What a concierge can actually do to somebody else's desk booking is: + * + * status column -> Approve Desk / Reject Desk (POST /bookings//approve | /reject) + * checked-in column -> Check-in / Check-out (POST /bookings//check_in?state=…) + * + * That is why the specs cover approve, reject and check-in rather than the + * "cancel somebody's booking" row in the plan (CON-DESK-03) — the app does not + * offer it here, so a test for it would have had to invent a control. + * + * ## It shows ONE day, and that day is today + * + * The listing queries a single local day, defaulting to today. `?date=` is + * NOT honoured on this route: the component actually rendered + * (`DesksComponent`) reads only `zone_ids` from the query and returns early + * without it. The `date` parameter is handled by `desks-topbar.component.ts`, + * which is referenced only by unit tests and is not on this route. So the specs + * book for TODAY, and the date is stepped with the arrow controls when a test + * needs another day. + * + * ## It filters by the BUILDING zone + * + * See `concierge.api.ts` — a booking without the full org/building/level chain + * is stored and never shown here. + */ +import { Page, expect } from '@playwright/test'; +import { + chooseRowMenuItem, + columnIndex, + readTable, + waitForAnyRow, + waitForRow, +} from './simple-table.page'; + +export const DESK_BOOKINGS_ROUTE = '/#/book/desks/events'; +export const DESK_MANAGE_ROUTE = '/#/book/desks/manage'; + +/** Open the desk bookings listing and wait for its grid. */ +export async function openDeskBookings(page: Page): Promise { + await page.goto(DESK_BOOKINGS_ROUTE); + await expect( + page.locator('desk-bookings'), + 'the desk bookings page never rendered', + ).toBeVisible({ timeout: 45_000 }); +} + +/** Open desk management and wait for its grid. */ +export async function openDeskManagement(page: Page): Promise { + await page.goto(DESK_MANAGE_ROUTE); + await expect( + page.locator('desks-manage'), + 'the desk management page never rendered', + ).toBeVisible({ timeout: 45_000 }); +} + +/** + * Open the dropdown in `field`'s cell on the row holding `needle`, and choose + * `option`. + * + * Both dropdowns on this page are plain `mat-menu`s whose items are identified + * by their English label — there are no test ids on them. That is a deliberate + * exception to the suite's "never assert on a translated string" rule: here the + * label is the only handle the control offers, and it is used to DRIVE the app, + * never to assert the outcome. Every assertion goes to the backend. + */ +export async function chooseRowAction( + page: Page, + needle: string, + field: 'status' | 'checked_in', + option: string, +): Promise { + await chooseRowMenuItem(page, needle, field, option); +} + +export { readTable, waitForRow, waitForAnyRow, columnIndex }; diff --git a/e2e/support/concierge/fixtures.ts b/e2e/support/concierge/fixtures.ts new file mode 100644 index 0000000000..546a8f77a6 --- /dev/null +++ b/e2e/support/concierge/fixtures.ts @@ -0,0 +1,186 @@ +/** + * Auth fixtures for the CONCIERGE app. + * + * ## Why this exists rather than reusing `e2e/support/fixtures.ts` + * + * The workplace fixtures build their storage state with + * `buildStorageState(mint, APP_URL)` — and a storage state is bound to ONE + * ORIGIN, because the access token lives in that origin's `localStorage`. A + * session minted for `localhost:4214` is invisible to a page on + * `localhost:4215`, so concierge cannot borrow it. + * + * What is shared is everything that costs something: the auth server, the + * seeded identities, the token mint and its verification. Only the origin + * differs, and that is the whole of this file. + * + * ## Signed in as ADMIN + * + * Concierge manages other people's bookings and the org hierarchy, so the specs + * run as the admin identity — see `concierge.env.ts` for the reasoning, and + * `concierge-access.spec.ts` for the test that keeps that choice honest by + * proving a plain staff user cannot get in. + */ +import { + APIRequestContext, + Page, + request as pwRequest, + test as base, +} from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { AUTH_DIR, BACKEND_URL, RoleName, roleFor } from '../env'; +import { ENGINE_API } from '../api'; +import { buildStorageState, mintToken } from '../auth'; +import { CONCIERGE_URL, assertConciergeLocal } from './concierge.env'; +import { ensureConciergeOAuthApp } from './concierge.seed'; + +interface ConciergeWorkerFixtures { + /** storageState path for the admin identity, bound to the CONCIERGE origin. */ + conciergeAdminState: string; + /** A bearer token for the admin identity, for API calls. */ + conciergeAdminToken: string; + /** A bearer token for a plain staff identity, for the access tests. */ + conciergeStaffToken: string; + /** + * storageState path for a PLAIN STAFF identity, bound to the CONCIERGE + * origin. Exists only so the access test can open concierge as somebody who + * should not be allowed in — nothing else should use it. + */ + conciergeStaffState: string; +} + +interface ConciergeTestFixtures { + /** A page signed in as the admin, on the concierge app. */ + adminPage: Page; + /** An API context as the admin. */ + adminApi: APIRequestContext; + /** An API context as a plain non-admin staff user. */ + staffApi: APIRequestContext; +} + +/** Mint for a role and write a storage state bound to the CONCIERGE origin. */ +async function mintForConcierge(role: RoleName, workerIndex: number) { + assertConciergeLocal(); + // Must come first. Without an OAuth application for THIS origin, authorize + // answers 401 and the error says nothing about the cause — see + // `concierge.seed.ts`. Idempotent, so calling it per mint is cheap. + await ensureConciergeOAuthApp(); + const r = roleFor(role, workerIndex); + const mint = await mintToken(BACKEND_URL, CONCIERGE_URL, r.email, r.password); + + // Same check the workplace fixtures make, and for the same reason: a token + // that parses but is rejected downstream is a failure mode that has caused + // a production revert once already. + const api = await pwRequest.newContext({ + baseURL: BACKEND_URL, + ignoreHTTPSErrors: true, + extraHTTPHeaders: { Authorization: `Bearer ${mint.accessToken}` }, + }); + try { + const res = await api.get(`${ENGINE_API}/users/current`); + if (!res.ok()) { + throw new Error( + `minted token for ${r.email} was rejected by rest-api: HTTP ${res.status()}`, + ); + } + const user = await res.json(); + if (role === 'admin' && !(user.sys_admin || user.support)) { + throw new Error( + `${r.email} is NOT an admin, and the concierge specs assume admin ` + + `rights for the management pages. Re-run e2e/support/seed.ts.`, + ); + } + if (role === 'staff' && (user.sys_admin || user.support)) { + throw new Error( + `${r.email} is admin/support — the access test would be vacuous. ` + + `Re-run e2e/support/seed.ts.`, + ); + } + } finally { + await api.dispose(); + } + + // A path of its own, so a concierge state can never be mistaken for a + // workplace one: same identity, different origin, different file. + fs.mkdirSync(AUTH_DIR, { recursive: true }); + const state_path = path.join( + AUTH_DIR, + `concierge-${role}-${workerIndex}.json`, + ); + fs.writeFileSync( + state_path, + JSON.stringify(buildStorageState(mint, CONCIERGE_URL), null, 2), + ); + return { state_path, token: mint.accessToken }; +} + +async function apiWithToken(token: string): Promise { + return pwRequest.newContext({ + baseURL: BACKEND_URL, + ignoreHTTPSErrors: true, + extraHTTPHeaders: { Authorization: `Bearer ${token}` }, + }); +} + +export const test = base.extend({ + // Every concierge test is signed in as the admin unless it opts out with + // `test.use({ storageState: undefined })` — which the access specs do. + storageState: ({ conciergeAdminState }, use) => use(conciergeAdminState), + baseURL: ({}, use) => use(CONCIERGE_URL), + + conciergeAdminState: [ + async ({}, use) => { + const { state_path } = await mintForConcierge( + 'admin', + test.info().parallelIndex, + ); + await use(state_path); + }, + { scope: 'worker' }, + ], + + conciergeAdminToken: [ + async ({}, use) => { + const { token } = await mintForConcierge('admin', test.info().parallelIndex); + await use(token); + }, + { scope: 'worker' }, + ], + + conciergeStaffToken: [ + async ({}, use) => { + const { token } = await mintForConcierge('staff', test.info().parallelIndex); + await use(token); + }, + { scope: 'worker' }, + ], + + conciergeStaffState: [ + async ({}, use) => { + const { state_path } = await mintForConcierge( + 'staff', + test.info().parallelIndex, + ); + await use(state_path); + }, + { scope: 'worker' }, + ], + + adminPage: async ({ page }, use) => { + await use(page); + }, + + adminApi: async ({ conciergeAdminToken }, use) => { + const api = await apiWithToken(conciergeAdminToken); + await use(api); + await api.dispose(); + }, + + staffApi: async ({ conciergeStaffToken }, use) => { + const api = await apiWithToken(conciergeStaffToken); + await use(api); + await api.dispose(); + }, +}); + +export { expect } from '@playwright/test'; diff --git a/e2e/support/concierge/locker.seed.ts b/e2e/support/concierge/locker.seed.ts new file mode 100644 index 0000000000..bec381f3ed --- /dev/null +++ b/e2e/support/concierge/locker.seed.ts @@ -0,0 +1,206 @@ +/** + * Locker banks and lockers — CON-LOCK-01 / CON-LOCK-03. + * + * ## This is the "two-level seeding" the plan kept flagging + * + * It is the gap that has kept workplace's WP-E2E-09 untested as well. A locker + * is not one record, it is two levels of asset in a hidden category: + * + * category `_LOCKERS_` hidden: true + * type `_LOCKER_BANKS_` one asset per BANK, on a level zone + * type `_LOCKERS_` one asset per LOCKER, `parent_id` -> its bank + * + * Both type names come from `libs/assets/src/lib/locker-assets.fn.ts`, and the + * category is HIDDEN — unlike the equipment seeder, where a hidden category + * would make the assets invisible. Lockers are read through + * `queryLockerBankAssets` / `queryLockerAssets`, which resolve the type id + * themselves rather than going through the visible-asset filter, so hidden is + * correct here and copying the equipment seeder's `hidden: false` would put + * locker banks into the general asset list. + * + * ## The fields are duplicated into `other_data`, and both copies matter + * + * `locker-state.service.ts` (`lockerBankToAsset`, `lockerToAsset`) writes the + * display fields BOTH as real asset columns and again inside `other_data`, and + * the UI reads the `other_data` copy. So a bank seeded with only `identifier` + * set exists, is returned by the API, and renders with an empty name. That is + * why this file writes both. + * + * `height` and the arrays are stored as STRINGS in `other_data` — `height: + * '3'`, `tags: '[]'` — because that is what the app writes and reads back. + */ +import { APIRequestContext } from '@playwright/test'; +import { ENGINE_API, zonesWithTag } from '../api'; + +const LOCKER_CATEGORY = '_LOCKERS_'; +const LOCKER_BANK_TYPE = '_LOCKER_BANKS_'; +const LOCKER_TYPE = '_LOCKERS_'; + +export const BANK_NAME = 'E2E Locker Bank'; +export const LOCKER_PREFIX = 'E2E Locker'; +/** Enough that a test can take one and still prove another is free. */ +export const LOCKER_COUNT = 3; + +export interface LockerFixture { + zone_id: string; + bank: { id: string; name: string }; + lockers: Array<{ id: string; name: 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 locker bank with lockers in it, and return them. + * + * Idempotent: matched by name, so it reuses whatever is already there. + */ +export async function ensureLockers( + api: APIRequestContext, +): Promise { + // THE BUILDING ZONE, not a level. This was got wrong first time. + // + // The locker-bank modal offers LEVELS when you create a bank by hand, so a + // level looks like the obvious place. But the management page's default + // view ("All Levels") queries a single zone and that zone is the BUILDING — + // measured 2026-09-17: + // + // GET /assets?zone_id=&type_id=<_LOCKER_BANKS_>&limit=500 + // + // Banks seeded onto a level are therefore created correctly, returned by + // the API, and shown nowhere: the page reports "No locker banks for the + // selected zone". Same building-not-level rule as the concierge booking + // listings (see concierge.api.ts). + // + // Not explored, and worth a look before relying on it: whether a bank on a + // level becomes visible once that specific level is selected in the topbar. + const [building] = await zonesWithTag(api, 'building'); + if (!building) throw new Error('no building zone — the stack is not seeded'); + const level = building; + + const categories = await listAll(api, 'asset_categories', { hidden: 'true' }); + let category = categories.find((c) => c.name === LOCKER_CATEGORY); + category = + category ?? + (await create(api, 'asset_categories', { + name: LOCKER_CATEGORY, + hidden: true, + })); + + const types = await listAll(api, 'asset_types', { + category_id: category.id, + }); + async function ensureType(name: string) { + const found = types.find((t) => t.name === name); + if (found) return found; + return create(api, 'asset_types', { + name, + // `brand` is required, same trap as every other asset type here. + brand: 'PlaceOS', + category_id: category.id, + }); + } + const bank_type = await ensureType(LOCKER_BANK_TYPE); + const locker_type = await ensureType(LOCKER_TYPE); + + const existing_banks = await listAll(api, 'assets', { + zone_id: level.id, + type_id: bank_type.id, + }); + let bank = existing_banks.find( + (a) => a.identifier === BANK_NAME || a.other_data?.name === BANK_NAME, + ); + bank = + bank ?? + (await create(api, 'assets', { + identifier: BANK_NAME, + name: BANK_NAME, + map_id: '', + notes: '', + zone_id: level.id, + zones: [level.id], + asset_type_id: bank_type.id, + tags: [], + // Both copies — see the header. + other_data: { + name: BANK_NAME, + map_id: '', + height: '3', + tags: '[]', + images: '[]', + }, + })); + + const existing_lockers = await listAll(api, 'assets', { + zone_id: level.id, + type_id: locker_type.id, + }); + const lockers: Array<{ id: string; name: string }> = []; + for (let i = 0; i < LOCKER_COUNT; i++) { + const name = `${LOCKER_PREFIX} ${i}`; + const found = existing_lockers.find( + (a) => a.identifier === name || a.other_data?.name === name, + ); + if (found) { + lockers.push({ id: found.id, name }); + continue; + } + const created = await create(api, 'assets', { + identifier: name, + name, + map_id: '', + zone_id: level.id, + zones: [level.id], + asset_type_id: locker_type.id, + bookable: true, + // THE LINK. A locker without this exists and belongs to no bank, so + // the management page lists the bank with nothing in it. + parent_id: bank.id, + features: [], + other_data: { + name, + map_id: '', + assigned_to: '', + assigned_name: '', + accessible: 'false', + position: JSON.stringify([i, 0]), + size: JSON.stringify([1, 1]), + features: '[]', + }, + }); + lockers.push({ id: created.id, name }); + } + + return { + zone_id: level.id, + bank: { id: bank.id, name: BANK_NAME }, + lockers, + }; +} diff --git a/e2e/support/concierge/simple-table.page.ts b/e2e/support/concierge/simple-table.page.ts new file mode 100644 index 0000000000..1bb69f1b73 --- /dev/null +++ b/e2e/support/concierge/simple-table.page.ts @@ -0,0 +1,336 @@ +/** + * Reading concierge's `simple-table`. + * + * ## Why this needs a page object at all + * + * `simple-table` is not a table. It renders a **flat CSS grid**: one `div` per + * cell, all of them siblings, with no row element and no ARIA roles on the + * cells. So there is nothing to write `.locator('tr')` against — "the row for my + * booking" has to be reconstructed by grouping the cells into runs of N, where N + * is the number of header buttons. + * + * Measured on `/#/book/desks/events`, 2026-09-17: `[role=table]` with 10 header + * children and 560 body children = 56 rows. The header buttons carry + * `id="column-"`, which is the only stable naming in the whole control — + * so columns are addressed by that id rather than by position, because the + * column ORDER is a product decision that may change. + * + * One quirk the ids do not fix: two different columns share `id="column-date"` + * (Date and Period). `columnIndex` returns the FIRST match, which is Date; + * anything needing Period should ask by index. + * + * ## Why rows are read in one page evaluation + * + * A row is only meaningful as a group, and these listings routinely carry 50+ + * rows of leftover cancelled bookings. Locating 10 cells per row through + * separate locator calls would be hundreds of round trips for one assertion. + * This reads the grid once and does the grouping in the page. + */ +import { Locator, Page, expect } from '@playwright/test'; + +export interface TableSnapshot { + /** `column-` ids, in render order. */ + column_ids: string[]; + /** Visible header labels, in render order. */ + headers: string[]; + /** One array of cell texts per row. Newlines become `~`. */ + rows: string[][]; +} + +/** The grid element itself, for waiting on. */ +export function tableOf(page: Page, host = 'simple-table'): Locator { + return page.locator(`${host} [role=table]`).first(); +} + +/** Wait until the listing has rendered its header row. */ +export async function waitForTable(page: Page, host = 'simple-table'): Promise { + await expect( + tableOf(page, host), + `the ${host} listing never rendered. Check the page's own loading state ` + + `before assuming the data is missing`, + ).toBeVisible({ timeout: 45_000 }); + await expect + .poll( + async () => tableOf(page, host).locator('[header]').count(), + { + message: `${host} rendered no column headers`, + timeout: 30_000, + }, + ) + .toBeGreaterThan(0); +} + +/** Read the whole grid in one go. */ +export async function readTable( + page: Page, + host = 'simple-table', +): Promise { + await waitForTable(page, host); + return tableOf(page, host).evaluate((table) => { + const kids = Array.from(table.children) as HTMLElement[]; + const header_cells = kids.filter((el) => el.hasAttribute('header')); + const body_cells = kids.filter((el) => !el.hasAttribute('header')); + const width = header_cells.length; + const rows: string[][] = []; + if (width > 0) { + for (let i = 0; i + width <= body_cells.length; i += width) { + rows.push( + body_cells + .slice(i, i + width) + .map((el) => (el.innerText || '').trim().replace(/\n+/g, '~')), + ); + } + } + return { + column_ids: header_cells.map((el) => el.id), + headers: header_cells.map((el) => + (el.innerText || '').trim().split('\n')[0], + ), + rows, + }; + }); +} + +/** + * Wait until the listing has at least one data row, and return the snapshot. + * + * `waitForTable` only waits for the HEADER, and every concierge listing renders + * its header immediately and fills the body after its request returns. A plain + * `readTable` therefore races the data and reports an empty listing — which + * looks exactly like "the page has no rows", the very thing these specs are + * trying to tell apart. Measured on desk management: headers present, body + * still empty, rows arriving ~1s later. + */ +export async function waitForAnyRow( + page: Page, + options: { host?: string; timeout?: number } = {}, +): Promise { + const host = options.host ?? 'simple-table'; + let last: TableSnapshot | null = null; + await expect + .poll( + async () => { + last = await readTable(page, host); + return last.rows.length; + }, + { + message: + `the ${host} listing rendered its header but never any rows. ` + + `If the page genuinely has no data this is the right failure — ` + + `but check the level/zone selector first, because these pages ` + + `scope to the selected building.`, + timeout: options.timeout ?? 45_000, + }, + ) + .toBeGreaterThan(0); + return last as unknown as TableSnapshot; +} + +/** + * The index of a column by its `column-` id. + * + * Throws rather than returning -1: a spec that silently read column -1 would + * assert against `undefined` and report a confusing failure about the data. + */ +export function columnIndex(snapshot: TableSnapshot, field: string): number { + const index = snapshot.column_ids.indexOf(`column-${field}`); + if (index < 0) { + throw new Error( + `No column with id "column-${field}" in this listing. Columns present: ` + + `${snapshot.column_ids.join(', ')}. The column ids come from the ` + + `component template, so this usually means the page changed, not the data.`, + ); + } + return index; +} + +/** + * Ignore cancelled rows. + * + * ## Why this is needed, and why it is not optional for anything reusable + * + * Cancelling a booking does NOT take it off these listings — it re-labels it + * `Cancelled` (desks, parking) or `Canceled` (visitors, one `l`) and leaves it + * there. Combined with the stale-booking problem recorded in `notes/STATUS.md` + * §4, a concierge listing accumulates dozens of dead rows: the desk listing had + * 56, all cancelled, on 2026-09-17. + * + * That matters for any test whose handle is not unique per run. The parking + * listing has no Title column, so its rows are found by SPACE NAME — and the + * second time that test runs, the space name matches both the live booking and + * the cancelled one from last time. `rowContaining` refuses to guess between + * them (correctly), so the test fails on an ambiguity that is really just + * history. + * + * Both spellings are matched deliberately; which one appears depends on the + * screen, and assuming either would work only on some pages. + */ +export const LIVE_ONLY = (row: string[]): boolean => + !row.some((cell) => /cancell?ed/i.test(cell)); + +/** + * The one row containing `needle` in any cell. + * + * Returns null when absent, so a spec can assert either presence or absence. + * Throws when it matches more than once, because that means the needle was not + * unique and any assertion on "the row" would be picking arbitrarily — use + * `uniqueTitle()` for anything a test needs to find again. + */ +export function rowContaining( + snapshot: TableSnapshot, + needle: string, + where: (row: string[]) => boolean = () => true, +): string[] | null { + const hits = snapshot.rows.filter( + (row) => row.some((cell) => cell.includes(needle)) && where(row), + ); + if (hits.length > 1) { + throw new Error( + `"${needle}" matched ${hits.length} rows, so "the row" is ambiguous. ` + + `Concierge listings are full of leftover bookings from earlier runs — ` + + `use a unique title per test.`, + ); + } + return hits[0] ?? null; +} + +/** + * Poll the listing until `needle` appears, and return its row. + * + * These pages load their data after the shell renders, so a single read races + * the request. Fails with the row count and a sample so the failure says + * whether the listing was empty or simply did not contain the booking. + */ +export async function waitForRow( + page: Page, + needle: string, + options: { + host?: string; + timeout?: number; + /** Narrow the match further — see `LIVE_ONLY`. */ + where?: (row: string[]) => boolean; + } = {}, +): Promise { + const host = options.host ?? 'simple-table'; + const where = options.where ?? (() => true); + let last: TableSnapshot | null = null; + await expect + .poll( + async () => { + last = await readTable(page, host); + return rowContaining(last, needle, where) !== null; + }, + { + message: + `"${needle}" never appeared in the ${host} listing. ` + + `If the listing has rows but not this one, the usual cause is the ` + + `booking's zones: concierge filters by the BUILDING zone, so a ` + + `booking created with only a level zone is stored but never shown ` + + `(see concierge.api.ts). The other cause is the date — these pages ` + + `show ONE day and default to today.`, + timeout: options.timeout ?? 45_000, + }, + ) + .toBe(true); + return rowContaining( + last as unknown as TableSnapshot, + needle, + where, + ) as string[]; +} + +/** + * Open the dropdown in `field`'s cell on the row holding `needle`, and choose + * `option`. + * + * Every concierge listing puts its per-row actions behind a `mat-menu` in one + * of the columns, so this is shared rather than written per area: + * + * desks status -> Approve Desk / Reject Desk + * desks checked_in -> Check-in / Check-out + * visitors status -> Approve Visitor / Decline Visitor + * visitors actions -> …/ Set Pass Number / Check-in Guest + * + * ## The menu items are matched by their English label + * + * There is no test id on any of them, so the label is the only handle. That is + * a deliberate exception to the suite's "never depend on a translated string" + * rule, and a narrow one: the label is used to DRIVE the app, never to assert + * an outcome. Every assertion goes to the backend. + * + * ## Scoped to the open menu panel, and that matters + * + * A bare `getByText('Check-in')` also matches the control's own TOOLTIP — + * `
Check-in or check-out desk
` — which is hidden, so + * the click waits forever on something that will never become visible. + * Measured: 18 polls, all resolving to the tooltip. + */ +export async function chooseRowMenuItem( + page: Page, + needle: string, + field: string, + option: string, + host = 'simple-table', +): Promise { + const snapshot = await readTable(page, host); + const width = snapshot.column_ids.length; + const row = snapshot.rows.findIndex((r) => r.some((c) => c.includes(needle))); + if (row < 0) { + throw new Error( + `"${needle}" is not in the ${host} listing, so "${option}" cannot be ` + + `chosen. The listing had ${snapshot.rows.length} row(s).`, + ); + } + const cell = page + .locator(`${host} [role=table] > *:not([header])`) + .nth(row * width + columnIndex(snapshot, field)); + await cell.locator('button, [matripple]').first().click(); + + const item = page + .locator('.mat-mdc-menu-panel, [role="menu"]') + .getByText(option, { exact: false }) + .first(); + await expect( + item, + `the "${option}" item never appeared in the ${field} menu`, + ).toBeVisible({ timeout: 15_000 }); + await item.click(); +} + +/** + * Confirm a "are you sure?" dialog, if one is open. + * + * Concierge is inconsistent about these, which is worth knowing before writing + * a test that acts on a row. Measured 2026-09-17: + * + * desks Approve Desk / Reject Desk -> acts immediately, NO dialog + * desks Check-in / Check-out -> acts immediately, NO dialog + * visitors Approve Visitor / Decline -> opens "Approve attendance of + * to their meeting?" + * with Cancel / Accept + * + * So a visitor test that only clicks the menu item fires no request at all and + * fails on an assertion about the backend, which points at the wrong thing + * entirely. `required: true` makes the expectation explicit where a dialog is + * known to appear. + */ +export async function confirmDialog( + page: Page, + options: { accept?: string; required?: boolean } = {}, +): Promise { + const accept = options.accept ?? 'Accept'; + const dialog = page.locator('mat-dialog-container, [role="dialog"]').first(); + if (options.required) { + await expect( + dialog, + `expected a confirmation dialog with "${accept}", and none opened`, + ).toBeVisible({ timeout: 15_000 }); + } else if (!(await dialog.isVisible().catch(() => false))) { + return; + } + await dialog.getByRole('button', { name: accept, exact: false }).first().click(); + await expect( + dialog, + 'the confirmation dialog never closed after accepting', + ).toBeHidden({ timeout: 15_000 }); +} diff --git a/e2e/support/room/asset.seed.ts b/e2e/support/room/asset.seed.ts new file mode 100644 index 0000000000..29f189d329 --- /dev/null +++ b/e2e/support/room/asset.seed.ts @@ -0,0 +1,166 @@ +/** + * Requestable EQUIPMENT for the room specs — ROOM-24. + * + * ## How this differs from `catering.seed.ts`, which it is modelled on + * + * Both build the same three-layer shape (asset category → asset type → assets + * on the building zone) on the ENGINE api, and both need admin. Two differences + * matter: + * + * 1. **The category must be VISIBLE.** Catering hides its `_CATERING_` + * category on purpose, so menu items never appear in the general asset + * lists. Equipment is the opposite: `queryAssets` filters out any asset + * whose type is hidden or whose type sits in a hidden category + * (`assets.fn.ts`, `queryAssets`/`queryAssetTypes`), so a hidden category + * here would leave the request field permanently empty. + * 2. **Several assets, not one.** A catering item is a line on a menu with a + * quantity. A piece of equipment is an individual thing that one meeting + * holds and another cannot: `queryGroupAvailability` removes any asset + * already covered by a live `asset-request` booking. So the count here is + * what decides whether two overlapping meetings can both be equipped, and + * one asset would make every second test fail for the wrong reason. + * + * ## The form section is off by default + * + * The meeting form only renders its equipment section when + * `app.events.has_assets` is true (`meeting-flow-form.component.ts`, + * `has_assets`). That is a SETTING, not seeding — see `ROOM_ASSETS_MODE` in + * `room.settings.ts`. Seeding without the setting gives a form with no section; + * the setting without seeding gives a section with nothing in it. + */ +import { APIRequestContext } from '@playwright/test'; +import { ENGINE_API, apiFor, zonesWithTag } from '../api'; + +/** Visible, unlike catering's. See the header. */ +export const ASSET_CATEGORY = 'E2E Equipment'; +export const ASSET_TYPE = 'E2E AV Equipment'; +export const ASSET_ITEM_PREFIX = 'E2E Projector Unit'; +/** + * Three units. + * + * One per parallel worker would be tidier, but equipment is requested far less + * often than a desk and the room specs that use it run in their own slots. + * Three is enough for a test to take one and still prove a second is available. + */ +export const ASSET_ITEM_COUNT = 3; + +export interface EquipmentCatalogue { + category_id: string; + type_id: string; + type_name: string; + zone_id: string; + items: Array<{ id: string; name: 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 requestable equipment on the building, and return it. + * + * Idempotent and cached per process, exactly like the catering and room + * seeders: the first caller creates, everyone after finds. + */ +export async function ensureEquipment(): 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'); + } + + // No `hidden` filter on the lookup: the categories this matches must be + // visible, and passing `hidden: 'true'` would search the wrong set. + const categories = await listAll(admin, 'asset_categories'); + let category = categories.find((c) => c.name === ASSET_CATEGORY); + category = + category ?? + (await create(admin, 'asset_categories', { + name: ASSET_CATEGORY, + hidden: false, + })); + + const types = await listAll(admin, 'asset_types', { + category_id: category.id, + }); + let type = types.find((t) => t.name === ASSET_TYPE); + type = + type ?? + (await create(admin, 'asset_types', { + name: ASSET_TYPE, + category_id: category.id, + // REQUIRED — an asset type without `brand` is a 422 "should not + // be nil". Same trap as the catering seeder. + brand: 'E2E', + description: 'Requestable equipment owned by the e2e suite. Safe to delete.', + })); + + const existing = await listAll(admin, 'assets', { + zone_id: building.id, + type_id: type.id, + }); + const items: Array<{ id: string; name: string }> = []; + for (let i = 0; i < ASSET_ITEM_COUNT; i++) { + const name = `${ASSET_ITEM_PREFIX} ${i}`; + const found = existing.find((a) => a.name === name); + if (found) { + items.push({ id: found.id, name }); + continue; + } + const asset = await create(admin, 'assets', { + name, + identifier: name, + zone_id: building.id, + asset_type_id: type.id, + description: 'Seeded by the e2e suite', + }); + items.push({ id: asset.id, name }); + } + + return { + category_id: category.id, + type_id: type.id, + type_name: ASSET_TYPE, + zone_id: building.id, + items, + }; + } finally { + await admin.dispose(); + } +} + +let cache: Promise | null = null; +export async function equipmentCatalogue(): Promise { + cache = cache ?? ensureEquipment(); + 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 index 3287cb9223..917e5d6619 100644 --- a/e2e/support/room/meeting-form.page.ts +++ b/e2e/support/room/meeting-form.page.ts @@ -414,6 +414,96 @@ export class MeetingForm { ).toHaveCount(1, { timeout: 20_000 }); } + /** + * Request a piece of EQUIPMENT on the meeting — ROOM-24. + * + * ## Two preconditions, neither of which fails loudly + * + * - `app.events.has_assets` must be on, or the section is not rendered at + * all (`ROOM_ASSETS_MODE` in `room.settings.ts`). + * - requestable assets must be seeded, or the modal opens with "0 results + * found" (`asset.seed.ts`). + * + * Set one without the other and the failure looks like a missing control. + * + * ## The modal has no test ids, unlike the catering one + * + * Catering's modal carries `name="select-catering-item"` and friends; the + * asset modal (`asset-select-modal`) carries nothing equivalent, so these + * steps are matched by role and label inside the modal. Measured sequence, + * 2026-09-17: + * + * "Request Assets" on the field -> opens `asset-select-modal` + * the GROUP row in `asset-list` -> opens `asset-details` + * "Add to booking" -> adds one, and the button becomes + * "Remove from booking" + * "Confirm Selection" -> closes the modal and puts the request on the form + * + * `type_name` is the asset TYPE, not an individual item: the modal lists + * groups ("E2E AV Equipment — 3 available") and the app allocates specific + * units itself (`validateAssetRequestsForResource`). + * + * Nothing reaches the backend here. Asset requests are sent as their own + * `asset-request` bookings only when the meeting is confirmed. + */ + async addAssetRequest(type_name: string): Promise { + const field = this.page.locator('asset-list-field'); + await expect( + field, + 'the equipment section is missing — is `app.events.has_assets` set? ' + + 'See ROOM_ASSETS_MODE in `room.settings.ts`', + ).toBeVisible({ timeout: 20_000 }); + + await field.getByRole('button', { name: /Request Assets/i }).first().click(); + + const modal = this.page.locator('asset-select-modal'); + await expect(modal, 'the asset modal did not open').toBeVisible({ + timeout: 20_000, + }); + + const group = modal + .locator('asset-list') + .getByText(type_name, { exact: false }) + .first(); + const found = await group + .waitFor({ state: 'visible', timeout: 20_000 }) + .then(() => true) + .catch(() => false); + if (!found) { + const offered = await modal.locator('asset-list').innerText().catch(() => ''); + throw new Error( + `no requestable asset group called "${type_name}". The modal shows: ` + + `${JSON.stringify(offered.replace(/\n+/g, ' | ').slice(0, 300))}. ` + + `A group is an ASSET TYPE in a VISIBLE category, with assets on the ` + + `building zone — see \`asset.seed.ts\`. A hidden category leaves ` + + `this list empty, because \`queryAssets\` filters those out.`, + ); + } + await group.click(); + + const add = modal.getByRole('button', { name: /Add to booking/i }); + await expect(add, "the asset details pane did not open").toBeVisible({ + timeout: 15_000, + }); + await add.click(); + // The same button flips to "Remove from booking" once one is held, which + // is the only confirmation the modal gives that the add registered. + await expect( + modal.getByRole('button', { name: /Remove from booking/i }), + `"${type_name}" was not added to the booking — the add button never ` + + `flipped to "Remove from booking"`, + ).toBeVisible({ timeout: 15_000 }); + + await modal.getByRole('button', { name: /Confirm Selection/i }).click(); + await expect(modal, 'the asset modal did not close').toBeHidden({ + timeout: 20_000, + }); + await expect( + field.getByText(type_name, { exact: false }).first(), + `"${type_name}" was requested but the form lists no asset request`, + ).toBeVisible({ timeout: 20_000 }); + } + /** * Tick or clear All Day. * diff --git a/e2e/support/room/room.env.ts b/e2e/support/room/room.env.ts index da28ae326d..134e6232a0 100644 --- a/e2e/support/room/room.env.ts +++ b/e2e/support/room/room.env.ts @@ -74,6 +74,27 @@ export const ROOM_SLOTS_2 = { features: { hour: 20 }, } as const; +/** + * A THIRD day, for the two specs written after `ROOM_SLOTS_2` filled up. + * + * `ROOM_SLOTS_2` uses 9 to 20 on `SECOND_DAY`, which is every hour the form + * offers. Rather than double up an hour — a worker's specs share one room, so + * two tests in one hour contend for the same asset — these move to a day of + * their own. + * + * `recurring` needs more room than an hour: a weekly booking made on this day + * puts its later instances on +7 and +14 days, which is why it sits on a day + * nothing else uses rather than squeezing into `SECOND_DAY`. + */ +export const THIRD_DAY = 5; + +export const ROOM_SLOTS_3 = { + /** room-assets.spec.ts — ROOM-24, equipment requested on a meeting */ + assets: { hour: 9, second: 10 }, + /** room-recurring.spec.ts — ROOM-25, and its instances land on +7 and +14 */ + recurring: { hour: 12 }, +} as const; + export interface RoomIdentity { /** Engine system id, filled in by the seeder — not known up front. */ id: string; diff --git a/e2e/support/room/room.flows.ts b/e2e/support/room/room.flows.ts index 9190d11d83..1d80c25e56 100644 --- a/e2e/support/room/room.flows.ts +++ b/e2e/support/room/room.flows.ts @@ -115,6 +115,16 @@ export async function bookRoomViaUI( * order lunch twice. */ catering?: string[]; + /** + * Asset TYPE names to request as equipment. Added in the same place and + * for the same reasons as `catering`: the request carries the room as + * its delivery location, so it cannot be made before a room is chosen, + * and the modal is a four-click flow that a retry would run twice. + * + * Needs `app.events.has_assets` (ROOM_ASSETS_MODE) and seeded assets + * (`asset.seed.ts`), or the section is absent / the modal is empty. + */ + assets?: string[]; /** * Tick All Day. Replaces the window entirely — `_allDayTimeRange` * overwrites whatever start and length were chosen — so it is set inside @@ -177,6 +187,11 @@ export async function bookRoomViaUI( await form.addCateringItem(item); } + // Equipment, for the same reason and in the same place as catering. + for (const type_name of options.assets ?? []) { + await form.addAssetRequest(type_name); + } + // 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. @@ -286,7 +301,12 @@ export async function bookRoomViaUI( } /** Drive the form's date picker to a given day. */ -async function setDate(form: MeetingForm, page: Page, timestamp_ms: number) { +/** + * Exported because a spec that drives the form WITHOUT booking still has to set + * the date before touching the times — the form defaults to today, so a slot on + * another day has its hour simply absent from the picker. That cost a run. + */ +export 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 }); diff --git a/e2e/support/room/room.settings.ts b/e2e/support/room/room.settings.ts index 1aa12a01c0..73f289c711 100644 --- a/e2e/support/room/room.settings.ts +++ b/e2e/support/room/room.settings.ts @@ -55,6 +55,31 @@ export async function useSettings( }, overrides); } +/** + * Turn on the meeting form's EQUIPMENT section — ROOM-24. + * + * `app.events.has_assets` is what `meeting-flow-form.component.ts` reads to + * decide whether to render the section at all, and it is off by default. The + * setting alone gives a section with nothing in it: the items also have to be + * seeded, by `asset.seed.ts`. Both are needed, and neither fails loudly without + * the other. + */ +export const ROOM_ASSETS_MODE = { + 'app.events.has_assets': true, +}; + +/** + * Turn on the meeting form's RECURRENCE control — ROOM-25. + * + * `events.allow_recurrence` is what `meeting-form-details.component.ts` reads, + * and it is off by default. Note the component ALSO requires the meeting to be + * 24 hours or shorter (`allow_recurrence = _allow_recurrence() && duration <= + * 24 * 60`), so a recurrence test cannot also be an all-day-multi-day test. + */ +export const ROOM_RECURRENCE_MODE = { + 'app.events.allow_recurrence': true, +}; + /** 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, diff --git a/e2e/support/visitor/visitor.env.ts b/e2e/support/visitor/visitor.env.ts index 2ab4a49392..3faf1bf504 100644 --- a/e2e/support/visitor/visitor.env.ts +++ b/e2e/support/visitor/visitor.env.ts @@ -50,6 +50,14 @@ export const VISITOR_SLOTS = { group_time: { memberA: 28, memberB: 29 }, /** visitor-times.spec.ts */ times: { chosen: 23, limits: 24 }, + /** + * visitor-concurrency.spec.ts — VIS-28. + * + * A BLOCK rather than a slot, because the test fires a burst: it needs one + * visitor identity per concurrent write, and it must not touch a slot any + * other file owns. 40 onwards is clear of everything above. + */ + concurrency: { base: 40, count: 8 }, } as const; export interface VisitorIdentity { diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json index 55d79c5f9b..9717cbb9da 100644 --- a/e2e/tsconfig.json +++ b/e2e/tsconfig.json @@ -22,5 +22,9 @@ // Scoped to the e2e project so the app build is unaffected. "strictNullChecks": true }, - "include": ["support/**/*.ts", "../apps/workplace/e2e/**/*.ts"] + "include": [ + "support/**/*.ts", + "../apps/workplace/e2e/**/*.ts", + "../apps/concierge/e2e/**/*.ts" + ] }