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
12 changes: 6 additions & 6 deletions E2E_USER_STORIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,9 @@ the PR gate.
| WP-E2E-06 | P1 | A deleted booking disappears from the listing (teardown really tears down). | **done** — `local/desk-booking.spec.ts` |
| WP-E2E-03 | P1 | Building/level selectors are populated from seeded zones, and changing them re-scopes what is bookable. | todo |
| WP-E2E-07 | P1 | "Your bookings" lists the user's own booking; cancelling it moves it out of the upcoming list. | todo |
| WP-E2E-08 | P1 | A booking made by one user is **not** visible in another user's "your bookings" (per-user scoping). | todosee AUTH-E2E-05 |
| WP-E2E-09 | P1 | Booking a **locker** end to end. Same metadata + per-worker-asset + sweep pattern as desks. | todo |
| WP-E2E-10 | P1 | Booking a **parking** space end to end. | todo |
| 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-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 |
Expand All @@ -133,11 +133,11 @@ environment, data or real-client gap that unit specs could not see.

| ID | P | Story | Status |
|----|---|-------|--------|
| AUTH-E2E-01 | P0 | Authorization-code + PKCE exchange in a real browser: no `client_secret` anywhere, `S256` challenge, token is a JWT. | **partial** — `login.spec.ts` asserts the exchange and token shape; the explicit no-secret / challenge-recomputation assertions still live in `tasks/PPT-2536/e2e/backoffice-login.spec.js` and should move here. |
| AUTH-E2E-01 | P0 | Authorization-code + PKCE exchange in a real browser: no `client_secret` anywhere, `S256` challenge, token is a JWT. | **done** — `local/pkce.spec.ts`. Asserts on the wire, not the response: a client that leaked a secret or dropped PKCE would still return a valid-looking token, so the response cannot tell you the handshake was sound. Also checks the password never appears in a URL and only ever reaches `/auth/signin`. Ported from `tasks/PPT-2536/e2e/backoffice-login.spec.js`. |
| AUTH-E2E-02 | P0 | A refreshed token keeps its scope, is rotated, preserves `sub`, and is still accepted by rest-api. | **done** — `login.spec.ts`. This is the exact 2026-07-23 revert (403 on `/oauth_apps` after refresh). |
| AUTH-E2E-03 | P1 | A refresh chain survives N sequential refreshes without degrading scope or access. | todo — covered API-only by `tasks/PPT-2536/integration/` (RF-03); wanted in-browser. |
| AUTH-E2E-04 | P1 | A stale/incompatible session cookie from a previous auth implementation does not break sign-in. | todo — verified manually (SC-01); needs automating. |
| AUTH-E2E-05 | P1 | A non-admin cannot read or mutate another user's bookings; an admin's own listing does not leak others'. | todo — **and it matters**: `GET /bookings` is caller-scoped, which we only learned by getting a leak check wrong. |
| AUTH-E2E-05 | P1 | A non-admin cannot read or mutate another user's bookings. | **done** — `local/booking-scoping.spec.ts`, same spec as WP-E2E-08. Covers both halves: the listing excludes it, and a delete attempt is rejected. Includes a control asserting you *can* see your own, so "nobody sees anything" can't pass as success. |
| AUTH-E2E-06 | P2 | Token expiry mid-session recovers without stranding the SPA. | todo |
| AUTH-E2E-07 | P2 | Malformed and hostile `/auth/*` requests return 4xx, never 5xx and never a backtrace. | todo — covered by auth.cr unit specs (SEC-01); browser-level coverage optional. |
| AUTH-E2E-08 | P1 | `SameSite` behaviour in a genuine third-party/iframe context. | **blocked** — Playwright Chromium cannot create a true third-party context. Known untested incident class (B.7). |
Expand All @@ -150,7 +150,7 @@ task that found it, so the row can be traced.
| ID | P | Story | Source | Status |
|----|---|-------|--------|--------|
| REG-01 | P0 | Scope is not lost on token refresh; downstream authorisation still passes. | PPT-2536, 2026-07-23 revert | **done** — AUTH-E2E-02 |
| REG-02 | P1 | An overlapping desk booking is rejected rather than silently accepted. | `2607.1` "Fix rejecting overlapping bookings on desk assignment" | todo |
| REG-02 | P1 | An overlapping desk booking is rejected rather than silently accepted. | `2607.1` "Fix rejecting overlapping bookings on desk assignment" | **done** — `local/desk-clash.spec.ts`. Identical and partially-overlapping slots both refused (409), attempted as a *second* user so a per-user-only check would fail. Includes a control that a non-overlapping slot is accepted, and that the desk frees up after deletion. Red-checked. |
| REG-03 | P1 | A clash check uses the **current** `booking_end`, not a stale one. | `2607.1` "Fix stale booking_end being used for clash check" | todo |
| REG-04 | P1 | Desk booking status displays correctly in the booking list. | `2606.1` "Fix status display for desk bookings" | todo |
| REG-05 | P2 | The authorised-user check has no race on boot (no flash of unauthorised). | `2607.1` "Fix race condition for authorised check" | todo |
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { Component, computed, effect, inject, signal } from '@angular/core';
import {
Component,
computed,
effect,
inject,
signal,
untracked,
} from '@angular/core';
import { form, FormField, required, validate } from '@angular/forms/signals';
import { MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
Expand Down Expand Up @@ -231,7 +238,7 @@ export class BroadcastEmailModalComponent {
effect(() => {
this.form.recipient_group().value();
this.form.recipients().value();
this.updateRecipients();
untracked(() => this.updateRecipients());
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import {
TableColumn,
TranslatePipe,
} from '@placeos/components';
import { isSameDay } from 'date-fns';
import { toZonedTime } from 'date-fns-tz';
import { ParkingBookingsWeekViewComponent } from './parking-bookings-week-view.component';
import { ParkingStateService } from './parking-state.service';
import {
Expand Down Expand Up @@ -147,6 +149,9 @@ interface ParkingBookingColumnTemplates {
} @else {
{{ row.date | date: time_format : timezone }} -
{{ row.date_end | date: time_format : timezone }}
@if (isNextDay(row)) {
<sup>+1</sup>
}
}
</div>
</ng-template>
Expand Down Expand Up @@ -593,7 +598,12 @@ export class ParkingBookingsListComponent
private _state = inject(ParkingStateService);
private _settings = inject(SettingsService);

public readonly bookings = this._state.bookings;
public readonly bookings = computed(() => {
const selected_date = this._state.options().date;
return this._state
.bookings()
.filter((booking) => this._isSameDay(booking.date, selected_date));
});
public readonly options = this._state.options;
public readonly loading = this._state.loading;
public readonly period = this._state.period;
Expand Down Expand Up @@ -774,6 +784,18 @@ export class ParkingBookingsListComponent
);
}

public isNextDay(booking: Booking) {
return !this._isSameDay(booking.date, booking.date_end);
}

private _isSameDay(first: number, second: number) {
const timezone = this.timezone;
return isSameDay(
timezone ? toZonedTime(first, timezone) : first,
timezone ? toZonedTime(second, timezone) : second,
);
}

public statusLabel(booking: Booking) {
return this.isAssignedBooking(booking)
? 'APP.CONCIERGE.BOOKING_STATUS_ASSIGNED'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { ComponentFixtureAutoDetect } from '@angular/core/testing';
import { ComponentFixtureAutoDetect, TestBed } from '@angular/core/testing';
import { MatDialogRef } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest';
import {
OrganisationService,
Expand Down Expand Up @@ -31,6 +34,7 @@ describe('BroadcastEmailModalComponent', () => {
MockComponent(FullscreenModalShellComponent),
MockComponent(UserListFieldComponent),
],
imports: [MatFormFieldModule, MatInputModule, MatSelectModule],
providers: [
{ provide: ComponentFixtureAutoDetect, useValue: false },
MockProvider(MatDialogRef, { close: dialog_close } as any),
Expand Down Expand Up @@ -103,6 +107,22 @@ describe('BroadcastEmailModalComponent', () => {
]);
});

it('should not re-resolve recipients when the subject changes', () => {
TestBed.flushEffects();
const update_recipients = vi.spyOn(
spectator.component,
'updateRecipients',
);

spectator.component.model.update((m) => ({
...m,
subject: 'Emergency notice',
}));
TestBed.flushEffects();

expect(update_recipients).not.toHaveBeenCalled();
});

it('should notify an error and skip sending when the mailer is missing', async () => {
smtp_module = null;
spectator.component.model.update((m) => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ describe('ParkingBookingsListComponent', () => {
let custom_booking_columns: any[] = [];
let bookable_hours: { start: number; end: number } | undefined;
let timezone = 'Australia/Perth';
let selected_date = Date.now();
let request_filter: 'all' | 'bookings' | 'requests' | 'waitlist' = 'all';

const createComponent = createComponentFactory({
Expand All @@ -26,7 +27,7 @@ describe('ParkingBookingsListComponent', () => {
MockProvider(ParkingStateService, {
bookings: (() => bookings) as any,
options: (() => ({
date: Date.now(),
date: selected_date,
search: '',
zones: [],
period: 'day',
Expand Down Expand Up @@ -93,6 +94,7 @@ describe('ParkingBookingsListComponent', () => {
custom_booking_columns = [];
bookable_hours = undefined;
timezone = 'Australia/Perth';
selected_date = Date.now();
request_filter = 'all';
settingSignal('parking.allow_editing', true).set(true);
settingSignal('parking.allow_deleting', false).set(false);
Expand Down Expand Up @@ -154,6 +156,7 @@ describe('ParkingBookingsListComponent', () => {
});

it('should show start and end times for all-day bookings', () => {
selected_date = new Date(2026, 6, 21, 8).valueOf();
bookings = [
{
id: 'booking-1',
Expand All @@ -174,6 +177,7 @@ describe('ParkingBookingsListComponent', () => {

it('should show all day when the booking matches the bookable period', () => {
bookable_hours = { start: 8, end: 17 };
selected_date = new Date(2026, 6, 21, 8).valueOf();
bookings = [
{
id: 'booking-1',
Expand All @@ -193,6 +197,50 @@ describe('ParkingBookingsListComponent', () => {
).not.toHaveText(':');
});

it('should only show bookings that start on the selected day', () => {
selected_date = new Date('2026-08-03T12:00:00+08:00').valueOf();
bookings = [
{
id: 'previous-day',
asset_id: 'bay-1',
status: 'approved',
date: new Date('2026-08-02T17:30:00+08:00').valueOf(),
date_end: new Date('2026-08-03T06:30:00+08:00').valueOf(),
duration: 13 * 60,
},
{
id: 'selected-day',
asset_id: 'bay-2',
status: 'approved',
date: new Date('2026-08-03T17:30:00+08:00').valueOf(),
date_end: new Date('2026-08-04T06:30:00+08:00').valueOf(),
duration: 13 * 60,
},
] as Booking[];
spectator = createComponent();

expect(
spectator.component.filtered_events().map(({ id }) => id),
).toEqual(['selected-day']);
});

it('should mark overnight booking end times as the next day', () => {
selected_date = new Date('2026-08-03T12:00:00+08:00').valueOf();
bookings = [
{
id: 'overnight',
asset_id: 'bay-1',
status: 'approved',
date: new Date('2026-08-03T17:30:00+08:00').valueOf(),
date_end: new Date('2026-08-04T06:30:00+08:00').valueOf(),
duration: 13 * 60,
} as Booking,
];
spectator = createComponent();

expect(spectator.query('sup')).toHaveText('+1');
});

it('should add custom extension data columns', () => {
custom_booking_columns = [
{ field: 'cost_code', name: 'Cost Code' },
Expand Down Expand Up @@ -456,6 +504,8 @@ describe('ParkingBookingsListComponent', () => {
bookings = [
{
asset_id: 'bay-1',
date: selected_date,
date_end: selected_date + 60 * 60 * 1000,
extension_data: { vehicle_type: 'truck' },
} as unknown as Booking,
];
Expand Down
Loading
Loading