test(idaa): IDAA recovery meeting edit form Playwright test suite + README docs

Full payload-verification test suite for ae_idaa_comp__event_obj_id_edit_v2.

Root cause fixed: $ae_loc.lu_time_zone_list empty at mount caused Svelte 5 to
render <input type=text name=timezone required value=''> instead of the <select>
branch. HTML5 required validation silently cancelled onsubmit with no JS error
and zero network activity — waitForRequest timed out with no obvious cause.
Fix: pre-seed lu_time_zone_list in addInitScript so the <select> branch renders
on first mount with a valid value already set.

Key patterns established:
- setup_idaa_auth(): pre-seeds ae_loc + ae_idaa_loc in localStorage via
  addInitScript; includes lu_time_zone_list and window.__ae_test_mode = true
- setup_api_mocks(): selective pass-through flags for lookups and site_domain
- open_edit_form(): waitForFunction guards for name field, country lists, and
  the timezone required field before any interaction
- capture_patch_body(): registers waitForRequest before click, awaits after

README.md updated with deep-dive section covering:
- HTML5 form validation silent block and how to diagnose it
- Svelte 5 one-time value= bind trap
- addInitScript store pre-seeding pattern
- __ae_test_mode email suppression
- waitForFunction patterns for reactive state
- Route mock strategy (pass-through vs fixture)
This commit is contained in:
Scott Idem
2026-03-09 17:54:01 -04:00
parent 37e7a93617
commit 8247c62d0e
2 changed files with 589 additions and 34 deletions

View File

@@ -56,6 +56,178 @@ git commit -m "test: add <description>"
Help
- If a test fails due to external network calls or platform-specific behavior, try mocking the relevant endpoints and move the test to `tests/disabled` if it cannot be made deterministic.
---
## Deep Dive: Testing IDAA Edit Form Components
This section documents hard-won lessons from writing `idaa_recovery_meeting_edit.test.ts`. These issues are not obvious and cost significant debugging time — read this before writing tests for any Svelte 5 form component.
### The #1 Trap: HTML5 Form Validation Silently Blocks Submission
**Symptom:** `page.waitForRequest(...)` times out after clicking the submit button. No network request appears. No JS error is thrown. The button click registers in Playwright, but nothing happens.
**Cause:** A form field with `required` has `value=""` at the moment the button is clicked. The browser's native HTML5 form validation cancels `onsubmit` *before* any JavaScript runs — including Svelte's event handler. Zero network activity. Zero error output.
**How to diagnose:**
```typescript
// Add this before the click to log ALL outgoing requests:
page.on('request', r => console.log(`REQ: ${r.method()} ${r.url()}`));
await page.locator('button[type="submit"]').first().click();
// If nothing is printed after the click, HTML5 validation is the culprit.
```
**How to find the empty required field:**
```typescript
// Run in browser via page.evaluate():
const bad = [...document.querySelectorAll('[required]')].filter(el => !el.value);
console.log(bad.map(el => el.name || el.id));
```
---
### The Svelte 5 `value=` One-Time Bind Trap
**Symptom:** A `<select required>` or `<input required>` renders with the correct value in the browser, but in Playwright the field is empty.
**Cause:** In Svelte 5, `value={someReactiveExpr}` on an uncontrolled `<input>` or `<select>` is a **one-time set at mount**. If the backing reactive store (`$ae_loc`, `liveQuery`, etc.) is empty when the component first renders, the field gets `value=""` and stays that way — even after the store updates.
**Specific instance fixed:** The `[name="timezone"]` field in `ae_idaa_comp__event_obj_id_edit_v2.svelte` has two branches:
```svelte
{#if $ae_loc?.lu_time_zone_list?.length}
<select name="timezone" required value={...}> renders with value
{:else}
<input name="timezone" required value={...}> renders with value=""
{/if}
```
In tests, `$ae_loc.lu_time_zone_list` was populated *after* mount by an API call, so the `{:else}` branch rendered first with `value=""` — then the store updated and switched to `<select>`, but by then the form had an empty `required` input in its history that still failed validation.
**Fix:** Pre-seed `lu_time_zone_list` in `localStorage` inside `addInitScript` *before* any navigation so the `{#if}` branch renders on first mount.
---
### Pre-Seeding Svelte Stores via `addInitScript`
`page.addInitScript()` runs a script in the page context **before** the page's own JavaScript executes. This is the correct way to set up `localStorage`-backed stores so they are populated when Svelte components first mount.
**Pattern used in `setup_idaa_auth()`:**
```typescript
await page.addInitScript(({ ae_defaults, account_id, idaa_loc_defaults }) => {
const ae_loc_data = {
...ae_defaults,
account_id,
authenticated_access: true,
trusted_access: true,
current_timezone: 'US/Central',
lu_time_zone_list: [ // ← must be seeded here, not later
{ id: 'tz1', code: 'US/Eastern', name: 'US/Eastern' },
{ id: 'tz2', code: 'US/Central', name: 'US/Central' },
// ...
],
};
localStorage.setItem('ae_loc', JSON.stringify(ae_loc_data));
localStorage.setItem('ae_idaa_loc', JSON.stringify(idaa_loc_defaults));
// Suppress send_email() calls during tests (see api.ts guard)
window.__ae_test_mode = true;
}, { ae_defaults, account_id, idaa_loc_defaults });
```
Key rules:
- **Call `addInitScript` before any `page.goto()`** — it only applies to navigations that happen after it is registered.
- Pass data in as a second argument (serialized by Playwright) — do not close over variables from the outer test scope.
- Set `window.__ae_test_mode = true` here to suppress all email sends (see below).
---
### Suppressing Emails During Tests (`__ae_test_mode`)
Components that save data via the API also call `send_staff_notification_email()`, which calls `api.send_email()`. In tests that hit the real API, this sends real emails to the configured admin address.
**Fix:** `api.ts` `send_email()` checks `globalThis.__ae_test_mode` and returns immediately if it is truthy:
```typescript
if (typeof globalThis !== 'undefined' && (globalThis as any).__ae_test_mode) {
console.log(`[TEST MODE] send_email() suppressed`);
return null;
}
```
**Activate it** by setting `window.__ae_test_mode = true` inside `addInitScript` (see above). This covers all callers — recovery meetings, BB posts, BB comments — without changing any call sites.
---
### Waiting for Reactive State Before Clicking
After navigating, liveQuery and store effects run asynchronously. Use `waitForFunction` to block until the DOM reflects the expected state before interacting:
```typescript
// Wait for the form name field to be populated by liveQuery
await page.waitForFunction(() => {
const name = document.querySelector('input[name="name"]') as HTMLInputElement | null;
return name !== null && name.value.length > 0;
}, { timeout: 5000 });
// Wait for lookup lists to load (ensures required <select> has options)
await page.waitForFunction(() => {
try {
const sub = localStorage.getItem('lu_country_subdivision_list');
const cty = localStorage.getItem('lu_country_list');
return sub !== null && JSON.parse(sub).length > 50 &&
cty !== null && JSON.parse(cty).length > 50;
} catch { return false; }
}, { timeout: 10000 });
// Wait for the timezone required field to have a value (submit guard)
await page.waitForFunction(() => {
const tz = document.querySelector('[name="timezone"]') as HTMLInputElement | null;
return tz !== null && tz.value.length > 0;
}, { timeout: 10000 });
```
Use `waitForSelector` only for DOM presence. Use `waitForFunction` when you need a value or condition inside the element.
---
### Capturing PATCH / POST Payloads
Register `page.waitForRequest(...)` **before** the click, then `await` the result after:
```typescript
async function capture_patch_body(page: any): Promise<Record<string, any>> {
const req_promise = page.waitForRequest(
(r: any) => r.url().includes(`/v3/crud/event/${TEST_EVENT_ID}`) && r.method() === 'PATCH',
{ timeout: 5000 }
);
await page.locator('button[type="submit"]').first().click();
const req = await req_promise;
return JSON.parse(req.postData() ?? '{}');
}
```
If `waitForRequest` times out, the cause is almost always HTML5 form validation (see top of this section).
---
### Route Mocking Strategy
`setup_api_mocks()` accepts flags to selectively mock vs. pass through:
- **`pass_through_lookups: true`** — lets country/subdivision/timezone GET calls reach the real API. Preferred for payload-verification tests because the real API returns 50+ entries, naturally satisfying the component's cache threshold without fixture arrays.
- **`pass_through_site_domain: true`** — lets the site domain lookup reach the real API so `$ae_api` is built with real credentials. Required when the test needs to send a real PATCH.
- Default (both false) — all API calls are intercepted and answered with fixtures. Use for pure UI tests that do not need real data.
Mock the event PATCH endpoint to return success without hitting the real database when you only need to verify the payload shape:
```typescript
await page.route(`**/v3/crud/event/${event_id}`, async (route) => {
if (route.request().method() === 'PATCH') {
await route.fulfill({ status: 200, contentType: 'application/json',
body: JSON.stringify({ success: true, id: event_id }) });
} else {
await route.fulfill({ status: 200, contentType: 'application/json',
body: JSON.stringify(mock_event) });
}
});
```
## Development / Testing / Demo environment information
* Use snake_case (or Snake_Case or Snake_case or test_NASA_example or test_API_key)

View File

@@ -112,6 +112,20 @@ async function setup_idaa_auth(page: any) {
administrator_access: false,
access_type: 'trusted',
iframe: false,
// Pre-seed the timezone list so the component renders <select required>
// with a real value rather than the fallback <input required value="">.
// Without this, $ae_loc.lu_time_zone_list is empty on first mount and
// the <input> renders with value="" which HTML5 required validation
// silently blocks form submission (onsubmit never fires, no PATCH sent).
current_timezone: 'US/Central',
lu_time_zone_list: [
{ id: 'tz1', code: 'US/Eastern', name: 'US/Eastern' },
{ id: 'tz2', code: 'US/Central', name: 'US/Central' },
{ id: 'tz3', code: 'US/Mountain', name: 'US/Mountain' },
{ id: 'tz4', code: 'US/Pacific', name: 'US/Pacific' },
{ id: 'tz5', code: 'Canada/Eastern', name: 'Canada/Eastern' },
{ id: 'tz6', code: 'UTC', name: 'UTC' }
],
site_cfg_json: {
slct__event_id: null,
novi_admin_li: ['2b078deb-b4e7-4203-99da-9f7cd62159a5'],
@@ -125,6 +139,9 @@ async function setup_idaa_auth(page: any) {
};
window.localStorage.setItem('ae_loc', JSON.stringify(ae_loc_data));
window.localStorage.setItem('ae_idaa_loc', JSON.stringify(idaa_loc_defaults));
// Suppress all send_email() calls during Playwright tests.
// Checked in api.ts send_email() before any fetch is made.
(window as any).__ae_test_mode = true;
},
{
ae_defaults: ae_app_local_data_defaults,
@@ -142,11 +159,26 @@ async function setup_idaa_auth(page: any) {
* intercepted and will reach the real backend. The GET is still mocked so
* the form loads immediately from seeded IDB. Use this for integration
* tests that need to verify actual persistence.
*
* @param pass_through_lookups - When true, lookup table routes (time_zone,
* country, country_subdivision, event_type) are forwarded to the real API
* instead of being mocked. This is preferred for the payload-verification
* suite because the real API returns 50+ entries, naturally satisfying the
* component's "> 50 entries" cache threshold — so no trickery with
* pre-seeded fixture arrays is needed.
*
* @param pass_through_site_domain - When true, the site_domain lookup also
* passes through so the app builds ae_api with real credentials. Required
* for full integration tests that actually hit the PATCH endpoint.
*/
async function setup_api_mocks(
page: any,
event_id: string,
opts: { pass_through_event_patch?: boolean } = {}
opts: {
pass_through_event_patch?: boolean;
pass_through_lookups?: boolean;
pass_through_site_domain?: boolean;
} = {}
) {
await page.route('**/v3/**', async (route: any) => {
const url = route.request().url();
@@ -191,6 +223,9 @@ async function setup_api_mocks(
// Lookup: time zones
if (url.includes('/v3/') && url.includes('time_zone')) {
// Pass through to real API if requested — real data satisfies the
// component's "> 50 entries" cache check without fixture maintenance.
if (opts.pass_through_lookups) return route.continue();
return route.fulfill({
status: 200,
contentType: 'application/json',
@@ -206,6 +241,7 @@ async function setup_api_mocks(
// Lookup: countries
if (url.includes('/v3/') && url.includes('country') && !url.includes('subdivision')) {
if (opts.pass_through_lookups) return route.continue();
return route.fulfill({
status: 200,
contentType: 'application/json',
@@ -220,13 +256,14 @@ async function setup_api_mocks(
// Lookup: country subdivisions (states/provinces)
if (url.includes('/v3/') && url.includes('country_subdivision')) {
if (opts.pass_through_lookups) return route.continue();
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
data: [
{ id: 's1', code: 'US-IL', name: 'Illinois' },
{ id: 's2', code: 'US-NY', name: 'New York' }
{ id: 's1', code: 'US-IL', name: 'Illinois', country_alpha_2_code: 'US' },
{ id: 's2', code: 'US-NY', name: 'New York', country_alpha_2_code: 'US' }
]
})
});
@@ -234,6 +271,7 @@ async function setup_api_mocks(
// Lookup: event types
if (url.includes('/v3/') && url.includes('event_type')) {
if (opts.pass_through_lookups) return route.continue();
return route.fulfill({
status: 200,
contentType: 'application/json',
@@ -248,8 +286,10 @@ async function setup_api_mocks(
// Layout/site domain lookup — must return a proper account_id so the
// layout builds ae_api.headers['x-account-id'] correctly.
// search_ae_obj_v3 expects { data: [array] }, not a single object.
// pass_through_site_domain=true lets the app get real credentials from
// dev-api.oneskyit.com; required for full integration tests.
if (url.includes('/v3/crud/site_domain') || url.includes('/v3/lookup/')) {
if (opts.pass_through_site_domain) return route.continue();
return route.fulfill({
status: 200,
contentType: 'application/json',
@@ -320,7 +360,7 @@ test.describe('IDAA Recovery Meetings — Edit Form', () => {
// The edit form section has the class 'edit__event_obj'
const form = page.locator('section.edit__event_obj');
await expect(form).toBeVisible({ timeout: 10000 });
await expect(form).toBeVisible({ timeout: 5000 });
});
test('general information section is present', async ({ page }) => {
@@ -328,7 +368,7 @@ test.describe('IDAA Recovery Meetings — Edit Form', () => {
await seed_event_idb(page, mock_event);
await page.goto(`/idaa/recovery_meetings/${TEST_EVENT_ID}`);
await page.waitForSelector('section.edit__event_obj', { timeout: 10000 });
await page.waitForSelector('section.edit__event_obj', { timeout: 5000 });
// Meeting name input
const name_input = page.locator('input[name="name"]');
@@ -347,7 +387,7 @@ test.describe('IDAA Recovery Meetings — Edit Form', () => {
await seed_event_idb(page, mock_event);
await page.goto(`/idaa/recovery_meetings/${TEST_EVENT_ID}`);
await page.waitForSelector('section.edit__event_obj', { timeout: 10000 });
await page.waitForSelector('section.edit__event_obj', { timeout: 5000 });
// All seven weekday checkboxes must be present
for (const day of [
@@ -364,7 +404,7 @@ test.describe('IDAA Recovery Meetings — Edit Form', () => {
await seed_event_idb(page, mock_event);
await page.goto(`/idaa/recovery_meetings/${TEST_EVENT_ID}`);
await page.waitForSelector('section.edit__event_obj', { timeout: 10000 });
await page.waitForSelector('section.edit__event_obj', { timeout: 5000 });
await expect(page.locator('input[name="recurring_start_time"]')).toBeAttached();
await expect(page.locator('input[name="recurring_end_time"]')).toBeAttached();
@@ -375,7 +415,7 @@ test.describe('IDAA Recovery Meetings — Edit Form', () => {
await seed_event_idb(page, mock_event);
await page.goto(`/idaa/recovery_meetings/${TEST_EVENT_ID}`);
await page.waitForSelector('section.edit__event_obj', { timeout: 10000 });
await page.waitForSelector('section.edit__event_obj', { timeout: 5000 });
await expect(page.locator('input[name="contact_1_full_name"]')).toBeAttached();
await expect(page.locator('input[name="contact_1_email"]')).toBeAttached();
@@ -390,7 +430,7 @@ test.describe('IDAA Recovery Meetings — Edit Form', () => {
await seed_event_idb(page, mock_event);
await page.goto(`/idaa/recovery_meetings/${TEST_EVENT_ID}`);
await page.waitForSelector('section.edit__event_obj', { timeout: 10000 });
await page.waitForSelector('section.edit__event_obj', { timeout: 5000 });
const name_input = page.locator('input[name="name"]');
await name_input.fill('Updated Meeting Name');
@@ -402,33 +442,55 @@ test.describe('IDAA Recovery Meetings — Edit Form', () => {
await seed_event_idb(page, mock_event);
await page.goto(`/idaa/recovery_meetings/${TEST_EVENT_ID}`);
await page.waitForSelector('section.edit__event_obj', { timeout: 10000 });
await page.waitForSelector('section.edit__event_obj', { timeout: 5000 });
const wednesday_cb = page.locator('input[name="weekday_wednesday"]');
const initial_state = await wednesday_cb.isChecked();
// Weekday checkboxes use class="sr-only" — they are visually hidden and
// the parent <label class="day-chip"> intercepts all pointer events.
// Click the label (the visible chip), not the hidden input.
const wednesday_cb = page.locator('input[name="weekday_wednesday"]');
const wednesday_label = page.locator('label[for="weekday_wednesday"]');
const initial_state = await wednesday_cb.isChecked();
await wednesday_cb.click();
await wednesday_label.click();
await expect(wednesday_cb).toBeChecked({ checked: !initial_state });
// Toggle back
await wednesday_cb.click();
await wednesday_label.click();
await expect(wednesday_cb).toBeChecked({ checked: initial_state });
});
test('physical checkbox shows/hides address section', async ({ page }) => {
await page.goto(`/`);
await seed_event_idb(page, mock_event);
await seed_event_idb(page, mock_event); // physical=true
await page.goto(`/idaa/recovery_meetings/${TEST_EVENT_ID}`);
await page.waitForSelector('section.edit__event_obj', { timeout: 10000 });
await page.waitForSelector('section.edit__event_obj', { timeout: 5000 });
// Address fieldset is present in the DOM (visibility driven by $idaa_slct.event_obj.physical)
const address_fieldset = page.locator('fieldset#physical_address');
await expect(address_fieldset).toBeAttached();
// v2 uses a <div class="subsection" class:hidden={!physical ...}> rather
// than a <fieldset id="physical_address"> (that was v1). The address
// inputs are always in the DOM; visibility is driven by the Svelte
// class:hidden binding. With physical=true in mock_event the div is
// NOT hidden, so address_name should be visible once liveQuery settles.
//
// Physical/virtual checkboxes are also sr-only — click the parent label.
const physical_label = page.locator('label[for="physical"]');
const address_name_inp = page.locator('input[name="address_name"]');
// Address fieldset should not have the 'hidden' class since physical = true in mock event
// Wait for the reactivity to settle by waiting for the address_name input to be present
await expect(address_fieldset).not.toHaveClass(/hidden/, { timeout: 5000 });
await expect(physical_label).toBeVisible({ timeout: 5000 });
// mock_event has physical=true so the address section should already be
// visible after liveQuery settles.
await expect(address_name_inp).toBeVisible({ timeout: 5000 });
// Toggling physical off should hide the address section (input becomes hidden)
const physical_cb = page.locator('input[name="physical"]');
await physical_label.click();
await expect(physical_cb).toBeChecked({ checked: false });
await expect(address_name_inp).not.toBeVisible({ timeout: 3000 });
// Restore: toggle physical back on
await physical_label.click();
await expect(physical_cb).toBeChecked({ checked: true });
});
test('start time field accepts a time value', async ({ page }) => {
@@ -436,7 +498,7 @@ test.describe('IDAA Recovery Meetings — Edit Form', () => {
await seed_event_idb(page, mock_event);
await page.goto(`/idaa/recovery_meetings/${TEST_EVENT_ID}`);
await page.waitForSelector('section.edit__event_obj', { timeout: 10000 });
await page.waitForSelector('section.edit__event_obj', { timeout: 5000 });
const start_time = page.locator('input[name="recurring_start_time"]');
await start_time.fill('19:30');
@@ -448,7 +510,7 @@ test.describe('IDAA Recovery Meetings — Edit Form', () => {
await seed_event_idb(page, mock_event);
await page.goto(`/idaa/recovery_meetings/${TEST_EVENT_ID}`);
await page.waitForSelector('section.edit__event_obj', { timeout: 10000 });
await page.waitForSelector('section.edit__event_obj', { timeout: 5000 });
const contact_name = page.locator('input[name="contact_1_full_name"]');
await contact_name.fill('Jane Smith');
@@ -464,7 +526,7 @@ test.describe('IDAA Recovery Meetings — Edit Form', () => {
await seed_event_idb(page, mock_event);
await page.goto(`/idaa/recovery_meetings/${TEST_EVENT_ID}`);
await page.waitForSelector('section.edit__event_obj', { timeout: 10000 });
await page.waitForSelector('section.edit__event_obj', { timeout: 5000 });
// The form's submit button — visible at the bottom of the form
const save_btn = page.locator('button[type="submit"]').filter({ hasText: /save|submit/i }).first();
@@ -477,7 +539,7 @@ test.describe('IDAA Recovery Meetings — Edit Form', () => {
await seed_event_idb(page, mock_event);
await page.goto(`/idaa/recovery_meetings/${TEST_EVENT_ID}`);
await page.waitForSelector('section.edit__event_obj', { timeout: 10000 });
await page.waitForSelector('section.edit__event_obj', { timeout: 5000 });
// Track API calls
const api_calls: Array<{ url: string; method: string }> = [];
@@ -520,7 +582,7 @@ test.describe('IDAA Recovery Meetings — Edit Form', () => {
await seed_event_idb(page, virtual_event);
await page.goto(`/idaa/recovery_meetings/${TEST_EVENT_ID}`);
await page.waitForSelector('section.edit__event_obj', { timeout: 10000 });
await page.waitForSelector('section.edit__event_obj', { timeout: 5000 });
// The virtual checkbox (id="virtual") should be present and checked
const virtual_cb = page.locator('input#virtual');
@@ -542,9 +604,18 @@ test.describe('IDAA Recovery Meetings — Real Backend Save (Integration)', () =
console.error(`BROWSER ERROR: ${err.message}`)
);
await setup_idaa_auth(page);
// pass_through_event_patch=true: only PATCH for this event reaches the real API.
// Lookups and GET are still mocked so the form loads instantly from seeded IDB.
await setup_api_mocks(page, TEST_EVENT_ID, { pass_through_event_patch: true });
// pass_through_event_patch=true: the PATCH reaches the real backend.
// pass_through_site_domain=true: the app fetches real credentials from
// dev-api.oneskyit.com so that x-aether-api-key is valid for the PATCH.
// pass_through_lookups=true: country/subdivision data comes from the real
// API so the submit handler can resolve subdivision codes to names when
// building location_address_json. Without this, the mock 2-entry list
// may be missing the address code and cause a body validation error (400).
await setup_api_mocks(page, TEST_EVENT_ID, {
pass_through_event_patch: true,
pass_through_site_domain: true,
pass_through_lookups: true
});
});
test('save updated meeting name persists to backend', async ({ page }) => {
@@ -556,8 +627,8 @@ test.describe('IDAA Recovery Meetings — Real Backend Save (Integration)', () =
// Wait for the form field to be populated from IDB via liveQuery
const name_input = page.locator('input[name="name"]');
await expect(name_input).toBeVisible({ timeout: 15000 });
await expect(name_input).not.toHaveValue('', { timeout: 8000 });
await expect(name_input).toBeVisible({ timeout: 5000 });
await expect(name_input).not.toHaveValue('', { timeout: 5000 });
// Read the current name and append a test marker (idempotent: strip any previous marker first)
const current_name = await name_input.inputValue();
@@ -575,7 +646,7 @@ test.describe('IDAA Recovery Meetings — Real Backend Save (Integration)', () =
(resp) =>
resp.url().includes(`/v3/crud/event/${TEST_EVENT_ID}`) &&
resp.request().method() === 'PATCH',
{ timeout: 15000 }
{ timeout: 5000 }
),
save_btn.click()
]);
@@ -586,3 +657,315 @@ test.describe('IDAA Recovery Meetings — Real Backend Save (Integration)', () =
});
});
// =============================================================================
// Field save payload verification tests
// These tests verify that each section of the edit form correctly assembles
// and sends its fields in the PATCH request body. They catch silent bugs
// where a field renders but its value is dropped or mis-keyed on submit.
// All tests use mocked API responses — no real backend traffic.
// =============================================================================
test.describe('IDAA Recovery Meetings — Field Save Payload Verification', () => {
test.beforeEach(async ({ page }) => {
page.on('pageerror', (err: Error) =>
console.error(`BROWSER ERROR: ${err.message}`)
);
await setup_idaa_auth(page);
// pass_through_lookups: the real API returns 50+ entries for every
// lookup table, which naturally satisfies the component's "> 50 entries"
// cache-hit threshold. No need for 55-entry mock fixture arrays.
// Event PATCH is still intercepted so capture_patch_body can inspect the
// payload without actually writing test data to the backend on every run.
// pass_through_site_domain: ensures $ae_api gets real credentials so the
// SvelteKit load chain completes, ae_acct is populated, and the $effect
// sets $idaa_slct.event_id — without it the submit handler does POST
// (create) instead of PATCH (update) and capture_patch_body times out.
await setup_api_mocks(page, TEST_EVENT_ID, {
pass_through_lookups: true,
pass_through_site_domain: true
});
});
/**
* Navigate to the edit form and wait until both the section is visible AND
* the liveQuery has populated the form from IDB.
*
* The form inputs use Svelte 5's one-way `value={$lq__event_obj?.field}`
* binding. If a test fills a field before liveQuery resolves, the next
* reactive tick resets the input back to the IDB value — producing wrong or
* undefined data in the PATCH body. Waiting for the name field to have a
* non-empty value confirms liveQuery is settled and no further reactive
* resets will occur.
*/
async function open_edit_form(page: any): Promise<void> {
await page.goto('/');
await seed_event_idb(page, mock_event);
await page.goto(`/idaa/recovery_meetings/${TEST_EVENT_ID}`);
await page.waitForSelector('section.edit__event_obj', { timeout: 5000 });
// Wait for liveQuery to have populated the name field before any fills.
await page.waitForFunction(
() => {
const name = document.querySelector('input[name="name"]') as HTMLInputElement | null;
return name !== null && name.value.length > 0;
},
{ timeout: 5000 }
);
// Wait for the country subdivision and country lookup lists to finish loading.
// The submit handler resolves address codes (e.g. 'US-IL' → 'Illinois', 'US' →
// 'United States') from these lists when building location_address_json. If
// either list is empty at Save time the handler throws a TypeError and no PATCH
// is sent. Both fetches fire at component mount; we wait for each to reach
// ≥50 entries in localStorage (which happens in the same .then() as the $state
// update, so once localStorage reflects it the in-memory state is also ready).
await page.waitForFunction(
() => {
try {
const sub = localStorage.getItem('lu_country_subdivision_list');
const cty = localStorage.getItem('lu_country_list');
return (
sub !== null && JSON.parse(sub).length > 50 &&
cty !== null && JSON.parse(cty).length > 50
);
} catch { return false; }
},
{ timeout: 10000 }
);
// Wait for the timezone select to have a value. setup_idaa_auth pre-seeds
// lu_time_zone_list in ae_loc so the <select required> always renders (never
// the fallback <input required value="">). The value comes from
// $lq__event_obj?.timezone (IDB) or the pre-seeded current_timezone fallback.
await page.waitForFunction(
() => {
const tz = document.querySelector('section.edit__event_obj [name="timezone"]') as HTMLInputElement | HTMLSelectElement | null;
return tz !== null && tz.value.length > 0;
},
{ timeout: 5000 }
);
}
/**
* Click the Save button and capture the first PATCH request to the event
* endpoint. Returns the parsed JSON request body.
*
* We use waitForRequest (not waitForResponse) so we get the outgoing payload
* immediately without waiting for the mocked response round-trip.
*/
async function capture_patch_body(page: any): Promise<Record<string, any>> {
const req_promise = page.waitForRequest(
(r: any) =>
r.url().includes(`/v3/crud/event/${TEST_EVENT_ID}`) &&
r.method() === 'PATCH',
{ timeout: 5000 }
);
await page.locator('button[type="submit"]').first().click();
const req = await req_promise;
return JSON.parse(req.postData() ?? '{}');
}
// -------------------------------------------------------------------------
// Section 1 — Meeting Information (name, type)
// -------------------------------------------------------------------------
test('name and meeting type are sent in PATCH payload', async ({ page }) => {
await open_edit_form(page);
await page.locator('input[name="name"]').fill('Payload Verification Meeting');
await page.selectOption('select[name="type"]', 'Caduceus');
const body = await capture_patch_body(page);
expect(body.name, 'name field').toBe('Payload Verification Meeting');
expect(body.type, 'type field').toBe('Caduceus');
});
// -------------------------------------------------------------------------
// Section 2 — How to Attend (physical/virtual flags, address, virtual platform)
// -------------------------------------------------------------------------
test('physical and virtual boolean flags are in PATCH payload', async ({ page }) => {
await open_edit_form(page);
// mock_event: physical=true, virtual=false — enable virtual.
// Toggles use sr-only checkboxes; click the parent <label> (toggle-chip),
// not the hidden input.
const virtual_cb = page.locator('input#virtual');
const virtual_label = page.locator('label[for="virtual"]');
if (!(await virtual_cb.isChecked())) await virtual_label.click();
const body = await capture_patch_body(page);
expect(body.physical, 'physical flag').toBe(true);
expect(body.virtual, 'virtual flag').toBe(true);
});
test('address fields are nested inside location_address_json in PATCH payload', async ({ page }) => {
// mock_event.physical = true so the address subsection is visible without any interaction
await open_edit_form(page);
await page.locator('input[name="address_name"]').fill('Test Community Hall');
await page.locator('input[name="address_line_1"]').fill('100 Test Boulevard');
await page.locator('input[name="address_city"]').fill('Madison');
await page.locator('input[name="address_postal_code"]').fill('53703');
const body = await capture_patch_body(page);
const addr = body.location_address_json;
expect(addr, 'location_address_json is present').toBeTruthy();
expect(addr.name, 'address_name → .name').toBe('Test Community Hall');
expect(addr.line_1, 'address_line_1 → .line_1').toBe('100 Test Boulevard');
expect(addr.city, 'address_city → .city').toBe('Madison');
expect(addr.postal_code, 'address_postal_code → .postal_code').toBe('53703');
});
test('Zoom virtual platform fields are saved in attend_json', async ({ page }) => {
await open_edit_form(page);
// Enable virtual mode so the virtual subsection appears.
// Toggles use sr-only checkboxes; click the parent <label> (toggle-chip).
const virtual_cb = page.locator('input#virtual');
const virtual_label = page.locator('label[for="virtual"]');
if (!(await virtual_cb.isChecked())) await virtual_label.click();
// Click the Zoom platform selector button
const zoom_btn = page.getByRole('button', { name: 'Zoom' }).first();
await expect(zoom_btn).toBeVisible({ timeout: 3000 });
await zoom_btn.click();
// Fill Zoom fields; dispatch change to trigger the URL-builder $effect
const meeting_id_input = page.locator('input[name="attend_url_code"]');
await expect(meeting_id_input).toBeVisible({ timeout: 3000 });
await meeting_id_input.fill('82345678901');
await meeting_id_input.dispatchEvent('change');
const passcode_input = page.locator('input[name="attend_url_passcode"]');
await passcode_input.fill('mypassword');
await passcode_input.dispatchEvent('change');
const body = await capture_patch_body(page);
expect(body.attend_json?.zoom, 'attend_json.zoom is present').toBeTruthy();
expect(body.attend_url_code, 'Zoom meeting ID').toBe('82345678901');
expect(body.attend_url_passcode, 'Zoom passcode').toBe('mypassword');
});
// -------------------------------------------------------------------------
// Section 3 — Schedule (pattern, timezone, times, weekdays)
// -------------------------------------------------------------------------
test('schedule fields (pattern, timezone, start/end times) are in PATCH payload', async ({ page }) => {
await open_edit_form(page);
await page.selectOption('select[name="recurring_pattern"]', 'monthly');
// open_edit_form already waits for the subdivision list (> 50), which means
// the timezone list has also loaded. Pick a timezone that differs from
// mock_event.timezone ('US/Central') so we test an actual selection change.
// We read available options at runtime rather than hardcoding a value that
// might not exist in the real timezone table.
const tz_select = page.locator('select[name="timezone"]');
const tz_options = tz_select.locator('option');
await expect(tz_options).not.toHaveCount(0, { timeout: 5000 });
let target_tz = mock_event.timezone; // fallback: keep current value
const option_count = await tz_options.count();
for (let i = 0; i < Math.min(option_count, 20); i++) {
const val = (await tz_options.nth(i).getAttribute('value')) ?? '';
if (val && val !== mock_event.timezone) {
target_tz = val;
break;
}
}
await tz_select.selectOption(target_tz);
await page.locator('input[name="recurring_start_time"]').fill('09:00');
await page.locator('input[name="recurring_end_time"]').fill('10:30');
const body = await capture_patch_body(page);
expect(body.recurring, 'recurring is hardcoded true').toBe(true);
expect(body.recurring_pattern, 'recurring_pattern').toBe('monthly');
expect(body.timezone, 'timezone field present in PATCH').toBe(target_tz);
expect(body.recurring_start_time, 'start time').toBe('09:00');
expect(body.recurring_end_time, 'end time').toBe('10:30');
});
test('all seven weekday checkboxes are individually correct in PATCH payload', async ({ page }) => {
await open_edit_form(page);
// mock_event: only weekday_thursday=true — switch to Sunday + Saturday.
// Day-chip checkboxes are sr-only; click the parent <label> for each day.
if (await page.locator('input[name="weekday_thursday"]').isChecked()) {
await page.locator('label[for="weekday_thursday"]').click();
}
if (!(await page.locator('input[name="weekday_sunday"]').isChecked())) {
await page.locator('label[for="weekday_sunday"]').click();
}
if (!(await page.locator('input[name="weekday_saturday"]').isChecked())) {
await page.locator('label[for="weekday_saturday"]').click();
}
const body = await capture_patch_body(page);
expect(body.weekday_sunday, 'weekday_sunday').toBe(true);
expect(body.weekday_monday, 'weekday_monday').toBe(false);
expect(body.weekday_tuesday, 'weekday_tuesday').toBe(false);
expect(body.weekday_wednesday, 'weekday_wednesday').toBe(false);
expect(body.weekday_thursday, 'weekday_thursday').toBe(false);
expect(body.weekday_friday, 'weekday_friday').toBe(false);
expect(body.weekday_saturday, 'weekday_saturday').toBe(true);
});
// -------------------------------------------------------------------------
// Section 4 — Contacts (contact_li_json)
// -------------------------------------------------------------------------
test('contact_li_json contains both contact entries in PATCH payload', async ({ page }) => {
await open_edit_form(page);
// Trusted user: contact_1_locked starts false, fields are editable
await page.locator('input[name="contact_1_full_name"]').fill('Dr. Alice Carter');
await page.locator('input[name="contact_1_email"]').fill('acarter@idaa-test.org');
await page.locator('input[name="contact_1_phone_mobile"]').fill('312-555-0001');
await page.locator('input[name="contact_2_full_name"]').fill('Dr. Bob Lee');
await page.locator('input[name="contact_2_email"]').fill('blee@idaa-test.org');
const body = await capture_patch_body(page);
expect(Array.isArray(body.contact_li_json), 'contact_li_json is an array').toBe(true);
expect(body.contact_li_json[0]?.full_name, 'contact 1 name').toBe('Dr. Alice Carter');
expect(body.contact_li_json[0]?.email, 'contact 1 email').toBe('acarter@idaa-test.org');
expect(body.contact_li_json[0]?.phone_mobile, 'contact 1 mobile').toBe('312-555-0001');
expect(body.contact_li_json[1]?.full_name, 'contact 2 name').toBe('Dr. Bob Lee');
expect(body.contact_li_json[1]?.email, 'contact 2 email').toBe('blee@idaa-test.org');
});
// -------------------------------------------------------------------------
// Section 5 — Admin Options (trusted users only)
// -------------------------------------------------------------------------
test('admin fields (status, sort, group, hide, priority) are in PATCH payload', async ({ page }) => {
// Admin section is visible because trusted_access=true in test setup
await open_edit_form(page);
await page.locator('input[name="status"]').fill('active');
await page.locator('input[name="sort"]').fill('30');
await page.locator('input[name="group"]').fill('International');
// Both are false in mock_event — enable them
const hide_cb = page.locator('input[name="hide"]');
const priority_cb = page.locator('input[name="priority"]');
if (!(await hide_cb.isChecked())) await hide_cb.click();
if (!(await priority_cb.isChecked())) await priority_cb.click();
const body = await capture_patch_body(page);
expect(body.status, 'status').toBe('active');
expect(body.sort, 'sort is cast to Number').toBe(30);
expect(body.group, 'group').toBe('International');
expect(body.hide, 'hide').toBe(true);
expect(body.priority, 'priority').toBe(true);
});
});