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:
172
tests/README.md
172
tests/README.md
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user