Files
OSIT-AE-App-Svelte/tests/idaa_recovery_meeting_edit.test.ts
Scott Idem f2765d6a5e feat(idaa): enforce mandatory Novi UUID linkage for member content
CRITICAL IDENTITY FIX:
Ensures all member-generated content (Meetings, Posts, Comments) is explicitly linked to the creator's Novi UUID via 'external_person_id' at the moment of creation.

Changes:
- Added 'external_person_id' to creation payloads in Recovery Meetings and BB Posts.
- Implemented 'identity scavenging' from localStorage in submit handlers to prevent race conditions where Svelte stores are briefly null.
- Refactored Post Comment edit component to robustly initialize and save creator identity.
- Added 'The Novi UUID Rule' to IDAA documentation to mandate this pattern for future development.
- Added Playwright test to verify creation linkage and fixed a version-mismatch bug in the test auth helper.

Note: Archives and Archive Content are excluded as they do not require member ownership.
2026-04-07 22:07:53 -04:00

1083 lines
49 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Playwright tests: IDAA Recovery Meetings — Edit Form
*
* These tests cover the edit/create form for IDAA Recovery Meeting events.
* They simulate an authenticated IDAA member with trusted-level access.
*
* Test data:
* - Recovery Meeting event ID: 'IDAA_RM_TEST01' (fixture — not a real record)
* - Novi UUID (trusted member): 'c9ea07b5-06b0-4a43-a2d0-8d06558c8a82'
*
* NOTE: These fixtures are synthetic. API calls are fully mocked so no
* real backend traffic is required.
*/
import { test, expect } from '@playwright/test';
import { ae_app_local_data_defaults } from './_helpers/ae_defaults';
import { testing_account_id, mock_site_domain } from './_helpers/env';
// --- Test fixtures -----------------------------------------------------------
const TEST_EVENT_ID = '1Pkd025vvxU';// Per README test data
const TEST_NOVI_UUID = 'c9ea07b5-06b0-4a43-a2d0-8d06558c8a82'; // in default novi_trusted_li
const TEST_NOVI_NAME = 'IDAA Test Novi Member';
const TEST_NOVI_EMAIL = 'test+novi-member@oneskyit.com';
/** Minimal mock event object returned by the API. */
const mock_event = {
id: TEST_EVENT_ID,
event_id: TEST_EVENT_ID,
account_id: testing_account_id,
name: 'Thursday Night Serenity Group',
type: 'IDAA',
physical: true,
virtual: false,
address_name: 'Community Center',
address_city: 'Chicago',
address_country_subdivision_code: 'US-IL',
address_country_alpha_2_code: 'US',
timezone: 'US/Central',
recurring: true,
recurring_pattern: 'Weekly',
weekday_thursday: true,
recurring_start_time: '19:00',
recurring_end_time: '20:00',
recurring_text: '*gen* Weekly: Thursday at 7:00 PM US/Central',
contact_li_json: [
{ full_name: TEST_NOVI_NAME, email: TEST_NOVI_EMAIL, phone_mobile: null },
{ full_name: null, email: null }
],
location_text: '<p>Main entrance on Oak Street.</p>',
attend_text: null,
attend_json: {},
notes: null,
enable: true,
hide: false,
priority: false,
sort: null,
cfg_json: {},
data_json: {}
};
// --- Shared setup helpers ----------------------------------------------------
/**
* Build the ae_idaa_loc localStorage value for a trusted IDAA member who has
* the edit form open.
*/
function build_idaa_loc_defaults(opts: { edit_event_obj?: boolean } = {}) {
return {
__version: 1, // MUST MATCH IDAA_LOC_VERSION in store_versions.ts
ver: '2024-08-21_1646',
ver_idb: '2024-08-21_1645',
novi_uuid: TEST_NOVI_UUID,
novi_email: TEST_NOVI_EMAIL,
novi_full_name: TEST_NOVI_NAME,
novi_admin_li: ['2b078deb-b4e7-4203-99da-9f7cd62159a5'],
novi_trusted_li: [TEST_NOVI_UUID, '58db22ee-4b0a-49a7-9f34-53d2ba85a84b'],
novi_jitsi_mod_li: [],
novi_meetings_base_url: 'https://www.idaa.org/idaa-meetings',
recovery_meetings: {
edit__event_obj: opts.edit_event_obj ?? false,
qry__enabled: 'enabled',
qry__hidden: 'not_hidden',
qry__limit: 150,
qry__order_by: 'updated_on',
qry__order_by_li: { priority: 'DESC', sort: 'DESC', updated_on: 'DESC' },
qry__offset: 0,
qry__fulltext_str: null,
qry__physical: null,
qry__type: null,
qry__virtual: null
},
bb: { edit_kv: {}, edit__post_obj: null, edit__post_comment_obj: null },
archives: { edit_kv: {}, edit__archive_obj: null, edit__archive_content_obj: null },
ds: {},
idaa_cfg_json: {}
};
}
/**
* Inject ae_loc + ae_idaa_loc into localStorage so the IDAA layout
* authenticates as a trusted member with the edit form open.
* Must be called via page.addInitScript BEFORE any navigation.
*/
async function setup_idaa_auth(page: any) {
await page.addInitScript(
({ ae_defaults, account_id, idaa_loc_defaults }: any) => {
const ae_loc_data = {
...ae_defaults,
account_id,
authenticated_access: true,
trusted_access: true,
administrator_access: false,
access_type: 'trusted',
edit_mode: true,
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'],
novi_trusted_li: [
'c9ea07b5-06b0-4a43-a2d0-8d06558c8a82',
'58db22ee-4b0a-49a7-9f34-53d2ba85a84b'
],
admin_email: 'admin@oneskyit.com',
noreply_email: 'noreply@oneskyit.com'
}
};
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,
account_id: testing_account_id,
idaa_loc_defaults: build_idaa_loc_defaults({ edit_event_obj: true })
}
);
}
/**
* Register route mocks for the V3 CRUD API and common lookup tables.
* Called inside each test after page creation.
*
* @param pass_through_event_patch - When true, the event PATCH (save) is NOT
* 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;
pass_through_lookups?: boolean;
pass_through_site_domain?: boolean;
} = {}
) {
await page.route('**/v3/**', async (route: any) => {
const url = route.request().url();
const method = route.request().method();
// Event GET by ID
if (url.includes(`/v3/crud/event/${event_id}`) && method === 'GET') {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ data: mock_event })
});
}
// Event PATCH (update)
if (url.includes(`/v3/crud/event/${event_id}`) && method === 'PATCH') {
if (opts.pass_through_event_patch) return route.continue();
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ data: { ...mock_event, event_id } })
});
}
// Event POST (create new)
if (url.includes('/v3/crud/event') && method === 'POST') {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ data: { ...mock_event, event_id: 'NEW_EVENT_123' } })
});
}
// Event list search (POST to search endpoint)
if (url.includes('/v3/crud/event') && url.includes('search') && method === 'POST') {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ data: [mock_event], meta: { total: 1 } })
});
}
// 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',
body: JSON.stringify({
data: [
{ id: 'tz1', code: 'US/Central', name: 'US/Central' },
{ id: 'tz2', code: 'US/Eastern', name: 'US/Eastern' },
{ id: 'tz3', code: 'US/Pacific', name: 'US/Pacific' }
]
})
});
}
// 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',
body: JSON.stringify({
data: [
{ id: 'c1', alpha_2_code: 'US', english_short_name: 'United States' },
{ id: 'c2', alpha_2_code: 'CA', english_short_name: 'Canada' }
]
})
});
}
// 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', country_alpha_2_code: 'US' },
{ id: 's2', code: 'US-NY', name: 'New York', country_alpha_2_code: 'US' }
]
})
});
}
// 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',
body: JSON.stringify({
data: [
{ id: 'et1', code: 'aa', name: 'AA' },
{ id: 'et2', code: 'al-anon', name: 'Al-Anon' }
]
})
});
}
// Layout/site domain lookup — must return a proper account_id so the
// layout builds ae_api.headers['x-account-id'] correctly.
// 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',
body: JSON.stringify({ data: [mock_site_domain] })
});
}
// Let everything else pass through or return a generic empty success
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ data: [] })
});
});
}
/**
* Seed the ae_events_db IndexedDB with the test event so liveQuery picks it up.
*/
async function seed_event_idb(page: any, event: typeof mock_event) {
await page.evaluate((ev: typeof mock_event) => {
return new Promise<void>((resolve) => {
try {
const req = indexedDB.open('ae_events_db', 6);
req.onupgradeneeded = (e) => {
const db = (e.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains('event')) {
db.createObjectStore('event', { keyPath: 'id' });
}
};
req.onsuccess = () => {
const db = req.result;
try {
const tx = db.transaction('event', 'readwrite');
tx.objectStore('event').put(ev);
tx.oncomplete = () => { db.close(); resolve(); };
tx.onerror = () => { db.close(); resolve(); };
} catch {
db.close(); resolve();
}
};
req.onerror = () => resolve();
} catch { resolve(); }
});
}, event);
}
// --- Test suite --------------------------------------------------------------
test.describe('IDAA Recovery Meetings — Edit Form', () => {
test.beforeEach(async ({ page }) => {
page.on('pageerror', (err: Error) =>
console.error(`BROWSER ERROR: ${err.message}`)
);
await setup_idaa_auth(page);
await setup_api_mocks(page, TEST_EVENT_ID);
});
// -------------------------------------------------------------------------
// Section 1: Page load and form structure
// -------------------------------------------------------------------------
test('edit form renders after navigation to event page', async ({ page }) => {
await page.goto(`/`);
await seed_event_idb(page, mock_event);
await page.goto(`/idaa/recovery_meetings/${TEST_EVENT_ID}`);
// The edit form section has the class 'edit__event_obj'
const form = page.locator('section.edit__event_obj');
await expect(form).toBeVisible({ timeout: 5000 });
});
test('general information section is present', async ({ page }) => {
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 });
// Meeting name input
const name_input = page.locator('input[name="name"]');
await expect(name_input).toBeVisible();
// Type select (IDAA meeting types)
const type_select = page.locator('select[name="type"]');
await expect(type_select).toBeVisible();
// Verify real option values exist
await expect(type_select.locator('option[value="IDAA"]')).toBeAttached();
await expect(type_select.locator('option[value="Caduceus"]')).toBeAttached();
});
test('timing section shows day-of-week checkboxes', async ({ page }) => {
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 });
// All seven weekday checkboxes must be present
for (const day of [
'weekday_sunday', 'weekday_monday', 'weekday_tuesday',
'weekday_wednesday', 'weekday_thursday', 'weekday_friday',
'weekday_saturday'
]) {
await expect(page.locator(`input[name="${day}"]`)).toBeAttached();
}
});
test('start and end time inputs are present', async ({ page }) => {
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 });
await expect(page.locator('input[name="recurring_start_time"]')).toBeAttached();
await expect(page.locator('input[name="recurring_end_time"]')).toBeAttached();
});
test('contact section shows contact_1 fields', async ({ page }) => {
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 });
await expect(page.locator('input[name="contact_1_full_name"]')).toBeAttached();
await expect(page.locator('input[name="contact_1_email"]')).toBeAttached();
});
// -------------------------------------------------------------------------
// Section 2: Field interactions
// -------------------------------------------------------------------------
test('meeting name field accepts text input', async ({ page }) => {
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 });
const name_input = page.locator('input[name="name"]');
await name_input.fill('Updated Meeting Name');
await expect(name_input).toHaveValue('Updated Meeting Name');
});
test('weekday checkbox can be checked and unchecked', async ({ page }) => {
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 });
// 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_label.click();
await expect(wednesday_cb).toBeChecked({ checked: !initial_state });
// Toggle back
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); // physical=true
await page.goto(`/idaa/recovery_meetings/${TEST_EVENT_ID}`);
await page.waitForSelector('section.edit__event_obj', { timeout: 5000 });
// 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"]');
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 }) => {
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 });
const start_time = page.locator('input[name="recurring_start_time"]');
await start_time.fill('19:30');
await expect(start_time).toHaveValue('19:30');
});
test('contact_1 full name field accepts text input', async ({ page }) => {
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 });
const contact_name = page.locator('input[name="contact_1_full_name"]');
await contact_name.fill('Jane Smith');
await expect(contact_name).toHaveValue('Jane Smith');
});
// -------------------------------------------------------------------------
// Section 3: Submit behavior
// -------------------------------------------------------------------------
test('save button is present and clickable', async ({ page }) => {
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 });
// 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();
await expect(save_btn).toBeVisible();
await expect(save_btn).toBeEnabled();
});
test('form submission sends PATCH request to event API', async ({ page }) => {
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 });
// Track API calls
const api_calls: Array<{ url: string; method: string }> = [];
page.on('request', (req: any) => {
if (req.url().includes('/v3/') && req.method() === 'PATCH') {
api_calls.push({ url: req.url(), method: req.method() });
}
});
// Make a visible change then submit
const name_input = page.locator('input[name="name"]');
await name_input.fill('Updated Thursday Group');
const save_btn = page.locator('button[type="submit"]').filter({ hasText: /save|submit/i }).first();
await save_btn.click();
// Wait briefly for the async submit to fire
await page.waitForTimeout(1500);
// Expect a PATCH was made to the event endpoint
const patch_to_event = api_calls.some(c => c.url.includes(`/v3/crud/event/${TEST_EVENT_ID}`));
expect(patch_to_event, 'Expected PATCH request to event API endpoint').toBe(true);
});
// -------------------------------------------------------------------------
// Section 4: Virtual / Zoom platform section
// -------------------------------------------------------------------------
test('virtual checkbox reveals virtual/platform section', async ({ page }) => {
await page.goto(`/`);
// Use a virtual-only event
const virtual_event = {
...mock_event,
id: TEST_EVENT_ID,
physical: false,
virtual: true,
attend_json: { zoom: null }
};
await seed_event_idb(page, virtual_event);
await page.goto(`/idaa/recovery_meetings/${TEST_EVENT_ID}`);
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');
await expect(virtual_cb).toBeAttached();
});
});
// =============================================================================
// Integration tests — real backend
// These tests use the real event ID and let CRUD calls reach the actual API.
// They will modify real data in the database.
// =============================================================================
test.describe('IDAA Recovery Meetings — Real Backend Save (Integration)', () => {
test.beforeEach(async ({ page }) => {
page.on('pageerror', (err: Error) =>
console.error(`BROWSER ERROR: ${err.message}`)
);
await setup_idaa_auth(page);
// 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 }) => {
// Seed IDB so the form populates immediately without waiting for the real API GET.
// The mock_event name will appear in the field; we overwrite it before saving.
await page.goto(`/`);
await seed_event_idb(page, mock_event);
await page.goto(`/idaa/recovery_meetings/${TEST_EVENT_ID}`);
// 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: 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();
const base_name = current_name.replace(/ \[PW\]$/, '').trim();
const new_name = `${base_name} [PW]`;
await name_input.fill(new_name);
const save_btn = page.locator('button[type="submit"]').filter({ hasText: /save/i }).first();
await expect(save_btn).toBeEnabled();
// Click Save and wait for the real PATCH response
const [response] = await Promise.all([
page.waitForResponse(
(resp) =>
resp.url().includes(`/v3/crud/event/${TEST_EVENT_ID}`) &&
resp.request().method() === 'PATCH',
{ timeout: 5000 }
),
save_btn.click()
]);
const body = await response.json();
expect(response.status(), 'PATCH should return HTTP 200').toBe(200);
expect(body?.data?.name ?? body?.data?.event_id, 'Response should include event data').toBeTruthy();
});
});
// =============================================================================
// 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', () => {
// Each test calls open_edit_form which waits for real lookup API responses
// (pass_through_lookups=true). On slow network or under load those can take
// 2025 s, leaving little margin at the default 30 s limit. 60 s gives a safe
// buffer without masking genuine hangs.
test.setTimeout(60000);
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 }
);
// Wait for the background SWR list fetch (load_ae_obj_li__event in +layout.ts) to
// have completed and written processed event data back to IDB.
//
// WHY THIS MATTERS: The layout fires load_ae_obj_li__event on every page load.
// It returns the IDB cache immediately (SWR), then fires a background API fetch.
// When the fetch completes, _process_generic_props processes the result and saves
// it to IDB — which triggers a liveQuery re-fire that resets reactive form inputs
// back to IDB values. If a test fills an input BEFORE this re-fire, the liveQuery
// overwrites it with the API value (e.g., address_name → undefined, attend_json → {}).
//
// _process_generic_props always computes tmp_sort_1 as part of processing. Its
// presence in IDB is the reliable signal that the background fetch has settled and
// no further liveQuery re-fires will overwrite values the test is about to fill.
await page.waitForFunction(
(event_id: string) => {
return new Promise<boolean>((resolve) => {
try {
const req = indexedDB.open('ae_events_db', 6);
req.onerror = () => resolve(false);
req.onsuccess = (e: Event) => {
try {
const db = (e.target as IDBOpenDBRequest).result;
const tx = db.transaction(['event'], 'readonly');
const store = tx.objectStore('event');
const get_req = store.get(event_id);
get_req.onerror = () => resolve(false);
get_req.onsuccess = (e2: Event) => {
const rec = (e2.target as IDBRequest).result;
resolve(rec != null && rec.tmp_sort_1 != null);
};
} catch { resolve(false); }
};
} catch { resolve(false); }
});
},
TEST_EVENT_ID,
{ 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);
// _json fields are auto-serialized to JSON strings by update_ae_obj before
// the PATCH is sent (Standard Aether Pattern: any key ending in _json is
// JSON.stringify'd so the backend receives a string, not an object).
const addr = JSON.parse(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);
// attend_json is auto-serialized to a JSON string by update_ae_obj.
const attend = JSON.parse(body.attend_json);
expect(attend?.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');
// Contact 2 is collapsed by default when both contact_2.full_name and
// contact_2.email are null (as in mock_event). The collapsed {:else} block renders
// type="hidden" inputs — Playwright's fill() rejects these. Expand the section first.
const contact_2_toggle = page.getByRole('button', { name: /Contact 2 \(Optional\)/ });
await contact_2_toggle.click();
await expect(page.locator('input[name="contact_2_full_name"]')).toBeVisible({ timeout: 2000 });
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);
// contact_li_json is auto-serialized to a JSON string by update_ae_obj.
const contacts = JSON.parse(body.contact_li_json);
expect(Array.isArray(contacts), 'contact_li_json is an array').toBe(true);
expect(contacts[0]?.full_name, 'contact 1 name').toBe('Dr. Alice Carter');
expect(contacts[0]?.email, 'contact 1 email').toBe('acarter@idaa-test.org');
expect(contacts[0]?.phone_mobile, 'contact 1 mobile').toBe('312-555-0001');
expect(contacts[1]?.full_name, 'contact 2 name').toBe('Dr. Bob Lee');
expect(contacts[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 }) => {
// The Admin Options section is rendered (trusted_access=true) but collapsed by default
// (show_admin_options starts false). Collapsed state renders type="hidden" inputs —
// Playwright's fill() rejects these and isChecked() always returns false for hidden
// checkboxes. Expand the section before interacting with any admin fields.
await open_edit_form(page);
const admin_toggle = page.getByRole('button', { name: /Admin Options/ });
await admin_toggle.click();
await expect(page.locator('input[name="status"]')).toBeVisible({ timeout: 2000 });
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);
});
});
// =============================================================================
// Creation and Identity Linkage Tests
// Verifies that new meetings are correctly linked to the creating member's Novi ID.
// =============================================================================
test.describe('IDAA Recovery Meetings — Creation and Identity', () => {
test.beforeEach(async ({ page }) => {
page.on('pageerror', (err: Error) =>
console.error(`BROWSER ERROR: ${err.message}`)
);
await setup_idaa_auth(page);
await setup_api_mocks(page, TEST_EVENT_ID);
});
test('creating a new meeting sends external_person_id in POST payload', async ({ page }) => {
// 1. Start at the meeting list page
await page.goto('/idaa/recovery_meetings');
// 2. Click "Create New Meeting" button
// The component uses window.confirm() — we must handle the dialog
page.on('dialog', dialog => dialog.accept());
// Track API calls to capture the POST request (Creation, NOT search)
const post_promise = page.waitForRequest(
(req: any) =>
req.url().includes('/v3/crud/event') &&
!req.url().includes('search') &&
req.method() === 'POST',
{ timeout: 5000 }
);
await page.getByRole('button', { name: /Create New Meeting/ }).click();
// 3. Capture and verify the POST body
const post_req = await post_promise;
const post_body = JSON.parse(post_req.postData() ?? '{}');
expect(post_body.external_person_id, 'external_person_id in POST body').toBe(TEST_NOVI_UUID);
expect(post_body.name, 'default meeting name').toContain('Recovery Meeting Name');
});
});