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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 110 additions & 8 deletions E2E_USER_STORIES.md

Large diffs are not rendered by default.

198 changes: 198 additions & 0 deletions apps/workplace/e2e/local/bookings-cancel.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
129 changes: 129 additions & 0 deletions apps/workplace/e2e/local/bookings-checkin.spec.ts
Original file line number Diff line number Diff line change
@@ -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());
}
});
});
Loading