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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 155 additions & 17 deletions E2E_USER_STORIES.md

Large diffs are not rendered by default.

11 changes: 8 additions & 3 deletions apps/concierge/e2e/local/concierge-catering-management.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
runName,
} from '../../../../e2e/support/concierge/management.api';
import { APP_URL, BACKEND_URL, roleFor } from '../../../../e2e/support/env';
import { MeetingForm } from '../../../../e2e/support/room/meeting-form.page';
import { MeetingFlow } from '../../../../e2e/support/room/meeting-flow.page';
import {
ROOM_BASE_SETTINGS,
useSettings,
Expand Down Expand Up @@ -82,7 +82,7 @@ test('CON-CAT-03: an item created in concierge is stored and offered on the work
const page = await context.newPage();
await useSettings(page, ROOM_BASE_SETTINGS);
await page.goto(`${APP_URL}/#/book/meeting/form`);
const form = new MeetingForm(page);
const form = new MeetingFlow(page);
const systems = await adminApi.get(`${ENGINE}/systems`, {
params: { limit: '500' },
});
Expand All @@ -92,7 +92,12 @@ test('CON-CAT-03: an item created in concierge is stored and offered on the work
(item: { name: string }) => item.name === 'E2E Room 0',
);
expect(room, 'the framework must seed E2E Room 0').toBeTruthy();
await form.chooseRoom(room.name);
// Catering is on the last step of the meeting flow. The first
// step needs a title and the second needs a room.
await form.title.fill(name);
await form.toRoomStep();
await form.selectRoom(room.name);
await form.toOptionsStep();
await form.addCateringItem(name);
await expect(form.cateringOrders).toContainText(name);
} finally {
Expand Down
13 changes: 13 additions & 0 deletions apps/workplace/e2e/discovery.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,19 @@ test.describe('Group Events', () => {
});

test.describe('Deals & Offers', () => {
// The default features do not include `deals-n-offers`, and the page goes
// back to the home page when its feature is off (`deals.component.ts`,
// `ngOnInit`). The override map is the debug hook that the app reads from
// localStorage (`settings.service.ts`, `loadDebugOverrides`).
test.beforeEach(async ({ page }) => {
await page.addInitScript(() =>
window.localStorage.setItem(
'PLACEOS.setting_overrides',
JSON.stringify({ 'app.features': ['deals-n-offers'] }),
),
);
});

test('US-071: renders the deals & offers page', async ({ page }) => {
await page.goto('/#/deals-n-offers?mock=true');
await page
Expand Down
4 changes: 3 additions & 1 deletion apps/workplace/e2e/error-states.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ const LOAD_TIMEOUT = 30000;
test.describe('Error & access states', () => {
test('US-090: renders the unauthorised page', async ({ page }) => {
await page.goto('/#/unauthorised?mock=true');
await expect(page.locator('app-unauthorised')).toBeVisible({
// Not the host element: its only child is `absolute inset-0`, so the
// host has no box and Playwright reports it as hidden.
await expect(page.locator('app-unauthorised [unauthorised]')).toBeVisible({
timeout: LOAD_TIMEOUT,
});
});
Expand Down
28 changes: 20 additions & 8 deletions apps/workplace/e2e/explore.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,15 +42,27 @@ test.describe('Explore / Map', () => {
timeout: LOAD_TIMEOUT,
});

await expect(page.locator('explore-map-view')).toBeVisible({
timeout: LOAD_TIMEOUT,
});

// The pill shows only while the mock user has a booking of today that
// is not done. The mock bookings are from 7am to 6pm, so an evening
// run has no pill. The pill renders a moment after the map, so wait
// for it: a count taken at once is always 0.
const booking_pill = page.locator('[name="upcoming-booking-map-pill"]');
if ((await booking_pill.count()) > 0) {
await booking_pill.locator('button').click();
await expect(page.getByRole('button', { name: 'Clear Pin' })).toBeVisible(
{ timeout: LOAD_TIMEOUT },
);
} else {
await expect(page.locator('explore-map-view')).toBeVisible();
}
const has_booking = await booking_pill
.waitFor({ state: 'visible', timeout: 10_000 })
.then(() => true)
.catch(() => false);
if (!has_booking) return;

await booking_pill.locator('button').click();
// The action puts the pin of the booking on the map. Not "Clear Pin":
// that is the control of a `locate` query (US-043), not of this pin.
await expect(page.locator('event-map-pin')).toBeAttached({
timeout: LOAD_TIMEOUT,
});
});

test('US-043/044: map can open with a located resource query', async ({
Expand Down
20 changes: 0 additions & 20 deletions apps/workplace/e2e/landing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,24 +100,4 @@ test.describe('Home / Landing', () => {
timeout: LOAD_TIMEOUT,
});
});

test('controlled free status shows a room and opens its booking modal', async ({
page,
}) => {
await page.goto('/#/?mock=true');
await expect(page.locator('app-landing')).toBeVisible({ timeout: 30_000 });

const room = page.locator('button[name="landing-book-room"]').first();
await expect(room, 'the mock Bookings module should publish free status').toBeVisible({
timeout: 30_000,
});
const room_name = ((await room.innerText()).split('\n')[0] ?? '').trim();
await room.click();

const modal = page.locator('.cdk-overlay-container mat-dialog-container');
await expect(modal, 'booking the available room should open its modal').toBeVisible({
timeout: 20_000,
});
await expect(modal).toContainText(room_name);
});
});
71 changes: 20 additions & 51 deletions apps/workplace/e2e/local/booking-scoping.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,23 +22,11 @@ import {
listBookings,
releaseAsset,
uniqueTitle,
zonesWithTag,
} from '../../../../e2e/support/api';
import type { APIRequestContext } from '@playwright/test';
import { createDeskBookingViaApi } from '../../../../e2e/support/desk/desk.api';
import { deskFor } from '../../../../e2e/support/env';


/**
* The org/building/level zone ids a booking carries, matching what the UI sends.
* Always queried by tag: `GET /zones` with no `tags` parameter comes back empty.
*/
async function bookingZones(api: APIRequestContext): Promise<string[]> {
const groups = await Promise.all(
['org', 'building', 'level'].map((tag) => zonesWithTag(api, tag)),
);
return groups.flat().map((z) => z.id);
}

const DAY = 86_400;
// Window comfortably wider than any booking these specs create. Keep it that
// way: a booking placed exactly on the boundary is not returned by the listing,
Expand All @@ -60,26 +48,17 @@ test.describe('booking visibility between users', () => {

// Create as *this* worker's user, through the API rather than the UI —
// the subject here is authorisation, not the booking form.
const me = await (await staffApi.get('/api/engine/v2/users/current')).json();
const zones = await bookingZones(staffApi);
const start = Math.floor(Date.now() / 1000) + DAY;

const created = await staffApi.post(`${STAFF_API}/bookings`, {
data: {
booking_type: 'desk',
asset_id: desk.id,
booking_start: start,
booking_end: start + 3600,
timezone: 'Etc/UTC',
user_email: me.email,
user_id: me.id,
user_name: me.name,
title,
zones,
},
// The helper creates the booking again when the backend refuses it
// with a concurrent-write signature (REG-09), which a parallel run
// can produce on any create.
const booking = await createDeskBookingViaApi(staffApi, {
desk,
title,
start,
end: start + 3600,
});
expect(created.ok(), `creating the booking should succeed: ${created.status()}`).toBeTruthy();
const booking = await created.json();

// A second, genuinely different user.
const other_role = roleFor('staff', theirs);
Expand All @@ -99,7 +78,9 @@ test.describe('booking visibility between users', () => {
// Sanity: the two identities really are different, or everything below
// would pass for the wrong reason.
const them = await (await other.get('/api/engine/v2/users/current')).json();
expect(them.email, 'the second user must be a different person').not.toBe(me.email);
expect(them.email, 'the second user must be a different person').not.toBe(
booking.user_email,
);

// The privacy boundary.
const their_view = await listBookings(other, 'desk', from(), to());
Expand Down Expand Up @@ -136,29 +117,17 @@ test.describe('booking visibility between users', () => {
const title = uniqueTitle('E2E Scoping Control');
await releaseAsset(staffApi, 'desk', desk.id, from(), to());

const me = await (await staffApi.get('/api/engine/v2/users/current')).json();
const zones = await bookingZones(staffApi);
const start = Math.floor(Date.now() / 1000) + DAY;

const created = await staffApi.post(`${STAFF_API}/bookings`, {
data: {
booking_type: 'desk',
asset_id: desk.id,
booking_start: start,
booking_end: start + 3600,
timezone: 'Etc/UTC',
user_email: me.email,
user_id: me.id,
user_name: me.name,
title,
zones,
},
// The helper creates the booking again when the backend refuses it
// with a concurrent-write signature (REG-09), which a parallel run
// can produce on any create.
const booking = await createDeskBookingViaApi(staffApi, {
desk,
title,
start,
end: start + 3600,
});
expect(
created.ok(),
`creating the booking should succeed: ${created.status()} ${await created.text()}`,
).toBeTruthy();
const booking = await created.json();

try {
const mine = await listBookings(staffApi, 'desk', from(), to());
Expand Down
10 changes: 6 additions & 4 deletions apps/workplace/e2e/local/bookings-edit.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ test.describe('your bookings — editing', () => {
booking_id = booking.id;

const schedule = new SchedulePage(staffPage);
schedule.expectEditForm('desk-flow-form');
schedule.expectEditForm('desk-flow-new');
await schedule.open();
await schedule.showBooking(booking_id, slot.date_ms, ['desk'], async () => {
const now = await readBooking(staffApi, booking_id!);
Expand Down Expand Up @@ -135,7 +135,7 @@ test.describe('your bookings — editing', () => {
booking_id = booking.id;

const schedule = new SchedulePage(staffPage);
schedule.expectEditForm('invite-visitor-form');
schedule.expectEditForm('visitor-flow-new');
await schedule.open();
await schedule.showBooking(booking_id, slot.date_ms, ['visitor']);
await schedule.startEdit(booking_id);
Expand All @@ -147,10 +147,12 @@ test.describe('your bookings — editing', () => {

// The visitor is the invite's identity, so the form arriving without
// them is the same failure as an empty desk form.
// In the visitor flow the address is the value of an input, so
// it is not in the text of the form.
await expect(
staffPage.locator('invite-visitor-form'),
staffPage.locator('visitor-flow-new input[name$=".asset_id"]'),
'the visitor form should carry the invited visitor',
).toContainText(visitor.email.split('@')[0], { timeout: 30_000 });
).toHaveValue(visitor.email, { timeout: 30_000 });
} finally {
if (booking_id != null) await deleteBooking(staffApi, booking_id);
await deleteGuestByEmail(staffApi, visitor.email);
Expand Down
4 changes: 4 additions & 0 deletions apps/workplace/e2e/local/desk-booking.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import { test, expect } from '../../../../e2e/support/fixtures';
import { deskFor } from '../../../../e2e/support/env';
import { bookDeskViaUI } from '../../../../e2e/support/flows';
import { DeskFlow } from '../../../../e2e/support/desk/desk-flow.page';
import {
deleteBooking,
getBooking,
Expand Down Expand Up @@ -52,7 +53,10 @@ test.describe('desk booking', () => {

// The UI reached its success state — proof the app believed it too,
// not merely that a POST happened.
const flow = new DeskFlow(staffPage);
await expect(staffPage).toHaveURL(/#\/book\/desk\/success/);
await expect(flow.successContinue).toBeVisible();
await expect(flow.success, 'the success view names the desk').toContainText(desk.name);

// Read it back independently of the response we just parsed.
const stored = await getBooking(staffApi, booking_id);
Expand Down
55 changes: 29 additions & 26 deletions apps/workplace/e2e/local/desk-delegate.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
* 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.
* desk flow has the same shape — a "Book for other" booking type, with a host
* field, that `can_book_for_others` turns on — 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
Expand All @@ -18,9 +18,10 @@
*
* ## 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.
* Unlike visitors, the desk flow reads `can_book_for_others` only
* (`desk-flow-details.component.ts`, from `app.desks` or `app.bookings`).
* `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 {
Expand All @@ -38,7 +39,7 @@ import {
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';
import { DeskFlow } from '../../../../e2e/support/desk/desk-flow.page';

test.describe('booking a desk for a colleague', () => {
test('the chosen colleague is stored as the user, and you as the booker', async ({
Expand All @@ -61,32 +62,34 @@ test.describe('booking a desk for a colleague', () => {

try {
const me = await currentUser(staffApi);
const form = new DeskForm(staffPage);
const form = new DeskFlow(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.
// The "Book for other" type only exists with the setting on, and
// the host field only shows for that type. 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?',
form.bookForOtherButton,
'the "Book for other" type is missing — is ' +
'`app.bookings.can_book_for_others` set?',
).toBeVisible({ timeout: 30_000 });
await form.bookForOther();

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);
}
await expect(form.title).toHaveValue(title, { timeout: 1_000 });
}).toPass({ timeout: 60_000 });

await form.setChecked(form.requireLocker, false);
await form.setAllDay(false);
await form.setStartTime(hhmm(slot.start));
await staffPage.waitForTimeout(3_000);
await form.title.fill(title);
await form.chooseUser(colleague);
await form.selectDesk(desk.name);

// Read the form back before the click. Confirm saves at once.
await expect(form.title).toHaveValue(title);
await expect(
form.userField.locator('a-user-avatar'),
'the host field should still hold the colleague',
).toBeVisible();
await expect(form.chosenDeskNames).toHaveText([desk.name]);

const [response] = await Promise.all([
staffPage.waitForResponse(
Expand Down
Loading
Loading