diff --git a/tests/idaa_recovery_meeting_edit.test.ts b/tests/idaa_recovery_meeting_edit.test.ts
new file mode 100644
index 00000000..09a37860
--- /dev/null
+++ b/tests/idaa_recovery_meeting_edit.test.ts
@@ -0,0 +1,514 @@
+/**
+ * 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 } from './_helpers/env';
+
+// --- Test fixtures -----------------------------------------------------------
+
+const TEST_EVENT_ID = 'IDAA_RM_TEST01';
+const TEST_NOVI_UUID = 'c9ea07b5-06b0-4a43-a2d0-8d06558c8a82'; // in default novi_trusted_li
+const TEST_NOVI_NAME = 'IDAA Test Member';
+const TEST_NOVI_EMAIL = 'testmember@idaa.org';
+
+/** 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: {}
+ };
+}
+
+/**
+ * Register route mocks for the V3 CRUD API and common lookup tables.
+ * Called inside each test after page creation.
+ */
+async function setup_api_mocks(page: any, event_id: string) {
+ 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') {
+ 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
+ if (url.includes('/v3/crud/site_domain') || url.includes('/v3/lookup/')) {
+ return route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ data: {} })
+ });
+ }
+
+ // 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}`)
+ );
+
+ // Inject localStorage for both ae_loc and ae_idaa_loc
+ await page.addInitScript(
+ ({ ae_defaults, account_id, idaa_loc_defaults }: any) => {
+ // ae_loc: full authenticated+trusted access
+ 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@idaa.org',
+ noreply_email: 'noreply@idaa.org'
+ }
+ };
+ window.localStorage.setItem('ae_loc', JSON.stringify(ae_loc_data));
+
+ // ae_idaa_loc: IDAA-specific state
+ 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 })
+ }
+ );
+
+ 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 PUT 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();
+ });
+
+});