/** * 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: '

Main entrance on Oak Street.

', 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 { 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', iframe: false, 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)); }, { 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. */ async function setup_api_mocks( page: any, event_id: string, opts: { pass_through_event_patch?: 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')) { 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')) { 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')) { 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' } ] }) }); } // Lookup: event types if (url.includes('/v3/') && url.includes('event_type')) { 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. // search_ae_obj_v3 expects { data: [array] }, not a single object. if (url.includes('/v3/crud/site_domain') || url.includes('/v3/lookup/')) { 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((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: 10000 }); }); 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: 10000 }); // 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: 10000 }); // 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: 10000 }); 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: 10000 }); 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: 10000 }); 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: 10000 }); const wednesday_cb = page.locator('input[name="weekday_wednesday"]'); const initial_state = await wednesday_cb.isChecked(); await wednesday_cb.click(); await expect(wednesday_cb).toBeChecked({ checked: !initial_state }); // Toggle back await wednesday_cb.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 page.goto(`/idaa/recovery_meetings/${TEST_EVENT_ID}`); await page.waitForSelector('section.edit__event_obj', { timeout: 10000 }); // 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(); // 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 }); }); 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: 10000 }); 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: 10000 }); 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: 10000 }); // 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: 10000 }); // 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: 10000 }); // 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: 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 }); }); 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: 15000 }); await expect(name_input).not.toHaveValue('', { timeout: 8000 }); // 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: 15000 } ), 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(); }); });