diff --git a/.docker/e2e-ldap/99.6-e2e-role-users.ldif b/.docker/e2e-ldap/99.6-e2e-role-users.ldif
new file mode 100644
index 0000000..28a387e
--- /dev/null
+++ b/.docker/e2e-ldap/99.6-e2e-role-users.ldif
@@ -0,0 +1,36 @@
+# Playwright authorization test users (password = password)
+# Loaded after dms-ad-openldap bootstrap LDIF on fresh LDAP volumes.
+
+dn: cn=honorariumadmin,ou=Members,dc=dms,dc=local
+cn: honorariumadmin
+displayName: Honorarium Admin
+employeeID: 0000000005
+memberOf: CN=Members,OU=Security,OU=Groups,DC=dms,DC=local
+memberOf: CN=Honorarium Admins,OU=Applications,OU=Groups,DC=dms,DC=local
+objectcategory: cn=Person,cn=Schema,cn=Configuration,dc=dms,dc=local
+objectclass: user
+objectclass: top
+sAMAccountName: honorariumadmin
+sn: honorariumadmin
+mail: honorariumadmin@dms.local
+userPassword: {SSHA}EadtUhuwl73vtXqj5ns6I5BdRqabRDs=
+userPrincipalName: honorariumadmin@dms.local
+UserAccountControl: 512
+telephonenumber: 555-555-5555
+
+dn: cn=financialadmin,ou=Members,dc=dms,dc=local
+cn: financialadmin
+displayName: Financial Admin
+employeeID: 0000000006
+memberOf: CN=Members,OU=Security,OU=Groups,DC=dms,DC=local
+memberOf: CN=Financial Reporting,OU=Security,OU=Groups,DC=dms,DC=local
+objectcategory: cn=Person,cn=Schema,cn=Configuration,dc=dms,dc=local
+objectclass: user
+objectclass: top
+sAMAccountName: financialadmin
+sn: financialadmin
+mail: financialadmin@dms.local
+userPassword: {SSHA}EadtUhuwl73vtXqj5ns6I5BdRqabRDs=
+userPrincipalName: financialadmin@dms.local
+UserAccountControl: 512
+telephonenumber: 555-555-5555
diff --git a/.docker/e2e-ldap/99.7-e2e-role-users-groups.ldif b/.docker/e2e-ldap/99.7-e2e-role-users-groups.ldif
new file mode 100644
index 0000000..652a8da
--- /dev/null
+++ b/.docker/e2e-ldap/99.7-e2e-role-users-groups.ldif
@@ -0,0 +1,11 @@
+dn: CN=Honorarium Admins,OU=Applications,OU=Groups,DC=dms,DC=local
+changetype: modify
+add: member
+member: cn=honorariumadmin,ou=Members,dc=dms,dc=local
+-
+
+dn: CN=Financial Reporting,OU=Security,OU=Groups,DC=dms,DC=local
+changetype: modify
+add: member
+member: cn=financialadmin,ou=Members,dc=dms,dc=local
+-
diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml
index bdf13fa..9a10754 100644
--- a/.github/workflows/playwright.yml
+++ b/.github/workflows/playwright.yml
@@ -29,6 +29,8 @@ jobs:
./setup.sh
- name: Start application stack
+ env:
+ DEBUG: false
run: docker compose up -d --build
- name: Wait for application
@@ -37,14 +39,34 @@ jobs:
for i in $(seq 1 60); do
if curl -sf http://localhost:8000/ > /dev/null; then
echo "Application is ready."
- exit 0
+ break
fi
echo "Attempt ${i}/60: application not ready yet."
sleep 10
done
- echo "Application failed to become ready in time."
- docker compose ps
- docker compose logs app --tail 200
+ if ! curl -sf http://localhost:8000/ > /dev/null; then
+ echo "Application failed to become ready in time."
+ docker compose ps
+ docker compose logs app --tail 200
+ exit 1
+ fi
+
+ - name: Wait for LDAP
+ run: |
+ echo "Waiting for LDAP user lookup ..."
+ for i in $(seq 1 30); do
+ if docker compose exec -T ldap ldapsearch -x -LLL \
+ -H ldap://localhost -b "dc=dms,dc=local" \
+ -D "cn=admin,dc=dms,dc=local" -w 'Adm1n!' \
+ "(sAMAccountName=user1)" dn 2>/dev/null | grep -q "user1"; then
+ echo "LDAP is ready."
+ exit 0
+ fi
+ echo "Attempt ${i}/30: LDAP not ready yet."
+ sleep 5
+ done
+ echo "LDAP failed to become ready in time."
+ docker compose logs ldap --tail 100
exit 1
- name: Run Playwright tests
diff --git a/docker-compose.yml b/docker-compose.yml
index d43a5df..df8f6c4 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -33,6 +33,9 @@ services:
extends:
file: ./dms-ad-openldap/docker-compose.yml
service: openldap
+ volumes:
+ - ./.docker/e2e-ldap/99.6-e2e-role-users.ldif:/container/service/slapd/assets/config/bootstrap/ldif/99.6-e2e-role-users.ldif
+ - ./.docker/e2e-ldap/99.7-e2e-role-users-groups.ldif:/container/service/slapd/assets/config/bootstrap/ldif/99.7-e2e-role-users-groups.ldif
networks:
main:
aliases:
@@ -118,7 +121,7 @@ services:
entrypoint: ['/var/www/.docker/wait-for-it.sh', "db:3306", "-s", "-t", "120", "--" ]
command: '/var/www/.docker/startup.sh'
environment:
- DEBUG: true
+ DEBUG: ${DEBUG:-true}
DB_HOST: 'db' # Leave this as 'db' to utilize MySQL container(s)
DB_USERNAME: 'calendar'
DB_PASSWORD: 'calendar'
diff --git a/e2e/PLAYWRIGHT.md b/e2e/PLAYWRIGHT.md
index eacea44..59a36c6 100644
--- a/e2e/PLAYWRIGHT.md
+++ b/e2e/PLAYWRIGHT.md
@@ -1,5 +1,7 @@
# Writing Playwright tests for DMS Calendar
+**Implementation plan:** [plan/README.md](plan/README.md) — test checklist with one checkbox per test section.
+
Guide for authoring end-to-end tests in this repo. Based on [Playwright documentation](https://playwright.dev/docs/intro), the [Page Object Model pattern](https://playwright.dev/docs/pom), and common community guidance on POM + fixtures.
---
@@ -22,7 +24,7 @@ await page.getByLabel('Username').fill('user1');
// ✅ Spec file
const loginPage = new LoginPage(page);
-await loginPage.goto();
+await loginPage.navigateViaUrl();
await loginPage.loginAsMember(testUsers.member.username, testUsers.member.password);
await expect(eventsIndex.heading).toBeVisible();
```
@@ -75,12 +77,33 @@ Codegen and prototyping are fine — but **move every locator into a page object
### Test accounts
-Defined in `tests/data/test-users.ts` (sourced from `dms-ad-openldap/03-users.ldif`):
+Defined in `tests/data/test-users.ts` (base users from `dms-ad-openldap/03-users.ldif`; role-only users from `.docker/e2e-ldap/`):
| Key | Username | Password | Notes |
| --- | --- | --- | --- |
| `testUsers.member` | `user1` | `password` | Regular member |
-| `testUsers.admin` | `user2` | `password` | Admin-capable |
+| `testUsers.admin` | `user2` | `password` | Full admin (all admin AD groups) |
+| `testUsers.honorariumAdmin` | `honorariumadmin` | `password` | Honorarium Admins only |
+| `testUsers.financialAdmin` | `financialadmin` | `password` | Financial Reporting only |
+
+### Test data
+
+| File | Use for |
+| --- | --- |
+| `tests/data/test-users.ts` | LDAP usernames/passwords — always shared |
+| `tests/data/reference-data.ts` | Domain values referenced by **two or more** spec files |
+| Inline in the spec | Values used by a **single** test — keep them in that test |
+
+**Rule:** Do not add data to `reference-data.ts` unless a second spec needs the same value. Put test-specific names, emails, and seed references as `const` declarations at the top of the test (or inline) so readers see everything in one place.
+
+```typescript
+test('admin manages categories', async ({ page }) => {
+ const categoryName = 'Test Category E2E';
+ // ...
+});
+```
+
+Never put locators or page object logic in `tests/data/`.
---
@@ -89,12 +112,17 @@ Defined in `tests/data/test-users.ts` (sourced from `dms-ad-openldap/03-users.ld
```
e2e/
├── PLAYWRIGHT.md
+├── plan/ # E2E implementation checklist (one checkbox per test)
+│ ├── README.md
+│ ├── layer-*.md
+│ └── appendices/
├── playwright.config.ts
├── package.json
├── tests/
│ ├── *.spec.ts # orchestration + assertions only
│ ├── data/
-│ │ └── test-users.ts # credentials, IDs — no locators
+│ │ ├── test-users.ts # LDAP credentials — shared across specs
+│ │ └── reference-data.ts # values shared by 2+ specs only
│ ├── pages/
│ │ ├── login.page.ts
│ │ └── events-index.page.ts
@@ -108,7 +136,7 @@ e2e/
| --- | --- | --- |
| **Page object** | `tests/pages/` | One class per logical page (`LoginPage`, `EventsIndexPage`) |
| **Component object** | `tests/components/` | UI shared across pages (navbar, dialogs) |
-| **Test data** | `tests/data/` | Static values reused across specs |
+| **Test data** | `tests/data/` | Credentials and values reused across specs |
| **Fixture** | `fixtures/` | Setup/teardown (auth state, shared sessions) |
| **Spec** | `tests/*.spec.ts` | User journey; **no locators** |
@@ -120,7 +148,7 @@ e2e/
| --- | --- |
| **Locators** | Private getter properties; public getters only when specs need them for `expect` |
| **Actions** | Public methods for user intent: `loginAsMember()`, `openCalendarView()` |
-| **Navigation** | `goto()` on the page object; never `page.goto()` in specs |
+| **Navigation** | `navigateViaMenu()` or `navigateViaUrl()` on page objects — never `page.goto()` in specs |
| **Cross-page flows** | Methods return the **destination page object** (`loginAsMember()` → `EventsIndexPage`) |
| **Assertions** | In **specs**, against locators exposed by page objects (`expect(loginPage.heading)`) |
| **State** | Page objects are stateless — no cached DOM text or step tracking |
@@ -223,7 +251,7 @@ import { EventsIndexPage } from './pages/events-index.page';
test('homepage displays upcoming events', async ({ page }) => {
const eventsIndex = new EventsIndexPage(page);
- await eventsIndex.goto();
+ await eventsIndex.navigateViaUrl();
await expect(eventsIndex.heading).toBeVisible();
});
```
@@ -241,7 +269,7 @@ test('member can log in with LDAP credentials', async ({ page }) => {
const loginPage = new LoginPage(page);
const eventsIndex = new EventsIndexPage(page);
- await loginPage.goto();
+ await loginPage.navigateViaUrl();
await expect(loginPage.heading).toBeVisible();
await loginPage.loginAsMember(
@@ -257,13 +285,15 @@ test('member can log in with LDAP credentials', async ({ page }) => {
## Adding a new test (checklist)
-1. **Define the user goal** — e.g. “Member opens calendar view from home.”
+1. **Pick a test** from [plan/README.md](plan/README.md) and check it off when done.
+2. **Define the user goal** — e.g. “Member opens calendar view from home.”
2. **Identify pages touched** — list each screen in the flow.
3. **Create or extend page objects first** — add locators and actions for every new interaction.
-4. **Add test data** if needed (`tests/data/`) — never hardcode credentials in specs.
+4. **Add test data** — inline values in the spec when used by one test; add to `tests/data/` only when shared (see [Test data](#test-data))
5. **Write the spec** — page object methods + `expect` on page object locators only.
6. **Run locally** — `npm test`, then `--headed` or `--debug` on failure.
-7. **Push** — CI runs via `.github/workflows/playwright.yml`.
+7. **Mark done** — `[x]` on the test line in `plan/layer-*.md`.
+8. **Push** — CI runs via `.github/workflows/playwright.yml`.
### Spec template
@@ -275,7 +305,7 @@ test.describe('Feature area', () => {
test('role can do thing', async ({ page }) => {
const somePage = new SomePage(page);
- await somePage.goto();
+ await somePage.navigateViaMenu();
await somePage.doSomething();
await expect(somePage.resultLocator).toBeVisible();
@@ -288,6 +318,7 @@ test.describe('Feature area', () => {
- **Page files:** `events-calendar.page.ts` → `EventsCalendarPage`
- **Spec files:** `events-calendar.spec.ts`
- **Tests:** `'guest can browse upcoming events'`, `'admin can open honoraria pending list'`
+- **Do not** prefix `test` or `test.describe` titles with plan section numbers (e.g. `1.1`, `§2.4`). Section IDs belong in [plan/](plan/README.md) checkboxes and the spec-file mapping table — spec titles should read as plain user-facing behavior.
---
@@ -302,7 +333,7 @@ test.beforeEach(async ({ page }) => {
const loginPage = new LoginPage(page);
const eventsIndex = new EventsIndexPage(page);
- await loginPage.goto();
+ await loginPage.navigateViaUrl();
await loginPage.loginAsMember(
testUsers.member.username,
testUsers.member.password,
@@ -329,7 +360,7 @@ setup('authenticate as member', async ({ page }) => {
const loginPage = new LoginPage(page);
const eventsIndex = new EventsIndexPage(page);
- await loginPage.goto();
+ await loginPage.navigateViaUrl();
await loginPage.loginAsMember(
testUsers.member.username,
testUsers.member.password,
@@ -357,7 +388,7 @@ export const test = base.extend<{ memberSession: void }>({
const loginPage = new LoginPage(page);
const eventsIndex = new EventsIndexPage(page);
- await loginPage.goto();
+ await loginPage.navigateViaUrl();
await loginPage.loginAsMember(
testUsers.member.username,
testUsers.member.password,
@@ -372,6 +403,20 @@ export { expect } from '@playwright/test';
---
+## Navigation
+
+Prefer **menu clicks** via `navigateViaMenu()` when the nav exposes a path; use `navigateViaUrl()` for direct URLs (login, homepage, pages with no nav link, access-denial tests).
+
+| Method | Use when |
+| --- | --- |
+| `navigateViaMenu()` | Header menus, top-level links |
+| `navigateViaUrl()` | No menu link (`/logs`, `/honoraria`), cold-start entry (`/`, `/users/login`), access denial, or no list link to target |
+| `HeaderComponent.goHome()` | Return to `/` via the brand link |
+
+Implementation detail: both methods live on page objects; `page.goto()` is only used inside page object methods, never in specs.
+
+---
+
## Core principles
### Test user-visible behavior
@@ -389,7 +434,15 @@ Never use `page.waitForTimeout()`. Playwright auto-waits on actions and `expect`
### Test isolation
-Each test gets a fresh browser context. Do not depend on another test’s server-side mutations unless you control seed data.
+Each test gets a fresh browser context. Tests must also be **self-contained on the server**:
+
+- Create every entity the test needs; delete it (or restore global settings) before the test ends.
+- Do not use `test.describe.configure({ mode: 'serial' })` to share mutable state between tests.
+- Exception: serialize tests in the same file when they POST the same singleton form (Super Calendar Admin settings) and concurrent saves would race.
+- Do not leave CRUD rows, config edits, or toggles for a later test or layer — later specs create their own data.
+- Pre-test “remove leftovers” loops are only for recovering from an **interrupted run of the same test** (fail-fast, no `try/finally` restore on failure).
+
+Shared LDAP users and seed rows shipped with the app (e.g. `Fiber Arts`, `Conference Room`) are fine; mutable test fixtures are not.
### No third-party assertions
@@ -443,10 +496,14 @@ Use codegen to **find** locators, then paste them into the appropriate page obje
| --- | --- |
| `page.getByRole(...)` in a spec | Add locator to page object |
| `readonly foo: Locator` public fields | Private/public getter properties |
-| `page.goto(...)` in a spec | `somePage.goto()` |
+| `page.goto(...)` in a spec | `somePage.navigateViaMenu()` or `somePage.navigateViaUrl()` |
| Inline / one-off tests without POM | Create page object first |
| CSS / `#id` in page objects | `getByRole`, `getByLabel` |
| Credentials in spec files | `tests/data/test-users.ts` |
+| Single-test CRUD names in `reference-data.ts` | Inline `const` in the spec |
+| `serial` describe to pass state between tests | Self-contained setup + teardown in each test |
+| `serial` without a singleton-form race reason | Only serialize when concurrent POSTs to one form would clobber fields |
+| Leave test entities for a later layer | Create and delete inside the same test |
| `waitForTimeout()` | `expect(locator).toBeVisible()` |
| Giant page object for whole app | Split by page + components |
| Committing `playwright/.auth/*.json` | `.gitignore` |
diff --git a/e2e/plan/README.md b/e2e/plan/README.md
new file mode 100644
index 0000000..43bb3ac
--- /dev/null
+++ b/e2e/plan/README.md
@@ -0,0 +1,93 @@
+# DMS Calendar — E2E Test Implementation Plan
+
+Bottom-up Playwright coverage mapped from all user-testable PHP under `src/`.
+
+**How to use:** Pick a test section, implement the spec + page objects ([Playwright guide](../PLAYWRIGHT.md)), then check `[x]` on that test. Steps under each test are procedure reference for writing specs — not separate trackable items.
+
+---
+
+## Progress
+
+| Layer | Doc | Tests | Done | Remaining |
+| --- | --- | ---: | ---: | ---: |
+| Setup | [Environment](environment.md) | — | — | — |
+| Layer 0 | [Pre-flight](layer-0-preflight.md) | 3 | 1 | 2 |
+| Layer 1 | [Reference data](layer-1-reference-data.md) | 12 | 12 | 0 |
+| Layer 2a | [Events & files](layer-2-events.md) | 12 | 8 | 4 |
+| Layer 2b | [Registration](layer-2-registration.md) | 10 | 7 | 3 |
+| Layer 2c | [Admin & host](layer-2-admin-host.md) | 10 | 5 | 5 |
+| Layer 3 | [Public calendar](layer-3-public.md) | 7 | 1 | 6 |
+| **Total** | | **54** | **34** | **20** |
+
+```bash
+grep -r '^- \[x\] ' e2e/plan/layer-*.md | wc -l
+```
+
+---
+
+## Table of contents
+
+1. [Environment & infrastructure](environment.md)
+2. [Layer 0 — Pre-flight](layer-0-preflight.md)
+3. [Layer 1 — Reference data](layer-1-reference-data.md)
+4. [Layer 2a — Auth, events & files](layer-2-events.md)
+5. [Layer 2b — Registration flows](layer-2-registration.md)
+6. [Layer 2c — Host, admin & integrations](layer-2-admin-host.md)
+7. [Layer 3 — Public calendar](layer-3-public.md)
+
+Appendices: [appendices/](appendices/)
+
+---
+
+## Suggested spec file mapping
+
+| Spec file | Test sections | Layer doc |
+| --- | --- | --- |
+| `login.spec.ts` ✓ | §2.1 | layer-2-events |
+| `home.spec.ts` ✓ | §0.1, §3.1 | layer-0, layer-3-public |
+| `ldap-login.spec.ts` ✓ | §1.1 | layer-1 |
+| `categories.spec.ts` ✓ | §1.2 | layer-1 |
+| `committees.spec.ts` ✓ | §1.3 | layer-1 |
+| `prerequisites.spec.ts` ✓ | §1.4 | layer-1 |
+| `rooms.spec.ts` ✓ | §1.5 | layer-1 |
+| `tools.spec.ts` ✓ | §1.6 | layer-1 |
+| `configurations.spec.ts` ✓ | §1.7 | layer-1 |
+| `calendar-admin.spec.ts` ✓ | §1.8 | layer-1 |
+| `contacts.spec.ts` ✓ | §1.9 | layer-1 |
+| `logs.spec.ts` ✓ | §1.10 | layer-1 |
+| `authorization.spec.ts` ✓ | §1.11 | layer-1 |
+| `events-create.spec.ts` ✓ | §2.1b – §2.3, §2.1c | layer-2-events |
+| `events-admin.spec.ts` ✓ | §2.6 – §2.8 | layer-2-events |
+| `registration.spec.ts` ✓ | §2.10 – §2.16, §2.19 | layer-2-registration |
+| `host-admin.spec.ts` ✓ | §2.22 – §2.26 (partial) | layer-2-admin-host |
+| `registration-paid.spec.ts` | §2.18 | layer-2-registration |
+| `email.spec.ts` | §2.27 | layer-2-admin-host |
+| `sso.spec.ts` | §2.28 | layer-2-admin-host |
+| `smoke.spec.ts` | §3.7 | layer-3-public |
+
+See layer docs for full section list.
+
+---
+
+## Playwright conventions
+
+| Item | Location |
+| --- | --- |
+| Specs | `tests/*.spec.ts` |
+| Page objects | `tests/pages/*.page.ts` |
+| Test users | `tests/data/test-users.ts` |
+| Authoring guide | [PLAYWRIGHT.md](../PLAYWRIGHT.md) |
+
+Spec and test titles must **not** include plan section numbers (`1.1`, `§2.4`, etc.); use descriptive names only. Track plan coverage via checkboxes in layer docs and the spec mapping table in this doc.
+
+### Marking tests done
+
+1. Implement the spec (page objects only — see PLAYWRIGHT.md).
+2. Check `[x]` on the line under the test heading.
+3. Update the progress table above.
+
+---
+
+## Out of scope
+
+See [appendices/out-of-scope.md](appendices/out-of-scope.md).
diff --git a/e2e/plan/appendices/infrastructure-index.md b/e2e/plan/appendices/infrastructure-index.md
new file mode 100644
index 0000000..b46b876
--- /dev/null
+++ b/e2e/plan/appendices/infrastructure-index.md
@@ -0,0 +1,51 @@
+# Appendix F — Infrastructure-tagged tests
+
+[← E2E plan index](../README.md)
+
+---
+
+
+Complete list of tests requiring setup beyond default `docker compose up`. Run §0.3 first.
+
+| Section | Tag(s) | What is verified |
+|---|---|---|
+| §0.3 | All | Infrastructure readiness smoke checks |
+| §1.9 step 10–11 | ~~`[INFRA: LDAP-setup]`~~ | Honorarium / Financial contact view — covered by `honorariumadmin` / `financialadmin` seed users |
+| §1.11 steps 4–8 | `[INFRA: LDAP-setup]` | Financial-only and honorarium-only auth matrix |
+| §2.2 | `[INFRA: SparkPost]` / `[INFRA: Email-dev]` | Event submission email |
+| §2.4.1 | `[INFRA: LDAP-setup]` | Prerequisite positive path |
+| §2.4 variant notes | `[INFRA: SparkPost]` / `[INFRA: Email-dev]` | Instructor notification email |
+| §2.6 approve/reject | `[INFRA: SparkPost]` / `[INFRA: Email-dev]` | Approval/rejection emails |
+| §2.6 honorarium auth | `[INFRA: LDAP-setup]` | Honorarium vs calendar admin split |
+| §2.10 | `[INFRA: SparkPost]` / `[INFRA: Email-dev]` | Registration confirmation email |
+| §2.11 | `[INFRA: SparkPost]` / `[INFRA: Email-dev]` | Pending/approved/rejected/instructor emails |
+| §2.11 | `[INFRA: Braintree]` | Paid host-reject refund message |
+| §2.13 | `[INFRA: OIDC]` | SSO prerequisite group refresh |
+| §2.18 (entire section) | `[INFRA: Braintree]` | Paid registration, void/refund, decline |
+| §2.19 | `[INFRA: SparkPost]` / `[INFRA: Email-dev]` | Cancellation + instructor emails |
+| §2.19 | `[INFRA: Braintree]` | Paid cancel refund |
+| §2.19 step 5 | `[INFRA: SparkPost]` / `[INFRA: Email-dev]` | Instructor cancel notification |
+| §2.22 | `[INFRA: SparkPost]` / `[INFRA: Email-dev]` | Event cancelled emails to registrants |
+| §2.22 step 4–5 | `[INFRA: Twilio]` | Cancel-event SMS |
+| §2.24 step 10–11 | `[INFRA: Pre-2017-data]` | Old registrations export branch |
+| §2.25 step 3 | `[INFRA: LDAP-setup]` | Honoraria CRUD requires Calendar Admins |
+| §2.26 (entire section) | `[INFRA: Time-manual]` | All cron branches |
+| §2.26 email sub-steps | `[INFRA: SparkPost]` / `[INFRA: Email-dev]` | Cron-triggered emails |
+| §2.26 paid refund | `[INFRA: Braintree]` | Refund on cron cancel |
+| §2.27 (entire section) | `[INFRA: SparkPost]` / `[INFRA: Email-dev]` | Full email method checklist |
+| §2.28 (entire section) | `[INFRA: OIDC]` | SSO login, failure, contact gap, groups |
+
+### Re-analysis notes (second pass, post–infra tagging)
+
+| Finding | Source | Test impact |
+|---|---|---|
+| `EmailComponent` always uses SparkPost transport | `EmailComponent.php:65` | MailHog docker env ignored unless `[INFRA: Email-dev]` patch |
+| Cron auto-approve does **not** send email | `EventsController::cron` | §2.26 step 5 — status change only |
+| Only `cancel` action calls `__sendText` | `EventsController.php:1489` | §2.22 Twilio; cron SMS tests N/A |
+| SSO bypasses `afterIdentify` | `UsersController::oidcCallback` | §2.28 step 6 — contact provisioning gap |
+| `/honoraria` uses Calendar Admins auth | `HonorariaController` + parent auth | §2.25 step 3 |
+| `sendEventRejected` is wired | `EventsController::reject` afterSave | §2.6 — not dead code |
+| `FriendlyTimeBehavior` | `EventsTable` — formats display times on marshal | Covered indirectly via §2.7 edit round-trip |
+| Dev `docker-compose.yml` omits Braintree/SparkPost/Twilio/OIDC env | `docker-compose.yml` vs `.docker/environment.conf` | §0.3 / infra tag table — must add vars manually |
+| `approve` afterSave sends email; `cron` auto-approve does not | Compare `approve()` vs `cron()` | Different expectations in §2.6 vs §2.26 |
+| Multipart reject/approve cascades | `reject`/`approve` afterSave | §2.6 steps added |
diff --git a/e2e/plan/appendices/model-validation.md b/e2e/plan/appendices/model-validation.md
new file mode 100644
index 0000000..589f7df
--- /dev/null
+++ b/e2e/plan/appendices/model-validation.md
@@ -0,0 +1,57 @@
+# Appendix C — Model validation branches
+
+[← E2E plan index](../README.md)
+
+---
+
+
+Use with §2.3 negative tests. Each row is a distinct logical branch in `src/Model`.
+
+### `EventsTable::validationDefault`
+
+| Field / rule | Valid test input | Invalid / branch |
+|---|---|---|
+| `event_start` + honorarium | Start ≥ config 3 days | §2.3 honorarium lead |
+| `event_start` no honorarium | Start ≥ config 4 days | §2.3 start too soon |
+| `event_start` max horizon | Within config 5 days | §2.3 max lead |
+| `event_end` | End after start | §2.3 end before start |
+| `eventbrite_link` | Optional URL on Eventbrite type | §2.4 Eventbrite variant |
+| `free_spaces` / `paid_spaces` | `0` = unlimited pool | §2.16 |
+| `age_restriction` | 0, 13, 16, 18, 21 | §2.3, §2.14 |
+| `extend_registration` | 0, 15, 20, 25, 30 only | §2.3, §2.17 |
+
+### `EventsTable::buildRules`
+
+| Rule | Test section |
+|---|---|
+| Exclusive room overlap | §2.3 |
+| Tool overlap | §2.3 |
+| Multipart session 2 room conflict | §2.3 |
+
+### `EventsTable` space helpers
+
+| Method | Branch | Test section |
+|---|---|---|
+| `getTotalSpaces` | Returns `true` when both pools 0 | §2.16 |
+| `hasFreeSpaces` / `hasPaidSpaces` | Separate pool exhaustion | §2.16 |
+| `getFilledSpaces` | Excludes cancelled/rejected | §2.16, §3.4 |
+
+### `RegistrationsTable`
+
+| Rule | Test section |
+|---|---|
+| Email unique per `event_id` | §2.16 |
+| `ad_username` unique per `event_id` | §2.10 |
+| `refund()` void vs settle vs no-op | §2.18, §2.19 |
+
+### Behaviors
+
+| Behavior | Test section |
+|---|---|
+| `RelationalTimeBehavior` booking/cancellation offsets | §2.7 |
+| `FriendlyTimeBehavior` US-format → UTC on save | §2.7 (admin edit round-trip) |
+| Multipart continued events (`_afterCreate/_afterUpdate`) | §2.4, §2.7, §2.8 |
+| `FilesTable` multipart file delete cascade | §2.9 |
+
+---
+
diff --git a/e2e/plan/appendices/out-of-scope.md b/e2e/plan/appendices/out-of-scope.md
new file mode 100644
index 0000000..d187658
--- /dev/null
+++ b/e2e/plan/appendices/out-of-scope.md
@@ -0,0 +1,24 @@
+# Appendix D — Out of scope & dead code
+
+[← E2E plan index](../README.md)
+
+---
+
+
+Not required for manual QA; listed so auditors know gaps are intentional.
+
+| Item | Location | Reason |
+|---|---|---|
+| `PagesController::display` | `config/routes.php` disabled | No static pages routed |
+| `W9sController` / `/w9s` | Header nav commented / missing controller | W-9 upload UI not wired; `w9_on_file` manual on contact |
+| `__add_with_intuit.ctp` | Template alternate | Not used by current add action |
+| Reports nav link | Header commented | No ReportsController |
+| `EventsController::reject` TODO comment | Line ~1160 | Stale — `sendEventRejected` **is** called in `afterSave` |
+| `RegistrationsController` `$$registration` | Line ~164 | Bug — race on full free pool |
+| `OpenIDConnectService::getGroupsFromUserInfo` | Missing return | SSO groups broken |
+| PHPUnit `*Test.php` in `src/Controller` | Automated only | Run via `bin/cake test` |
+| `bin/cake.php` console | No custom commands | No shell UX to test |
+| Room id `58` in `_applyAddress` | Hardcoded | May not exist in Docker seed (26 rooms); Offsite typically id 23 |
+
+---
+
diff --git a/e2e/plan/appendices/source-coverage.md b/e2e/plan/appendices/source-coverage.md
new file mode 100644
index 0000000..975b4f2
--- /dev/null
+++ b/e2e/plan/appendices/source-coverage.md
@@ -0,0 +1,113 @@
+# Appendix B — Source coverage index
+
+[← E2E plan index](../README.md)
+
+---
+
+
+Maps every user-testable controller action and major auth/branch to manual test section(s). **✓** = explicit steps; **~** = infrastructure-dependent (see tag + Appendix F); **—** = dead or out of scope.
+
+### Controllers — actions
+
+| Source | Test section | Notes |
+|---|---|---|
+| `UsersController::login` | §1.1, §2.1, §3.6 | Negative paths, redirect, email-as-username |
+| `UsersController::logout` | §1.1, §3.6 | |
+| `UsersController::ssoLogin` | §2.28 | `[INFRA: OIDC]` |
+| `UsersController::oidcCallback` | §2.28 | Failure path; success redirect bug |
+| `UsersController::afterIdentify` | §2.1b | Contact sync, blacklist, `contact_error` |
+| `CategoriesController::index` (+ CRUD via Crud) | §1.2 | |
+| `CommitteesController::index` (+ CRUD) | §1.3 | |
+| `PrerequisitesController::index` (+ CRUD) | §1.4 | |
+| `RoomsController::index` (+ CRUD) | §1.5 | Exclusive flag |
+| `ToolsController::index` (+ CRUD) | §1.6 | |
+| `ConfigurationsController::index` (+ edit) | §1.7 | Config 7 excluded from index |
+| `CalendarAdminController::edit` | §1.8 | Honoraria toggle + custom message |
+| `ContactsController::index/view` (+ CRUD) | §1.9 | Blacklist, W-9 flag, duplicate email |
+| `LogsController::index` | §1.10 | Filters, auth, `customLog` skip rules |
+| `FilesController::delete` | §2.9 | Auth, multipart cleanup, size validation |
+| `EventsController::add` | §2.2–2.5, §2.3 | Copy param, sponsored, variants |
+| `EventsController::edit` | §2.7 | Owner vs admin, multipart, logging |
+| `EventsController::view` | §2.7, §3.4 | Child redirect, registration states |
+| `EventsController::cancel` | §2.22 | Multipart, SMS, refunds |
+| `EventsController::approve/reject/processRejection` | §2.6 | Honorarium auth split |
+| `EventsController::pending` | §2.6 | Calendar Admins only |
+| `EventsController::pendingHonoraria` etc. | §2.6, §2.23 | Honorarium Admins only |
+| `EventsController::all` | §2.23 | Date filter, all statuses |
+| `EventsController::submitted/attending` | §2.23 | Member lists |
+| `EventsController::attendance` | §2.20 | Config 6 window |
+| `EventsController::assignments` | §2.21 | LDAP attach, irreversible |
+| `EventsController::exportHonoraria/Csv` | §2.24 | Finance auth, pay enums, ≤2 attendees |
+| `EventsController::cron` | §2.26 | Complete, auto-approve, reminders |
+| `EventsController::index/embed/calendar` | §3.1–3.3 | Filters, completed on calendar only |
+| `EventsController::feed/ics/eventsJson` | §3.5 | All feed types, newJson -1 |
+| `RegistrationsController::event` | §2.10–2.17 | Guards, Braintree, prereq, pools |
+| `RegistrationsController::view/cancel` | §2.15, §2.19 | edit_key, cutoff, refund |
+| `RegistrationsController::accept/reject` | §2.11, §2.17 | Host auth, cutoff asymmetry |
+| `HonorariaController::index/view/add/edit/delete` | §2.25 | No nav link |
+| `AppController::beforeRender` | §1.11 | Menu flags |
+| `AppController::customLog` | §1.10 | |
+| `AppController::currentUserInGroup` | §2.13 | SSO refresh (broken) |
+| `EmailComponent::*` | §2.27 | All send* methods |
+| `PagesController::display` | — | Route disabled |
+| `W9sController` | — | Nav link only; no controller |
+
+### `EventsController::isAuthorized` branches
+
+| Branch | Test section |
+|---|---|
+| `add/submitted/attending` → logged in | §2.1, §2.23 |
+| `attendance/assignments/cancel/edit` → owner or admin | §2.7, §2.20–2.22 |
+| `pending/all` → Calendar Admins | §2.6, §2.23 |
+| Honoraria list actions → Honorarium Admins | §2.6, §2.23 |
+| Export → Financial Reporting | §2.24, §1.11 |
+| `approve/reject` + `hasHonorarium` → Honorarium Admins | §2.6 |
+| `approve/reject` without honorarium → Calendar Admins | §2.6 |
+
+### `RegistrationsController::isAuthorized` branches
+
+| Branch | Test section |
+|---|---|
+| Host (`created_by`) accept/cancel/reject/view | §2.11, §2.19 |
+| `isOwnedBy` ad_username | §2.10 |
+| `isOwnedBy` edit_key | §2.15 |
+| Calendar Admin override | §2.19 |
+
+### Event lifecycle hooks (indirect)
+
+| Hook | Test section |
+|---|---|
+| `_beforeCreate/_beforeUpdate` marshal | §2.1c, §2.3 |
+| `_afterCreate/_afterUpdate` emails, multipart | §2.4, §2.6, §2.7 |
+| `_applyAddress` | §2.4, §3.2, §3.4 |
+| `_filterContent` | §3.1 |
+| `RelationalTimeBehavior` | §2.7 |
+| `FilesTable` beforeDelete multipart | §2.9 |
+
+### Model validation & rules (user-visible)
+
+| Rule | Test section |
+|---|---|
+| `EventsTable` start/end/lead/honorarium lead | §2.3 |
+| `EventsTable` room/tool conflict (`buildRules`) | §2.3 |
+| `EventsTable` age_restriction enum | §2.3, §2.14 |
+| `EventsTable` extend_registration enum | §2.3, §2.17 |
+| `EventsTable` requires_prerequisite → members_only | §2.3 |
+| `RegistrationsTable` unique email per event | §2.16 |
+| `RegistrationsTable` unique ad_username per event | §2.10 |
+| `ContactsTable` unique email | §1.9 |
+| `FilesTable` file size | §2.9 |
+| `HonorariaTable` CRUD validation | §2.25 |
+
+### Coverage summary
+
+| Category | Count | Status |
+|---|---|---|
+| Controller actions (excl. tests/dead) | 52 | All mapped above |
+| Auth branches (`isAuthorized`) | 18 | §1.11, §2.6–2.7, §2.19, §2.24 |
+| Email send methods | 15 | §2.27 table |
+| Known code defects | 6 | §Known code issues table |
+| Dead / unreachable UI | 5 | Appendix D |
+
+---
+
diff --git a/e2e/plan/appendices/test-data.md b/e2e/plan/appendices/test-data.md
new file mode 100644
index 0000000..5cf9738
--- /dev/null
+++ b/e2e/plan/appendices/test-data.md
@@ -0,0 +1,24 @@
+# Appendix E — Test data quick reference
+
+[← E2E plan index](../README.md)
+
+---
+
+
+| Seed / create name | Purpose |
+|---|---|
+| `E2E Free Class` | Baseline free registration |
+| `E2E Approval Required` | Host approve/reject |
+| `E2E Members Only` | Auth gate |
+| `E2E Age 18+` | Advisories + age gate |
+| `E2E Prereq Gated` / `E2E Fulfills Prereq` | Prerequisite + AD assignment |
+| `E2E Multipart` | Continued sessions, redirects |
+| `E2E Sponsored` | External instructor contact |
+| `E2E Eventbrite` | Third-party paid link |
+| `E2E Honorarium` | Honorarium approval queue |
+| `E2E Unlimited Capacity` | `-1` in feeds, no cap UI |
+| `E2E Mixed Free/Paid` | Registration type selector |
+| `E2E Notify Instructor` | Instructor email paths |
+
+---
+
diff --git a/e2e/plan/appendices/url-reference.md b/e2e/plan/appendices/url-reference.md
new file mode 100644
index 0000000..e3773d8
--- /dev/null
+++ b/e2e/plan/appendices/url-reference.md
@@ -0,0 +1,36 @@
+# Appendix A — Quick URL reference
+
+[← E2E plan index](../README.md)
+
+---
+
+
+| Action | URL |
+|---|---|
+| Login | `/users/login` |
+| SSO | `/users/sso-login` |
+| OIDC callback | `/users/oidc-callback` |
+| Submit event | `/events/add` |
+| Pending (admin) | `/events/pending` |
+| Pending honoraria | `/events/pending-honoraria` |
+| Archive | `/events/all` |
+| Hosting events | `/events/submitted` |
+| Attending events | `/events/attending` |
+| Calendar | `/events/calendar` |
+| Embed | `/events/embed` |
+| Register | `/registrations/event/{eventId}` |
+| Export honoraria | `/events/export-honoraria` |
+| Export honoraria CSV | `/events/export-honoraria-csv` |
+| Super admin | `/calendar-admin/edit` |
+| Standalone honoraria | `/honoraria` |
+| Logs | `/logs` |
+| Cron | `/events/cron` |
+| iCal feed | `/events/feed/vcal` |
+| RSS feed | `/events/feed/rss` |
+| JSON feed (FeedIo) | `/events/feed/json` |
+| JSON feed (legacy API) | `/events/feed/newJson` |
+| Single event ICS | `/events/ics/{id}` |
+| Process rejection | `/events/process-rejection/{id}` |
+
+---
+
diff --git a/e2e/plan/environment.md b/e2e/plan/environment.md
new file mode 100644
index 0000000..1ef5691
--- /dev/null
+++ b/e2e/plan/environment.md
@@ -0,0 +1,99 @@
+# Environment & infrastructure
+
+Part of the [E2E implementation plan](README.md). Reference material — individual infra checks are checkboxes in [Layer 0 §0.3](layer-0-preflight.md).
+
+---
+
+
+### Services
+
+| Service | URL | Purpose |
+|---|---|---|
+| Calendar app | http://localhost:8000 | Application under test |
+| MailHog | http://localhost:8025 | Present in Docker; app ignores unless `[INFRA: Email-dev]` patch |
+| phpMyAdmin | http://localhost:8081 | DB inspection (`root` / `cakephp`) |
+| phpLDAPadmin | http://localhost:8888 | LDAP group management |
+| Keycloak | http://localhost:8080 | SSO (`[INFRA: OIDC]` — needs hosts + env) |
+
+### Infrastructure dependency tags
+
+Every test that needs something beyond bare `docker compose up` is **included in this plan** and marked with a tag. **Do not skip tagged tests** — run them when the listed setup is in place, or record **BLOCKED** with the tag reason.
+
+| Tag | In default Docker stack? | What you need |
+|---|---|---|
+| `[INFRA: SparkPost]` | No — `EmailComponent` hardcodes SparkPost transport | `SPARKPOST_APIKEY` in `.env` **and** passed into `app` container (see §0.3). Alternative: `[INFRA: Email-dev]` |
+| `[INFRA: Email-dev]` | Partial — MailHog runs but app ignores it | Temporarily point `EmailComponent::sendEmail` at SMTP transport `default` (MailHog `mail:1025`) |
+| `[INFRA: Braintree]` | No — empty `.env` keys | Braintree sandbox credentials in `.env` + `app` container env |
+| `[INFRA: OIDC]` | Partial — Keycloak container runs | `127.0.0.1 keycloak` in host `/etc/hosts`; OIDC URLs aimed at localhost; client secret in `.env`; fix `getGroupsFromUserInfo` return for group tests |
+| `[INFRA: Twilio]` | No | `TWILIO_ACCTSID`, `TWILIO_AUTHTOKEN`, `TWILIO_PHONENUM` in `.env` + container env |
+| `[INFRA: LDAP-setup]` | Yes — phpLDAPadmin on :8888 | Manual group membership edits (prerequisites, single-group auth matrix) |
+| `[INFRA: Time-manual]` | Yes — no scheduler | Edit event timestamps in DB/phpMyAdmin; hit `GET /events/cron` manually |
+| `[INFRA: Pre-2017-data]` | No — seed data is modern | Insert synthetic `old_registrations` rows for events before 2017-01-01 |
+
+**Passing env vars to the app container (dev):** Add to `docker-compose.yml` under `app.environment` (values from `.env`):
+
+```yaml
+SPARKPOST_APIKEY: ${SPARKPOST_APIKEY}
+BRAINTREE_ENV: ${BRAINTREE_ENV}
+BRAINTREE_MERCHID: ${BRAINTREE_MERCHID}
+BRAINTREE_PUBKEY: ${BRAINTREE_PUBKEY}
+BRAINTREE_PRIVKEY: ${BRAINTREE_PRIVKEY}
+TWILIO_ACCTSID: ${TWILIO_ACCTSID}
+TWILIO_AUTHTOKEN: ${TWILIO_AUTHTOKEN}
+TWILIO_PHONENUM: ${TWILIO_PHONENUM}
+OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-calendar}
+OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-dummy-secret-for-dev-mode}
+OIDC_URL_AUTHORIZE: http://keycloak:8080/realms/DMS/
+OIDC_URL_ACCESS_TOKEN: http://keycloak:8080/realms/DMS/protocol/openid-connect/token
+OIDC_URL_RESOURCE_OWNER: http://keycloak:8080/realms/DMS/protocol/openid-connect/userinfo
+```
+
+Restart `app` after changes. `.docker/environment.conf` already exposes these to PHP when set in the container.
+
+### Known infrastructure gaps (summary)
+
+| Gap | Impact | Tag |
+|---|---|---|
+| Email uses SparkPost, not MailHog SMTP | No emails in MailHog UI unless SparkPost key or Email-dev patch | `[INFRA: SparkPost]` or `[INFRA: Email-dev]` |
+| Braintree keys empty | Paid registration / refunds untestable | `[INFRA: Braintree]` |
+| OIDC URLs default to production hostnames | SSO redirect fails without hosts + local URLs | `[INFRA: OIDC]` |
+| Twilio keys empty | Cancel-event SMS fails silently (logged) | `[INFRA: Twilio]` |
+| `user1` not in prerequisite LDAP groups | Prereq positive path blocked until phpLDAPadmin edit | `[INFRA: LDAP-setup]` |
+| No cron daemon | Time-based behavior needs manual cron URL + DB dates | `[INFRA: Time-manual]` |
+| Cron reminder/start SMS calls commented out | Only **event cancel** sends SMS (`EventsController::cancel`) | N/A — code disabled |
+
+### Start stack
+
+```bash
+git submodule update --init --recursive
+./setup.sh
+docker compose up
+```
+
+Wait until the app responds at http://localhost:8000 and migrations/seeds complete in the `app` container logs.
+
+### Test accounts
+
+| User | Password | AD groups |
+|---|---|---|
+| `user1` | `password` | Members (regular member) |
+| `user2` | `password` | Calendar Admins, Honorarium Admins, Financial Reporting, Calendar Super Admins |
+| `honorariumadmin` | `password` | Members, Honorarium Admins only |
+| `financialadmin` | `password` | Members, Financial Reporting only |
+| `user3` | `password` | Members, Electronics committee |
+| LDAP admin | `Adm1n!` | `cn=admin,dc=dms,dc=local` |
+
+### Known code issues affecting tests
+
+| Issue | Location | Test impact |
+|---|---|---|
+| `getGroupsFromUserInfo()` missing `return $result` | `OpenIDConnectService.php` | SSO group checks and SSO prerequisite refresh always fail until fixed |
+| `$$registration` typo | `RegistrationsController.php` ~line 164 | Free registration when full may error instead of showing flash |
+| Braintree failure `stopPropagation` commented out | `RegistrationsController.php` ~line 158 | Failed payment may still save registration |
+| OIDC success redirect uses invalid `controller => '/'` | `UsersController.php` | SSO callback may error after auth |
+| SSO login bypasses `Auth.afterIdentify` | `UsersController::oidcCallback` | SSO users may lack `contact_id` / `contact_error` handling |
+| Address null uses hardcoded room ids 23, 58 | `EventsController::_applyAddress` | Offsite/online address suppression only if those DB ids match production |
+| Config index excludes id ≥ 7 | `ConfigurationsController` | “Allow Honoraria” only editable via Super Admin, not Configuration index |
+
+---
+
diff --git a/e2e/plan/layer-0-preflight.md b/e2e/plan/layer-0-preflight.md
new file mode 100644
index 0000000..c3118b0
--- /dev/null
+++ b/e2e/plan/layer-0-preflight.md
@@ -0,0 +1,59 @@
+# Layer 0 — Pre-flight
+
+Stack health and baseline before E2E suite.
+
+Part of the [E2E implementation plan](README.md).
+
+---
+
+> [!NOTE]
+> **Tracking:** Check `[x]` when a Playwright spec covers the test section. Steps below are manual procedure reference — not separate tests.
+
+
+### 0.1 Stack health
+- [x] 0.1 Stack health
+
+
+**Steps:**
+
+1. Open http://localhost:8000 — homepage loads without 500 error. _(partial: `tests/home.spec.ts`)_
+2. Open http://localhost:8081 — phpMyAdmin loads.
+3. Open http://localhost:8888 — phpLDAPadmin loads (`cn=admin,dc=dms,dc=local` / `Adm1n!`).
+4. Run `docker compose ps` — all containers show `Up` (app, db, openldap, mail, keycloak, etc.).
+
+
+**Expected:** All services reachable; event index shows seeded upcoming events or empty list (not an error page).
+
+
+### 0.2 Record baseline config values
+- [ ] 0.2 Record baseline config values
+
+
+**Steps:**
+
+1. Log in as `user2`.
+2. Go to **Admin → Configuration**.
+3. Note values for configs 1–6 (approval times, lead times, role call cutoff).
+
+
+**Expected:** Six configuration rows with day-based values (defaults: 2, 3, 10, 2, 190, 2).
+
+
+### 0.3 Infrastructure readiness `[INFRA: *]`
+- [ ] 0.3 Infrastructure readiness `[INFRA: *]`
+
+
+Run once before Layer 2 tagged tests. Record PASS/FAIL/BLOCKED per row.
+
+1. SparkPost API reachable — set `SPARKPOST_APIKEY`; restart `app`; submit event as `user1` `[INFRA: SparkPost]`
+2. MailHog SMTP workaround — patch `EmailComponent` to use `default` transport; submit event `[INFRA: Email-dev]`
+3. Braintree sandbox — set four `BRAINTREE_*` vars; open paid event registration `[INFRA: Braintree]`
+4. Twilio credentials — cancel event with `send_text=1` registrant `[INFRA: Twilio]`
+5. Keycloak SSO — add `127.0.0.1 keycloak` to host file; open `/users/sso-login` `[INFRA: OIDC]`
+6. LDAP group edit — add/remove `user1` from a test group in phpLDAPadmin `[INFRA: LDAP-setup]`
+7. Manual cron — `curl http://localhost:8000/events/cron` returns 200 `[INFRA: Time-manual]`
+
+
+---
+
+
diff --git a/e2e/plan/layer-1-reference-data.md b/e2e/plan/layer-1-reference-data.md
new file mode 100644
index 0000000..0f85c22
--- /dev/null
+++ b/e2e/plan/layer-1-reference-data.md
@@ -0,0 +1,372 @@
+# Layer 1 — Reference data
+
+Admin CRUD and auth menus (reference data leaves).
+
+Part of the [E2E implementation plan](README.md).
+
+---
+
+> [!NOTE]
+> **Tracking:** Check `[x]` when a Playwright spec covers the test section. Steps below are manual procedure reference — not separate tests.
+
+
+### 1.1 LDAP login (admin access)
+- [x] 1.1 LDAP login (admin access)
+
+
+**Steps:**
+
+1. Log out if logged in.
+2. Go to http://localhost:8000/users/login.
+3. Enter username `user2`, password `password`, click **Login**.
+
+
+**Expected:** Redirect to event index; **Admin**, **Honoraria**, **Financials**, and **Super Calendar Admin** menus visible.
+
+**Steps (negative):**
+
+1. Log out.
+2. Enter `user2` with wrong password.
+
+
+**Expected:** Flash error: invalid username or password.
+
+**Steps (email rejection):**
+
+1. Enter an email address (e.g. `user2@dms.local`) as username.
+
+
+**Expected:** Specific error telling you to use DMS username, not email.
+
+**Steps (unknown user):**
+
+1. Enter username `nonexistent`, password `password`.
+
+
+**Expected:** Generic invalid username/password flash (LDAP user not found).
+
+**Steps (disabled account):**
+
+1. Log in as `disableduser` / `password` (UserAccountControl disabled in LDAP seed).
+
+
+**Expected:** Login fails.
+
+**Steps (login redirect):**
+
+1. While logged out, open http://localhost:8000/events/add — redirect to login with `?redirect=/events/add`.
+2. Log in successfully.
+
+
+**Expected:** Lands on event add form after login.
+
+**Steps (logout):**
+
+1. **My Account → Logout**.
+
+
+**Expected:** Session cleared; returned to public index; admin menus hidden.
+
+---
+
+
+### 1.2 Categories — `/categories`
+- [x] 1.2 Categories — `/categories`
+
+
+**Steps (index):**
+
+1. **Admin → Categories**.
+2. Confirm list shows optional categories only (not Class/Event types).
+
+
+**Expected:** Categories with id > 2 listed alphabetically; no “Class” or “Event” type rows.
+
+**Steps (add):**
+
+1. Click **Add Category** (or `/categories/add`).
+2. Enter name `Test Category E2E`, save.
+
+
+**Expected:** Success flash; category appears in index.
+
+**Steps (edit):**
+
+1. Edit the new category; rename to `Test Category E2E Updated`, save.
+
+
+**Expected:** Updated name in index.
+
+**Steps (delete):**
+
+1. Delete the test category.
+
+
+**Expected:** Removed from index.
+
+**Steps (downstream check):**
+
+1. Go to **Submit Event** — optional categories multi-select includes categories from index.
+
+
+**Expected:** New categories appear in event form after add; delete the test category when done.
+
+---
+
+
+### 1.3 Committees — `/committees`
+- [x] 1.3 Committees — `/committees`
+
+
+**Steps:**
+
+1. **Admin → Committees**.
+2. **Add** committee named `Test Committee E2E`.
+3. **Edit** to `Test Committee E2E Updated`.
+4. Open **Submit Event** → honorarium section (Class type) — confirm committee in dropdown.
+5. **Delete** test committee (if unused).
+
+
+**Expected:** Full CRUD works; committee appears in honorarium dropdown on event add form.
+
+---
+
+
+### 1.4 Prerequisites — `/prerequisites`
+- [x] 1.4 Prerequisites — `/prerequisites`
+
+
+**Steps:**
+
+1. **Admin → Prerequisites**.
+2. **Add** prerequisite: name `Test Prereq E2E`, AD group `Test Prereq E2E`.
+3. **Edit** the AD group name if needed.
+4. On event add form, confirm it appears in **Requires** and **Fulfills** dropdowns.
+5. Delete the test prerequisite after confirming it on the event form.
+
+
+**Expected:** CRUD works; dropdowns on event form update.
+
+---
+
+
+### 1.5 Rooms — `/rooms`
+- [x] 1.5 Rooms — `/rooms`
+
+
+**Steps:**
+
+1. **Admin → Rooms**.
+2. Note an **exclusive** room (e.g. Conference Room) and **non-exclusive** room (e.g. Common Area).
+3. **Add** room `Test Room E2E`, exclusive = unchecked, save.
+4. **Edit** — toggle exclusive on, save.
+5. On event add form, confirm all rooms in **Select Room** dropdown.
+6. Delete test room.
+
+
+**Expected:** CRUD works; exclusive flag persists; rooms available on event form.
+
+---
+
+
+### 1.6 Tools — `/tools`
+- [x] 1.6 Tools — `/tools`
+
+
+**Steps:**
+
+1. **Admin → Tools**.
+2. **Add** tool `Test Tool E2E`.
+3. **Edit** name to `Test Tool E2E Updated`.
+4. On event add form, confirm tool in multi-select.
+5. Delete test tool.
+
+
+**Expected:** CRUD works; tools appear on event form and index **By Tool** filter (Layer 3).
+
+---
+
+
+### 1.7 System configuration — `/configurations`
+- [x] 1.7 System configuration — `/configurations`
+
+
+**Steps:**
+
+1. **Admin → Configuration**.
+2. Confirm six rows (ids 1–6): Automatic Approval, Honoraria Approval, Honoraria Booking Lead Time, Minimum Booking Lead Time, Maximum Booking Lead Time, Role Call Cutoff.
+3. **Edit** config 4 (Minimum Booking Lead Time) — change value to `3`, save.
+4. Open **Submit Event** — read help text on start date field.
+5. Restore config 4 to `2`.
+
+
+**Expected:** Index shows values in days; edits persist; lead-time validation uses config values on submit.
+
+**Steps (config 7 not in this index):**
+
+1. Confirm **Allow Honoraria** (config id 7) does **not** appear in this list — it is only on **Super Calendar Admin → Settings** (§1.8).
+
+
+**Expected:** Six rows only (ids 1–6); honoraria toggle isolated to Super Admin.
+
+---
+
+
+### 1.8 Super Calendar Admin — `/calendar-admin/edit`
+- [x] 1.8 Super Calendar Admin — `/calendar-admin/edit`
+
+
+**Steps (honoraria toggle):**
+
+1. **Super Calendar Admin → Settings**.
+2. Uncheck **Allow Honoraria**, save.
+3. Go to **Submit Event** (Class type) — honorarium checkbox should be disabled.
+4. Return to settings; re-enable **Allow Honoraria**, save.
+
+
+**Expected:** Honorarium request checkbox enabled/disabled with setting.
+
+**Steps (custom message):**
+
+1. Set **Message to be displayed** to `Test honorarium message`, save.
+2. Open **Submit Event** — red message visible in honorarium section.
+3. Clear message and save.
+
+
+**Expected:** Message appears/disappears on add form.
+
+---
+
+
+### 1.9 Contacts — `/contacts`
+- [x] 1.9 Contacts — `/contacts`
+
+
+**Steps (external contact CRUD):**
+
+1. **Admin → Contacts**.
+2. **Add** contact: name `External Instructor`, email `external@test.local`, phone `555-0100`, W-9 on file unchecked, blacklisted unchecked.
+3. **Edit** — set W-9 on file checked, save.
+4. On **Submit Event**, check **Sponsored Event** — `External Instructor` appears in **Existing Instructors** dropdown.
+
+
+**Expected:** External contact CRUD; appears in sponsored-event dropdown.
+
+**Steps (duplicate email validation):**
+
+1. **Add** another contact with the same email as `External Instructor`.
+
+
+**Expected:** Validation error (unique email rule).
+
+**Steps (Honorarium Admin view access):** `[INFRA: LDAP-setup]`
+
+1. Create or use LDAP user in **Honorarium Admins** only (remove Calendar Admins temporarily, or use dedicated test user).
+2. Log in as that user; open `/contacts/view/user1`.
+
+
+**Expected:** Contact view allowed for Honorarium Admins / Financial Reporting per `ContactsController::isAuthorized`.
+
+**Steps (blacklist blocks submit — Layer 2 prep):**
+
+1. Set `user1` contact **blacklisted** = true; verify in §2.1b.
+
+
+**Steps (cleanup):**
+
+1. Delete `External Instructor` and any duplicate test contact after validation checks.
+2. Remove blacklist from `user1` before Layer 2 member tests when testing §2.1b (each Layer 2 test restores its own mutations).
+
+
+**Steps (contact view):**
+
+1. Open `/contacts/view/user1`.
+
+
+**Expected:** Hosted events and attended events sections render.
+
+---
+
+
+### 1.10 Audit logs — `/logs`
+- [x] 1.10 Audit logs — `/logs`
+
+
+No nav link — navigate directly.
+
+**Steps:**
+
+1. As `user2`, open http://localhost:8000/logs.
+2. Confirm table of recent log entries (date, user, description, URL).
+3. Filter: set **start date** and **end date** to today, submit.
+4. Filter: enter `user2` in username field.
+5. Filter: enter a word from a known log description in search.
+
+
+**Expected:** Super Admin access only; filters narrow results; config edit from §1.7 may appear in logs.
+
+**Steps (authorization):**
+
+1. Log in as `user1`, open `/logs`.
+
+
+**Expected:** Access denied or redirect (not Calendar Super Admin).
+
+**Steps (logging rules — `AppController::customLog`):**
+
+1. Edit a configuration (§1.7) — reload logs; entry should appear for the save action.
+2. Browse **Admin → Categories** index and a public event **view** — confirm these do **not** create new log rows (`index`/`view` actions skipped).
+3. Submit logs filter with only one of start/end date filled — document observed behavior.
+
+
+---
+
+
+### 1.11 Authorization & menu flags (`AppController::beforeRender`)
+- [x] 1.11 Authorization & menu flags (`AppController::beforeRender`)
+
+
+Maps AD groups to UI flags. Use accounts with **single** group membership where possible (adjust LDAP temporarily) or infer from combined `user2`.
+
+| Flag / menu | Required AD group | Verify |
+|---|---|---|
+| Admin menu + CRUD | Calendar Admins | `user2` ✓; `user1` ✗ |
+| Honoraria menu | Honorarium Admins | `user2` ✓ |
+| Financials → Export | Financial Reporting | `user2` ✓ |
+| Super Calendar Admin | Calendar Super Admins | `user2` ✓ |
+| `canManageContacts` in header | Calendar Admins | Contacts link in Admin menu |
+| Financial-only user | Financial Reporting without Calendar Admins | Export visible; Admin CRUD hidden |
+
+**Steps (financial-only user matrix):** `[INFRA: LDAP-setup]`
+
+1. Temporarily configure LDAP user (e.g. clone `user2` groups): **Financial Reporting** only — no Calendar/Honorarium/Super Admin groups.
+2. Log in — confirm **Financials → Export Honoraria** visible; **Admin** menu hidden.
+3. Open `/events/export-honoraria` — allowed. Open `/categories` — denied.
+4. Restore original group membership after test.
+
+
+**Steps (honorarium-admin-only user):** `[INFRA: LDAP-setup]`
+
+1. User with **Honorarium Admins** only (no Calendar Admins): **Honoraria** menu works; `/events/pending` denied; `/honoraria` denied (uses parent `Calendar Admins` auth).
+
+
+**Steps:**
+
+1. As `user1`, confirm no Admin/Honoraria/Financials/Super Admin menus.
+2. As `user2`, confirm all four dropdown menus present.
+3. As `user3` (Members + Electronics committee only), confirm no admin menus.
+
+
+**Expected:** Menu visibility matches group membership table in `AppController.php`.
+
+---
+
+
+### Layer 1 exit checklist
+- [x] Layer 1 exit checklist
+
+Each Layer 1 scenario is covered by a **self-contained** Playwright test: create the data the test needs, assert behavior, then delete entities or restore global settings (config, honoraria toggle, custom message) before the test finishes. Tests do not depend on execution order, on other tests in the same file, or on data left behind from earlier layers.
+
+---
+
+
diff --git a/e2e/plan/layer-2-admin-host.md b/e2e/plan/layer-2-admin-host.md
new file mode 100644
index 0000000..4132bfc
--- /dev/null
+++ b/e2e/plan/layer-2-admin-host.md
@@ -0,0 +1,372 @@
+# Layer 2c — Host, admin & integrations
+
+Host ops, export, cron, email, SSO (§2.20–2.28).
+
+Part of the [E2E implementation plan](README.md). Implement tests bottom-up; check boxes when a Playwright spec covers the case.
+
+Spec naming: `tests/.spec.ts` · Page objects under `tests/pages/`.
+
+---
+
+> [!NOTE]
+> **Tracking:** Check `[x]` when a Playwright spec covers the test section. Steps below are manual procedure reference — not separate tests.
+
+
+### 2.20 Host — attendance marking
+- [ ] 2.20 Host — attendance marking
+
+
+**Steps:**
+
+1. Set event `E2E Free Class` start time to **1 hour ago** (edit as admin or update DB).
+2. As owner, open event view → **Attendance** tab.
+3. Check **Attended** for confirmed registrants.
+4. Click **Mark Attended**.
+
+
+**Expected:** Within config 6 × 24h window after start — form submittable; attended flags saved.
+
+**Steps (closed window):**
+
+1. Set event start to **3 days ago**; reload attendance tab.
+
+
+**Expected:** “Attendance is closed for this class.”
+
+---
+
+
+### 2.21 Host — AD group assignment
+- [ ] 2.21 Host — AD group assignment
+
+
+**Requires:** Approved `E2E Fulfills Prereq` with confirmed registrant (`user3`).
+
+**Steps:**
+
+1. As owner/admin, open event view → **AD Assignment** tab.
+2. Read irreversibility warning.
+3. Check **Assign AD** for a confirmed member with AD username.
+4. Submit **Assign AD Group**.
+
+
+**Expected:** Checkbox replaced with checkmark; user added to LDAP group (verify in phpLDAPadmin).
+
+---
+
+
+### 2.22 Cancel entire event
+- [x] 2.22 Cancel entire event
+
+
+**Steps:**
+
+1. Create approved event with at least one confirmed registration.
+2. As owner, **Edit Event → Cancel Event**; confirm.
+
+
+**Expected:** Event status cancelled; banner on view; all registrations cancelled/refunded; cancellation emails `[INFRA: SparkPost]` / `[INFRA: Email-dev]` (`sendEventCancelled` per registrant).
+
+**Steps (multipart cancellation):**
+
+1. Cancel primary multipart event — verify continuation events also `cancelled` in DB.
+
+
+**Steps (Twilio SMS on event cancel):** `[INFRA: Twilio]`
+
+1. Register for event with valid US phone; check **Receive text message alerts** on registration form.
+2. As owner/admin, cancel entire event (§2.22 step 1–2).
+
+
+**Expected:** SMS sent via `EventsController::__sendText` with body “has been cancelled”; verify in Twilio console **or** app logs on failure.
+
+**Note:** Cron cancellation/start reminder SMS paths are **commented out** in source — not `[INFRA: Twilio]` tests.
+
+**Steps (admin cancel):**
+
+1. As Calendar Admin, cancel another user's event via edit page.
+
+
+**Expected:** Same cascade to registrations/refunds.
+
+---
+
+
+### 2.23 Events archive & honorarium lists
+- [x] 2.23 Events archive & honorarium lists
+
+
+**Steps:**
+
+1. **Admin → Events Archive** — confirm all statuses listed; test date filter.
+2. **Honoraria → Accepted / Rejected / Counts** — pages load; counts show monthly totals.
+
+
+**Expected:** Lists paginate; filters work on archive.
+
+**Steps (member lists — `Events/submitted`, `Events/attending`):**
+
+1. As `user1`, **My Account → Hosting Events** — lists own events across statuses (`submitted` action).
+2. **My Account → Attending Events** — lists events with registrations for current user (`attending` action).
+
+
+**Expected:** Only user's hosted/attended events; links to view/edit/copy where applicable.
+
+**Steps (upcoming honoraria counts accuracy):**
+
+1. **Honoraria → Counts** — note per-month totals; cross-check against DB count of honorarium events in each month window (`upcomingHonoraria`).
+
+
+**Steps (honoraria list sorting):**
+
+1. On **Accepted** honoraria list, use column sort headers if present.
+
+
+**Expected:** Default sort by event start DESC when no `?sort` query.
+
+---
+
+
+### 2.24 Financial export — `/events/export-honoraria`
+- [x] 2.24 Financial export — `/events/export-honoraria`
+
+
+**Steps:**
+
+1. As `user2`, **Financials → Export Honoraria**.
+2. Set date range including a **completed** honorarium event with 3+ attended registrants.
+3. Click **List Honoraria**.
+4. Change **Paid** dropdown; click **Save**.
+5. Click **Export List as CSV**.
+
+
+**Expected:** Table shows pay yes/no logic (>2 attendees); CSV downloads.
+
+**Steps (authorization):**
+
+1. As `user1`, open `/events/export-honoraria`.
+
+
+**Expected:** Access denied.
+
+**Steps (export without date range):**
+
+1. Open `/events/export-honoraria` without submitting date form.
+
+
+**Expected:** Page renders without honorarium table (`exportHonoraria` only sets data when GET dates present).
+
+**Steps (≤2 attendees — “Don't Pay”):**
+
+1. Include completed honorarium event with 0–2 attended registrants in date range.
+
+
+**Expected:** Pay column shows “Don't Pay” / “Honoraria Not Met” in HTML and CSV.
+
+**Steps (paid status enum in CSV):**
+
+1. In CSV export, verify paid status labels: Not Paid, Paid, Pending, Missing Info, Denied, Paid by Script (`payTypes` array).
+
+
+**Steps (pre-2017 old registrations):** `[INFRA: Pre-2017-data]`
+
+1. Insert or identify completed honorarium event with `event_start` before `2017-01-01` and linked `old_registrations` rows (`status=confirmed`).
+2. Include in export date range; run **Export List as CSV**.
+
+
+**Expected:** Attendee count uses `old_registrations` branch when `event_start < oldCutoff` (`exportHonoraria` / `exportHonorariaCsv`).
+
+**Steps (CSV direct URL):** `[INFRA: SparkPost]` not required
+
+1. After HTML export with dates, open `/events/export-honoraria-csv?start_date=YYYY-MM-DD&end_date=YYYY-MM-DD` as `user2`.
+
+
+**Expected:** CSV download with same rows as HTML table.
+
+---
+
+
+### 2.25 Standalone honoraria CRUD — `/honoraria`
+- [x] 2.25 Standalone honoraria CRUD — `/honoraria`
+
+
+No nav link. Requires **Calendar Admins** (`HonorariaController` inherits `AppController::isAuthorized` — not Honorarium Admins alone).
+
+**Steps:**
+
+1. As `user2`, open http://localhost:8000/honoraria.
+2. View list; open one record; add/edit/delete test honorarium linked to event + committee.
+
+
+**Expected:** CRUD pages functional (legacy admin UI).
+
+**Steps (honorarium-admin-only denied):** `[INFRA: LDAP-setup]`
+
+1. As user with **Honorarium Admins** only (no Calendar Admins), open `/honoraria`.
+
+
+**Expected:** Access denied (unlike `/events/pending-honoraria` which uses Honorarium Admins auth).
+
+---
+
+
+### 2.26 Cron job — `/events/cron` `[INFRA: Time-manual]`
+- [x] 2.26 Cron job — `/events/cron` `[INFRA: Time-manual]`
+
+
+All cron tests require manual HTTP trigger and DB timestamp manipulation. Email sub-steps also need `[INFRA: SparkPost]` / `[INFRA: Email-dev]`.
+
+**Steps (complete event + pending registration cleanup):**
+
+1. Create approved event with `event_end` in the past (DB or edit).
+2. Add pending registration on that event.
+3. Open http://localhost:8000/events/cron in browser (or `curl`).
+4. Refresh event/registrations in phpMyAdmin.
+
+
+**Expected:**
+
+- Event status → `completed`
+- Pending registrations → `cancelled` with refund attempt `[INFRA: Braintree]` if paid
+- `sendUnapprovedRegistrationCancelled` email per cancelled pending registration `[INFRA: SparkPost]` / `[INFRA: Email-dev]`
+
+**Steps (auto-approve non-honorarium pending — config 1):** `[INFRA: Time-manual]`
+
+1. Create pending event **without** honorarium; set `created` timestamp older than config 1 days in DB.
+2. Run cron.
+
+
+**Expected:** Event status → `approved` (no approval email — cron auto-approve does not call `sendEventApproved`).
+
+**Steps (cancellation reminder emails):** `[INFRA: Time-manual]` + `[INFRA: SparkPost]` / `[INFRA: Email-dev]`
+
+1. Set approved event `attendee_cancellation` to ~25 hours from now; `cancel_notification = 0`.
+2. Run cron.
+
+
+**Expected:** `sendCancellationReminder` to confirmed/pending registrants; `cancel_notification = 1`.
+
+**Note:** Associated SMS in this cron block is **commented out** — do not expect Twilio delivery here.
+
+**Steps (honorarium auto-approve — config 2):** `[INFRA: Time-manual]`
+
+1. Age a pending honorarium event past config 2 days in DB; run cron.
+
+
+**Expected:** Event auto-approved (`cron` honorarium branch).
+
+**Steps (event-starting reminder):** `[INFRA: Time-manual]` + `[INFRA: SparkPost]` / `[INFRA: Email-dev]`
+
+1. Set approved event `event_start` within ~24h; `reminder_notification=0`; run cron.
+
+
+**Expected:** `sendEventStarting` emails; `reminder_notification = 1`.
+
+**Note:** Start-reminder SMS in cron is **commented out**.
+
+**Steps (skip multipart children on complete):**
+
+1. Complete parent event — verify child `part_of_id` rows not double-processed.
+
+
+**Steps (unauthenticated access):**
+
+1. Confirm `/events/cron` is allowed without login (`beforeFilter` Auth allow).
+
+
+---
+
+
+### 2.27 Email verification checklist `[INFRA: SparkPost]` or `[INFRA: Email-dev]`
+- [ ] 2.27 Email verification checklist `[INFRA: SparkPost]` or `[INFRA: Email-dev]`
+
+
+Run §0.3 email check first. Confirm at least one delivery of each method triggered during Layer 2:
+
+| Email method | Typical trigger | Section | Infra |
+|---|---|---|---|
+1. `sendEventSubmitted` — Member submits event
+2. `sendEventApproved` — Admin approves
+3. `sendEventRejected` — Admin rejects
+4. `sendEventCancelled` — Event cancelled
+5. `sendRegistrationConfirmation` — Auto-confirm registration
+6. `sendRegistrationPending` — Approval-required registration
+7. `sendRegistrationRequested` — Notifies host of pending RSVP
+8. `sendRegistrationApproved` — Host accepts
+9. `sendRegistrationRejected` — Host rejects
+10. `sendRegistrationCancelled` — Registrant cancels
+11. `sendRegistrationToInstructor` — Notify on registration
+12. `sendCancellationToInstructor` — Notify on cancellation
+13. `sendCancellationReminder` — Cron ~24h before cutoff
+14. `sendEventStarting` — Cron ~24h before start
+15. `sendUnapprovedRegistrationCancelled` — Cron completes event
+
+
+**Steps (email transport failure — default stack without keys):**
+
+1. With empty `SPARKPOST_APIKEY`, trigger any email action (e.g. submit event).
+
+
+**Expected:** App does not crash; error logged silently (`EmailComponent::sendEmail` catch). Record **BLOCKED** for content verification, **PASS** for graceful failure.
+
+---
+
+
+### 2.28 SSO login `[INFRA: OIDC]`
+- [ ] 2.28 SSO login `[INFRA: OIDC]`
+
+
+Complete §0.3 Keycloak check first. Browser must resolve `keycloak` hostname.
+
+**Steps (happy path):**
+
+1. Log out; go to login page.
+2. Click **SSO Login**.
+3. Authenticate in Keycloak as LDAP user (e.g. `user1` / `password`).
+4. Return to app via callback.
+
+
+**Expected:** Logged in with `ssologin=true` in session; **SSO Profile** link in My Account menu (`ssoprofile` URL).
+
+**Known defect:** Success redirect uses invalid `controller => '/'` — may 404 after auth until fixed; user may still be authenticated.
+
+**Steps (OIDC failure):** `[INFRA: OIDC]`
+
+1. Break OIDC config intentionally (wrong client secret in container env); attempt SSO.
+
+
+**Expected:** Flash error; redirect to login (`oidcCallback` catch).
+
+**Steps (SSO contact provisioning gap):** `[INFRA: OIDC]`
+
+1. Log in via SSO as user with no existing `contacts` row.
+2. Check phpMyAdmin `contacts` table and attempt **Submit Event**.
+
+
+**Expected (document actual):** LDAP login runs `Auth.afterIdentify` → contact created; SSO sets user directly **without** `afterIdentify` — submit form may fail validation or lack `contact_id` until code fixed.
+
+**Steps (SSO groups empty):** `[INFRA: OIDC]` + code fix
+
+1. After SSO login, attempt action requiring AD group (e.g. admin menu).
+
+
+**Expected:** Groups empty until `getGroupsFromUserInfo` returns `$result`; admin menus hidden even if user is in LDAP groups.
+
+**Steps (SSO prerequisite refresh):** see §2.13.
+
+---
+
+
+### Layer 2 exit checklist
+- [ ] Layer 2 exit checklist
+
+
+1. At least 2 approved upcoming events on calendar
+2. At least 1 completed event with attendance marked
+3. Registrations in confirmed, pending, cancelled states exist
+4. Note event IDs for Layer 3
+
+
+---
+
+
diff --git a/e2e/plan/layer-2-events.md b/e2e/plan/layer-2-events.md
new file mode 100644
index 0000000..bbaaabe
--- /dev/null
+++ b/e2e/plan/layer-2-events.md
@@ -0,0 +1,461 @@
+# Layer 2a — Auth, events & files
+
+Events, validation, approval, editing (§2.1–2.9).
+
+Part of the [E2E implementation plan](README.md). Implement tests bottom-up; check boxes when a Playwright spec covers the case.
+
+Spec naming: `tests/.spec.ts` · Page objects under `tests/pages/`.
+
+---
+
+> [!NOTE]
+> **Tracking:** Check `[x]` when a Playwright spec covers the test section. Steps below are manual procedure reference — not separate tests.
+
+
+### 2.1 Member login & contact provisioning
+- [x] 2.1 Member login & contact provisioning
+
+
+**Steps:**
+
+1. Log out; log in as `user1` / `password`.
+2. Confirm **Submit Event** visible; no Admin menus.
+3. As `user2`, open **Admin → Contacts** — find `user1` auto-created contact with AD username.
+
+
+**Expected:** Member menus only; LDAP login creates/syncs contact record.
+
+---
+
+
+### 2.1b Blocked submission states (`Events/add.ctp`)
+- [x] 2.1b Blocked submission states (`Events/add.ctp`)
+
+
+**Steps (blacklisted):**
+
+1. As `user2`, set `user1` contact **blacklisted** = true.
+2. Log in as `user1` → **Submit Event**.
+
+
+**Expected:** Red alert “event submission privileges have been revoked”; **no form** rendered.
+
+**Steps (contact_error — incomplete AD profile):**
+
+1. Create LDAP user missing `mail` or `telephonenumber` (via phpLDAPadmin) and log in.
+
+
+**Expected:** Alert about AD sync failure; `contact_error` prevents form (`UsersController::afterIdentify`).
+
+**Steps (cleanup):**
+
+1. Remove blacklist from `user1` before continuing.
+
+
+---
+
+
+### 2.1c Non-sponsored submit path (`__constructPostForMarshal`)
+- [x] 2.1c Non-sponsored submit path (`__constructPostForMarshal`)
+
+
+**Steps:**
+
+1. As `user1`, submit event with **Sponsored Event** unchecked.
+2. In phpMyAdmin, verify event `contact_id` matches `user1`'s contact record (not a manual instructor contact).
+
+
+**Expected:** Logged-in user's contact linked automatically; no instructor name/email fields required.
+
+---
+
+
+### 2.2 Event creation — free class (baseline)
+- [x] 2.2 Event creation — free class (baseline)
+
+
+**Steps:**
+
+1. As `user1`, click **Submit Event**.
+2. Fill **General:**
+
+
+ - Title: `E2E Free Class`
+ - Type: **Class** (radio)
+ - Short description: `Short desc for E2E test`
+ - Long description: `Long description body`
+ - Start: at least **3 days** from today (respect config 4 minimum)
+ - End: same day, 2 hours after start
+3. **Facilities:** select a non-exclusive room; setup/teardown as needed.
+4. **Attendance:** cost $0, free spaces `10`, paid spaces `0`, members-only off, approval off, age restriction none.
+5. Select one optional category and one tool.
+6. Submit.
+
+
+**Expected:** Success flash (48-hour approval message); event in **My Account → Hosting Events** as `pending`.
+
+**Expected (submission email):** `[INFRA: SparkPost]` or `[INFRA: Email-dev]` — `sendEventSubmitted` to submitter contact; verify in SparkPost/MailHog per §0.3.
+
+---
+
+
+### 2.3 Event validation (negative tests)
+- [x] 2.3 Event validation (negative tests)
+
+
+Repeat **Submit Event** with intentional errors:
+
+
+**Start too soon:**
+
+1. Set start date to tomorrow (less than config 4 days).
+2. Submit.
+
+
+**Expected:** Validation error on start date.
+
+
+**End before start:**
+
+1. Set end before start.
+2. Submit.
+
+
+**Expected:** “Event cannot end before it starts.”
+
+
+**Exclusive room conflict:**
+
+1. Note an approved/pending event using Conference Room (exclusive) at a specific time.
+2. Submit new event with same room and overlapping booking window.
+
+
+**Expected:** Conflict error with link to conflicting event.
+
+
+**Tool conflict:**
+
+1. Submit event using same tool and overlapping time as existing pending/approved event.
+
+
+**Expected:** Error listing tools in use.
+
+
+**Maximum lead time (config 5, default 190 days):**
+
+1. Set event start beyond config 5 maximum horizon.
+
+
+**Expected:** Validation error: events can only be scheduled N days in advance.
+
+
+**Honorarium minimum lead (config 3, default 10 days):**
+
+1. Submit honorarium class with start only 5 days out.
+
+
+**Expected:** Error referencing honorarium lead time (config 3).
+
+
+**Multipart session 2 room conflict:**
+
+1. Submit multipart event where **second session** overlaps another booking in same exclusive room.
+
+
+**Expected:** Error mentions “second date” / `class_number` wording.
+
+
+**Age restriction values:**
+
+1. Submit events with age restrictions 13, 16, and 21 (not only 18).
+
+
+**Expected:** Each allowed value saves; invalid values rejected.
+
+
+**Extend registration values:**
+
+1. Create events with `extend_registration` of 15, 20, and 25 minutes; verify registration cutoff behavior differs from 0.
+
+
+**Expected:** Only values in `[0, 15, 20, 25, 30]` accepted.
+
+
+**Requires prerequisite → members-only (`__constructPostForMarshal`):**
+
+1. Set **Requires prerequisite** without checking members-only manually.
+
+
+**Expected:** Server sets `members_only=1` on save.
+
+---
+
+
+### 2.4 Event variants (create one of each for later tests)
+- [ ] 2.4 Event variants (create one of each for later tests)
+
+
+Create separate events as `user1`; approve in §2.6. Each row is a fixture event used by later specs.
+
+1. Create & approve `E2E Approval Required` (`attendees_require_approval`)
+2. Create & approve `E2E Members Only` (`members_only`)
+3. Create & approve `E2E Age 18+` (age restriction 18 + advisories)
+4. Create & approve `E2E Prereq Gated` (requires `3D Printer Basics`) `[INFRA: LDAP-setup]`
+5. Create & approve `E2E Fulfills Prereq` (fulfills prerequisite)
+6. Create & approve `E2E Multipart` (session 2 dates)
+7. Create & approve `E2E Sponsored` (external instructor contact)
+8. Create & approve `E2E Eventbrite` (Eventbrite URL)
+9. Create & approve `E2E Honorarium` (honorarium + committee)
+10. Create & approve `E2E Unlimited Capacity` (`free_spaces=0`, `paid_spaces=0`)
+11. Create & approve `E2E Mixed Free/Paid`
+12. Create & approve `E2E Notify Instructor` (both instructor notification flags)
+
+
+**Variant verification (after approval):**
+
+1. Eventbrite view — external link + disclaimer; no Braintree
+2. Notify instructor — register; confirm email `[INFRA: SparkPost]` / `[INFRA: Email-dev]`
+3. Unlimited capacity — no “X of Y spaces” on public view
+4. Age 13/16/21 variants — repeat age gate (§2.14)
+5. Offsite room — DMS address not shown on view/calendar/index
+
+
+---
+
+
+#### 2.4.1 Prerequisite positive path setup `[INFRA: LDAP-setup]`
+- [ ] 2.4.1 Prerequisite positive path setup `[INFRA: LDAP-setup]`
+
+
+**Steps:**
+
+1. Open phpLDAPadmin (http://localhost:8888).
+2. Log in as admin.
+3. Find group `3D Printer Basics` under `ou=Security,ou=Groups,dc=dms,dc=local`.
+4. Add `cn=user1,ou=Members,dc=dms,dc=local` as member.
+5. Log out and log in as `user1` again (refresh LDAP groups in session).
+
+
+**Expected:** `user1` can register for `E2E Prereq Gated` after event is approved.
+
+---
+
+
+### 2.5 File attachments on create
+- [ ] 2.5 File attachments on create
+
+
+**Steps:**
+
+1. On any new event submit, upload:
+
+
+ - File 1: public image (jpg/png)
+ - File 2: private PDF (check **private**)
+2. After approval, view event as anonymous — public image visible, private hidden.
+3. View as owner — both visible.
+
+
+**Expected:** Public/private file visibility rules enforced.
+
+---
+
+
+### 2.6 Event approval
+- [x] 2.6 Event approval
+
+
+**Steps (non-honorarium):**
+
+1. Log in as `user2`.
+2. **Admin → Pending Events**.
+3. Find `E2E Free Class` (and other non-honorarium pending events).
+4. Click **Approve**; confirm dialog.
+
+
+**Expected:** Event status `approved`; approval email to submitter `[INFRA: SparkPost]` / `[INFRA: Email-dev]` (`sendEventApproved`).
+
+**Steps (reject):**
+
+1. Submit another throwaway event as `user1`.
+2. As `user2`, **Pending Events → Reject**.
+3. Enter rejection reason on process-rejection form; submit.
+
+
+**Expected:** Status `rejected`; rejection email `[INFRA: SparkPost]` / `[INFRA: Email-dev]` (`sendEventRejected`); rejected banner on event view.
+
+**Steps (reject multipart cascade):**
+
+1. Reject pending **multipart** event — verify continuation rows (`part_of_id`) also `rejected` in DB.
+
+
+**Steps (honorarium):**
+
+1. **Honoraria → Pending** — approve `E2E Honorarium`.
+
+
+**Expected:** Honorarium pending queue separate from Admin pending; approve works.
+
+**Steps (approve multipart cascade):**
+
+1. Approve pending multipart event — verify continuation rows set to `approved` in DB; approval email `[INFRA: SparkPost]` / `[INFRA: Email-dev]` sent once to submitter.
+
+
+**Steps (honorarium approval auth split):** `[INFRA: LDAP-setup]`
+
+1. Create pending event **with** honorarium as `user1`.
+2. Temporarily remove `user2` from **Honorarium Admins** (phpLDAPadmin) keeping Calendar Admins — attempt approve from **Honoraria → Pending**.
+
+
+**Expected:** With honorarium attached, only **Honorarium Admins** may approve (`EventsController::isAuthorized` + `hasHonorarium`).
+
+**Steps (reject honorarium event):**
+
+1. Reject a pending honorarium event via **process-rejection** form.
+2. Open **Honoraria → Rejected** — event listed with reason and rejector.
+
+
+**Steps (pending vs honorarium pending separation):**
+
+1. Confirm non-honorarium pending events appear in **Admin → Pending Events** only, not Honoraria pending.
+
+
+**Steps (processRejection page):**
+
+1. From pending list, click **Reject** — lands on `/events/process-rejection/{id}` with reason field before final POST to `/events/reject/{id}`.
+
+
+---
+
+
+### 2.7 Event editing
+- [x] 2.7 Event editing
+
+
+**Steps (owner — limited):**
+
+1. As `user1`, open approved `E2E Free Class` → **Edit Event**.
+2. Change short description; save.
+
+
+**Expected:** Success flash; change visible on view.
+
+**Steps (admin — full):**
+
+1. As `user2`, edit same event — change start/end times and booking times.
+2. Save.
+
+
+**Expected:** Admin can edit scheduling fields owner cannot.
+
+**Steps (multipart edit):**
+
+1. Edit `E2E Multipart` — verify continued session dates editable by admin.
+
+
+**Steps (authorization):**
+
+1. As `user1`, attempt `/events/edit/{other-users-event-id}`.
+
+
+**Expected:** Access denied for another user's event.
+
+**Steps (rejected event — no edit button):**
+
+1. Open **rejected** event view as owner — **Edit Event** button must not appear (`view.ctp`).
+
+
+**Steps (multipart child redirect):**
+
+1. Note id of a continuation event (`part_of_id` set) in phpMyAdmin; browse `/events/view/{childId}` and `/events/edit/{childId}`.
+
+
+**Expected:** Redirect to primary (parent) event view/edit.
+
+**Steps (owner cannot edit schedule — behaviors removed):**
+
+1. As owner, confirm event start/end fields read-only or not shown compared to admin edit.
+
+
+**Steps (admin booking offset round-trip — `RelationalTimeBehavior`):**
+
+1. As admin, edit cancellation window days and setup/teardown minutes; save and re-open edit form.
+
+
+**Expected:** Display values match saved offsets (`convertToOffset` / `convertToFormat`).
+
+**Steps (failed edit logging):**
+
+1. Submit edit with invalid data (e.g. end before start) — check `/logs` for failure entry (`_afterUpdate` failure path).
+
+
+**Steps (cancel event — §2.22 cross-ref):**
+
+1. See §2.22 for full event cancellation with registrant emails/SMS.
+
+
+---
+
+
+### 2.8 Copy event
+- [x] 2.8 Copy event
+
+
+**Steps:**
+
+1. As `user1`, open owned approved event → **Copy Event** (or `/events/add?copy={id}`).
+2. Confirm form pre-filled except dates/status.
+3. Set new future dates; submit.
+
+
+**Expected:** New pending event; files copied; categories preserved.
+
+**Steps (copy unauthorized):**
+
+1. As `user1`, open `/events/add?copy={event-owned-by-user2}`.
+
+
+**Expected:** No prefill from unauthorized event (copy guard in `EventsController::add`).
+
+**Steps (multipart copy):**
+
+1. Copy a multipart event — verify continued dates cleared on copy form; files copied via `files_to_copy`.
+
+
+---
+
+
+### 2.9 Delete event file
+- [ ] 2.9 Delete event file
+
+
+**Steps:**
+
+1. As `user2`, edit event with attachments.
+2. Delete one file via delete link (`/files/delete/{fileId}/{eventId}`).
+
+
+**Expected:** File removed from event; disk file removed if no other references.
+
+**Steps (authorization — owner cannot delete):**
+
+1. As event owner (non-admin), attempt file delete URL.
+
+
+**Expected:** Only Calendar Admins authorized (`FilesController::isAuthorized` → parent Calendar Admins).
+
+**Steps (multipart sibling cleanup):**
+
+1. Delete file on primary multipart event — verify same file row removed from continuation events (`beforeDelete` hook).
+
+
+**Steps (oversized upload — `FilesTable` validation):**
+
+1. Attempt upload exceeding form size limit.
+
+
+**Expected:** Validation error “file is too large” or upload rejected.
+
+---
+
+
diff --git a/e2e/plan/layer-2-registration.md b/e2e/plan/layer-2-registration.md
new file mode 100644
index 0000000..0782739
--- /dev/null
+++ b/e2e/plan/layer-2-registration.md
@@ -0,0 +1,381 @@
+# Layer 2b — Registration flows
+
+Registration, payment, RSVP (§2.10–2.19).
+
+Part of the [E2E implementation plan](README.md). Implement tests bottom-up; check boxes when a Playwright spec covers the case.
+
+Spec naming: `tests/.spec.ts` · Page objects under `tests/pages/`.
+
+---
+
+> [!NOTE]
+> **Tracking:** Check `[x]` when a Playwright spec covers the test section. Steps below are manual procedure reference — not separate tests.
+
+
+### 2.10 Free registration — auto confirm
+- [x] 2.10 Free registration — auto confirm
+
+
+**Steps:**
+
+1. Log out (or use incognito).
+2. Open approved `E2E Free Class` → **Register for this Event**.
+3. Fill name, email, phone; submit.
+
+
+**Expected:** Registration status **confirmed**; redirect to `/registrations/view/{id}` with success message.
+
+**Expected (confirmation email):** `[INFRA: SparkPost]` / `[INFRA: Email-dev]` — `sendRegistrationConfirmation` to registrant email.
+
+**Steps (logged-in member):**
+
+1. Log in as `user3`; register for another approved free event.
+
+
+**Expected:** Fields pre-filled from AD; `ad_username` stored.
+
+**Steps (already registered redirect):**
+
+1. As logged-in user, open `/registrations/event/{eventId}` for event already registered.
+
+
+**Expected:** Redirect to existing `/registrations/view/{id}` (`RegistrationsController::event`).
+
+**Steps (duplicate AD username per event):**
+
+1. Attempt second registration with different email but same member account on same event.
+
+
+**Expected:** Unique validation on `ad_username` scoped to `event_id`.
+
+---
+
+
+### 2.11 Registration — requires host approval
+- [x] 2.11 Registration — requires host approval
+
+
+**Steps:**
+
+1. Open `E2E Approval Required` → register as guest or member.
+2. Submit registration.
+
+
+**Expected:** Status **pending**; pending message on registration view.
+
+**Steps (host approve):**
+
+1. Log in as event owner (`user1`) or `user2`.
+2. Open event view → **Registered Attendees** tab.
+3. Click **Approve** on pending registration.
+
+
+**Expected:** Status **confirmed**; approval email `[INFRA: SparkPost]` / `[INFRA: Email-dev]` (`sendRegistrationApproved`).
+
+**Steps (host reject):**
+
+1. Register another user for same event (use different email).
+2. **Reject** from registrations tab.
+
+
+**Expected:** Status **rejected**; rejection email `[INFRA: SparkPost]` / `[INFRA: Email-dev]` (`sendRegistrationRejected`); refund message if paid `[INFRA: Braintree]`.
+
+**Steps (host is event creator — `RegistrationsController::isAuthorized`):**
+
+1. As `user1` (event owner, not admin), approve/reject pending registration on own event.
+
+
+**Expected:** Owner authorized via `created_by` match without Calendar Admin role.
+
+**Steps (instructor notification on approve path):**
+
+1. For auto-confirm event with **Notify Instructor on Registrations**, register — verify instructor receives registration email `[INFRA: SparkPost]` / `[INFRA: Email-dev]` (`sendRegistrationToInstructor`).
+
+
+**Steps (pending + requested emails):** `[INFRA: SparkPost]` / `[INFRA: Email-dev]`
+
+1. Register for `E2E Approval Required` — verify **both** `sendRegistrationPending` (to registrant) and `sendRegistrationRequested` (to host) delivered.
+
+
+---
+
+
+### 2.12 Registration — members only
+- [x] 2.12 Registration — members only
+
+
+**Steps:**
+
+1. Log out; open `E2E Members Only`.
+
+
+**Expected:** Login prompt; no registration form.
+
+**Steps:**
+
+1. Log in as `user1`; register.
+
+
+**Expected:** Form available; registration succeeds.
+
+---
+
+
+### 2.13 Registration — prerequisite gating
+- [ ] 2.13 Registration — prerequisite gating
+
+
+**Steps (negative — before LDAP setup):**
+
+1. Remove `user1` from `3D Printer Basics` if added; re-login.
+2. Open `E2E Prereq Gated`.
+
+
+**Expected:** Warning about missing prerequisite; no form.
+
+**Steps (positive — after §2.4.1):**
+
+1. Add `user1` to group; re-login.
+2. Register for `E2E Prereq Gated`.
+
+
+**Expected:** Form available; registration succeeds.
+
+**Steps (SSO prerequisite refresh):** `[INFRA: OIDC]` + code fix for `getGroupsFromUserInfo`
+
+1. Log in via SSO; register for prereq-gated event after group added in LDAP mid-session.
+
+
+**Expected:** `currentUserInGroup(..., forceRefreshGroups=true)` re-fetches groups via OIDC `updateGroups` (currently broken: missing `return` in `getGroupsFromUserInfo`).
+
+---
+
+
+### 2.14 Registration — advisories & age gate
+- [x] 2.14 Registration — advisories & age gate
+
+
+**Steps:**
+
+1. Open `E2E Age 18+` → register.
+2. Confirm **Safety/advisories** checkbox required.
+3. Confirm **Age confirmation** checkbox required.
+4. Submit without checking — browser validation or server error.
+5. Check both; submit.
+
+
+**Expected:** Registration succeeds only with acknowledgments.
+
+---
+
+
+### 2.15 Guest registration & edit_key
+- [x] 2.15 Guest registration & edit_key
+
+
+**Steps:**
+
+1. Log out; register for free event with email `guest@test.local`.
+2. Note URL includes `?edit_key=...` or copy link from confirmation page.
+3. Open registration view in new session using full URL with `edit_key`.
+4. **Cancel RSVP** before cutoff.
+
+
+**Expected:** Guest can view/cancel without login via edit_key.
+
+**Steps (registration view authorization — negative):**
+
+1. Open `/registrations/view/{id}` without login and without valid `edit_key`.
+
+
+**Expected:** Redirect away (`RegistrationsController::view` + `isOwnedBy` fails).
+
+2. As different logged-in member (not registrant, not host, not admin), open same URL.
+
+
+**Expected:** Access denied / redirect.
+
+---
+
+
+### 2.16 Registration — full event / duplicate email
+- [x] 2.16 Registration — full event / duplicate email
+
+
+**Steps (full — capped capacity):**
+
+1. Create event with `free_spaces = 1`; approve.
+2. Register two different emails.
+
+
+**Expected:** Second registrant sees “no spaces available”.
+
+**Steps (unlimited capacity — `getTotalSpaces` returns true):**
+
+1. Create event with `free_spaces=0` and `paid_spaces=0`; approve.
+2. Register multiple attendees.
+
+
+**Expected:** No capacity limit; UI may omit “X of Y spaces” (`openSpaces` true).
+
+**Steps (mixed free + paid pools):**
+
+1. Use `E2E Mixed Free/Paid` — registration form shows type selector (free observer vs paid participant).
+2. Fill free pool first; confirm paid still available and vice versa (`hasFreeSpaces` / `hasPaidSpaces`).
+
+
+**Steps (duplicate email):**
+
+1. Register same email twice on one event.
+
+
+**Expected:** Validation error.
+
+**Steps (race — full free pool at submit):**
+
+1. With one free space left, open two registration tabs; submit both nearly simultaneously.
+
+
+**Expected:** Second submit shows “no free spaces” flash; note known `$$registration` typo may cause PHP error instead of clean validation.
+
+---
+
+
+### 2.17 Registration — cutoff & extend
+- [ ] 2.17 Registration — cutoff & extend
+
+
+**Steps:**
+
+1. Find event where cancellation cutoff has passed (or temporarily set `attendee_cancellation` in DB to past).
+2. Attempt registration.
+
+
+**Expected:** “Registration closed” on event view; `/registrations/event/{id}` redirects away.
+
+**Steps (extend registration):**
+
+1. Event with `extend_registration = 30` minutes — verify registration allowed briefly after nominal cutoff (if event still approved and before event start).
+
+
+**Steps (accept vs reject cutoff asymmetry):**
+
+1. After nominal cutoff but within extend window: host **Accept** pending registration — should succeed.
+2. Host **Reject** after nominal cutoff — should fail (`reject` does not add extend minutes).
+
+
+**Steps (wrong event state):**
+
+1. Open `/registrations/event/{id}` for pending/unapproved event or multipart child event.
+
+
+**Expected:** Redirect away (`RegistrationsController::event` guards).
+
+---
+
+
+### 2.18 Paid registration (Braintree) `[INFRA: Braintree]`
+- [ ] 2.18 Paid registration (Braintree) `[INFRA: Braintree]`
+
+
+Complete §0.3 Braintree check first. All steps in this section require sandbox credentials.
+
+**Steps:**
+
+1. Create approved paid event: cost `$5`, paid_spaces `5`, free_spaces `0`.
+2. Open registration — Braintree drop-in UI visible.
+3. Use Braintree sandbox test card (e.g. `4111111111111111`).
+4. Complete registration.
+
+
+**Expected:** `transaction_id` saved; status confirmed; charge in Braintree sandbox dashboard.
+
+**Steps (refund on cancel):** `[INFRA: Braintree]` + `[INFRA: SparkPost]` / `[INFRA: Email-dev]`
+
+1. Cancel registration before cutoff.
+
+
+**Expected:** Braintree void/refund; `sendRegistrationCancelled` email.
+
+**Steps (Braintree failure):** `[INFRA: Braintree]`
+
+1. Submit paid registration with declined test card (e.g. Braintree sandbox decline nonce).
+
+
+**Expected:** Flash error with gateway message; verify whether registration row is created (known issue: `stopPropagation` commented out).
+
+**Steps (refund void vs settle — `RegistrationsTable::refund`):** `[INFRA: Braintree]`
+
+1. Cancel paid registration while transaction still `submitted_for_settlement` — void path.
+2. Cancel after settlement — refund path.
+
+
+**Expected:** Appropriate Braintree void/refund API called.
+
+**Steps (refund no-op):**
+
+1. Cancel registration already `cancelled` or without `transaction_id`.
+
+
+**Expected:** `refund()` returns without error (no-op branches).
+
+**If Braintree not configured:** Record **BLOCKED** `[INFRA: Braintree]` — do not skip; return when credentials available.
+
+---
+
+
+### 2.19 Cancel RSVP
+- [x] 2.19 Cancel RSVP
+
+
+**Steps:**
+
+1. As registrant (or guest with edit_key), open registration view.
+2. Click **Cancel RSVP**; confirm dialog.
+
+
+**Expected:** Status cancelled; cannot cancel again; refund message for paid events `[INFRA: Braintree]`.
+
+**Steps (cancellation email):** `[INFRA: SparkPost]` / `[INFRA: Email-dev]`
+
+1. After cancel — verify `sendRegistrationCancelled` delivered to registrant email.
+
+
+**Steps (after cutoff):**
+
+1. As `user1`, attempt cancel after cutoff.
+
+
+**Expected:** Blocked with error flash.
+
+**Steps (admin override):**
+
+1. As `user2` (Calendar Admin), cancel same registration after cutoff.
+
+
+**Expected:** Admin can cancel past cutoff.
+
+**Steps (cancel instructor notification):**
+
+1. Register for event with **Notify Instructor on Cancellations** enabled; cancel registration.
+
+
+**Expected:** Instructor receives `sendCancellationToInstructor` email `[INFRA: SparkPost]` / `[INFRA: Email-dev]`.
+
+**Steps (send_text on registration form):**
+
+1. On `/registrations/event/{id}`, confirm **Receive text message alerts** checkbox is visible (`Registrations/event.ctp`).
+
+
+**Expected:** `send_text` field rendered (only cancel-event SMS is active in code; cron SMS is commented out).
+
+**Steps (already cancelled):**
+
+1. Attempt cancel again on cancelled registration.
+
+
+**Expected:** Cancel button not shown on registration view.
+
+---
+
+
diff --git a/e2e/plan/layer-3-public.md b/e2e/plan/layer-3-public.md
new file mode 100644
index 0000000..56b1f60
--- /dev/null
+++ b/e2e/plan/layer-3-public.md
@@ -0,0 +1,286 @@
+# Layer 3 — Public calendar
+
+Anonymous and navigation flows.
+
+Part of the [E2E implementation plan](README.md).
+
+---
+
+> [!NOTE]
+> **Tracking:** Check `[x]` when a Playwright spec covers the test section. Steps below are manual procedure reference — not separate tests.
+
+
+### 3.1 Event index — `/`
+- [x] 3.1 Event index — `/`
+
+
+**Steps:**
+
+1. Log out; open http://localhost:8000.
+2. Confirm only **approved upcoming** events listed.
+3. Click **By Type → Class** — list filters.
+4. Click **By Type → Event** — list filters.
+5. **By Category** — pick a category; list narrows.
+6. **By Tool** — pick a tool.
+7. **By Room** — pick a room.
+8. Apply **type + category** together.
+
+
+**Expected:** Each filter reduces list correctly; combined type+category uses AND logic.
+
+**Steps (links):**
+
+1. Click event title or “More Info and RSVP »”.
+
+
+**Expected:** Navigates to event view.
+
+**Steps (RSS):**
+
+1. Click RSS icon — feed URL includes active filter query params.
+
+
+---
+
+
+### 3.2 Calendar view — `/events/calendar`
+- [ ] 3.2 Calendar view — `/events/calendar`
+
+
+**Steps:**
+
+1. Click **Calendar View** from index.
+2. Confirm month grid shows events on correct days.
+3. Navigate to `/events/calendar/2026/6/1` (adjust year/month).
+4. Open daily view `/events/calendar/2026/6/15`.
+5. Apply `?category={id}` filter on calendar URL.
+
+
+**Expected:** Month and day views render; approved + completed events in range; filters apply.
+
+**Steps (completed events on calendar only):**
+
+1. Open `/events/calendar` for month containing a **completed** event (from §2.20 / cron).
+
+
+**Expected:** Completed event appears on calendar (calendar includes `approved` + `completed`; index does not).
+
+**Steps (highlight flag — `highlight` template var):**
+
+1. Navigate to `/events/calendar/{currentYear}/{currentMonth}/1` — today’s date highlighted in month grid.
+2. Navigate to a different month/year (e.g. `/events/calendar/2027/1/1`).
+
+
+**Expected:** `highlight=false`; today styling not applied on non-current month view.
+
+**Steps (invalid / edge date params):**
+
+1. Open `/events/calendar/2015/6/1` (year below 2016 minimum).
+
+
+**Expected:** Year param ignored; falls back to current year behavior (no crash).
+2. Open `/events/calendar/2026/13/1` or `/events/calendar/2026/6/32`.
+
+
+**Expected:** Invalid month/day ignored; page still renders.
+
+**Steps (offsite address on calendar list):**
+
+1. Filter calendar to month with **Offsite** event — no DMS address appended to location line.
+
+
+---
+
+
+### 3.3 Embed view — `/events/embed`
+- [ ] 3.3 Embed view — `/events/embed`
+
+
+**Steps:**
+
+1. Open http://localhost:8000/events/embed.
+2. Confirm no site header/footer (minimal chrome).
+3. Apply `?tool={id}` filter.
+
+
+**Expected:** Embeddable list; filters work.
+
+---
+
+
+### 3.4 Event detail page — `/events/view/:id`
+- [ ] 3.4 Event detail page — `/events/view/:id`
+
+
+**Steps (content):**
+
+1. Open an approved upcoming event.
+2. Verify: title, when/where/what/host, categories (clickable filters), tools, long description, advisories.
+3. Multipart event: all session dates listed.
+4. Download public attachment; view image tabs.
+
+
+**Expected:** All public fields render; URLs in description become links.
+
+**Steps (registration states):**
+
+1. **Before cutoff, spaces available:** green Register button.
+2. **Already registered (logged in):** “View Your Registration”.
+3. **Full event:** “no more spaces” alert.
+4. **Pending event:** pending approval message.
+5. **Past cutoff:** registration closed.
+6. **Cancelled event:** red cancellation banner.
+
+
+**Steps (add to calendar):**
+
+1. Click Google Calendar icon — opens Google with prefilled event.
+2. Click Outlook icon — opens Outlook compose.
+3. Click Apple/iCal — downloads `.ics`.
+
+
+**Steps (private files):**
+
+1. As anonymous, open event with private attachment — private file not listed.
+2. Log in as owner — private file visible.
+
+
+**Steps (instructor controls):**
+
+1. As anonymous — no Instructor Controls section.
+2. As owner — Registered Attendees / Attendance / AD Assignment tabs visible.
+
+
+**Steps (Eventbrite paid type):**
+
+1. Open approved `E2E Eventbrite` as anonymous.
+
+
+**Expected:** “Paid through Eventbrite” cost line; external registration link; Eventbrite disclaimer; **no** DMS Register button / Braintree.
+
+**Steps (pending / rejected / completed public view):**
+
+1. Open **pending** event by direct URL `/events/view/{pendingId}`.
+
+
+**Expected:** “pending approval” info alert; no registration form.
+2. Open **rejected** event — rejection banner; **Edit Event** hidden for owner (§2.7).
+3. Open **completed** event — no registration button; attendance/history may still show for owner.
+
+
+**Steps (offsite address):**
+
+1. Open offsite-room event — location shows room name only; no “1825 Monetary Ln” address block.
+
+
+**Steps (space count on index vs detail):**
+
+1. On index, note registration count for capped event; open detail — counts consistent with `openSpaces` / `hasOpenSpaces`.
+
+
+---
+
+
+### 3.5 Feeds & ICS API
+- [ ] 3.5 Feeds & ICS API
+
+
+**Steps:**
+
+| URL | Check |
+|---|---|
+| `/events/feed/vcal` | Valid iCal; future approved events |
+| `/events/feed/rss` | XML; descriptions contain HTML |
+| `/events/feed/json` | JSON feed body |
+| `/events/feed/newJson` | JSON array; `Access-Control-Allow-Origin: *` |
+| `/events/ics/{eventId}` | Single-event `.ics` download |
+
+
+**Filter test:**
+
+1. `/events/feed/rss?category={id}&tool={id}` — feed contents match filters.
+
+
+**Expected:** Private attachments excluded from feeds.
+
+**Steps (unknown feedtype defaults to RSS):**
+
+1. Open `/events/feed/atom` or `/events/feed/unknown`.
+
+
+**Expected:** Valid RSS XML (default branch in `feed()` else block).
+
+**Steps (newJson unlimited capacity — `-1` sentinel):**
+
+1. Open `/events/feed/newJson`; find `E2E Unlimited Capacity` entry.
+
+
+**Expected:** `totalSpaces` and `filledSpaces` are `-1` when unlimited (`eventsJson`).
+
+**Steps (newJson file/image payloads):**
+
+1. Confirm public images appear under `images[]`; non-image public files under `files[]`; private files omitted.
+
+
+**Steps (feed filter title addon):**
+
+1. `/events/feed/rss?category={id}` — channel title/description include filter subject names.
+
+
+**Steps (ics single event):**
+
+1. `/events/ics/{eventId}` for offsite event — verify LOCATION/address handling matches view.
+
+
+---
+
+
+### 3.6 Navigation & session UX
+- [ ] 3.6 Navigation & session UX
+
+
+**Anonymous:**
+
+1. **Submit Event** → redirects to login with `?redirect=/events/add`.
+2. **DMS Login** from any page preserves return URL.
+
+
+**Logged in (`user1`):**
+
+1. **Submit Event** → add form directly.
+2. **My Account → Hosting Events / Attending Events** — lists populate.
+
+
+**Logged in (`user2`):**
+
+1. Admin, Honoraria, Financials, Super Calendar Admin menus visible.
+
+
+**Development:**
+
+1. With `DEBUG=true`, navbar has red background.
+
+
+---
+
+
+### 3.7 End-to-end smoke (all layers)
+- [ ] 3.7 End-to-end smoke (all layers)
+
+
+**Steps:**
+
+1. **Layer 1:** Admin adds category `Smoke Category` and room `Smoke Room`.
+2. **Layer 2:** `user1` submits class using those references; `user2` approves.
+3. **Layer 3:** Anonymous user filters index by `Smoke Category` — event appears.
+4. Anonymous opens event detail — register for free event.
+5. Index shows decreased space count (if capped).
+6. `/events/feed/rss` includes the event.
+7. `/events/calendar` shows event on correct day.
+
+
+**Expected:** Full path from admin setup → member submit → public discovery → registration without errors.
+
+---
+
+
diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts
index 3d0f9cd..e4f21de 100644
--- a/e2e/playwright.config.ts
+++ b/e2e/playwright.config.ts
@@ -7,7 +7,8 @@ export default defineConfig({
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
- workers: process.env.CI ? 1 : undefined,
+ workers: 1,
+ timeout: process.env.CI ? 60_000 : 30_000,
reporter: process.env.CI
? [
['junit', { outputFile: 'test-results/junit.xml' }],
diff --git a/e2e/tests/authorization.spec.ts b/e2e/tests/authorization.spec.ts
new file mode 100644
index 0000000..ab3409e
--- /dev/null
+++ b/e2e/tests/authorization.spec.ts
@@ -0,0 +1,74 @@
+import { test, expect } from '@playwright/test';
+
+import { testUsers } from './data/test-users';
+import { loginAs, loginAsAdmin } from './helpers/admin-session';
+import { CategoriesPage } from './pages/categories.page';
+import { EventsIndexPage } from './pages/events-index.page';
+import { ExportHonorariaPage } from './pages/export-honoraria.page';
+import { HonorariaIndexPage } from './pages/honoraria-index.page';
+import { PendingEventsPage } from './pages/pending-events.page';
+import { PendingHonorariaPage } from './pages/pending-honoraria.page';
+
+test.describe('Authorization and menu flags', () => {
+ test('members without admin groups see no admin menus', async ({ page }) => {
+ const eventsIndex = new EventsIndexPage(page);
+
+ for (const user of [testUsers.member, testUsers.memberCommittee]) {
+ await loginAs(page, user.username, user.password);
+ await eventsIndex.header.expectNoAdminMenus();
+ await eventsIndex.header.logout();
+ }
+ });
+
+ test('full admin sees all admin menus and contacts link', async ({ page }) => {
+ const eventsIndex = new EventsIndexPage(page);
+
+ await loginAsAdmin(page);
+ await eventsIndex.header.expectAllAdminMenus();
+ await eventsIndex.header.expectAdminContactsLinkVisible();
+ });
+
+ test('financial admin sees export but not calendar admin CRUD', async ({ page }) => {
+ const eventsIndex = new EventsIndexPage(page);
+ const exportHonoraria = new ExportHonorariaPage(page);
+ const categories = new CategoriesPage(page);
+
+ await loginAs(
+ page,
+ testUsers.financialAdmin.username,
+ testUsers.financialAdmin.password,
+ );
+ await expect(eventsIndex.header.financialsMenu).toBeVisible();
+ await expect(eventsIndex.header.adminMenu).toBeHidden();
+
+ await exportHonoraria.navigateViaMenu();
+ await expect(exportHonoraria.heading).toBeVisible();
+
+ await categories.navigateViaUrl();
+ await categories.expectAccessDenied();
+ });
+
+ test('honorarium admin sees honoraria menu but not calendar admin routes', async ({ page }) => {
+ const eventsIndex = new EventsIndexPage(page);
+ const pendingEvents = new PendingEventsPage(page);
+ const pendingHonoraria = new PendingHonorariaPage(page);
+ const honorariaIndex = new HonorariaIndexPage(page);
+
+ await loginAs(
+ page,
+ testUsers.honorariumAdmin.username,
+ testUsers.honorariumAdmin.password,
+ );
+ await expect(eventsIndex.header.honorariaMenu).toBeVisible();
+ await expect(eventsIndex.header.adminMenu).toBeHidden();
+
+ await pendingHonoraria.navigateViaMenu();
+ await expect(pendingHonoraria.heading).toBeVisible();
+
+ await pendingEvents.navigateViaUrl();
+ await pendingEvents.expectAccessDenied();
+
+ await honorariaIndex.navigateViaUrl();
+ await honorariaIndex.expectAccessDenied();
+ });
+});
diff --git a/e2e/tests/calendar-admin.spec.ts b/e2e/tests/calendar-admin.spec.ts
new file mode 100644
index 0000000..b5c6ef4
--- /dev/null
+++ b/e2e/tests/calendar-admin.spec.ts
@@ -0,0 +1,60 @@
+import { test, expect } from '@playwright/test';
+
+import { loginAsAdmin } from './helpers/admin-session';
+import { CalendarAdminPage } from './pages/calendar-admin.page';
+import { EventsAddPage } from './pages/events-add.page';
+
+test.describe('Super Calendar Admin settings', () => {
+ // Both tests POST the same singleton settings form; run sequentially to avoid races.
+ test.describe.configure({ mode: 'serial' });
+
+ test('honoraria toggle enables and disables honorarium requests on the event form', async ({
+ page,
+ }) => {
+ const calendarAdmin = new CalendarAdminPage(page);
+ const eventsAdd = new EventsAddPage(page);
+
+ await loginAsAdmin(page);
+
+ await calendarAdmin.navigateViaMenu();
+ await expect(calendarAdmin.heading).toBeVisible();
+ await calendarAdmin.setAllowHonoraria(true);
+
+ await calendarAdmin.setAllowHonoraria(false);
+
+ await eventsAdd.navigateViaMenu();
+ await eventsAdd.selectClassType();
+ await eventsAdd.expectRequestHonorariumEnabled(false);
+
+ await calendarAdmin.navigateViaMenu();
+ await calendarAdmin.setAllowHonoraria(true);
+
+ await eventsAdd.navigateViaMenu();
+ await eventsAdd.selectClassType();
+ await eventsAdd.expectRequestHonorariumEnabled(true);
+ });
+
+ test('custom honorarium message appears on the event form', async ({ page }) => {
+ const honorariumMessage = 'Test honorarium message';
+ const calendarAdmin = new CalendarAdminPage(page);
+ const eventsAdd = new EventsAddPage(page);
+
+ await loginAsAdmin(page);
+
+ await calendarAdmin.navigateViaMenu();
+ await calendarAdmin.setHonorariumMessage('');
+
+ await calendarAdmin.setHonorariumMessage(honorariumMessage);
+
+ await eventsAdd.navigateViaMenu();
+ await eventsAdd.selectClassType();
+ await eventsAdd.expectHonorariumMessage(honorariumMessage);
+
+ await calendarAdmin.navigateViaMenu();
+ await calendarAdmin.setHonorariumMessage('');
+
+ await eventsAdd.navigateViaMenu();
+ await eventsAdd.selectClassType();
+ await eventsAdd.expectHonorariumMessageHidden(honorariumMessage);
+ });
+});
diff --git a/e2e/tests/categories.spec.ts b/e2e/tests/categories.spec.ts
new file mode 100644
index 0000000..66e1f59
--- /dev/null
+++ b/e2e/tests/categories.spec.ts
@@ -0,0 +1,49 @@
+import { test, expect } from '@playwright/test';
+
+import { loginAsAdmin } from './helpers/admin-session';
+import { CategoriesPage } from './pages/categories.page';
+import { EventsAddPage } from './pages/events-add.page';
+
+test.describe('Categories admin CRUD', () => {
+ test('admin manages categories and exposes them on the event form', async ({ page }) => {
+ const categoryName = 'Test Category E2E';
+ const categoryUpdatedName = 'Test Category E2E Updated';
+ const seedCategoryName = 'Fiber Arts';
+ const categories = new CategoriesPage(page);
+ const eventsAdd = new EventsAddPage(page);
+
+ await loginAsAdmin(page);
+ await categories.navigateViaMenu();
+ await expect(categories.heading).toBeVisible();
+
+ // Remove leftovers from a previous run that failed before delete.
+ for (const name of [categoryUpdatedName, categoryName]) {
+ if ((await categories.row(name).count()) > 0) {
+ await categories.delete(name);
+ }
+ }
+
+ await categories.expectTypeCategoriesHidden();
+ await categories.expectRowVisible(seedCategoryName);
+
+ await categories.addCategory(categoryName);
+ await categories.expectRowVisible(categoryName);
+
+ await categories.openEdit(categoryName);
+ await categories.saveCategory(categoryUpdatedName);
+ await categories.expectRowVisible(categoryUpdatedName);
+
+ await categories.delete(categoryUpdatedName);
+ await categories.expectRowHidden(categoryUpdatedName);
+
+ await categories.addCategory(categoryName);
+ await categories.expectRowVisible(categoryName);
+
+ await eventsAdd.navigateViaMenu();
+ await eventsAdd.expectOptionalCategoryOption(categoryName);
+
+ await categories.navigateViaMenu();
+ await categories.delete(categoryName);
+ await categories.expectRowHidden(categoryName);
+ });
+});
diff --git a/e2e/tests/committees.spec.ts b/e2e/tests/committees.spec.ts
new file mode 100644
index 0000000..8f81285
--- /dev/null
+++ b/e2e/tests/committees.spec.ts
@@ -0,0 +1,40 @@
+import { test, expect } from '@playwright/test';
+
+import { loginAsAdmin } from './helpers/admin-session';
+import { CommitteesPage } from './pages/committees.page';
+import { EventsAddPage } from './pages/events-add.page';
+
+test.describe('Committees admin CRUD', () => {
+ test('admin manages committees and exposes them on the event form', async ({ page }) => {
+ const committeeName = 'Test Committee E2E';
+ const committeeUpdatedName = 'Test Committee E2E Updated';
+ const committees = new CommitteesPage(page);
+ const eventsAdd = new EventsAddPage(page);
+
+ await loginAsAdmin(page);
+ await committees.navigateViaMenu();
+ await expect(committees.heading).toBeVisible();
+
+ // Remove leftovers from a previous run that failed before delete.
+ for (const name of [committeeUpdatedName, committeeName]) {
+ if ((await committees.row(name).count()) > 0) {
+ await committees.delete(name);
+ }
+ }
+
+ await committees.addCommittee(committeeName);
+ await committees.expectRowVisible(committeeName);
+
+ await committees.openEdit(committeeName);
+ await committees.saveCommittee(committeeUpdatedName);
+ await committees.expectRowVisible(committeeUpdatedName);
+
+ await eventsAdd.navigateViaMenu();
+ await eventsAdd.selectClassType();
+ await eventsAdd.expectCommitteeOption(committeeUpdatedName);
+
+ await committees.navigateViaMenu();
+ await committees.delete(committeeUpdatedName);
+ await committees.expectRowHidden(committeeUpdatedName);
+ });
+});
diff --git a/e2e/tests/components/header.component.ts b/e2e/tests/components/header.component.ts
new file mode 100644
index 0000000..31aad12
--- /dev/null
+++ b/e2e/tests/components/header.component.ts
@@ -0,0 +1,112 @@
+import { expect, type Locator } from '@playwright/test';
+
+export class HeaderComponent {
+ constructor(private readonly root: Locator) {}
+
+ get adminMenu(): Locator {
+ return this.root.getByRole('button', { name: 'Admin', exact: true });
+ }
+
+ get honorariaMenu(): Locator {
+ return this.root.getByRole('button', { name: 'Honoraria', exact: true });
+ }
+
+ get financialsMenu(): Locator {
+ return this.root.getByRole('button', { name: 'Financials', exact: true });
+ }
+
+ get superCalendarAdminMenu(): Locator {
+ return this.root.getByRole('button', { name: 'Super Calendar Admin', exact: true });
+ }
+
+ get dmsLoginLink(): Locator {
+ return this.root.getByRole('link', { name: 'DMS Login' });
+ }
+
+ get myAccountMenu(): Locator {
+ return this.root.getByRole('button', { name: 'My Account' });
+ }
+
+ private get logoutLink(): Locator {
+ return this.root.getByRole('link', { name: 'Logout' });
+ }
+
+ private get submitEventLink(): Locator {
+ return this.root.getByRole('link', { name: 'Submit Event' });
+ }
+
+ async goToSubmitEvent() {
+ await this.submitEventLink.click();
+ }
+
+ async goHome() {
+ await this.root.getByRole('link', { name: 'Dallas Makerspace Calendar' }).click();
+ }
+
+ async expectLoggedOut() {
+ await expect(this.dmsLoginLink).toBeVisible();
+ await expect(this.myAccountMenu).toBeHidden();
+ await expect(this.adminMenu).toBeHidden();
+ await expect(this.honorariaMenu).toBeHidden();
+ await expect(this.financialsMenu).toBeHidden();
+ await expect(this.superCalendarAdminMenu).toBeHidden();
+ }
+
+ async expectNoAdminMenus() {
+ await expect(this.adminMenu).toBeHidden();
+ await expect(this.honorariaMenu).toBeHidden();
+ await expect(this.financialsMenu).toBeHidden();
+ await expect(this.superCalendarAdminMenu).toBeHidden();
+ }
+
+ async expectAllAdminMenus() {
+ await expect(this.adminMenu).toBeVisible();
+ await expect(this.honorariaMenu).toBeVisible();
+ await expect(this.financialsMenu).toBeVisible();
+ await expect(this.superCalendarAdminMenu).toBeVisible();
+ }
+
+ private get adminContactsLink(): Locator {
+ return this.root.getByRole('link', { name: 'Contacts' });
+ }
+
+ async expectAdminContactsLinkVisible() {
+ await this.adminMenu.click();
+ await expect(this.adminContactsLink).toBeVisible();
+ }
+
+ async openFinancialsLink(linkName: string) {
+ await this.financialsMenu.click();
+ await this.root.getByRole('link', { name: linkName }).click();
+ }
+
+ async openHonorariaLink(linkName: string) {
+ await this.honorariaMenu.click();
+ await this.root.getByRole('link', { name: linkName }).click();
+ }
+
+ async logout() {
+ await this.myAccountMenu.click();
+ await this.logoutLink.click();
+ }
+
+ async openAdminLink(linkName: string) {
+ await this.adminMenu.click();
+ await this.root.getByRole('link', { name: linkName }).click();
+ }
+
+ async openSuperCalendarAdminSettings() {
+ await this.superCalendarAdminMenu.click();
+ await this.root.getByRole('link', { name: 'Settings' }).click();
+ }
+
+ async openHostingEvents() {
+ await this.myAccountMenu.click();
+ await this.root.getByRole('link', { name: 'Hosting Events' }).click();
+ }
+
+ async openAttendingEvents() {
+ await this.myAccountMenu.click();
+ await this.root.getByRole('link', { name: 'Attending Events' }).click();
+ }
+}
diff --git a/e2e/tests/configurations.spec.ts b/e2e/tests/configurations.spec.ts
new file mode 100644
index 0000000..9575092
--- /dev/null
+++ b/e2e/tests/configurations.spec.ts
@@ -0,0 +1,48 @@
+import { test, expect } from '@playwright/test';
+
+import { referenceData } from './data/reference-data';
+import { loginAsAdmin } from './helpers/admin-session';
+import { ConfigurationsPage } from './pages/configurations.page';
+import { EventsAddPage } from './pages/events-add.page';
+
+const configurationNames = [
+ 'Automatic Approval Time',
+ 'Honoraria Approval Time',
+ 'Honoraria Booking Lead Time',
+ 'Minimum Booking Lead Time',
+ 'Maximum Booking Lead Time',
+ 'Role Call Cutoff',
+] as const;
+
+test.describe('System configuration admin', () => {
+ test('admin views and edits booking lead time configuration', async ({ page }) => {
+ const { configuration } = referenceData;
+ const configurations = new ConfigurationsPage(page);
+ const eventsAdd = new EventsAddPage(page);
+
+ await loginAsAdmin(page);
+ await configurations.navigateViaMenu();
+ await expect(configurations.heading).toBeVisible();
+ await configurations.expectConfigurationRows(configurationNames);
+ await configurations.expectAllowHonorariaHidden();
+
+ await configurations.openEdit(configuration.minimumBookingLeadTime);
+ await configurations.saveValue(configuration.testMinimumLeadDays);
+ await configurations.expectValueInIndex(
+ configuration.minimumBookingLeadTime,
+ configuration.testMinimumLeadDays,
+ );
+
+ await eventsAdd.navigateViaMenu();
+ await expect(eventsAdd.minimumBookingLeadTime).toHaveText(configuration.testMinimumLeadDays);
+ await expect(eventsAdd.eventStartHelp).toContainText('10 days from today');
+
+ await configurations.navigateViaMenu();
+ await configurations.openEdit(configuration.minimumBookingLeadTime);
+ await configurations.saveValue(configuration.defaultMinimumLeadDays);
+ await configurations.expectValueInIndex(
+ configuration.minimumBookingLeadTime,
+ configuration.defaultMinimumLeadDays,
+ );
+ });
+});
diff --git a/e2e/tests/contacts.spec.ts b/e2e/tests/contacts.spec.ts
new file mode 100644
index 0000000..1fc8026
--- /dev/null
+++ b/e2e/tests/contacts.spec.ts
@@ -0,0 +1,100 @@
+import { test, expect } from '@playwright/test';
+
+import { testUsers } from './data/test-users';
+import { loginAsAdmin, loginAs } from './helpers/admin-session';
+import { ContactsPage } from './pages/contacts.page';
+import { EventsAddPage } from './pages/events-add.page';
+
+test.describe('Contacts admin CRUD', () => {
+ test('admin manages external contacts for sponsored events', async ({ page }) => {
+ const contactName = 'External Instructor';
+ const contactEmail = 'external@test.local';
+ const contactPhone = '555-0100';
+ const duplicateContactName = 'External Instructor Duplicate';
+ const contacts = new ContactsPage(page);
+ const eventsAdd = new EventsAddPage(page);
+
+ await loginAsAdmin(page);
+ await contacts.navigateViaMenu();
+ await expect(contacts.heading).toBeVisible();
+
+ // Remove leftovers from a previous run that failed before delete.
+ for (const name of [duplicateContactName, contactName]) {
+ if ((await contacts.row(name).count()) > 0) {
+ await contacts.delete(name);
+ }
+ }
+
+ await contacts.addContact(contactName, contactEmail, contactPhone);
+ await contacts.expectW9OnFile(contactName, false);
+
+ await contacts.openEdit(contactName);
+ await contacts.setW9OnFile(true);
+ await contacts.expectW9OnFile(contactName, true);
+
+ await eventsAdd.navigateViaMenu();
+ await eventsAdd.enableSponsoredEvent();
+ await eventsAdd.expectExistingInstructorOption(contactName);
+
+ await contacts.navigateViaMenu();
+ await contacts.openAddForm();
+ await contacts.fillContactForm(duplicateContactName, contactEmail, contactPhone);
+ await contacts.submitAddForm();
+ await expect(contacts.emailValidationError).toContainText('This value is already in use');
+
+ await contacts.navigateViaMenu();
+ await contacts.delete(contactName);
+ await contacts.expectRowHidden(contactName);
+ });
+
+ test('contact view shows hosted and attended events for LDAP users', async ({ page }) => {
+ const contacts = new ContactsPage(page);
+
+ await loginAsAdmin(page);
+ await contacts.openContactFromIndex(testUsers.member.username);
+
+ await expect(contacts.contactViewHeading).toBeVisible();
+ await expect(contacts.attendedEventsHeading).toBeVisible();
+ await expect(contacts.hostedEventsHeading).toBeVisible();
+ });
+
+ test('honorarium admin can view contacts but not open contacts index', async ({ page }) => {
+ const contacts = new ContactsPage(page);
+
+ await loginAs(
+ page,
+ testUsers.honorariumAdmin.username,
+ testUsers.honorariumAdmin.password,
+ );
+ await expect(contacts.header.honorariaMenu).toBeVisible();
+ await expect(contacts.header.adminMenu).toBeHidden();
+
+ await contacts.openContactViewDirect(testUsers.member.username);
+ await expect(contacts.contactViewHeading).toBeVisible();
+ await expect(contacts.attendedEventsHeading).toBeVisible();
+ await expect(contacts.hostedEventsHeading).toBeVisible();
+
+ await contacts.navigateViaUrl();
+ await contacts.expectIndexAccessDenied();
+ });
+
+ test('financial admin can view contacts but not open contacts index', async ({ page }) => {
+ const contacts = new ContactsPage(page);
+
+ await loginAs(
+ page,
+ testUsers.financialAdmin.username,
+ testUsers.financialAdmin.password,
+ );
+ await expect(contacts.header.financialsMenu).toBeVisible();
+ await expect(contacts.header.adminMenu).toBeHidden();
+
+ await contacts.openContactViewDirect(testUsers.member.username);
+ await expect(contacts.contactViewHeading).toBeVisible();
+ await expect(contacts.attendedEventsHeading).toBeVisible();
+ await expect(contacts.hostedEventsHeading).toBeVisible();
+
+ await contacts.navigateViaUrl();
+ await contacts.expectIndexAccessDenied();
+ });
+});
diff --git a/e2e/tests/data/reference-data.ts b/e2e/tests/data/reference-data.ts
new file mode 100644
index 0000000..99d3fd4
--- /dev/null
+++ b/e2e/tests/data/reference-data.ts
@@ -0,0 +1,8 @@
+/** Shared reference-data values used by more than one spec. */
+export const referenceData = {
+ configuration: {
+ minimumBookingLeadTime: 'Minimum Booking Lead Time',
+ defaultMinimumLeadDays: '2',
+ testMinimumLeadDays: '3',
+ },
+} as const;
diff --git a/e2e/tests/data/test-users.ts b/e2e/tests/data/test-users.ts
index 4f05645..d0d62fd 100644
--- a/e2e/tests/data/test-users.ts
+++ b/e2e/tests/data/test-users.ts
@@ -1,5 +1,10 @@
/** OpenLDAP seed users from dms-ad-openldap/03-users.ldif */
export const testUsers = {
member: { username: 'user1', password: 'password' },
- admin: { username: 'user2', password: 'password' },
+ memberCommittee: { username: 'user3', password: 'password' },
+ admin: { username: 'user2', password: 'password', email: 'user2@dms.local' },
+ honorariumAdmin: { username: 'honorariumadmin', password: 'password' },
+ financialAdmin: { username: 'financialadmin', password: 'password' },
+ disabled: { username: 'disableduser', password: 'password' },
+ unknown: { username: 'nonexistent', password: 'password' },
} as const;
diff --git a/e2e/tests/events-admin.spec.ts b/e2e/tests/events-admin.spec.ts
new file mode 100644
index 0000000..05624f4
--- /dev/null
+++ b/e2e/tests/events-admin.spec.ts
@@ -0,0 +1,180 @@
+import { test, expect } from '@playwright/test';
+
+import { loginAsAdmin, loginAsMember } from './helpers/admin-session';
+import {
+ approveEvent,
+ cancelEvent,
+ createApprovedEvent,
+ createPendingEvent,
+ rejectEvent,
+} from './helpers/event-workflow';
+import { EventsAddPage } from './pages/events-add.page';
+import { EventsEditPage } from './pages/events-edit.page';
+import { EventsViewPage } from './pages/events-view.page';
+import { PendingEventsPage } from './pages/pending-events.page';
+import { PendingHonorariaPage } from './pages/pending-honoraria.page';
+import { ProcessRejectionPage } from './pages/process-rejection.page';
+
+test.describe('Event approval and administration', () => {
+ test('admin approves a pending free class event', async ({ page }) => {
+ const eventTitle = `E2E Approve ${Date.now()}`;
+ const pending = new PendingEventsPage(page);
+ const eventsView = new EventsViewPage(page);
+
+ await loginAsMember(page);
+ await createPendingEvent(page, { title: eventTitle, freeSpaces: 10 });
+
+ const eventId = await approveEvent(page, eventTitle);
+ await pending.navigateViaMenu();
+ await expect(pending.row(eventTitle)).toBeHidden();
+
+ await eventsView.navigateViaUrl(eventId);
+ await eventsView.expectTitle(eventTitle);
+
+ await loginAsAdmin(page);
+ await cancelEvent(page, eventId);
+ });
+
+ test('admin rejects a pending event with a reason', async ({ page }) => {
+ const eventTitle = `E2E Reject ${Date.now()}`;
+ const pending = new PendingEventsPage(page);
+
+ await loginAsMember(page);
+ await createPendingEvent(page, { title: eventTitle, freeSpaces: 5 });
+
+ await loginAsAdmin(page);
+ await pending.navigateViaMenu();
+ const eventId = await pending.getEventId(eventTitle);
+ await rejectEvent(page, eventTitle, 'E2E test rejection reason');
+
+ await pending.navigateViaMenu();
+ await expect(pending.row(eventTitle)).toBeHidden();
+
+ const eventsView = new EventsViewPage(page);
+ await eventsView.navigateViaUrl(eventId);
+ await eventsView.expectCancelledBanner();
+ });
+
+ test('reject flow uses process-rejection form', async ({ page }) => {
+ const eventTitle = `E2E Process Rejection ${Date.now()}`;
+ const pending = new PendingEventsPage(page);
+ const processRejection = new ProcessRejectionPage(page);
+
+ await loginAsMember(page);
+ await createPendingEvent(page, { title: eventTitle, freeSpaces: 5 });
+
+ await loginAsAdmin(page);
+ await pending.navigateViaMenu();
+ await pending.openRejectForm(eventTitle);
+ await processRejection.expectVisible();
+ await processRejection.rejectWithReason('Not suitable for calendar');
+ });
+
+ test('honorarium pending events appear in honoraria queue', async ({ page }) => {
+ const eventTitle = `E2E Honorarium ${Date.now()}`;
+ const honorariaPending = new PendingHonorariaPage(page);
+
+ await loginAsMember(page);
+ await createPendingEvent(page, {
+ title: eventTitle,
+ freeSpaces: 10,
+ requestHonorarium: true,
+ committee: 'Creative Arts',
+ startDaysFromNow: 12,
+ });
+
+ await loginAsAdmin(page);
+ await honorariaPending.navigateViaMenu();
+ await expect(honorariaPending.row(eventTitle)).toBeVisible();
+ await honorariaPending.approveEvent(eventTitle);
+
+ const pending = new PendingEventsPage(page);
+ await pending.navigateViaMenu();
+ await expect(pending.row(eventTitle)).toBeHidden();
+ });
+
+ test('owner can edit short description on approved event', async ({ page }) => {
+ const eventTitle = `E2E Owner Edit ${Date.now()}`;
+ const updatedDescription = 'Updated short description from E2E';
+ const eventsView = new EventsViewPage(page);
+ const eventsEdit = new EventsEditPage(page);
+
+ const eventId = await createApprovedEvent(page, { title: eventTitle, freeSpaces: 10 });
+
+ await loginAsMember(page);
+ await eventsView.navigateViaUrl(eventId);
+ await eventsView.openEdit();
+ await eventsEdit.updateShortDescription(updatedDescription);
+ await eventsEdit.expectSuccessFlash();
+
+ await eventsView.navigateViaUrl(eventId);
+ await eventsView.expectShortDescription(updatedDescription);
+
+ await loginAsAdmin(page);
+ await cancelEvent(page, eventId);
+ });
+
+ test('admin can edit schedule fields the owner cannot', async ({ page }) => {
+ const eventTitle = `E2E Admin Edit ${Date.now()}`;
+ const eventsView = new EventsViewPage(page);
+ const eventsEdit = new EventsEditPage(page);
+
+ const eventId = await createApprovedEvent(page, { title: eventTitle, freeSpaces: 10 });
+
+ await loginAsMember(page);
+ await eventsEdit.navigateViaUrl(eventId);
+ await eventsEdit.expectEventStartReadOnly();
+
+ await loginAsAdmin(page);
+ await eventsEdit.navigateViaUrl(eventId);
+ await eventsEdit.expectEventStartEditable();
+ await eventsEdit.updateSchedule(10);
+
+ await cancelEvent(page, eventId);
+ });
+
+ test('member can copy an owned approved event', async ({ page }) => {
+ const eventTitle = `E2E Copy Source ${Date.now()}`;
+ const copyTitle = `E2E Copy Target ${Date.now()}`;
+ const eventsView = new EventsViewPage(page);
+ const eventsAdd = new EventsAddPage(page);
+
+ const eventId = await createApprovedEvent(page, {
+ title: eventTitle,
+ freeSpaces: 10,
+ category: 'Fiber Arts',
+ });
+
+ await loginAsMember(page);
+ await eventsView.navigateViaUrl(eventId);
+ await eventsView.openCopy();
+ await eventsAdd.expectPrefilledTitle(eventTitle);
+ await eventsAdd.expectEmptyDates();
+ await eventsAdd.fillEventForm({ title: copyTitle, startDaysFromNow: 8, freeSpaces: 10 });
+ await eventsAdd.submit();
+ await eventsAdd.expectSuccessFlash();
+
+ await loginAsAdmin(page);
+ await cancelEvent(page, eventId);
+ const copyId = await approveEvent(page, copyTitle);
+ await cancelEvent(page, copyId);
+ });
+
+ test('rejected events hide the edit button for the owner', async ({ page }) => {
+ const eventTitle = `E2E Rejected Edit ${Date.now()}`;
+ const eventsView = new EventsViewPage(page);
+ const pending = new PendingEventsPage(page);
+
+ await loginAsMember(page);
+ await createPendingEvent(page, { title: eventTitle, freeSpaces: 5 });
+
+ await loginAsAdmin(page);
+ await pending.navigateViaMenu();
+ const eventId = await pending.getEventId(eventTitle);
+ await rejectEvent(page, eventTitle, 'E2E rejection');
+
+ await loginAsMember(page);
+ await eventsView.navigateViaUrl(eventId);
+ await eventsView.expectEditEventHidden();
+ });
+});
diff --git a/e2e/tests/events-create.spec.ts b/e2e/tests/events-create.spec.ts
new file mode 100644
index 0000000..11ca3db
--- /dev/null
+++ b/e2e/tests/events-create.spec.ts
@@ -0,0 +1,135 @@
+import { test, expect } from '@playwright/test';
+
+import { testUsers } from './data/test-users';
+import { loginAsAdmin, loginAsMember } from './helpers/admin-session';
+import { approveEvent, cancelEvent, createPendingEvent } from './helpers/event-workflow';
+import { eventStartEnd } from './helpers/dates';
+import { ContactsPage } from './pages/contacts.page';
+import { EventsAddPage } from './pages/events-add.page';
+import { MemberEventsPage } from './pages/member-events.page';
+
+test.describe('Event creation', () => {
+ test('blacklisted member cannot access the submit event form', async ({ page }) => {
+ const contacts = new ContactsPage(page);
+ const eventsAdd = new EventsAddPage(page);
+
+ await loginAsAdmin(page);
+ await contacts.openEditForAdUsername(testUsers.member.username);
+ await contacts.setBlacklisted(true);
+
+ await loginAsMember(page);
+ await eventsAdd.navigateViaMenu();
+ await eventsAdd.expectBlacklistedAlert();
+
+ await loginAsAdmin(page);
+ await contacts.openEditForAdUsername(testUsers.member.username);
+ await contacts.setBlacklisted(false);
+ });
+
+ test('non-sponsored submit links event to the logged-in member contact', async ({ page }) => {
+ const eventTitle = `E2E Non-Sponsored ${Date.now()}`;
+ const eventsAdd = new EventsAddPage(page);
+ const memberEvents = new MemberEventsPage(page);
+
+ await loginAsMember(page);
+ await createPendingEvent(page, {
+ title: eventTitle,
+ room: 'Common Area',
+ category: 'Fiber Arts',
+ freeSpaces: 10,
+ });
+
+ await memberEvents.navigateToHostingViaMenu();
+ await memberEvents.expectHostedEvent(eventTitle, 'pending');
+
+ const eventId = await approveEvent(page, eventTitle);
+ await loginAsMember(page);
+ await memberEvents.navigateToHostingViaMenu();
+ await memberEvents.expectHostedEvent(eventTitle, 'approved');
+
+ await loginAsAdmin(page);
+ await cancelEvent(page, eventId);
+ });
+
+ test('member submits a free class event', async ({ page }) => {
+ const eventTitle = `E2E Free Class ${Date.now()}`;
+ const memberEvents = new MemberEventsPage(page);
+
+ await loginAsMember(page);
+ await createPendingEvent(page, {
+ title: eventTitle,
+ room: 'Common Area',
+ category: 'Fiber Arts',
+ freeSpaces: 10,
+ });
+ await memberEvents.navigateToHostingViaMenu();
+ await memberEvents.expectHostedEvent(eventTitle, 'pending');
+
+ const eventId = await approveEvent(page, eventTitle);
+ await loginAsAdmin(page);
+ await cancelEvent(page, eventId);
+ });
+
+ test('validation rejects start date before minimum lead time', async ({ page }) => {
+ const eventsAdd = new EventsAddPage(page);
+ const { start, end } = eventStartEnd(1, 2);
+
+ await loginAsMember(page);
+ await eventsAdd.navigateViaMenu();
+ await eventsAdd.selectClassType();
+ await eventsAdd.fillEventForm({
+ title: `E2E Start Too Soon ${Date.now()}`,
+ startDaysFromNow: 5,
+ freeSpaces: 5,
+ });
+ await eventsAdd.fillDates(start, end);
+ await eventsAdd.submit();
+ await eventsAdd.expectFieldError(/scheduled at least \d+ days in advance/);
+ });
+
+ test('validation rejects end date before start date', async ({ page }) => {
+ const eventsAdd = new EventsAddPage(page);
+ const { start } = eventStartEnd(5, 2);
+ const { end: invalidEnd } = eventStartEnd(3, 2);
+
+ await loginAsMember(page);
+ await eventsAdd.navigateViaMenu();
+ await eventsAdd.selectClassType();
+ await eventsAdd.fillEventForm({
+ title: `E2E End Before Start ${Date.now()}`,
+ startDaysFromNow: 5,
+ });
+ await eventsAdd.fillDates(start, invalidEnd);
+ await eventsAdd.submit();
+ await eventsAdd.expectFieldError(/can not end before it starts/);
+ });
+
+ test('validation rejects events scheduled beyond maximum lead time', async ({ page }) => {
+ const eventsAdd = new EventsAddPage(page);
+ const { start, end } = eventStartEnd(200, 2);
+
+ await loginAsMember(page);
+ await eventsAdd.navigateViaMenu();
+ await eventsAdd.fillEventForm({
+ title: `E2E Max Lead ${Date.now()}`,
+ startDaysFromNow: 5,
+ freeSpaces: 5,
+ });
+ await eventsAdd.fillDates(start, end);
+ await eventsAdd.submit();
+ await eventsAdd.expectFieldError(/can only be scheduled \d+ days in advance/);
+ });
+
+ test('requires prerequisite automatically enables members only', async ({ page }) => {
+ const eventsAdd = new EventsAddPage(page);
+
+ await loginAsMember(page);
+ await eventsAdd.navigateViaMenu();
+ await eventsAdd.fillEventForm({
+ title: `E2E Prereq Members Only ${Date.now()}`,
+ requiresPrerequisite: '3D Printer Basics',
+ freeSpaces: 5,
+ });
+ await eventsAdd.expectMembersOnlyChecked(true);
+ });
+});
diff --git a/e2e/tests/events-scheduling.spec.ts b/e2e/tests/events-scheduling.spec.ts
new file mode 100644
index 0000000..f504537
--- /dev/null
+++ b/e2e/tests/events-scheduling.spec.ts
@@ -0,0 +1,358 @@
+import { test, expect } from '@playwright/test';
+
+import { loginAsAdmin, loginAsMember } from './helpers/admin-session';
+import {
+ chicagoYmd,
+ formatChicagoCancellationDeadline,
+ formatChicagoViewEndTime,
+ formatChicagoViewTime,
+ scheduleSession,
+ type ScheduleSession,
+} from './helpers/dates';
+import { cancelEvent, createApprovedEvent } from './helpers/event-workflow';
+import { EventsAddPage } from './pages/events-add.page';
+import { EventsEditPage } from './pages/events-edit.page';
+import { EventsViewPage } from './pages/events-view.page';
+
+const cancellationDays = 3;
+const createdEventIds: number[] = [];
+
+function uniqueBaseDay() {
+ return 14 + (Date.now() % 25);
+}
+
+async function trackApprovedEvent(
+ page: import('@playwright/test').Page,
+ options: Parameters[1],
+) {
+ const eventId = await createApprovedEvent(page, options);
+ createdEventIds.push(eventId);
+ return eventId;
+}
+
+test.describe('Event scheduling and capacity on the event page', () => {
+ test.afterEach(async ({ page }) => {
+ while (createdEventIds.length > 0) {
+ const eventId = createdEventIds.pop()!;
+ try {
+ await loginAsAdmin(page);
+ await cancelEvent(page, eventId);
+ } catch {
+ // Best-effort cleanup so later tests are not blocked by leftover bookings.
+ }
+ }
+ });
+
+ test('same-day event spans multiple hours on one calendar day', async ({ page }) => {
+ const baseDay = uniqueBaseDay();
+ const title = `E2E Same Day Workshop ${Date.now()}`;
+ const schedule: ScheduleSession = { daysFromNow: baseDay, startHour: 10, durationHours: 7 };
+ const primary = scheduleSession(schedule);
+ const eventsView = new EventsViewPage(page);
+
+ const eventId = await trackApprovedEvent(page, {
+ title,
+ shortDescription: 'All-day workshop on a single day',
+ schedule,
+ room: 'Common Area',
+ freeSpaces: 20,
+ });
+
+ await eventsView.navigateViaUrl(eventId);
+ await eventsView.expectTitle(title);
+ await eventsView.expectWhenSectionContains(formatChicagoViewTime(primary.startDate));
+ await eventsView.expectWhenSectionContains(
+ formatChicagoViewEndTime(primary.startDate, primary.endDate),
+ );
+ await eventsView.expectWhenSectionLineCount(1);
+ await eventsView.expectRegisterButtonVisible();
+ await eventsView.expectSpaceCountAvailable(20, 20);
+ });
+
+ test('48-hour hackathon spans from one day to the next on the event page', async ({ page }) => {
+ const baseDay = uniqueBaseDay();
+ const title = `E2E 48 Hour Hackathon ${Date.now()}`;
+ const schedule: ScheduleSession = { daysFromNow: baseDay, startHour: 18, durationHours: 48 };
+ const primary = scheduleSession(schedule);
+ const eventsView = new EventsViewPage(page);
+
+ const eventId = await trackApprovedEvent(page, {
+ title,
+ shortDescription: 'Continuous 48-hour hackathon across two calendar days',
+ schedule,
+ primaryType: 'Event',
+ room: 'Back Parking Lot',
+ freeSpaces: 100,
+ });
+
+ await eventsView.navigateViaUrl(eventId);
+ await eventsView.expectTitle(title);
+ await eventsView.expectWhenSectionContains(formatChicagoViewTime(primary.startDate));
+ await eventsView.expectWhenSectionContains(formatChicagoViewTime(primary.endDate));
+ expect(chicagoYmd(primary.startDate)).not.toBe(chicagoYmd(primary.endDate));
+ await eventsView.expectWhenSectionLineCount(1);
+ await eventsView.expectRegisterButtonVisible();
+ });
+
+ test('multipart wood skills training lists every session on the event page', async ({ page }) => {
+ const baseDay = uniqueBaseDay();
+ const title = `E2E Wood Skills Training ${Date.now()}`;
+ const schedule: ScheduleSession = { daysFromNow: baseDay, startHour: 19, durationHours: 2 };
+ const continuedSessions: ScheduleSession[] = [
+ { daysFromNow: baseDay + 2, startHour: 19, durationHours: 2 },
+ { daysFromNow: baseDay + 7, startHour: 10, durationHours: 2 },
+ { daysFromNow: baseDay + 8, startHour: 10, durationHours: 2 },
+ ];
+ const eventsView = new EventsViewPage(page);
+
+ const eventId = await trackApprovedEvent(page, {
+ title,
+ shortDescription: 'Evening and weekend wood skills sessions in one registration',
+ schedule,
+ continuedSessions,
+ multipart: true,
+ room: 'Common Area',
+ category: 'Woodshop',
+ tool: 'Table Saw',
+ freeSpaces: 12,
+ });
+
+ await eventsView.navigateViaUrl(eventId);
+ await eventsView.expectTitle(title);
+ await eventsView.expectWhenSectionLineCount(4);
+ for (const session of [schedule, ...continuedSessions]) {
+ const dates = scheduleSession(session);
+ await eventsView.expectWhenSectionContains(formatChicagoViewTime(dates.startDate));
+ await eventsView.expectWhenSectionContains(
+ formatChicagoViewEndTime(dates.startDate, dates.endDate),
+ );
+ }
+ await eventsView.expectRegisterButtonVisible();
+ });
+
+ test('setup and teardown extend room booking for single-day and multiday events', async ({ page }) => {
+ const baseDay = uniqueBaseDay();
+ const sameDayTitle = `E2E Setup Same Day ${Date.now()}`;
+ const hackathonTitle = `E2E Setup Hackathon ${Date.now()}`;
+ const sameDaySchedule: ScheduleSession = { daysFromNow: baseDay, startHour: 11, durationHours: 4 };
+ const hackathonSchedule: ScheduleSession = {
+ daysFromNow: baseDay + 10,
+ startHour: 18,
+ durationHours: 48,
+ };
+ const eventsEdit = new EventsEditPage(page);
+
+ const sameDayId = await trackApprovedEvent(page, {
+ title: sameDayTitle,
+ schedule: sameDaySchedule,
+ room: 'Purple Classroom',
+ setupMinutes: 30,
+ teardownMinutes: 30,
+ freeSpaces: 15,
+ });
+
+ await loginAsAdmin(page);
+ await eventsEdit.navigateViaUrl(sameDayId);
+ await eventsEdit.expectSetupMinutes(30);
+ await eventsEdit.expectTeardownMinutes(30);
+
+ const hackathonId = await trackApprovedEvent(page, {
+ title: hackathonTitle,
+ schedule: hackathonSchedule,
+ room: 'Back Parking Lot',
+ setupMinutes: 30,
+ teardownMinutes: 30,
+ freeSpaces: 50,
+ });
+
+ await loginAsAdmin(page);
+ await eventsEdit.navigateViaUrl(hackathonId);
+ await eventsEdit.expectSetupMinutes(30);
+ await eventsEdit.expectTeardownMinutes(30);
+ });
+
+ test('multipart setup and teardown reserve the room and tools for each session', async ({
+ page,
+ }) => {
+ const baseDay = uniqueBaseDay() + 20;
+ const title = `E2E Multipart Booking ${Date.now()}`;
+ const schedule: ScheduleSession = { daysFromNow: baseDay, startHour: 9, durationHours: 2 };
+ const secondSession: ScheduleSession = { daysFromNow: baseDay + 3, startHour: 9, durationHours: 2 };
+ const continuedSessions = [secondSession];
+ const secondDates = scheduleSession(secondSession);
+ const eventsEdit = new EventsEditPage(page);
+ const eventsAdd = new EventsAddPage(page);
+
+ const eventId = await trackApprovedEvent(page, {
+ title,
+ schedule,
+ continuedSessions,
+ multipart: true,
+ room: 'Interactive Classroom',
+ tool: 'Leather Sewing Machine',
+ setupMinutes: 30,
+ teardownMinutes: 30,
+ freeSpaces: 8,
+ });
+
+ await loginAsAdmin(page);
+ await eventsEdit.navigateViaUrl(eventId);
+ await eventsEdit.expectSetupMinutes(30);
+ await eventsEdit.expectContinuedDateStart(2, secondDates.start);
+
+ await loginAsMember(page);
+ await eventsAdd.navigateViaUrl();
+ await eventsAdd.fillEventForm({
+ title: `E2E Room Conflict ${Date.now()}`,
+ schedule: secondSession,
+ room: 'Interactive Classroom',
+ setupMinutes: 30,
+ teardownMinutes: 30,
+ freeSpaces: 5,
+ });
+ await eventsAdd.submit();
+ await eventsAdd.expectFieldError(/not available at the requested time/i);
+ await expect(page.getByRole('link', { name: title })).toBeVisible();
+
+ await eventsAdd.navigateViaUrl();
+ await eventsAdd.fillEventForm({
+ title: `E2E Tool Conflict ${Date.now()}`,
+ schedule: secondSession,
+ room: 'Galley',
+ tool: 'Leather Sewing Machine',
+ setupMinutes: 30,
+ teardownMinutes: 30,
+ freeSpaces: 5,
+ });
+ await eventsAdd.submit();
+ await eventsAdd.expectFieldError(/tools selected for this event are not available/i);
+ await expect(
+ page.locator('.help-block').filter({ hasText: /tools selected for this event are not available/i }),
+ ).toContainText('Leather Sewing Machine');
+ });
+
+ test('cancellation window is based on the first session start for all schedule shapes', async ({
+ page,
+ }) => {
+ const baseDay = uniqueBaseDay() + 30;
+ const eventsView = new EventsViewPage(page);
+
+ const scenarios = [
+ {
+ title: `E2E Cancel Same Day ${Date.now()}`,
+ schedule: { daysFromNow: baseDay, startHour: 10, durationHours: 8 } satisfies ScheduleSession,
+ },
+ {
+ title: `E2E Cancel Hackathon ${Date.now()}`,
+ schedule: { daysFromNow: baseDay + 1, startHour: 18, durationHours: 48 } satisfies ScheduleSession,
+ },
+ {
+ title: `E2E Cancel Multipart ${Date.now()}`,
+ schedule: { daysFromNow: baseDay + 2, startHour: 19, durationHours: 2 } satisfies ScheduleSession,
+ continuedSessions: [
+ { daysFromNow: baseDay + 4, startHour: 19, durationHours: 2 },
+ { daysFromNow: baseDay + 9, startHour: 10, durationHours: 2 },
+ ] as ScheduleSession[],
+ multipart: true,
+ },
+ ];
+
+ for (const scenario of scenarios) {
+ const primary = scheduleSession(scenario.schedule);
+ const wrongSession = scenario.continuedSessions?.[0]
+ ? scheduleSession(scenario.continuedSessions[0])
+ : null;
+ const expectedDeadline = formatChicagoCancellationDeadline(
+ primary.startDate,
+ cancellationDays,
+ );
+
+ const eventId = await trackApprovedEvent(page, {
+ title: scenario.title,
+ schedule: scenario.schedule,
+ continuedSessions: scenario.continuedSessions,
+ multipart: scenario.multipart,
+ cancellationDays,
+ room: 'Common Area',
+ freeSpaces: 10,
+ });
+
+ await eventsView.navigateViaUrl(eventId);
+ await eventsView.expectCancellationDeadline(expectedDeadline);
+ if (wrongSession) {
+ const wrongDeadline = formatChicagoCancellationDeadline(
+ wrongSession.startDate,
+ cancellationDays,
+ );
+ await expect(eventsView.cancellationNotice).not.toContainText(wrongDeadline);
+ }
+ }
+ });
+
+ test('zero free and zero paid spaces means open attendance without a capacity counter', async ({
+ page,
+ }) => {
+ const title = `E2E Open House ${Date.now()}`;
+ const eventsView = new EventsViewPage(page);
+
+ const eventId = await trackApprovedEvent(page, {
+ title,
+ shortDescription:
+ 'Open house with no registration cap (0 free / 0 paid spaces — walk-ins welcome)',
+ schedule: { daysFromNow: uniqueBaseDay(), startHour: 12, durationHours: 3 },
+ room: 'Common Area',
+ freeSpaces: 0,
+ paidSpaces: 0,
+ });
+
+ await eventsView.navigateViaUrl(eventId);
+ await eventsView.expectTitle(title);
+ await eventsView.expectCost('Free');
+ await eventsView.expectNoCapacityCountDisplayed();
+ await eventsView.expectRegisterButtonVisible();
+ });
+
+ test('mixed free and paid capacity appears correctly on the event page', async ({ page }) => {
+ const title = `E2E Mixed Capacity ${Date.now()}`;
+ const eventsView = new EventsViewPage(page);
+
+ const eventId = await trackApprovedEvent(page, {
+ title,
+ shortDescription: 'Limited free observer spots plus paid participant seats',
+ schedule: { daysFromNow: uniqueBaseDay(), startHour: 14, durationHours: 2 },
+ room: 'Common Area',
+ paidEventType: 'paid',
+ cost: 10,
+ freeSpaces: 5,
+ paidSpaces: 10,
+ });
+
+ await eventsView.navigateViaUrl(eventId);
+ await eventsView.expectCost('$10.00');
+ await eventsView.expectSpaceCountAvailable(15, 15);
+ await eventsView.expectRegisterButtonVisible();
+ });
+
+ test('free spots with unlimited paid seats shows only the free pool on the event page', async ({
+ page,
+ }) => {
+ const title = `E2E Free Plus Unlimited Paid ${Date.now()}`;
+ const eventsView = new EventsViewPage(page);
+
+ const eventId = await trackApprovedEvent(page, {
+ title,
+ shortDescription: 'Capped free observers with unlimited paid participant slots (paid_spaces=0)',
+ schedule: { daysFromNow: uniqueBaseDay(), startHour: 16, durationHours: 2 },
+ room: 'Common Area',
+ paidEventType: 'paid',
+ cost: 5,
+ freeSpaces: 5,
+ paidSpaces: 0,
+ });
+
+ await eventsView.navigateViaUrl(eventId);
+ await eventsView.expectCost('$5.00');
+ await eventsView.expectSpaceCountAvailable(5, 5);
+ await eventsView.expectRegisterButtonVisible();
+ });
+});
diff --git a/e2e/tests/helpers/admin-session.ts b/e2e/tests/helpers/admin-session.ts
new file mode 100644
index 0000000..d403c28
--- /dev/null
+++ b/e2e/tests/helpers/admin-session.ts
@@ -0,0 +1,34 @@
+import { expect, type Page } from '@playwright/test';
+
+import { testUsers } from '../data/test-users';
+import { EventsIndexPage } from '../pages/events-index.page';
+import { LoginPage } from '../pages/login.page';
+
+export async function logout(page: Page): Promise {
+ await page.goto('/');
+ const eventsIndex = new EventsIndexPage(page);
+ if (await eventsIndex.header.myAccountMenu.isVisible()) {
+ await eventsIndex.header.logout();
+ }
+ await eventsIndex.header.expectLoggedOut();
+}
+
+export async function loginAs(
+ page: Page,
+ username: string,
+ password: string,
+): Promise {
+ const loginPage = new LoginPage(page);
+ await loginPage.navigateViaUrl();
+ const eventsIndex = await loginPage.loginAsMember(username, password);
+ await expect(eventsIndex.heading).toBeVisible();
+ return eventsIndex;
+}
+
+export async function loginAsAdmin(page: Page): Promise {
+ return loginAs(page, testUsers.admin.username, testUsers.admin.password);
+}
+
+export async function loginAsMember(page: Page): Promise {
+ return loginAs(page, testUsers.member.username, testUsers.member.password);
+}
diff --git a/e2e/tests/helpers/dates.ts b/e2e/tests/helpers/dates.ts
new file mode 100644
index 0000000..096e461
--- /dev/null
+++ b/e2e/tests/helpers/dates.ts
@@ -0,0 +1,165 @@
+function localIsoDate(date = new Date()): string {
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, '0');
+ const day = String(date.getDate()).padStart(2, '0');
+ return `${year}-${month}-${day}`;
+}
+
+/** Format for CakePHP datetimepicker fields (mm/dd/yyyy hh:MM tt). */
+function formatEventDateTime(date: Date): string {
+ const month = String(date.getMonth() + 1).padStart(2, '0');
+ const day = String(date.getDate()).padStart(2, '0');
+ const year = date.getFullYear();
+ let hours = date.getHours();
+ const minutes = String(date.getMinutes()).padStart(2, '0');
+ const ampm = hours >= 12 ? 'PM' : 'AM';
+ hours = hours % 12 || 12;
+ return `${month}/${day}/${year} ${hours}:${minutes} ${ampm}`;
+}
+
+function daysFromNow(days: number, hour = 10, minute = 0): Date {
+ const date = new Date();
+ date.setDate(date.getDate() + days);
+ date.setHours(hour, minute, 0, 0);
+ return date;
+}
+
+function eventStartEnd(
+ daysFromToday: number,
+ durationHours = 2,
+ hour?: number,
+ minute?: number,
+): {
+ start: string;
+ end: string;
+ startDate: Date;
+ endDate: Date;
+} {
+ const uniqueHour = hour ?? 8 + (new Date().getSeconds() % 10);
+ const uniqueMinute = minute ?? new Date().getMinutes() % 60;
+ const startDate = daysFromNow(daysFromToday, uniqueHour, uniqueMinute);
+ const endDate = new Date(startDate.getTime() + durationHours * 60 * 60 * 1000);
+ return {
+ start: formatEventDateTime(startDate),
+ end: formatEventDateTime(endDate),
+ startDate,
+ endDate,
+ };
+}
+
+export type ScheduleSession = {
+ daysFromNow: number;
+ startHour: number;
+ startMinute?: number;
+ durationHours: number;
+};
+
+function scheduleSession(session: ScheduleSession) {
+ return eventStartEnd(
+ session.daysFromNow,
+ session.durationHours,
+ session.startHour,
+ session.startMinute ?? 0,
+ );
+}
+
+function chicagoYmd(date: Date): string {
+ return new Intl.DateTimeFormat('en-CA', {
+ timeZone: 'America/Chicago',
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ }).format(date);
+}
+
+/** Matches public event view When column (`E MMM d h:mma` in America/Chicago, `:00` stripped). */
+function formatChicagoViewTime(date: Date): string {
+ const formatted = new Intl.DateTimeFormat('en-US', {
+ timeZone: 'America/Chicago',
+ weekday: 'short',
+ month: 'short',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ hour12: true,
+ }).format(date);
+ return formatted.replace(/,/g, '').replace(':00', '').replace(/\sAM/g, 'am').replace(/\sPM/g, 'pm');
+}
+
+function formatChicagoViewTimeOnly(date: Date): string {
+ const formatted = new Intl.DateTimeFormat('en-US', {
+ timeZone: 'America/Chicago',
+ hour: 'numeric',
+ minute: '2-digit',
+ hour12: true,
+ }).format(date);
+ return formatted.replace(':00', '').replace(/\sAM/g, 'am').replace(/\sPM/g, 'pm');
+}
+
+/** End time on the event view: time-only when same Chicago calendar day as start. */
+function formatChicagoViewEndTime(startDate: Date, endDate: Date): string {
+ if (chicagoYmd(startDate) === chicagoYmd(endDate)) {
+ return formatChicagoViewTimeOnly(endDate);
+ }
+ return formatChicagoViewTime(endDate);
+}
+
+/** Matches event view cancellation copy (`MMMM d, y — h:mma` in America/Chicago). */
+function formatChicagoCancellationDeadline(startDate: Date, cancellationDays: number): string {
+ const cutoff = new Date(startDate);
+ cutoff.setDate(cutoff.getDate() - cancellationDays);
+ const parts = new Intl.DateTimeFormat('en-US', {
+ timeZone: 'America/Chicago',
+ month: 'long',
+ day: 'numeric',
+ year: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ hour12: true,
+ }).formatToParts(cutoff);
+ const pick = (type: Intl.DateTimeFormatPartTypes) =>
+ parts.find((part) => part.type === type)?.value ?? '';
+ const hour = pick('hour');
+ const minute = pick('minute');
+ const dayPeriod = pick('dayPeriod').toLowerCase();
+ return `${pick('month')} ${pick('day')}, ${pick('year')} — ${hour}:${minute}${dayPeriod}`;
+}
+
+/** Matches owner edit booking display (`MMMM d, y - h:mma` in America/Chicago). */
+function formatChicagoBookingTime(date: Date): string {
+ const parts = new Intl.DateTimeFormat('en-US', {
+ timeZone: 'America/Chicago',
+ month: 'long',
+ day: 'numeric',
+ year: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ hour12: true,
+ }).formatToParts(date);
+ const pick = (type: Intl.DateTimeFormatPartTypes) =>
+ parts.find((part) => part.type === type)?.value ?? '';
+ const hour = pick('hour');
+ const minute = pick('minute');
+ const dayPeriod = pick('dayPeriod').toLowerCase();
+ const minuteSuffix = minute === '00' ? '' : `:${minute}`;
+ return `${pick('month')} ${pick('day')}, ${pick('year')} - ${hour}${minuteSuffix}${dayPeriod}`;
+}
+
+function addMinutes(date: Date, minutes: number): Date {
+ return new Date(date.getTime() + minutes * 60 * 1000);
+}
+
+export {
+ addMinutes,
+ chicagoYmd,
+ daysFromNow,
+ eventStartEnd,
+ formatChicagoBookingTime,
+ formatChicagoCancellationDeadline,
+ formatChicagoViewEndTime,
+ formatChicagoViewTime,
+ formatChicagoViewTimeOnly,
+ formatEventDateTime,
+ localIsoDate,
+ scheduleSession,
+};
diff --git a/e2e/tests/helpers/event-workflow.ts b/e2e/tests/helpers/event-workflow.ts
new file mode 100644
index 0000000..1c28728
--- /dev/null
+++ b/e2e/tests/helpers/event-workflow.ts
@@ -0,0 +1,45 @@
+import type { Page } from '@playwright/test';
+
+import { loginAsAdmin, loginAsMember } from './admin-session';
+import { EventsAddPage, type EventFormOptions } from '../pages/events-add.page';
+import { EventsEditPage } from '../pages/events-edit.page';
+import { PendingEventsPage } from '../pages/pending-events.page';
+
+export async function createPendingEvent(page: Page, options: EventFormOptions) {
+ const eventsAdd = new EventsAddPage(page);
+ await eventsAdd.navigateViaMenu();
+ await eventsAdd.fillAndSubmit(options);
+ await eventsAdd.expectSuccessFlash();
+}
+
+export async function approveEvent(page: Page, eventName: string): Promise {
+ await loginAsAdmin(page);
+ const pending = new PendingEventsPage(page);
+ await pending.navigateViaMenu();
+ const eventId = await pending.getEventId(eventName);
+ await pending.approveEvent(eventName);
+ return eventId;
+}
+
+export async function cancelEvent(page: Page, eventId: number) {
+ const edit = new EventsEditPage(page);
+ await edit.navigateViaUrl(eventId);
+ await edit.cancelEvent();
+}
+
+export async function createApprovedEvent(
+ page: Page,
+ options: EventFormOptions,
+): Promise {
+ await loginAsMember(page);
+ await createPendingEvent(page, options);
+ return approveEvent(page, options.title);
+}
+
+export async function rejectEvent(page: Page, eventName: string, reason: string) {
+ await loginAsAdmin(page);
+ const pending = new PendingEventsPage(page);
+ await pending.navigateViaMenu();
+ const rejection = await pending.openRejectForm(eventName);
+ await rejection.rejectWithReason(reason);
+}
diff --git a/e2e/tests/home.spec.ts b/e2e/tests/home.spec.ts
index 2cc7a8c..0baa39e 100644
--- a/e2e/tests/home.spec.ts
+++ b/e2e/tests/home.spec.ts
@@ -5,6 +5,6 @@ import { EventsIndexPage } from './pages/events-index.page';
test('homepage displays upcoming events', async ({ page }) => {
const eventsIndex = new EventsIndexPage(page);
- await eventsIndex.goto();
+ await eventsIndex.navigateViaUrl();
await expect(eventsIndex.heading).toBeVisible();
});
diff --git a/e2e/tests/host-admin.spec.ts b/e2e/tests/host-admin.spec.ts
new file mode 100644
index 0000000..608fa21
--- /dev/null
+++ b/e2e/tests/host-admin.spec.ts
@@ -0,0 +1,130 @@
+import { test, expect } from '@playwright/test';
+
+import { testUsers } from './data/test-users';
+import { loginAsAdmin, loginAsMember } from './helpers/admin-session';
+import { cancelEvent, createApprovedEvent } from './helpers/event-workflow';
+import { EventsArchivePage } from './pages/events-archive.page';
+import { EventsEditPage } from './pages/events-edit.page';
+import { EventsViewPage } from './pages/events-view.page';
+import { ExportHonorariaPage } from './pages/export-honoraria.page';
+import { HonorariaIndexPage } from './pages/honoraria-index.page';
+import { MemberEventsPage } from './pages/member-events.page';
+import { RegistrationsEventPage } from './pages/registrations-event.page';
+
+test.describe('Host and admin operations', () => {
+ test('member sees hosting and attending event lists', async ({ page }) => {
+ const eventTitle = `E2E Hosting List ${Date.now()}`;
+ const memberEvents = new MemberEventsPage(page);
+
+ await loginAsMember(page);
+ await memberEvents.navigateToHostingViaMenu();
+ await expect(memberEvents.hostingHeading).toBeVisible();
+
+ await memberEvents.navigateToAttendingViaMenu();
+ await expect(memberEvents.attendingHeading).toBeVisible();
+
+ const eventId = await createApprovedEvent(page, { title: eventTitle, freeSpaces: 10 });
+ await loginAsMember(page);
+ await memberEvents.navigateToHostingViaMenu();
+ await memberEvents.expectHostedEvent(eventTitle);
+
+ await loginAsAdmin(page);
+ await cancelEvent(page, eventId);
+ });
+
+ test('admin can browse the events archive', async ({ page }) => {
+ const archive = new EventsArchivePage(page);
+
+ await loginAsAdmin(page);
+ await archive.navigateViaMenu();
+ await archive.expectVisible();
+ });
+
+ test('financial export page loads for admin and denies members', async ({ page }) => {
+ const exportPage = new ExportHonorariaPage(page);
+
+ await loginAsAdmin(page);
+ await exportPage.navigateViaMenu();
+ await expect(exportPage.heading).toBeVisible();
+
+ await loginAsMember(page);
+ await exportPage.navigateViaUrl();
+ await expect(exportPage.heading).toBeHidden();
+ });
+
+ test('standalone honoraria CRUD index is admin-only', async ({ page }) => {
+ const honoraria = new HonorariaIndexPage(page);
+
+ await loginAsAdmin(page);
+ await honoraria.navigateViaUrl();
+ await expect(honoraria.heading).toBeVisible();
+
+ await loginAsMember(page);
+ await honoraria.navigateViaUrl();
+ await honoraria.expectAccessDenied();
+ });
+
+ test('owner can cancel an approved event with registrations', async ({ page }) => {
+ const eventTitle = `E2E Cancel Event ${Date.now()}`;
+ const eventsView = new EventsViewPage(page);
+ const registration = new RegistrationsEventPage(page);
+
+ const eventId = await createApprovedEvent(page, { title: eventTitle, freeSpaces: 10 });
+
+ await eventsView.navigateViaUrl(eventId);
+ await eventsView.registerForEvent();
+ await registration.fillAndSubmit({
+ name: 'Cancel Test Guest',
+ email: `cancel-event-${Date.now()}@test.local`,
+ phone: '555-0110',
+ });
+
+ await loginAsMember(page);
+ const edit = new EventsEditPage(page);
+ await edit.navigateViaUrl(eventId);
+ await edit.cancelEvent();
+
+ await eventsView.navigateViaUrl(eventId);
+ await eventsView.expectCancelledBanner();
+ });
+});
+
+test.describe('Cron endpoint', () => {
+ test('cron URL is reachable without authentication', async ({ page }) => {
+ const response = await page.goto('/events/cron');
+ expect(response?.ok()).toBeTruthy();
+ });
+});
+
+test.describe('Paid registration', () => {
+ test.skip(
+ !process.env.BRAINTREE_MERCHID,
+ 'INFRA: Braintree — set BRAINTREE_MERCHID to run paid registration tests',
+ );
+
+ test('placeholder for Braintree paid registration', async () => {
+ // Implemented when Braintree sandbox credentials are available.
+ });
+});
+
+test.describe('SSO login', () => {
+ test.skip(
+ process.env.OIDC_ENABLED !== 'true',
+ 'INFRA: OIDC — enable Keycloak and set OIDC_ENABLED=true to run SSO tests',
+ );
+
+ test('placeholder for SSO login flow', async () => {
+ // Implemented when OIDC infrastructure is configured.
+ });
+});
+
+test.describe('Email delivery verification', () => {
+ test.skip(
+ !process.env.SPARKPOST_APIKEY && process.env.EMAIL_DEV !== 'true',
+ 'INFRA: SparkPost or Email-dev — configure email transport to verify delivery',
+ );
+
+ test('placeholder for email delivery checklist', async () => {
+ // Implemented when SparkPost or MailHog SMTP patch is active.
+ });
+});
diff --git a/e2e/tests/ldap-login.spec.ts b/e2e/tests/ldap-login.spec.ts
new file mode 100644
index 0000000..ab654eb
--- /dev/null
+++ b/e2e/tests/ldap-login.spec.ts
@@ -0,0 +1,123 @@
+import { test, expect } from '@playwright/test';
+
+import { testUsers } from './data/test-users';
+import { EventsAddPage } from './pages/events-add.page';
+import { EventsIndexPage } from './pages/events-index.page';
+import { LoginPage } from './pages/login.page';
+
+test.describe('LDAP login (admin access)', () => {
+ test('admin login shows admin navigation menus', async ({ page }) => {
+ const loginPage = new LoginPage(page);
+ const eventsIndex = new EventsIndexPage(page);
+
+ await loginPage.navigateViaUrl();
+ await loginPage.header.expectLoggedOut();
+ await loginPage.loginAsMember(testUsers.admin.username, testUsers.admin.password);
+
+ await expect(eventsIndex.heading).toBeVisible();
+ await expect(eventsIndex.header.adminMenu).toBeVisible();
+ await expect(eventsIndex.header.honorariaMenu).toBeVisible();
+ await expect(eventsIndex.header.financialsMenu).toBeVisible();
+ await expect(eventsIndex.header.superCalendarAdminMenu).toBeVisible();
+ });
+
+ test('wrong password shows invalid credentials flash', async ({ page }) => {
+ const loginPage = new LoginPage(page);
+
+ await loginPage.navigateViaUrl();
+ await loginPage.header.expectLoggedOut();
+ await loginPage.submitCredentials(testUsers.admin.username, 'wrong-password');
+
+ await expect(loginPage.heading).toBeVisible();
+ await expect(loginPage.errorMessage).toContainText('Invalid username or password, try again.');
+ await loginPage.header.expectLoggedOut();
+ });
+
+ test('email as username shows DMS username guidance', async ({ page }) => {
+ const loginPage = new LoginPage(page);
+
+ await loginPage.navigateViaUrl();
+ await loginPage.header.expectLoggedOut();
+ await loginPage.submitCredentials(testUsers.admin.email, testUsers.admin.password);
+
+ await expect(loginPage.heading).toBeVisible();
+ await expect(loginPage.errorMessage).toContainText('Invalid username or password, try again.');
+ await expect(loginPage.errorMessage).toContainText(
+ 'Be sure to use your DMS username, NOT your email or Talk username',
+ );
+ await loginPage.header.expectLoggedOut();
+ });
+
+ test('unknown user shows generic invalid credentials flash', async ({ page }) => {
+ const loginPage = new LoginPage(page);
+
+ await loginPage.navigateViaUrl();
+ await loginPage.header.expectLoggedOut();
+ await loginPage.submitCredentials(testUsers.unknown.username, testUsers.unknown.password);
+
+ await expect(loginPage.heading).toBeVisible();
+ await expect(loginPage.errorMessage).toContainText('Invalid username or password, try again.');
+ await loginPage.header.expectLoggedOut();
+ });
+
+ test('disabled account cannot log in', async ({ page }) => {
+ const loginPage = new LoginPage(page);
+
+ await loginPage.navigateViaUrl();
+ await loginPage.header.expectLoggedOut();
+ await loginPage.submitCredentials(testUsers.disabled.username, testUsers.disabled.password);
+
+ await expect(loginPage.heading).toBeVisible();
+ await expect(loginPage.errorMessage).toContainText('Invalid username or password, try again.');
+ await loginPage.header.expectLoggedOut();
+ });
+
+ test('login redirect returns to protected page after authentication', async ({ page }) => {
+ const eventsAdd = new EventsAddPage(page);
+ const loginPage = new LoginPage(page);
+
+ await eventsAdd.navigateViaUrl();
+ await loginPage.expectShowsRedirect('/events/add');
+ await loginPage.header.expectLoggedOut();
+
+ await loginPage.loginAndReturnToEventsAdd(
+ testUsers.admin.username,
+ testUsers.admin.password,
+ );
+
+ await expect(eventsAdd.generalLegend).toBeVisible();
+ await expect(eventsAdd.submitEventButton).toBeVisible();
+ });
+
+ test('logout clears session and hides admin menus', async ({ page }) => {
+ const loginPage = new LoginPage(page);
+ const eventsIndex = new EventsIndexPage(page);
+
+ await loginPage.navigateViaUrl();
+ await loginPage.loginAsMember(testUsers.admin.username, testUsers.admin.password);
+ await expect(eventsIndex.header.adminMenu).toBeVisible();
+
+ await eventsIndex.header.logout();
+
+ await expect(eventsIndex.heading).toBeVisible();
+ await eventsIndex.header.expectLoggedOut();
+ });
+
+ test('logout then DMS Login link opens login form for a new session', async ({ page }) => {
+ const loginPage = new LoginPage(page);
+ const eventsIndex = new EventsIndexPage(page);
+
+ await loginPage.navigateViaUrl();
+ await loginPage.loginAsMember(testUsers.member.username, testUsers.member.password);
+ await expect(eventsIndex.heading).toBeVisible();
+
+ await eventsIndex.header.logout();
+ await eventsIndex.header.expectLoggedOut();
+
+ await loginPage.navigateViaMenu();
+ await expect(loginPage.heading).toBeVisible();
+
+ await loginPage.loginAsMember(testUsers.member.username, testUsers.member.password);
+ await expect(eventsIndex.heading).toBeVisible();
+ });
+});
diff --git a/e2e/tests/login.spec.ts b/e2e/tests/login.spec.ts
index 34b466d..22ea658 100644
--- a/e2e/tests/login.spec.ts
+++ b/e2e/tests/login.spec.ts
@@ -1,6 +1,8 @@
import { test, expect } from '@playwright/test';
import { testUsers } from './data/test-users';
+import { loginAsAdmin, loginAsMember } from './helpers/admin-session';
+import { ContactsPage } from './pages/contacts.page';
import { EventsIndexPage } from './pages/events-index.page';
import { LoginPage } from './pages/login.page';
@@ -8,10 +10,24 @@ test('member can log in with LDAP credentials', async ({ page }) => {
const loginPage = new LoginPage(page);
const eventsIndex = new EventsIndexPage(page);
- await loginPage.goto();
+ await loginPage.navigateViaUrl();
await expect(loginPage.heading).toBeVisible();
await loginPage.loginAsMember(testUsers.member.username, testUsers.member.password);
await expect(eventsIndex.heading).toBeVisible();
+ await expect(eventsIndex.header.adminMenu).toBeHidden();
+ await expect(page.getByRole('link', { name: 'Submit Event' })).toBeVisible();
+});
+
+test('member login provisions a contact record for admin lookup', async ({ page }) => {
+ const loginPage = new LoginPage(page);
+ const contacts = new ContactsPage(page);
+
+ await loginPage.navigateViaUrl();
+ await loginPage.loginAsMember(testUsers.member.username, testUsers.member.password);
+
+ await loginAsAdmin(page);
+ await contacts.navigateViaMenu();
+ await contacts.expectRowVisible(testUsers.member.username);
});
diff --git a/e2e/tests/logs.spec.ts b/e2e/tests/logs.spec.ts
new file mode 100644
index 0000000..c5d2a61
--- /dev/null
+++ b/e2e/tests/logs.spec.ts
@@ -0,0 +1,94 @@
+import { test, expect } from '@playwright/test';
+
+import { localIsoDate } from './helpers/dates';
+import { loginAsAdmin, loginAs } from './helpers/admin-session';
+import { testUsers } from './data/test-users';
+import { CategoriesPage } from './pages/categories.page';
+import { ConfigurationsPage } from './pages/configurations.page';
+import { EventsViewPage } from './pages/events-view.page';
+import { LogsPage } from './pages/logs.page';
+
+test.describe('Audit logs', () => {
+ test('super admin can browse and filter audit logs', async ({ page }) => {
+ const logs = new LogsPage(page);
+ const today = localIsoDate();
+
+ await loginAsAdmin(page);
+ await logs.navigateViaUrl();
+ await expect(logs.heading).toBeVisible();
+ await logs.expectTableColumnsVisible();
+ await logs.expectHasEntries();
+
+ await logs.filter({ startDate: today, endDate: today });
+ await expect(logs.heading).toBeVisible();
+
+ await logs.navigateViaUrl();
+ await logs.filter({ userName: testUsers.admin.username });
+ await expect(logs.rowContaining(testUsers.admin.username).first()).toBeVisible();
+
+ await logs.filter({ searchString: 'Viewed' });
+ await expect(logs.rowContaining('Viewed').first()).toBeVisible();
+ });
+
+ test('member cannot access audit logs', async ({ page }) => {
+ const logs = new LogsPage(page);
+
+ await loginAs(page, testUsers.member.username, testUsers.member.password);
+ await logs.navigateViaUrl();
+ await logs.expectAccessDenied();
+ });
+
+ test('configuration edits are logged but index and view actions are skipped', async ({ page }) => {
+ const logs = new LogsPage(page);
+ const configurations = new ConfigurationsPage(page);
+ const categories = new CategoriesPage(page);
+ const eventsView = new EventsViewPage(page);
+ const configurationName = 'Maximum Booking Lead Time';
+ const defaultMaximumLeadDays = '190';
+ const testMaximumLeadDays = '191';
+ const configEditFilter = {
+ userName: testUsers.admin.username,
+ searchString: 'Viewed /configurations/edit/5',
+ };
+
+ await loginAsAdmin(page);
+
+ await configurations.navigateViaMenu();
+ await configurations.openEdit(configurationName);
+ await configurations.saveValue(defaultMaximumLeadDays);
+
+ const configEditLog = await logs.expectFilteredLogEntryAdded(configEditFilter);
+
+ await configurations.navigateViaMenu();
+ await configurations.openEdit(configurationName);
+ await configurations.saveValue(testMaximumLeadDays);
+
+ await configEditLog.assertNewEntry();
+ const afterEditCount = await configEditLog.countFiltered();
+
+ await categories.navigateViaMenu();
+ const afterCategoriesCount = await logs.countFiltered(configEditFilter);
+ expect(afterCategoriesCount).toBe(afterEditCount);
+
+ await eventsView.navigateViaUrl(1);
+ const afterViewCount = await logs.countFiltered(configEditFilter);
+ expect(afterViewCount).toBe(afterCategoriesCount);
+
+ await configurations.navigateViaMenu();
+ await configurations.openEdit(configurationName);
+ await configurations.saveValue(defaultMaximumLeadDays);
+ });
+
+ test('date filter requires both start and end dates', async ({ page }) => {
+ const logs = new LogsPage(page);
+ const today = localIsoDate();
+
+ await loginAsAdmin(page);
+ await logs.navigateViaUrl();
+ const unfilteredCount = await logs.rowCount();
+
+ await logs.filter({ startDate: today });
+ const singleDateCount = await logs.rowCount();
+ expect(singleDateCount).toBe(unfilteredCount);
+ });
+});
diff --git a/e2e/tests/pages/admin-index.page.ts b/e2e/tests/pages/admin-index.page.ts
new file mode 100644
index 0000000..b2f958a
--- /dev/null
+++ b/e2e/tests/pages/admin-index.page.ts
@@ -0,0 +1,26 @@
+import { expect, type Locator, type Page } from '@playwright/test';
+
+export abstract class AdminIndexPage {
+ constructor(protected readonly page: Page) {}
+
+ row(name: string): Locator {
+ return this.page.getByRole('row').filter({ hasText: name });
+ }
+
+ async expectRowVisible(name: string) {
+ await expect(this.row(name)).toBeVisible();
+ }
+
+ async expectRowHidden(name: string) {
+ await expect(this.row(name)).toBeHidden();
+ }
+
+ async openEdit(name: string) {
+ await this.row(name).getByRole('link', { name: 'Edit' }).click();
+ }
+
+ async delete(name: string) {
+ this.page.once('dialog', (dialog) => dialog.accept());
+ await this.row(name).getByRole('link', { name: 'Delete' }).click();
+ }
+}
diff --git a/e2e/tests/pages/calendar-admin.page.ts b/e2e/tests/pages/calendar-admin.page.ts
new file mode 100644
index 0000000..32e77d6
--- /dev/null
+++ b/e2e/tests/pages/calendar-admin.page.ts
@@ -0,0 +1,63 @@
+import { expect, type Locator, type Page } from '@playwright/test';
+
+import { HeaderComponent } from '../components/header.component';
+
+export class CalendarAdminPage {
+ readonly header: HeaderComponent;
+
+ constructor(private readonly page: Page) {
+ this.header = new HeaderComponent(page.getByRole('navigation'));
+ }
+
+ get heading(): Locator {
+ return this.page.getByRole('heading', { name: 'Calendar Super Admin' });
+ }
+
+ get successMessage(): Locator {
+ return this.page.getByRole('alert');
+ }
+
+ private get allowHonorariaCheckbox(): Locator {
+ return this.page.getByLabel('Allow Honoraria');
+ }
+
+ private get honorariumMessageInput(): Locator {
+ return this.page.getByLabel('Message to be displayed');
+ }
+
+ async navigateViaMenu() {
+ await this.header.openSuperCalendarAdminSettings();
+ }
+
+ private async readFormState() {
+ return {
+ allowHonoraria: await this.allowHonorariaCheckbox.isChecked(),
+ message: await this.honorariumMessageInput.inputValue(),
+ };
+ }
+
+ private async submitForm(state: { allowHonoraria: boolean; message: string }) {
+ if (state.allowHonoraria) {
+ await this.allowHonorariaCheckbox.check();
+ } else {
+ await this.allowHonorariaCheckbox.uncheck();
+ }
+ await this.honorariumMessageInput.fill(state.message);
+ await this.save();
+ }
+
+ async setAllowHonoraria(enabled: boolean) {
+ const state = await this.readFormState();
+ await this.submitForm({ ...state, allowHonoraria: enabled });
+ }
+
+ async setHonorariumMessage(message: string) {
+ const state = await this.readFormState();
+ await this.submitForm({ ...state, message });
+ }
+
+ async save() {
+ await this.page.getByRole('button', { name: 'Save Configuration Value' }).click();
+ await expect(this.successMessage).toContainText('Updated successfully');
+ }
+}
diff --git a/e2e/tests/pages/categories.page.ts b/e2e/tests/pages/categories.page.ts
new file mode 100644
index 0000000..20b203b
--- /dev/null
+++ b/e2e/tests/pages/categories.page.ts
@@ -0,0 +1,53 @@
+import { expect, type Locator, type Page } from '@playwright/test';
+
+import { HeaderComponent } from '../components/header.component';
+import { AdminIndexPage } from './admin-index.page';
+
+export class CategoriesPage extends AdminIndexPage {
+ readonly header: HeaderComponent;
+
+ constructor(protected readonly page: Page) {
+ super(page);
+ this.header = new HeaderComponent(page.getByRole('navigation'));
+ }
+
+ get heading(): Locator {
+ return this.page.getByRole('heading', { name: 'Categories' });
+ }
+
+ private get nameInput(): Locator {
+ return this.page.getByLabel('Name');
+ }
+
+ async navigateViaMenu() {
+ await this.header.openAdminLink('Categories');
+ }
+
+ async navigateViaUrl() {
+ await this.page.goto('/categories');
+ }
+
+ async openAddForm() {
+ await this.page.getByRole('link', { name: 'Add Category' }).click();
+ }
+
+ async addCategory(name: string) {
+ await this.openAddForm();
+ await this.nameInput.fill(name);
+ await this.page.getByRole('button', { name: 'Add New Category' }).click();
+ }
+
+ async saveCategory(name: string) {
+ await this.nameInput.fill(name);
+ await this.page.getByRole('button', { name: 'Save Category' }).click();
+ }
+
+ async expectTypeCategoriesHidden() {
+ await expect(this.row('Class')).toBeHidden();
+ await expect(this.row('Event')).toBeHidden();
+ }
+
+ async expectAccessDenied() {
+ await expect(this.heading).toBeHidden();
+ }
+}
diff --git a/e2e/tests/pages/committees.page.ts b/e2e/tests/pages/committees.page.ts
new file mode 100644
index 0000000..83167c2
--- /dev/null
+++ b/e2e/tests/pages/committees.page.ts
@@ -0,0 +1,40 @@
+import { type Locator, type Page } from '@playwright/test';
+
+import { HeaderComponent } from '../components/header.component';
+import { AdminIndexPage } from './admin-index.page';
+
+export class CommitteesPage extends AdminIndexPage {
+ readonly header: HeaderComponent;
+
+ constructor(protected readonly page: Page) {
+ super(page);
+ this.header = new HeaderComponent(page.getByRole('navigation'));
+ }
+
+ get heading(): Locator {
+ return this.page.getByRole('heading', { name: 'Committees' });
+ }
+
+ private get nameInput(): Locator {
+ return this.page.getByLabel('Name');
+ }
+
+ async navigateViaMenu() {
+ await this.header.openAdminLink('Committees');
+ }
+
+ async openAddForm() {
+ await this.page.getByRole('link', { name: 'Add Committee' }).click();
+ }
+
+ async addCommittee(name: string) {
+ await this.openAddForm();
+ await this.nameInput.fill(name);
+ await this.page.getByRole('button', { name: 'Add New Committee' }).click();
+ }
+
+ async saveCommittee(name: string) {
+ await this.nameInput.fill(name);
+ await this.page.getByRole('button', { name: 'Save Committee' }).click();
+ }
+}
diff --git a/e2e/tests/pages/configurations.page.ts b/e2e/tests/pages/configurations.page.ts
new file mode 100644
index 0000000..1a18710
--- /dev/null
+++ b/e2e/tests/pages/configurations.page.ts
@@ -0,0 +1,49 @@
+import { expect, type Locator, type Page } from '@playwright/test';
+
+import { HeaderComponent } from '../components/header.component';
+import { AdminIndexPage } from './admin-index.page';
+
+export class ConfigurationsPage extends AdminIndexPage {
+ readonly header: HeaderComponent;
+
+ constructor(protected readonly page: Page) {
+ super(page);
+ this.header = new HeaderComponent(page.getByRole('navigation'));
+ }
+
+ get heading(): Locator {
+ return this.page.getByRole('heading', { name: 'Configuration' });
+ }
+
+ private get valueInput(): Locator {
+ return this.page.getByLabel(/\(Days\)$/);
+ }
+
+ async navigateViaMenu() {
+ await this.header.openAdminLink('Configuration');
+ }
+
+ async expectConfigurationRows(names: readonly string[]) {
+ for (const name of names) {
+ await expect(this.row(name)).toBeVisible();
+ }
+ await expect(this.page.getByRole('row')).toHaveCount(names.length + 1);
+ }
+
+ async expectAllowHonorariaHidden() {
+ await expect(this.row('Allow Honoraria')).toBeHidden();
+ }
+
+ async openEdit(name: string) {
+ await this.row(name).getByRole('link', { name: 'Edit' }).click();
+ }
+
+ async saveValue(days: string) {
+ await this.valueInput.fill(days);
+ await this.page.getByRole('button', { name: 'Save Configuration Value' }).click();
+ }
+
+ async expectValueInIndex(name: string, days: string) {
+ await expect(this.row(name)).toContainText(`${days} Days`);
+ }
+}
diff --git a/e2e/tests/pages/contacts.page.ts b/e2e/tests/pages/contacts.page.ts
new file mode 100644
index 0000000..7016d1d
--- /dev/null
+++ b/e2e/tests/pages/contacts.page.ts
@@ -0,0 +1,130 @@
+import { expect, type Locator, type Page } from '@playwright/test';
+
+import { HeaderComponent } from '../components/header.component';
+import { AdminIndexPage } from './admin-index.page';
+
+export class ContactsPage extends AdminIndexPage {
+ readonly header: HeaderComponent;
+
+ constructor(protected readonly page: Page) {
+ super(page);
+ this.header = new HeaderComponent(page.getByRole('navigation'));
+ }
+
+ get heading(): Locator {
+ return this.page.getByRole('heading', { name: 'Contacts' });
+ }
+
+ private get nameInput(): Locator {
+ return this.page.getByLabel('Name');
+ }
+
+ private get emailInput(): Locator {
+ return this.page.getByLabel('Email');
+ }
+
+ private get phoneInput(): Locator {
+ return this.page.getByLabel('Phone');
+ }
+
+ private get w9OnFileCheckbox(): Locator {
+ return this.page.getByLabel('W9 On File');
+ }
+
+ private get blacklistedCheckbox(): Locator {
+ return this.page.getByLabel('Blacklisted');
+ }
+
+ get emailValidationError(): Locator {
+ return this.page.locator('.form-group.has-error').filter({ has: this.emailInput });
+ }
+
+ async navigateViaMenu() {
+ await this.header.openAdminLink('Contacts');
+ }
+
+ async navigateViaUrl() {
+ await this.page.goto('/contacts');
+ }
+
+ async openEditForAdUsername(adUsername: string) {
+ await this.navigateViaMenu();
+ await this.row(adUsername).getByRole('link', { name: 'Edit' }).click();
+ }
+
+ async openContactFromIndex(adUsername: string) {
+ await this.navigateViaMenu();
+ await this.page
+ .getByRole('row')
+ .filter({ hasText: adUsername })
+ .getByRole('link')
+ .first()
+ .click();
+ }
+
+ async openContactViewDirect(adUsername: string) {
+ await this.page.goto(`/contacts/view/${adUsername}`);
+ }
+
+ async openAddForm() {
+ await this.page.getByRole('link', { name: 'Add Contact' }).click();
+ }
+
+ async addContact(name: string, email: string, phone: string) {
+ await this.openAddForm();
+ await this.fillContactForm(name, email, phone);
+ await this.submitAddForm();
+ }
+
+ async submitAddForm() {
+ await this.page.getByRole('button', { name: 'Add New Contact' }).click();
+ }
+
+ async fillContactForm(name: string, email: string, phone: string) {
+ await this.nameInput.fill(name);
+ await this.emailInput.fill(email);
+ await this.phoneInput.fill(phone);
+ }
+
+ async saveContact() {
+ await this.page.getByRole('button', { name: 'Save Contact' }).click();
+ }
+
+ async setW9OnFile(checked: boolean) {
+ if (checked) {
+ await this.w9OnFileCheckbox.check();
+ } else {
+ await this.w9OnFileCheckbox.uncheck();
+ }
+ await this.saveContact();
+ }
+
+ async setBlacklisted(checked: boolean) {
+ if (checked) {
+ await this.blacklistedCheckbox.check();
+ } else {
+ await this.blacklistedCheckbox.uncheck();
+ }
+ await this.saveContact();
+ }
+
+ get contactViewHeading(): Locator {
+ return this.page.getByRole('heading', { level: 1 });
+ }
+
+ get attendedEventsHeading(): Locator {
+ return this.page.getByRole('heading', { name: 'Attended Events' });
+ }
+
+ get hostedEventsHeading(): Locator {
+ return this.page.getByRole('heading', { name: 'Hosted Events' });
+ }
+
+ async expectW9OnFile(name: string, onFile: boolean) {
+ await expect(this.row(name)).toContainText(onFile ? 'Yes' : 'No');
+ }
+
+ async expectIndexAccessDenied() {
+ await expect(this.heading).toBeHidden();
+ }
+}
diff --git a/e2e/tests/pages/events-add.page.ts b/e2e/tests/pages/events-add.page.ts
new file mode 100644
index 0000000..5795f32
--- /dev/null
+++ b/e2e/tests/pages/events-add.page.ts
@@ -0,0 +1,512 @@
+import { expect, type Locator, type Page } from '@playwright/test';
+
+import { eventStartEnd, scheduleSession, type ScheduleSession } from '../helpers/dates';
+import { HeaderComponent } from '../components/header.component';
+
+export type EventFormOptions = {
+ title: string;
+ shortDescription?: string;
+ longDescription?: string;
+ startDaysFromNow?: number;
+ durationHours?: number;
+ schedule?: ScheduleSession;
+ room?: string;
+ category?: string;
+ tool?: string;
+ freeSpaces?: number;
+ paidSpaces?: number;
+ cancellationDays?: number;
+ membersOnly?: boolean;
+ attendeesRequireApproval?: boolean;
+ ageRestriction?: string;
+ requiresPrerequisite?: string;
+ fulfillsPrerequisite?: string;
+ advisories?: string;
+ extendRegistration?: string;
+ sponsored?: boolean;
+ existingInstructor?: string;
+ requestHonorarium?: boolean;
+ committee?: string;
+ notifyInstructorRegistrations?: boolean;
+ eventbriteLink?: string;
+ cost?: number;
+ paidEventType?: 'paid' | 'eventbrite';
+ primaryType?: 'Class' | 'Event';
+ multipart?: boolean;
+ continuedSessions?: ScheduleSession[];
+ setupMinutes?: 0 | 15 | 30 | 45 | 60;
+ teardownMinutes?: 0 | 15 | 30 | 45 | 60;
+};
+
+export class EventsAddPage {
+ readonly header: HeaderComponent;
+
+ constructor(private readonly page: Page) {
+ this.header = new HeaderComponent(page.getByRole('navigation'));
+ }
+
+ get generalLegend(): Locator {
+ return this.page.getByRole('group', { name: 'General' });
+ }
+
+ get submitEventButton(): Locator {
+ return this.page.getByRole('button', { name: 'Submit Event' });
+ }
+
+ get successMessage(): Locator {
+ return this.page.locator('.alert-success');
+ }
+
+ get honorariumLegend(): Locator {
+ return this.page.getByRole('group', { name: 'Honorarium' });
+ }
+
+ private get titleInput(): Locator {
+ return this.page.getByLabel('Class or Event Title');
+ }
+
+ private get shortDescriptionInput(): Locator {
+ return this.page.getByLabel('Short Description');
+ }
+
+ private get longDescriptionInput(): Locator {
+ return this.page.locator('#long-description');
+ }
+
+ private get eventStartInput(): Locator {
+ return this.page.getByLabel('Event Start');
+ }
+
+ private get eventEndInput(): Locator {
+ return this.page.getByLabel('Event End');
+ }
+
+ private get optionalCategoriesSelect(): Locator {
+ return this.page.getByLabel('Categories');
+ }
+
+ private get committeeSelect(): Locator {
+ return this.page.locator('#honorarium-committee-id');
+ }
+
+ private get fulfillsPrerequisiteSelect(): Locator {
+ return this.page.locator('#fulfills-prerequisite-id');
+ }
+
+ private get requiresPrerequisiteSelect(): Locator {
+ return this.page.locator('#requires-prerequisite-id');
+ }
+
+ private get roomSelect(): Locator {
+ return this.page.locator('#room-id');
+ }
+
+ private get toolsSelect(): Locator {
+ return this.page.locator('#tools-ids');
+ }
+
+ private get sponsoredEventCheckbox(): Locator {
+ return this.page.getByLabel('Sponsored Event');
+ }
+
+ private get existingInstructorsSelect(): Locator {
+ return this.page.getByLabel('Existing Instructors');
+ }
+
+ private get requestHonorariumCheckbox(): Locator {
+ return this.page.getByLabel('Request Honorarium');
+ }
+
+ private get freeSpacesInput(): Locator {
+ return this.page.getByLabel('Free Spaces');
+ }
+
+ private get paidSpacesInput(): Locator {
+ return this.page.getByLabel('Paid Spaces');
+ }
+
+ private get cancellationWindowInput(): Locator {
+ return this.page.getByLabel('Cancellation Window');
+ }
+
+ private get membersOnlyCheckbox(): Locator {
+ return this.page.getByLabel('Only allow DMS members to register for this event');
+ }
+
+ private get attendeesRequireApprovalCheckbox(): Locator {
+ return this.page.getByLabel('Attendees Require Approval');
+ }
+
+ private get ageRestrictionSelect(): Locator {
+ return this.page.getByLabel('Age Restriction');
+ }
+
+ private get advisoriesInput(): Locator {
+ return this.page.getByLabel('Special Considerations and Warnings');
+ }
+
+ private get extendRegistrationSelect(): Locator {
+ return this.page.getByLabel('Extend Registration');
+ }
+
+ private get paidEventTypeSelect(): Locator {
+ return this.page.locator('select.payment-type-select');
+ }
+
+ private get costInput(): Locator {
+ return this.page.getByLabel('Cost');
+ }
+
+ private get eventbriteLinkInput(): Locator {
+ return this.page.getByLabel('Eventbrite Link');
+ }
+
+ private get payInstructorSelect(): Locator {
+ return this.page.getByLabel('Pay Instructor');
+ }
+
+ private get notifyInstructorRegistrationsCheckbox(): Locator {
+ return this.page.getByLabel('Notify Instructor Registrations');
+ }
+
+ private get multipartEventCheckbox(): Locator {
+ return this.page.getByLabel('Multipart Event');
+ }
+
+ private get setupTimeSelect(): Locator {
+ return this.page.getByLabel('Setup Time');
+ }
+
+ private get teardownTimeSelect(): Locator {
+ return this.page.getByLabel('Teardown Time');
+ }
+
+ private continuedStartInput(sessionNumber: number): Locator {
+ return this.page.getByLabel(`${this.ordinal(sessionNumber)} Date Start`);
+ }
+
+ private continuedEndInput(sessionNumber: number): Locator {
+ return this.page.getByLabel(`${this.ordinal(sessionNumber)} Date End`);
+ }
+
+ private ordinal(sessionNumber: number): string {
+ const names = ['', 'First', 'Second', 'Third', 'Fourth', 'Fifth'];
+ return names[sessionNumber] ?? `${sessionNumber}th`;
+ }
+
+ private setupLabel(minutes: number): string {
+ if (minutes === 0) {
+ return 'No setup time required';
+ }
+ if (minutes === 60) {
+ return '1 hour';
+ }
+ return `${minutes} minutes`;
+ }
+
+ get eventStartHelp(): Locator {
+ return this.page.getByText(/at least \d+ days from today/);
+ }
+
+ get minimumBookingLeadTime(): Locator {
+ return this.page.locator('#config-mininum-booking-lead-time');
+ }
+
+ private get honorariumSection(): Locator {
+ return this.page.getByRole('group', { name: 'Honorarium' });
+ }
+
+ async navigateViaMenu() {
+ await this.header.goToSubmitEvent();
+ }
+
+ async navigateViaUrl(copyEventId?: number) {
+ const url = copyEventId ? `/events/add?copy=${copyEventId}` : '/events/add';
+ await this.page.goto(url);
+ }
+
+ async selectClassType() {
+ await this.page.getByRole('radio', { name: 'Class' }).check();
+ }
+
+ async selectPrimaryType(type: 'Class' | 'Event') {
+ await this.page.getByRole('radio', { name: type }).check();
+ }
+
+ async fillEventForm(options: EventFormOptions) {
+ const {
+ title,
+ shortDescription = 'Short description for E2E test',
+ longDescription = 'Long description body for E2E test',
+ startDaysFromNow = 3,
+ durationHours = 2,
+ room = 'Common Area',
+ category,
+ tool,
+ freeSpaces = 10,
+ paidSpaces = 0,
+ cancellationDays = 0,
+ membersOnly,
+ attendeesRequireApproval,
+ ageRestriction,
+ requiresPrerequisite,
+ fulfillsPrerequisite,
+ advisories,
+ extendRegistration,
+ sponsored,
+ existingInstructor,
+ requestHonorarium,
+ committee,
+ notifyInstructorRegistrations,
+ eventbriteLink,
+ cost,
+ paidEventType,
+ primaryType,
+ multipart,
+ continuedSessions,
+ setupMinutes,
+ teardownMinutes,
+ schedule,
+ } = options;
+
+ await this.selectPrimaryType(primaryType ?? 'Class');
+ await this.titleInput.fill(title);
+ await this.shortDescriptionInput.fill(shortDescription);
+ await this.longDescriptionInput.fill(longDescription);
+
+ const primarySchedule =
+ schedule ??
+ ({
+ daysFromNow: startDaysFromNow,
+ startHour: 10,
+ durationHours,
+ } satisfies ScheduleSession);
+ const primaryDates = scheduleSession(primarySchedule);
+ await this.eventStartInput.fill(primaryDates.start);
+ await this.eventEndInput.fill(primaryDates.end);
+ await this.fillDates(primaryDates.start, primaryDates.end);
+
+ const rooms = ['Common Area', 'Back Parking Lot', 'Offsite (See Event Description)'];
+ const selectedRoom = rooms[new Date().getMilliseconds() % rooms.length];
+ await this.roomSelect.selectOption({ label: room ?? selectedRoom });
+
+ if (category) {
+ await this.optionalCategoriesSelect.selectOption({ label: category });
+ }
+ if (tool) {
+ await this.toolsSelect.selectOption({ label: tool });
+ }
+
+ if (paidEventType === 'eventbrite') {
+ await this.paidEventTypeSelect.selectOption({ label: 'Paid (Eventbrite)' });
+ if (eventbriteLink) {
+ await this.eventbriteLinkInput.fill(eventbriteLink);
+ }
+ await this.paidSpacesInput.fill(String(paidSpaces));
+ } else if (paidEventType === 'paid' || cost !== undefined) {
+ await this.paidEventTypeSelect.selectOption({ label: 'Paid (DMS)' });
+ await this.paidEventTypeSelect.dispatchEvent('change');
+ if (cost !== undefined) {
+ await expect(this.costInput).toBeVisible();
+ await this.costInput.fill(String(cost));
+ await this.costInput.dispatchEvent('change');
+ }
+ if (paidSpaces > 0) {
+ await expect(this.paidSpacesInput).toBeVisible();
+ await this.paidSpacesInput.fill(String(paidSpaces));
+ }
+ }
+
+ await this.freeSpacesInput.fill(String(freeSpaces));
+ await this.cancellationWindowInput.fill(String(cancellationDays));
+
+ if (extendRegistration) {
+ await this.extendRegistrationSelect.selectOption({ label: extendRegistration });
+ }
+ if (membersOnly) {
+ await this.membersOnlyCheckbox.check();
+ }
+ if (attendeesRequireApproval) {
+ await this.attendeesRequireApprovalCheckbox.check();
+ }
+ if (ageRestriction) {
+ await this.ageRestrictionSelect.selectOption({ label: ageRestriction });
+ }
+ if (requiresPrerequisite) {
+ await this.requiresPrerequisiteSelect.selectOption({ label: requiresPrerequisite });
+ }
+ if (fulfillsPrerequisite) {
+ await this.fulfillsPrerequisiteSelect.selectOption({ label: fulfillsPrerequisite });
+ }
+ if (advisories) {
+ await this.advisoriesInput.fill(advisories);
+ }
+ if (sponsored) {
+ await this.sponsoredEventCheckbox.check();
+ if (existingInstructor) {
+ await this.existingInstructorsSelect.selectOption({ label: existingInstructor });
+ }
+ }
+ if (requestHonorarium) {
+ await this.requestHonorariumCheckbox.check();
+ if (committee) {
+ await this.committeeSelect.selectOption({ label: committee });
+ }
+ await this.payInstructorSelect.selectOption({ label: 'No' });
+ }
+ if (notifyInstructorRegistrations) {
+ await this.notifyInstructorRegistrationsCheckbox.check();
+ }
+
+ if (setupMinutes !== undefined) {
+ await this.setupTimeSelect.selectOption({ label: this.setupLabel(setupMinutes) });
+ }
+ if (teardownMinutes !== undefined) {
+ await this.teardownTimeSelect.selectOption({ label: this.setupLabel(teardownMinutes) });
+ }
+
+ if (multipart) {
+ await this.multipartEventCheckbox.check();
+ }
+ if (continuedSessions?.length) {
+ for (let index = 0; index < continuedSessions.length; index += 1) {
+ const sessionNumber = index + 2;
+ const sessionDates = scheduleSession(continuedSessions[index]);
+ await this.continuedStartInput(sessionNumber).fill(sessionDates.start);
+ await this.continuedEndInput(sessionNumber).fill(sessionDates.end);
+ await this.fillContinuedDates(sessionNumber, sessionDates.start, sessionDates.end);
+ }
+ }
+ }
+
+ async submit() {
+ await this.submitEventButton.click();
+ }
+
+ async fillAndSubmit(options: EventFormOptions) {
+ await this.fillEventForm(options);
+ await this.submit();
+ }
+
+ async expectSuccessFlash() {
+ await expect(this.successMessage).toContainText('The event has been created');
+ }
+
+ async expectErrorFlash() {
+ await expect(this.page.locator('.alert-danger, .alert-error').first()).toContainText(
+ 'The event could not be created',
+ );
+ }
+
+ async expectFieldError(text: string | RegExp) {
+ await expect(this.page.getByText(text)).toBeVisible();
+ }
+
+ async expectBlacklistedAlert() {
+ await expect(this.page.getByText('Your event submission privileges have been revoked')).toBeVisible();
+ await expect(this.generalLegend).toBeHidden();
+ }
+
+ async expectContactErrorAlert() {
+ await expect(this.page.getByText('Your account is unable to submit events')).toBeVisible();
+ await expect(this.generalLegend).toBeHidden();
+ }
+
+ async expectOptionalCategoryOption(name: string) {
+ await expect(this.optionalCategoriesSelect.locator('option', { hasText: name })).toHaveCount(1);
+ }
+
+ async expectSelectOption(select: Locator, name: string) {
+ await expect(select.locator('option', { hasText: name })).toHaveCount(1);
+ }
+
+ async expectCommitteeOption(name: string) {
+ await this.expectSelectOption(this.committeeSelect, name);
+ }
+
+ async expectPrerequisiteInDropdowns(name: string) {
+ await this.expectSelectOption(this.fulfillsPrerequisiteSelect, name);
+ await this.expectSelectOption(this.requiresPrerequisiteSelect, name);
+ }
+
+ async expectRoomOption(name: string) {
+ await this.expectSelectOption(this.roomSelect, name);
+ }
+
+ async expectToolOption(name: string) {
+ await this.expectSelectOption(this.toolsSelect, name);
+ }
+
+ async enableSponsoredEvent() {
+ await this.sponsoredEventCheckbox.check();
+ }
+
+ async expectExistingInstructorOption(name: string) {
+ await this.expectSelectOption(this.existingInstructorsSelect, name);
+ }
+
+ async expectRequestHonorariumEnabled(enabled: boolean) {
+ if (enabled) {
+ await expect(this.requestHonorariumCheckbox).toBeEnabled();
+ } else {
+ await expect(this.requestHonorariumCheckbox).toBeDisabled();
+ }
+ }
+
+ async expectHonorariumMessage(text: string) {
+ await expect(this.honorariumSection).toContainText(text);
+ }
+
+ async expectHonorariumMessageHidden(text: string) {
+ await expect(this.honorariumSection).not.toContainText(text);
+ }
+
+ async expectPrefilledTitle(title: string) {
+ await expect(this.titleInput).toHaveValue(title);
+ }
+
+ async expectEmptyDates() {
+ await expect(this.eventStartInput).toHaveValue('');
+ await expect(this.eventEndInput).toHaveValue('');
+ }
+
+ async fillDates(start: string, end: string) {
+ await this.page.evaluate(
+ ({ start, end }) => {
+ const startEl = document.querySelector('#event-start') as HTMLInputElement | null;
+ const endEl = document.querySelector('#event-end') as HTMLInputElement | null;
+ if (startEl) {
+ startEl.value = start;
+ }
+ if (endEl) {
+ endEl.value = end;
+ }
+ },
+ { start, end },
+ );
+ }
+
+ async fillContinuedDates(sessionNumber: number, start: string, end: string) {
+ await this.page.evaluate(
+ ({ sessionNumber, start, end }) => {
+ const startEl = document.querySelector(`#event-start-${sessionNumber}`) as HTMLInputElement | null;
+ const endEl = document.querySelector(`#event-end-${sessionNumber}`) as HTMLInputElement | null;
+ if (startEl) {
+ startEl.value = start;
+ }
+ if (endEl) {
+ endEl.value = end;
+ }
+ },
+ { sessionNumber, start, end },
+ );
+ }
+
+ async expectMembersOnlyChecked(checked: boolean) {
+ if (checked) {
+ await expect(this.membersOnlyCheckbox).toBeChecked();
+ } else {
+ await expect(this.membersOnlyCheckbox).not.toBeChecked();
+ }
+ }
+}
diff --git a/e2e/tests/pages/events-archive.page.ts b/e2e/tests/pages/events-archive.page.ts
new file mode 100644
index 0000000..b4e97fe
--- /dev/null
+++ b/e2e/tests/pages/events-archive.page.ts
@@ -0,0 +1,27 @@
+import { expect, type Locator, type Page } from '@playwright/test';
+
+import { HeaderComponent } from '../components/header.component';
+
+export class EventsArchivePage {
+ readonly header: HeaderComponent;
+
+ constructor(private readonly page: Page) {
+ this.header = new HeaderComponent(page.getByRole('navigation'));
+ }
+
+ get heading(): Locator {
+ return this.page.getByRole('heading', { name: 'Events Archive' });
+ }
+
+ async navigateViaMenu() {
+ await this.header.openAdminLink('Events Archive');
+ }
+
+ async navigateViaUrl() {
+ await this.page.goto('/events/all');
+ }
+
+ async expectVisible() {
+ await expect(this.heading).toBeVisible();
+ }
+}
diff --git a/e2e/tests/pages/events-edit.page.ts b/e2e/tests/pages/events-edit.page.ts
new file mode 100644
index 0000000..0cb4551
--- /dev/null
+++ b/e2e/tests/pages/events-edit.page.ts
@@ -0,0 +1,112 @@
+import { expect, type Locator, type Page } from '@playwright/test';
+
+import { eventStartEnd } from '../helpers/dates';
+
+export class EventsEditPage {
+ constructor(private readonly page: Page) {}
+
+ get successMessage(): Locator {
+ return this.page.locator('.alert-success');
+ }
+
+ private get shortDescriptionInput(): Locator {
+ return this.page.getByLabel('Short Description');
+ }
+
+ private get eventStartInput(): Locator {
+ return this.page.getByLabel('Event Start');
+ }
+
+ private get eventEndInput(): Locator {
+ return this.page.getByLabel('Event End');
+ }
+
+ private get updateEventButton(): Locator {
+ return this.page.getByRole('button', { name: 'Update Event' });
+ }
+
+ private get cancelEventLink(): Locator {
+ return this.page.getByRole('link', { name: 'Cancel Event' });
+ }
+
+ async navigateViaUrl(eventId: number) {
+ await this.page.goto(`/events/edit/${eventId}`);
+ }
+
+ async updateShortDescription(text: string) {
+ await this.shortDescriptionInput.fill(text);
+ await this.updateEventButton.click();
+ }
+
+ async updateSchedule(daysFromNow: number, durationHours = 2) {
+ const { start, end } = eventStartEnd(daysFromNow, durationHours);
+ await this.eventStartInput.fill(start);
+ await this.eventEndInput.fill(end);
+ await this.updateEventButton.click();
+ }
+
+ async cancelEvent() {
+ this.page.once('dialog', (dialog) => dialog.accept());
+ await this.cancelEventLink.click();
+ }
+
+ async expectSuccessFlash() {
+ await expect(this.successMessage).toContainText('The event has been updated');
+ }
+
+ async expectEventStartReadOnly() {
+ await expect(this.eventStartInput).toBeHidden();
+ await expect(this.page.getByText('Event Start').first()).toBeVisible();
+ }
+
+ async expectEventStartEditable() {
+ await expect(this.eventStartInput).toBeVisible();
+ }
+
+ async expectBlockedEditAlert() {
+ await expect(this.page.getByText(/events can no longer be edited/)).toBeVisible();
+ }
+
+ private facilitiesFieldset(): Locator {
+ return this.page.getByRole('group', { name: 'Facilities' });
+ }
+
+ private fixedDataAfterHeading(heading: string): Locator {
+ return this.facilitiesFieldset().locator('h5', { hasText: heading }).locator('..').locator('p.fixed-data');
+ }
+
+ async expectSetupMinutes(minutes: number) {
+ await expect(this.page.getByLabel('Setup Time')).toHaveValue(String(minutes));
+ }
+
+ async expectTeardownMinutes(minutes: number) {
+ await expect(this.page.getByLabel('Teardown Time')).toHaveValue(String(minutes));
+ }
+
+ async expectContinuedDateStart(sessionNumber: number, value: string | RegExp) {
+ const labels = ['', 'First', 'Second', 'Third', 'Fourth', 'Fifth'];
+ const label = `${labels[sessionNumber] ?? sessionNumber} Date Start`;
+ await expect(this.page.getByLabel(label)).toHaveValue(value);
+ }
+
+ private ordinal(sessionNumber: number): string {
+ const names = ['', 'First', 'Second', 'Third', 'Fourth', 'Fifth'];
+ return names[sessionNumber] ?? `${sessionNumber}th`;
+ }
+
+ async expectSetupBeginsAt(text: string) {
+ await expect(this.fixedDataAfterHeading('Setup Begins At')).toContainText(text);
+ }
+
+ async expectTeardownEndsAt(text: string) {
+ await expect(this.fixedDataAfterHeading('Teardown Ends At')).toContainText(text);
+ }
+
+ async expectContinuedSessionStart(sessionNumber: number, text: string | RegExp) {
+ await expect(this.fixedDataAfterHeading(`Date ${sessionNumber} Start`)).toContainText(text);
+ }
+
+ async expectContinuedSessionEnd(sessionNumber: number, text: string | RegExp) {
+ await expect(this.fixedDataAfterHeading(`Date ${sessionNumber} End`)).toContainText(text);
+ }
+}
diff --git a/e2e/tests/pages/events-index.page.ts b/e2e/tests/pages/events-index.page.ts
index 7fdcc57..77938db 100644
--- a/e2e/tests/pages/events-index.page.ts
+++ b/e2e/tests/pages/events-index.page.ts
@@ -1,7 +1,13 @@
import type { Locator, Page } from '@playwright/test';
+import { HeaderComponent } from '../components/header.component';
+
export class EventsIndexPage {
- constructor(private readonly page: Page) {}
+ readonly header: HeaderComponent;
+
+ constructor(private readonly page: Page) {
+ this.header = new HeaderComponent(page.getByRole('navigation'));
+ }
get heading(): Locator {
return this.page.getByRole('heading', { name: 'Upcoming Classes and Events' });
@@ -11,7 +17,7 @@ export class EventsIndexPage {
return this.page.getByRole('link', { name: 'Calendar View' });
}
- async goto() {
+ async navigateViaUrl() {
await this.page.goto('/');
}
diff --git a/e2e/tests/pages/events-view.page.ts b/e2e/tests/pages/events-view.page.ts
new file mode 100644
index 0000000..27affd3
--- /dev/null
+++ b/e2e/tests/pages/events-view.page.ts
@@ -0,0 +1,162 @@
+import { expect, type Locator, type Page } from '@playwright/test';
+
+import { RegistrationsEventPage } from './registrations-event.page';
+
+export class EventsViewPage {
+ constructor(private readonly page: Page) {}
+
+ get title(): Locator {
+ return this.page.getByRole('heading', { level: 1 });
+ }
+
+ get whenCell(): Locator {
+ return this.page.locator('tr').filter({ hasText: 'When' }).locator('td').nth(1);
+ }
+
+ get registrationSection(): Locator {
+ return this.page.locator('h3', { hasText: 'Registration' }).locator('..');
+ }
+
+ get successMessage(): Locator {
+ return this.page.getByRole('alert');
+ }
+
+ get registerLink(): Locator {
+ return this.page.getByRole('link', { name: 'Register for this Event' });
+ }
+
+ get viewRegistrationLink(): Locator {
+ return this.page.getByRole('link', { name: 'View Your Registration' });
+ }
+
+ get editEventButton(): Locator {
+ return this.page.getByRole('link', { name: 'Edit Event' });
+ }
+
+ get copyEventButton(): Locator {
+ return this.page.getByRole('link', { name: 'Copy Event' });
+ }
+
+ get noSpacesAlert(): Locator {
+ return this.page.getByText('There are no more spaces available for this event');
+ }
+
+ get registrationClosedAlert(): Locator {
+ return this.page.getByText('Registration for the event is closed');
+ }
+
+ get pendingApprovalAlert(): Locator {
+ return this.page.getByText('This event is pending approval');
+ }
+
+ get cancelledBanner(): Locator {
+ return this.page.getByText('This event has been cancelled');
+ }
+
+ get cancellationNotice(): Locator {
+ return this.page.getByText(/Cancellations for this event must be made before/);
+ }
+
+ get spacesAvailableText(): Locator {
+ return this.page.locator('.spaces_avaliable');
+ }
+
+ async navigateViaUrl(eventId: number) {
+ await this.page.goto(`/events/view/${eventId}`);
+ }
+
+ async expectTitle(name: string) {
+ await expect(this.title).toContainText(name);
+ }
+
+ async expectShortDescription(text: string) {
+ await expect(this.page.getByText(text)).toBeVisible();
+ }
+
+ async expectWhenSectionContains(text: string | RegExp) {
+ await expect(this.whenCell).toContainText(text);
+ }
+
+ async expectWhenSectionLineCount(count: number) {
+ const html = await this.whenCell.innerHTML();
+ const lineBreaks = (html.match(/
/gi) ?? []).length;
+ expect(lineBreaks + 1).toBe(count);
+ }
+
+ async expectCancellationDeadline(text: string) {
+ await expect(this.cancellationNotice).toContainText(text);
+ }
+
+ async expectCost(text: string | RegExp) {
+ await expect(this.registrationSection).toContainText(text);
+ }
+
+ async expectRegisterButtonVisible() {
+ await expect(this.registerLink).toBeVisible();
+ }
+
+ async expectSpaceCountAvailable(open: number, total: number) {
+ await expect(this.spacesAvailableText).toContainText(`${open} spaces of ${total} available`);
+ }
+
+ async expectNoCapacityCountDisplayed() {
+ await expect(this.spacesAvailableText).toBeHidden();
+ }
+
+ async registerForEvent(): Promise {
+ await this.registerLink.click();
+ return new RegistrationsEventPage(this.page);
+ }
+
+ async openEdit(): Promise {
+ await this.editEventButton.click();
+ }
+
+ async openCopy(): Promise {
+ await this.copyEventButton.click();
+ }
+
+ async openRegisteredAttendeesTab() {
+ await this.page.getByRole('tab', { name: /Registrations/ }).click();
+ }
+
+ async approveRegistration(name: string) {
+ const row = this.page.getByRole('row').filter({ hasText: name });
+ this.page.once('dialog', (dialog) => dialog.accept());
+ await row.getByRole('link', { name: 'Approve' }).click();
+ }
+
+ async rejectRegistration(name: string) {
+ const row = this.page.getByRole('row').filter({ hasText: name });
+ this.page.once('dialog', (dialog) => dialog.accept());
+ await row.getByRole('link', { name: 'Reject' }).click();
+ }
+
+ async openAttendanceTab() {
+ await this.page.getByRole('tab', { name: 'Attendance' }).click();
+ }
+
+ async markAttended(name: string) {
+ await this.openAttendanceTab();
+ const row = this.page.getByRole('row').filter({ hasText: name });
+ await row.getByLabel('Attended').check();
+ await this.page.getByRole('button', { name: 'Mark Attended' }).click();
+ }
+
+ async expectAttendanceClosed() {
+ await this.openAttendanceTab();
+ await expect(this.page.getByText('Attendance is closed for this class')).toBeVisible();
+ }
+
+ async expectEditEventHidden() {
+ await expect(this.editEventButton).toBeHidden();
+ }
+
+ async expectCancelledBanner() {
+ await expect(this.cancelledBanner).toBeVisible();
+ }
+
+ async expectNoSpacesMessage() {
+ await expect(this.noSpacesAlert).toBeVisible();
+ }
+}
diff --git a/e2e/tests/pages/export-honoraria.page.ts b/e2e/tests/pages/export-honoraria.page.ts
new file mode 100644
index 0000000..43440cf
--- /dev/null
+++ b/e2e/tests/pages/export-honoraria.page.ts
@@ -0,0 +1,23 @@
+import type { Locator, Page } from '@playwright/test';
+
+import { HeaderComponent } from '../components/header.component';
+
+export class ExportHonorariaPage {
+ readonly header: HeaderComponent;
+
+ constructor(private readonly page: Page) {
+ this.header = new HeaderComponent(page.getByRole('navigation'));
+ }
+
+ get heading(): Locator {
+ return this.page.getByRole('heading', { name: 'Export Honoraria' });
+ }
+
+ async navigateViaMenu() {
+ await this.header.openFinancialsLink('Export Honoraria');
+ }
+
+ async navigateViaUrl() {
+ await this.page.goto('/events/export-honoraria');
+ }
+}
diff --git a/e2e/tests/pages/honoraria-index.page.ts b/e2e/tests/pages/honoraria-index.page.ts
new file mode 100644
index 0000000..3bef5f1
--- /dev/null
+++ b/e2e/tests/pages/honoraria-index.page.ts
@@ -0,0 +1,23 @@
+import { expect, type Locator, type Page } from '@playwright/test';
+
+import { HeaderComponent } from '../components/header.component';
+
+export class HonorariaIndexPage {
+ readonly header: HeaderComponent;
+
+ constructor(private readonly page: Page) {
+ this.header = new HeaderComponent(page.getByRole('navigation'));
+ }
+
+ get heading(): Locator {
+ return this.page.getByRole('heading', { name: 'Honoraria' });
+ }
+
+ async navigateViaUrl() {
+ await this.page.goto('/honoraria');
+ }
+
+ async expectAccessDenied() {
+ await expect(this.heading).toBeHidden();
+ }
+}
diff --git a/e2e/tests/pages/login.page.ts b/e2e/tests/pages/login.page.ts
index c43fd89..1eada2d 100644
--- a/e2e/tests/pages/login.page.ts
+++ b/e2e/tests/pages/login.page.ts
@@ -1,14 +1,24 @@
-import type { Locator, Page } from '@playwright/test';
+import { expect, type Locator, type Page } from '@playwright/test';
+import { HeaderComponent } from '../components/header.component';
+import { EventsAddPage } from './events-add.page';
import { EventsIndexPage } from './events-index.page';
export class LoginPage {
- constructor(private readonly page: Page) {}
+ readonly header: HeaderComponent;
+
+ constructor(private readonly page: Page) {
+ this.header = new HeaderComponent(page.getByRole('navigation'));
+ }
get heading(): Locator {
return this.page.getByRole('heading', { name: 'DMS Member Log In' });
}
+ get errorMessage(): Locator {
+ return this.page.getByRole('alert');
+ }
+
private get usernameInput(): Locator {
return this.page.getByLabel('Username');
}
@@ -21,18 +31,34 @@ export class LoginPage {
return this.page.getByRole('button', { name: 'Login' });
}
- async goto() {
+ async navigateViaUrl() {
await this.page.goto('/users/login');
}
+ async navigateViaMenu() {
+ await this.header.dmsLoginLink.click();
+ }
+
async submitCredentials(username: string, password: string) {
await this.usernameInput.fill(username);
await this.passwordInput.fill(password);
- await this.loginButton.click();
+ await this.passwordInput.press('Enter');
}
async loginAsMember(username: string, password: string): Promise {
await this.submitCredentials(username, password);
return new EventsIndexPage(this.page);
}
+
+ async loginAndReturnToEventsAdd(
+ username: string,
+ password: string,
+ ): Promise {
+ await this.submitCredentials(username, password);
+ return new EventsAddPage(this.page);
+ }
+
+ async expectShowsRedirect(path: string) {
+ await expect(this.page).toHaveURL(new RegExp(`redirect=${encodeURIComponent(path)}`));
+ }
}
diff --git a/e2e/tests/pages/logs.page.ts b/e2e/tests/pages/logs.page.ts
new file mode 100644
index 0000000..9abc6f2
--- /dev/null
+++ b/e2e/tests/pages/logs.page.ts
@@ -0,0 +1,116 @@
+import { expect, type Locator, type Page } from '@playwright/test';
+
+import { HeaderComponent } from '../components/header.component';
+
+export class LogsPage {
+ readonly header: HeaderComponent;
+
+ constructor(private readonly page: Page) {
+ this.header = new HeaderComponent(page.getByRole('navigation'));
+ }
+
+ get heading(): Locator {
+ return this.page.getByRole('heading', { name: 'Logs' });
+ }
+
+ get rows(): Locator {
+ return this.page.locator('table tbody tr');
+ }
+
+ async rowCount(): Promise {
+ return this.rows.count();
+ }
+
+ async newestRowDateTime(): Promise {
+ return this.rows.first().locator('td').first().innerText();
+ }
+
+ async expectFilteredLogEntryAdded(options: {
+ userName?: string;
+ searchString?: string;
+ }) {
+ await this.navigateViaUrl();
+ await this.filter(options);
+ const baselineCount = await this.rowCount();
+ const baselineDateTime =
+ baselineCount > 0 ? await this.newestRowDateTime() : null;
+
+ return {
+ assertNewEntry: async () => {
+ await expect
+ .poll(async () => {
+ await this.navigateViaUrl();
+ await this.filter(options);
+ const count = await this.rowCount();
+ if (baselineDateTime === null) {
+ return count > 0;
+ }
+ const newestDateTime = await this.newestRowDateTime();
+ return count > baselineCount || newestDateTime !== baselineDateTime;
+ })
+ .toBe(true);
+ },
+ countFiltered: async () => this.countFiltered(options),
+ };
+ }
+
+ async countFiltered(options: {
+ startDate?: string;
+ endDate?: string;
+ userName?: string;
+ searchString?: string;
+ }): Promise {
+ await this.navigateViaUrl();
+ await this.filter(options);
+ return this.rowCount();
+ }
+
+ async navigateViaUrl() {
+ await this.page.goto('/logs');
+ }
+
+ async expectTableColumnsVisible() {
+ await expect(this.page.getByRole('columnheader', { name: 'Date/Time' })).toBeVisible();
+ await expect(this.page.getByRole('columnheader', { name: 'User' })).toBeVisible();
+ await expect(this.page.getByRole('columnheader', { name: 'Description' })).toBeVisible();
+ await expect(this.page.getByRole('columnheader', { name: 'URL' })).toBeVisible();
+ await expect(this.page.getByRole('columnheader', { name: 'IP', exact: true })).toBeVisible();
+ }
+
+ async expectHasEntries() {
+ await expect(this.rows.first()).toBeVisible();
+ }
+
+ rowContaining(text: string): Locator {
+ return this.rows.filter({ hasText: text });
+ }
+
+ async expectRowCount(count: number) {
+ await expect(this.rows).toHaveCount(count);
+ }
+
+ async filter(options: {
+ startDate?: string;
+ endDate?: string;
+ userName?: string;
+ searchString?: string;
+ }) {
+ if (options.startDate !== undefined) {
+ await this.page.locator('#start-date').fill(options.startDate);
+ }
+ if (options.endDate !== undefined) {
+ await this.page.locator('#end-date').fill(options.endDate);
+ }
+ if (options.userName !== undefined) {
+ await this.page.locator('#user-name').fill(options.userName);
+ }
+ if (options.searchString !== undefined) {
+ await this.page.locator('#search-string').fill(options.searchString);
+ }
+ await this.page.getByRole('button', { name: 'Narrow Results' }).click();
+ }
+
+ async expectAccessDenied() {
+ await expect(this.heading).toBeHidden();
+ }
+}
diff --git a/e2e/tests/pages/member-events.page.ts b/e2e/tests/pages/member-events.page.ts
new file mode 100644
index 0000000..db9cd1a
--- /dev/null
+++ b/e2e/tests/pages/member-events.page.ts
@@ -0,0 +1,38 @@
+import { expect, type Locator, type Page } from '@playwright/test';
+
+import { HeaderComponent } from '../components/header.component';
+
+export class MemberEventsPage {
+ readonly header: HeaderComponent;
+
+ constructor(private readonly page: Page) {
+ this.header = new HeaderComponent(page.getByRole('navigation'));
+ }
+
+ get hostingHeading(): Locator {
+ return this.page.getByRole('heading', { name: 'Your Hosted Classes and Events' });
+ }
+
+ get attendingHeading(): Locator {
+ return this.page.getByRole('heading', { name: 'Your Upcoming Classes and Events' });
+ }
+
+ async navigateToHostingViaMenu() {
+ await this.header.openHostingEvents();
+ }
+
+ async navigateToAttendingViaMenu() {
+ await this.header.openAttendingEvents();
+ }
+
+ async expectHostedEvent(name: string, status?: string) {
+ await expect(this.page.getByText(name)).toBeVisible();
+ if (status) {
+ await expect(this.page.locator(`.event-${status}`).filter({ hasText: name })).toBeVisible();
+ }
+ }
+
+ async expectAttendingEvent(name: string) {
+ await expect(this.page.getByText(name)).toBeVisible();
+ }
+}
diff --git a/e2e/tests/pages/pending-events.page.ts b/e2e/tests/pages/pending-events.page.ts
new file mode 100644
index 0000000..85d20fc
--- /dev/null
+++ b/e2e/tests/pages/pending-events.page.ts
@@ -0,0 +1,55 @@
+import { expect, type Locator, type Page } from '@playwright/test';
+
+import { HeaderComponent } from '../components/header.component';
+import { ProcessRejectionPage } from './process-rejection.page';
+
+export class PendingEventsPage {
+ readonly header: HeaderComponent;
+
+ constructor(private readonly page: Page) {
+ this.header = new HeaderComponent(page.getByRole('navigation'));
+ }
+
+ get heading(): Locator {
+ return this.page.getByRole('heading', { name: 'Pending Events' });
+ }
+
+ async navigateViaMenu() {
+ await this.header.openAdminLink('Pending Events');
+ }
+
+ async navigateViaUrl() {
+ await this.page.goto('/events/pending');
+ }
+
+ row(name: string): Locator {
+ return this.page.getByRole('row').filter({ hasText: name });
+ }
+
+ async expectEventVisible(name: string) {
+ await expect(this.row(name)).toBeVisible();
+ }
+
+ async getEventId(name: string): Promise {
+ const href = await this.row(name).getByRole('link', { name, exact: true }).getAttribute('href');
+ const match = href?.match(/\/events\/view\/(\d+)/);
+ if (!match) {
+ throw new Error(`Could not find event id for ${name}`);
+ }
+ return Number.parseInt(match[1], 10);
+ }
+
+ async approveEvent(name: string) {
+ this.page.once('dialog', (dialog) => dialog.accept());
+ await this.row(name).getByRole('link', { name: 'Approve', exact: true }).click();
+ }
+
+ async openRejectForm(name: string): Promise {
+ await this.row(name).getByRole('link', { name: 'Reject', exact: true }).click();
+ return new ProcessRejectionPage(this.page);
+ }
+
+ async expectAccessDenied() {
+ await expect(this.heading).toBeHidden();
+ }
+}
diff --git a/e2e/tests/pages/pending-honoraria.page.ts b/e2e/tests/pages/pending-honoraria.page.ts
new file mode 100644
index 0000000..75bba87
--- /dev/null
+++ b/e2e/tests/pages/pending-honoraria.page.ts
@@ -0,0 +1,38 @@
+import { expect, type Locator, type Page } from '@playwright/test';
+
+import { HeaderComponent } from '../components/header.component';
+import { ProcessRejectionPage } from './process-rejection.page';
+
+export class PendingHonorariaPage {
+ readonly header: HeaderComponent;
+
+ constructor(private readonly page: Page) {
+ this.header = new HeaderComponent(page.getByRole('navigation'));
+ }
+
+ get heading(): Locator {
+ return this.page.getByRole('heading', { name: 'Pending Events Requesting Honorarium' });
+ }
+
+ async navigateViaMenu() {
+ await this.header.openHonorariaLink('Pending');
+ }
+
+ async navigateViaUrl() {
+ await this.page.goto('/events/honoraria/pending');
+ }
+
+ row(name: string): Locator {
+ return this.page.getByRole('row').filter({ hasText: name });
+ }
+
+ async approveEvent(name: string) {
+ this.page.once('dialog', (dialog) => dialog.accept());
+ await this.row(name).getByRole('link', { name: 'Approve', exact: true }).click();
+ }
+
+ async openRejectForm(name: string): Promise {
+ await this.row(name).getByRole('link', { name: 'Reject', exact: true }).click();
+ return new ProcessRejectionPage(this.page);
+ }
+}
diff --git a/e2e/tests/pages/prerequisites.page.ts b/e2e/tests/pages/prerequisites.page.ts
new file mode 100644
index 0000000..5568420
--- /dev/null
+++ b/e2e/tests/pages/prerequisites.page.ts
@@ -0,0 +1,46 @@
+import { type Locator, type Page } from '@playwright/test';
+
+import { HeaderComponent } from '../components/header.component';
+import { AdminIndexPage } from './admin-index.page';
+
+export class PrerequisitesPage extends AdminIndexPage {
+ readonly header: HeaderComponent;
+
+ constructor(protected readonly page: Page) {
+ super(page);
+ this.header = new HeaderComponent(page.getByRole('navigation'));
+ }
+
+ get heading(): Locator {
+ return this.page.getByRole('heading', { name: 'Prerequisites' });
+ }
+
+ private get nameInput(): Locator {
+ return this.page.getByLabel('Name');
+ }
+
+ private get adGroupInput(): Locator {
+ return this.page.getByLabel('Ad Group');
+ }
+
+ async navigateViaMenu() {
+ await this.header.openAdminLink('Prerequisites');
+ }
+
+ async openAddForm() {
+ await this.page.getByRole('link', { name: 'Add Prerequisite' }).click();
+ }
+
+ async addPrerequisite(name: string, adGroup: string) {
+ await this.openAddForm();
+ await this.nameInput.fill(name);
+ await this.adGroupInput.fill(adGroup);
+ await this.page.getByRole('button', { name: 'Add New Prerequisite' }).click();
+ }
+
+ async savePrerequisite(name: string, adGroup: string) {
+ await this.nameInput.fill(name);
+ await this.adGroupInput.fill(adGroup);
+ await this.page.getByRole('button', { name: 'Save Prerequisite' }).click();
+ }
+}
diff --git a/e2e/tests/pages/process-rejection.page.ts b/e2e/tests/pages/process-rejection.page.ts
new file mode 100644
index 0000000..f2bce6b
--- /dev/null
+++ b/e2e/tests/pages/process-rejection.page.ts
@@ -0,0 +1,22 @@
+import { expect, type Locator, type Page } from '@playwright/test';
+
+export class ProcessRejectionPage {
+ constructor(private readonly page: Page) {}
+
+ get heading(): Locator {
+ return this.page.getByRole('heading', { name: 'Process Event Rejection' });
+ }
+
+ private get reasonInput(): Locator {
+ return this.page.locator('#event-rejection-reason, textarea[name="event[rejection_reason]"]').first();
+ }
+
+ async rejectWithReason(reason: string) {
+ await this.reasonInput.fill(reason);
+ await this.page.getByRole('button', { name: 'Reject Event' }).click();
+ }
+
+ async expectVisible() {
+ await expect(this.heading).toBeVisible();
+ }
+}
diff --git a/e2e/tests/pages/registrations-event.page.ts b/e2e/tests/pages/registrations-event.page.ts
new file mode 100644
index 0000000..ebcc3f0
--- /dev/null
+++ b/e2e/tests/pages/registrations-event.page.ts
@@ -0,0 +1,104 @@
+import { expect, type Locator, type Page } from '@playwright/test';
+
+import { RegistrationsViewPage } from './registrations-view.page';
+
+export type RegistrationFormData = {
+ name: string;
+ email: string;
+ phone: string;
+ safetyConfirmation?: boolean;
+ ageConfirmation?: boolean;
+ type?: 'free' | 'paid';
+};
+
+export class RegistrationsEventPage {
+ constructor(private readonly page: Page) {}
+
+ get heading(): Locator {
+ return this.page.getByRole('heading', { name: 'Event Registration' });
+ }
+
+ get membersOnlyNotice(): Locator {
+ return this.page.getByText('This event is for DMS Members only');
+ }
+
+ get fullEventNotice(): Locator {
+ return this.page.getByText('No available spaces!');
+ }
+
+ private get nameInput(): Locator {
+ return this.page.getByLabel('Name');
+ }
+
+ private get emailInput(): Locator {
+ return this.page.getByLabel('Email');
+ }
+
+ private get phoneInput(): Locator {
+ return this.page.getByLabel('Phone');
+ }
+
+ private get safetyConfirmationCheckbox(): Locator {
+ return this.page.getByLabel('I acknowledge the above considerations and warnings.');
+ }
+
+ private ageConfirmationCheckbox(age: number): Locator {
+ return this.page.getByLabel(`I acknowledge that I am ${age} years old or older.`);
+ }
+
+ private get registrationTypeSelect(): Locator {
+ return this.page.getByLabel('Registration Type');
+ }
+
+ private get submitButton(): Locator {
+ return this.page.getByRole('button', { name: /Confirm Registration|Submit Registration for Approval/ });
+ }
+
+ async navigateViaUrl(eventId: number) {
+ await this.page.goto(`/registrations/event/${eventId}`);
+ }
+
+ async expectPrerequisiteGate(prerequisiteName: string) {
+ await expect(this.page.getByText(`requires completion of the ${prerequisiteName}`)).toBeVisible();
+ await expect(this.submitButton).toBeHidden();
+ }
+
+ async expectMembersOnlyGate() {
+ await expect(this.membersOnlyNotice).toBeVisible();
+ await expect(this.submitButton).toBeHidden();
+ }
+
+ async fillRegistrationForm(data: RegistrationFormData) {
+ await this.nameInput.fill(data.name);
+ await this.emailInput.fill(data.email);
+ await this.phoneInput.fill(data.phone);
+ if (data.safetyConfirmation) {
+ await this.safetyConfirmationCheckbox.check();
+ }
+ if (data.ageConfirmation) {
+ await this.ageConfirmationCheckbox(18).check();
+ }
+ if (data.type) {
+ const label = data.type === 'paid' ? /Paid Registration/ : 'Free Registration';
+ await this.registrationTypeSelect.selectOption({ label });
+ }
+ }
+
+ async submit(): Promise {
+ await this.submitButton.click();
+ return new RegistrationsViewPage(this.page);
+ }
+
+ async fillAndSubmit(data: RegistrationFormData): Promise {
+ await this.fillRegistrationForm(data);
+ return this.submit();
+ }
+
+ async expectDuplicateEmailError() {
+ await expect(this.page.getByText('already associated with a registration')).toBeVisible();
+ }
+
+ async expectNamePrefilled() {
+ await expect(this.nameInput).not.toHaveValue('');
+ }
+}
diff --git a/e2e/tests/pages/registrations-view.page.ts b/e2e/tests/pages/registrations-view.page.ts
new file mode 100644
index 0000000..594a659
--- /dev/null
+++ b/e2e/tests/pages/registrations-view.page.ts
@@ -0,0 +1,64 @@
+import { expect, type Locator, type Page } from '@playwright/test';
+
+export class RegistrationsViewPage {
+ constructor(private readonly page: Page) {}
+
+ get heading(): Locator {
+ return this.page.getByRole('heading', { name: 'Registration Status' });
+ }
+
+ get confirmedAlert(): Locator {
+ return this.page.getByText("You're all set!");
+ }
+
+ get pendingAlert(): Locator {
+ return this.page.getByText('Your registration is still pending');
+ }
+
+ get cancelledAlert(): Locator {
+ return this.page.getByText('Your registration has been cancelled');
+ }
+
+ get cancelRsvpButton(): Locator {
+ return this.page.getByRole('link', { name: 'Cancel RSVP' });
+ }
+
+ get successMessage(): Locator {
+ return this.page.getByRole('alert');
+ }
+
+ async navigateViaUrl(registrationId: number, editKey?: string) {
+ const query = editKey ? `?edit_key=${editKey}` : '';
+ await this.page.goto(`/registrations/view/${registrationId}${query}`);
+ }
+
+ async getRegistrationIdFromUrl(): Promise {
+ const match = this.page.url().match(/\/registrations\/view\/(\d+)/);
+ if (!match) {
+ throw new Error('Not on a registration view page');
+ }
+ return Number.parseInt(match[1], 10);
+ }
+
+ async getEditKeyFromUrl(): Promise {
+ const url = new URL(this.page.url());
+ return url.searchParams.get('edit_key');
+ }
+
+ async cancelRsvp() {
+ this.page.once('dialog', (dialog) => dialog.accept());
+ await this.cancelRsvpButton.click();
+ }
+
+ async expectConfirmed() {
+ await expect(this.confirmedAlert).toBeVisible();
+ }
+
+ async expectPending() {
+ await expect(this.pendingAlert).toBeVisible();
+ }
+
+ async expectCancelRsvpHidden() {
+ await expect(this.cancelRsvpButton).toBeHidden();
+ }
+}
diff --git a/e2e/tests/pages/rooms.page.ts b/e2e/tests/pages/rooms.page.ts
new file mode 100644
index 0000000..c53dd1e
--- /dev/null
+++ b/e2e/tests/pages/rooms.page.ts
@@ -0,0 +1,59 @@
+import { expect, type Locator, type Page } from '@playwright/test';
+
+import { HeaderComponent } from '../components/header.component';
+import { AdminIndexPage } from './admin-index.page';
+
+export class RoomsPage extends AdminIndexPage {
+ readonly header: HeaderComponent;
+
+ constructor(protected readonly page: Page) {
+ super(page);
+ this.header = new HeaderComponent(page.getByRole('navigation'));
+ }
+
+ get heading(): Locator {
+ return this.page.getByRole('heading', { name: 'Rooms' });
+ }
+
+ private get nameInput(): Locator {
+ return this.page.getByLabel('Name');
+ }
+
+ private get exclusiveCheckbox(): Locator {
+ return this.page.getByLabel('Exclusive Use - Only one event at a time in this room.');
+ }
+
+ async navigateViaMenu() {
+ await this.header.openAdminLink('Rooms');
+ }
+
+ async openAddForm() {
+ await this.page.getByRole('link', { name: 'Add Room' }).click();
+ }
+
+ async addRoom(name: string, exclusive = false) {
+ await this.openAddForm();
+ await this.nameInput.fill(name);
+ if (exclusive) {
+ await this.exclusiveCheckbox.check();
+ } else {
+ await this.exclusiveCheckbox.uncheck();
+ }
+ await this.page.getByRole('button', { name: 'Add New Room' }).click();
+ }
+
+ async saveRoom(name: string, exclusive: boolean) {
+ await this.nameInput.fill(name);
+ if (exclusive) {
+ await this.exclusiveCheckbox.check();
+ } else {
+ await this.exclusiveCheckbox.uncheck();
+ }
+ await this.page.getByRole('button', { name: 'Save Room' }).click();
+ }
+
+ async expectExclusiveUse(name: string, exclusive: boolean) {
+ const row = this.row(name);
+ await expect(row.getByRole('cell', { name: exclusive ? 'Yes' : 'No' })).toBeVisible();
+ }
+}
diff --git a/e2e/tests/pages/tools.page.ts b/e2e/tests/pages/tools.page.ts
new file mode 100644
index 0000000..70e2570
--- /dev/null
+++ b/e2e/tests/pages/tools.page.ts
@@ -0,0 +1,40 @@
+import { type Locator, type Page } from '@playwright/test';
+
+import { HeaderComponent } from '../components/header.component';
+import { AdminIndexPage } from './admin-index.page';
+
+export class ToolsPage extends AdminIndexPage {
+ readonly header: HeaderComponent;
+
+ constructor(protected readonly page: Page) {
+ super(page);
+ this.header = new HeaderComponent(page.getByRole('navigation'));
+ }
+
+ get heading(): Locator {
+ return this.page.getByRole('heading', { name: 'Tools' });
+ }
+
+ private get nameInput(): Locator {
+ return this.page.getByLabel('Name');
+ }
+
+ async navigateViaMenu() {
+ await this.header.openAdminLink('Tools');
+ }
+
+ async openAddForm() {
+ await this.page.getByRole('link', { name: 'Add Tool' }).click();
+ }
+
+ async addTool(name: string) {
+ await this.openAddForm();
+ await this.nameInput.fill(name);
+ await this.page.getByRole('button', { name: 'Add New Tool' }).click();
+ }
+
+ async saveTool(name: string) {
+ await this.nameInput.fill(name);
+ await this.page.getByRole('button', { name: 'Save Tool' }).click();
+ }
+}
diff --git a/e2e/tests/prerequisites.spec.ts b/e2e/tests/prerequisites.spec.ts
new file mode 100644
index 0000000..49f219e
--- /dev/null
+++ b/e2e/tests/prerequisites.spec.ts
@@ -0,0 +1,40 @@
+import { test, expect } from '@playwright/test';
+
+import { loginAsAdmin } from './helpers/admin-session';
+import { EventsAddPage } from './pages/events-add.page';
+import { PrerequisitesPage } from './pages/prerequisites.page';
+
+test.describe('Prerequisites admin CRUD', () => {
+ test('admin manages prerequisites and exposes them on the event form', async ({ page }) => {
+ const prerequisiteName = 'Test Prereq E2E';
+ const prerequisiteAdGroup = 'Test Prereq E2E';
+ const prerequisiteUpdatedAdGroup = 'Test Prereq E2E Updated';
+ const prerequisites = new PrerequisitesPage(page);
+ const eventsAdd = new EventsAddPage(page);
+
+ await loginAsAdmin(page);
+ await prerequisites.navigateViaMenu();
+ await expect(prerequisites.heading).toBeVisible();
+
+ // Remove leftovers from a previous run that failed before delete.
+ if ((await prerequisites.row(prerequisiteName).count()) > 0) {
+ await prerequisites.delete(prerequisiteName);
+ }
+
+ await prerequisites.addPrerequisite(prerequisiteName, prerequisiteAdGroup);
+ await prerequisites.expectRowVisible(prerequisiteName);
+
+ await prerequisites.openEdit(prerequisiteName);
+ await prerequisites.savePrerequisite(prerequisiteName, prerequisiteUpdatedAdGroup);
+ await expect(prerequisites.row(prerequisiteName)).toContainText(prerequisiteUpdatedAdGroup);
+
+ await eventsAdd.navigateViaMenu();
+ await eventsAdd.expectPrerequisiteInDropdowns(prerequisiteName);
+
+ await prerequisites.navigateViaMenu();
+ await prerequisites.expectRowVisible(prerequisiteName);
+
+ await prerequisites.delete(prerequisiteName);
+ await prerequisites.expectRowHidden(prerequisiteName);
+ });
+});
diff --git a/e2e/tests/registration.spec.ts b/e2e/tests/registration.spec.ts
new file mode 100644
index 0000000..3067103
--- /dev/null
+++ b/e2e/tests/registration.spec.ts
@@ -0,0 +1,221 @@
+import { test, expect } from '@playwright/test';
+
+import { testUsers } from './data/test-users';
+import { loginAs, loginAsAdmin, loginAsMember, logout } from './helpers/admin-session';
+import { cancelEvent, createApprovedEvent } from './helpers/event-workflow';
+import { EventsViewPage } from './pages/events-view.page';
+import { RegistrationsEventPage } from './pages/registrations-event.page';
+import { RegistrationsViewPage } from './pages/registrations-view.page';
+
+test.describe('Event registration', () => {
+ test('guest registers for a free approved event', async ({ page }) => {
+ const eventTitle = `E2E Register Free ${Date.now()}`;
+ const guestEmail = `guest-${Date.now()}@test.local`;
+ const eventsView = new EventsViewPage(page);
+
+ const eventId = await createApprovedEvent(page, { title: eventTitle, freeSpaces: 10 });
+
+ await logout(page);
+ await eventsView.navigateViaUrl(eventId);
+ const registration = await eventsView.registerForEvent();
+ const registrationView = await registration.fillAndSubmit({
+ name: 'Guest Registrant',
+ email: guestEmail,
+ phone: '555-0101',
+ });
+ await registrationView.expectConfirmed();
+
+ await loginAsAdmin(page);
+ await cancelEvent(page, eventId);
+ });
+
+ test('logged-in member registers with prefilled fields', async ({ page }) => {
+ const eventTitle = `E2E Member Register ${Date.now()}`;
+ const eventsView = new EventsViewPage(page);
+ const registration = new RegistrationsEventPage(page);
+
+ const eventId = await createApprovedEvent(page, { title: eventTitle, freeSpaces: 10 });
+
+ await loginAs(page, testUsers.memberCommittee.username, testUsers.memberCommittee.password);
+ await registration.navigateViaUrl(eventId);
+ await registration.expectNamePrefilled();
+ const registrationView = await registration.fillAndSubmit({
+ name: 'Committee Member',
+ email: `user3-${Date.now()}@test.local`,
+ phone: '555-0102',
+ });
+ await registrationView.expectConfirmed();
+
+ await loginAsAdmin(page);
+ await cancelEvent(page, eventId);
+ });
+
+ test('approval-required registration stays pending until host approves', async ({ page }) => {
+ const eventTitle = `E2E Approval Required ${Date.now()}`;
+ const guestEmail = `approval-${Date.now()}@test.local`;
+ const eventsView = new EventsViewPage(page);
+
+ const eventId = await createApprovedEvent(page, {
+ title: eventTitle,
+ freeSpaces: 10,
+ attendeesRequireApproval: true,
+ });
+
+ await logout(page);
+ await eventsView.navigateViaUrl(eventId);
+ const registration = await eventsView.registerForEvent();
+ const registrationView = await registration.fillAndSubmit({
+ name: 'Pending Guest',
+ email: guestEmail,
+ phone: '555-0103',
+ });
+ await registrationView.expectPending();
+ const registrationId = await registrationView.getRegistrationIdFromUrl();
+
+ await loginAsMember(page);
+ await eventsView.navigateViaUrl(eventId);
+ await eventsView.openRegisteredAttendeesTab();
+ await eventsView.approveRegistration('Pending Guest');
+
+ await registrationView.navigateViaUrl(registrationId);
+ await registrationView.expectConfirmed();
+
+ await loginAsAdmin(page);
+ await cancelEvent(page, eventId);
+ });
+
+ test('members-only event requires login to register', async ({ page }) => {
+ const eventTitle = `E2E Members Only ${Date.now()}`;
+ const registration = new RegistrationsEventPage(page);
+
+ const eventId = await createApprovedEvent(page, {
+ title: eventTitle,
+ freeSpaces: 10,
+ membersOnly: true,
+ });
+
+ await logout(page);
+ await registration.navigateViaUrl(eventId);
+ await registration.expectMembersOnlyGate();
+
+ await loginAsMember(page);
+ await registration.navigateViaUrl(eventId);
+ const registrationView = await registration.fillAndSubmit({
+ name: 'Member Attendee',
+ email: `member-only-${Date.now()}@test.local`,
+ phone: '555-0104',
+ });
+ await registrationView.expectConfirmed();
+
+ await loginAsAdmin(page);
+ await cancelEvent(page, eventId);
+ });
+
+ test('age-restricted event requires advisory and age confirmations', async ({ page }) => {
+ const eventTitle = `E2E Age 18 ${Date.now()}`;
+ const eventsView = new EventsViewPage(page);
+ const registration = new RegistrationsEventPage(page);
+
+ const eventId = await createApprovedEvent(page, {
+ title: eventTitle,
+ freeSpaces: 10,
+ ageRestriction: '18 and up',
+ advisories: 'Safety glasses required.',
+ });
+
+ await logout(page);
+ await eventsView.navigateViaUrl(eventId);
+ await eventsView.registerForEvent();
+ const registrationView = await registration.fillAndSubmit({
+ name: 'Age Gate Guest',
+ email: `age-${Date.now()}@test.local`,
+ phone: '555-0105',
+ safetyConfirmation: true,
+ ageConfirmation: true,
+ });
+ await registrationView.expectConfirmed();
+
+ await loginAsAdmin(page);
+ await cancelEvent(page, eventId);
+ });
+
+ test('guest can cancel RSVP using edit_key without logging in', async ({ page }) => {
+ const eventTitle = `E2E Guest Cancel ${Date.now()}`;
+ const guestEmail = `guest-cancel-${Date.now()}@test.local`;
+ const eventsView = new EventsViewPage(page);
+
+ const eventId = await createApprovedEvent(page, { title: eventTitle, freeSpaces: 10 });
+
+ await logout(page);
+ await eventsView.navigateViaUrl(eventId);
+ const first = await eventsView.registerForEvent();
+ const registrationView = await first.fillAndSubmit({
+ name: 'Guest Cancel',
+ email: guestEmail,
+ phone: '555-0106',
+ });
+ const registrationId = await registrationView.getRegistrationIdFromUrl();
+ const editKey = await registrationView.getEditKeyFromUrl();
+
+ await registrationView.cancelRsvp();
+ await registrationView.navigateViaUrl(registrationId, editKey ?? undefined);
+ await registrationView.expectCancelRsvpHidden();
+
+ await loginAsAdmin(page);
+ await cancelEvent(page, eventId);
+ });
+
+ test('duplicate email registration is rejected', async ({ page }) => {
+ const eventTitle = `E2E Duplicate Email ${Date.now()}`;
+ const guestEmail = `duplicate-${Date.now()}@test.local`;
+ const eventsView = new EventsViewPage(page);
+ const registration = new RegistrationsEventPage(page);
+
+ const eventId = await createApprovedEvent(page, { title: eventTitle, freeSpaces: 10 });
+
+ await logout(page);
+ await eventsView.navigateViaUrl(eventId);
+ const first = await eventsView.registerForEvent();
+ await first.fillAndSubmit({
+ name: 'First Guest',
+ email: guestEmail,
+ phone: '555-0107',
+ });
+
+ await registration.navigateViaUrl(eventId);
+ await expect(registration.submitButton).toBeVisible();
+ await registration.fillRegistrationForm({
+ name: 'Second Guest',
+ email: guestEmail,
+ phone: '555-0108',
+ });
+ await registration.submit();
+ await registration.expectDuplicateEmailError();
+
+ await loginAsAdmin(page);
+ await cancelEvent(page, eventId);
+ });
+
+ test('full event shows no spaces available', async ({ page }) => {
+ const eventTitle = `E2E Full Event ${Date.now()}`;
+ const eventsView = new EventsViewPage(page);
+ const registration = new RegistrationsEventPage(page);
+
+ const eventId = await createApprovedEvent(page, { title: eventTitle, freeSpaces: 1 });
+
+ await logout(page);
+ await eventsView.navigateViaUrl(eventId);
+ const first = await eventsView.registerForEvent();
+ await first.fillAndSubmit({
+ name: 'First Space',
+ email: `full1-${Date.now()}@test.local`,
+ phone: '555-0109',
+ });
+
+ await registration.navigateViaUrl(eventId);
+ await expect(registration.fullEventNotice).toBeVisible();
+
+ await loginAsAdmin(page);
+ await cancelEvent(page, eventId);
+ });
+});
diff --git a/e2e/tests/rooms.spec.ts b/e2e/tests/rooms.spec.ts
new file mode 100644
index 0000000..943ebe5
--- /dev/null
+++ b/e2e/tests/rooms.spec.ts
@@ -0,0 +1,43 @@
+import { test, expect } from '@playwright/test';
+
+import { loginAsAdmin } from './helpers/admin-session';
+import { EventsAddPage } from './pages/events-add.page';
+import { RoomsPage } from './pages/rooms.page';
+
+test.describe('Rooms admin CRUD', () => {
+ test('admin manages rooms and exposes them on the event form', async ({ page }) => {
+ const roomName = 'Test Room E2E';
+ const exclusiveRoomName = 'Conference Room';
+ const nonExclusiveRoomName = 'Common Area';
+ const rooms = new RoomsPage(page);
+ const eventsAdd = new EventsAddPage(page);
+
+ await loginAsAdmin(page);
+ await rooms.navigateViaMenu();
+ await expect(rooms.heading).toBeVisible();
+
+ // Remove leftovers from a previous run that failed before delete.
+ if ((await rooms.row(roomName).count()) > 0) {
+ await rooms.delete(roomName);
+ }
+
+ await rooms.expectExclusiveUse(exclusiveRoomName, true);
+ await rooms.expectExclusiveUse(nonExclusiveRoomName, false);
+
+ await rooms.addRoom(roomName, false);
+ await rooms.expectRowVisible(roomName);
+ await rooms.expectExclusiveUse(roomName, false);
+
+ await rooms.openEdit(roomName);
+ await rooms.saveRoom(roomName, true);
+ await rooms.expectExclusiveUse(roomName, true);
+
+ await eventsAdd.navigateViaMenu();
+ await eventsAdd.expectRoomOption(roomName);
+ await eventsAdd.expectRoomOption(exclusiveRoomName);
+
+ await rooms.navigateViaMenu();
+ await rooms.delete(roomName);
+ await rooms.expectRowHidden(roomName);
+ });
+});
diff --git a/e2e/tests/tools.spec.ts b/e2e/tests/tools.spec.ts
new file mode 100644
index 0000000..aaf633a
--- /dev/null
+++ b/e2e/tests/tools.spec.ts
@@ -0,0 +1,39 @@
+import { test, expect } from '@playwright/test';
+
+import { loginAsAdmin } from './helpers/admin-session';
+import { EventsAddPage } from './pages/events-add.page';
+import { ToolsPage } from './pages/tools.page';
+
+test.describe('Tools admin CRUD', () => {
+ test('admin manages tools and exposes them on the event form', async ({ page }) => {
+ const toolName = 'Test Tool E2E';
+ const toolUpdatedName = 'Test Tool E2E Updated';
+ const tools = new ToolsPage(page);
+ const eventsAdd = new EventsAddPage(page);
+
+ await loginAsAdmin(page);
+ await tools.navigateViaMenu();
+ await expect(tools.heading).toBeVisible();
+
+ // Remove leftovers from a previous run that failed before delete.
+ for (const name of [toolUpdatedName, toolName]) {
+ if ((await tools.row(name).count()) > 0) {
+ await tools.delete(name);
+ }
+ }
+
+ await tools.addTool(toolName);
+ await tools.expectRowVisible(toolName);
+
+ await tools.openEdit(toolName);
+ await tools.saveTool(toolUpdatedName);
+ await tools.expectRowVisible(toolUpdatedName);
+
+ await eventsAdd.navigateViaMenu();
+ await eventsAdd.expectToolOption(toolUpdatedName);
+
+ await tools.navigateViaMenu();
+ await tools.delete(toolUpdatedName);
+ await tools.expectRowHidden(toolUpdatedName);
+ });
+});
diff --git a/src/Auth/AdAuthenticate.php b/src/Auth/AdAuthenticate.php
index 3a14f80..9816b76 100644
--- a/src/Auth/AdAuthenticate.php
+++ b/src/Auth/AdAuthenticate.php
@@ -111,6 +111,17 @@ public function findAdUser($username, $password)
$results[$key] = $value;
}
}
+
+ // Test OpenLDAP accepts a password bind even when userAccountControl is disabled;
+ // production AD rejects that bind, so this check only closes the test gap and
+ // should not change behavior where AD already blocks disabled accounts.
+ $userAccountControl = $results['useraccountcontrol']
+ ?? $results['UserAccountControl']
+ ?? null;
+ if ($userAccountControl !== null && ((int) $userAccountControl & 2)) {
+ return false;
+ }
+
$groups = $user->groups()->get();
$results['groups'] = [];
foreach ($groups as $g) {