diff --git a/tests/event_badge_attendee_workflow.test.ts b/tests/event_badge_attendee_workflow.test.ts new file mode 100644 index 00000000..4baaf0b7 --- /dev/null +++ b/tests/event_badge_attendee_workflow.test.ts @@ -0,0 +1,408 @@ +import { test, expect } from '@playwright/test'; +import { ae_app_local_data_defaults } from './_helpers/ae_defaults'; +import { testing_event_id, testing_account_id, testing_person_id, mock_site_domain } from './_helpers/env'; + +const event_id = testing_event_id; +const badge_id = 'test-badge-attendee-1'; + +/** + * Attendee BadgeWorkflow Test + * * **STATUS: WORK IN PROGRESS - Debugging search results display** + * * Simulates the complete attendee badge check-in workflow: + * 1. Navigate from home page to Event Badges + * 2. Search for attendee by name + * 3. Click badge to view details + * 4. Edit professional_title using override field + * 5. Save changes + * 6. Increment print count (simulate printing badge) + * 7. Return to badge search for next attendee + * + * This test validates: + * - Navigation flow from home → badges + * - Search functionality (fulltext) + * - Badge detail view rendering + * - Quick edit feature (override fields) + * - Save/cancel functionality + * - Print button behavior (count increment, timestamp) + * - Return navigation to badge list + */ + +test.describe('Event Badge - Attendee Workflow', () => { + test.beforeEach(async ({ page }) => { + // Error logging + page.on('pageerror', (err) => console.error(`BROWSER ERROR: ${err.message}`)); + page.on('console', (msg) => { + if (msg.type() === 'error' || msg.type() === 'warn') + console.error(`BROWSER [${msg.type().toUpperCase()}]: ${msg.text()}`); + }); + + // Mock V3 API responses + await page.route('**/v3/**', async (route) => { + const req = route.request(); + const url = req.url(); + const method = req.method(); + + // Site domain lookup (prevents "Domain Not Registered" overlay) + if (url.includes('site_domain/search')) { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ data: [mock_site_domain] }) + }); + } + + // Event object + if (url.includes(`/v3/crud/event/${event_id}`) && method === 'GET') { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + data: { + id: event_id, + event_id: event_id, + name: 'Demo One Sky IT Conference', + cfg_json: {}, + mod_pres_mgmt_json: {}, + mod_badges_json: {}, + mod_abstracts_json: {}, + mod_exhibits_json: {}, + mod_meetings_json: {} + } + }) + }); + } + + // Badge search (returns test attendee) + if (url.includes(`/v3/crud/event/${event_id}/event_badge/search`) && method === 'POST') { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + data: [{ + id: badge_id, + event_badge_id: badge_id, + event_id: event_id, + event_badge_template_id: 'template-1', + given_name: 'Scott', + family_name: 'Idem', + full_name: 'Scott Idem', + full_name_override: null, + professional_title: 'Software Developer', + professional_title_override: null, + affiliations: 'One Sky IT', + affiliations_override: null, + email: 'scott@oneskyit.com', + email_override: null, + location: 'Seattle, WA', + location_override: null, + badge_type: 'member', + badge_type_code: 'current_member', + badge_type_override: null, + badge_type_code_override: null, + print_count: 0, + print_first_datetime: null, + print_last_datetime: null, + default_qry_str: 'scott idem scott@oneskyit.com', + enable: '1', + hide: '0', + created_on: '2026-02-01T10:00:00Z', + updated_on: '2026-02-01T10:00:00Z' + }] + }) + }); + } + + // Badge single object GET + if (url.match(new RegExp(`/v3/crud/event/${event_id}/event_badge/${badge_id}`)) && method === 'GET') { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + data: { + id: badge_id, + event_badge_id: badge_id, + event_id: event_id, + event_badge_template_id: 'template-1', + given_name: 'Scott', + family_name: 'Idem', + full_name: 'Scott Idem', + full_name_override: null, + professional_title: 'Software Developer', + professional_title_override: null, + affiliations: 'One Sky IT', + affiliations_override: null, + email: 'scott@oneskyit.com', + email_override: null, + location: 'Seattle, WA', + location_override: null, + badge_type: 'member', + badge_type_code: 'current_member', + badge_type_override: null, + badge_type_code_override: null, + print_count: 0, + print_first_datetime: null, + print_last_datetime: null, + default_qry_str: 'scott idem scott@oneskyit.com', + enable: '1', + hide: '0', + created_on: '2026-02-01T10:00:00Z', + updated_on: '2026-02-01T10:00:00Z' + } + }) + }); + } + + // Badge PATCH/PUT (update) + if (url.match(new RegExp(`/v3/crud/event/${event_id}/event_badge/${badge_id}`)) && (method === 'PATCH' || method === 'PUT')) { + const post_data = await req.postData(); + const body = post_data ? JSON.parse(post_data) : {}; + + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + data: { + id: badge_id, + event_badge_id: badge_id, + event_id: event_id, + event_badge_template_id: 'template-1', + given_name: 'Scott', + family_name: 'Idem', + full_name: 'Scott Idem', + full_name_override: body.full_name_override ?? null, + professional_title: 'Software Developer', + professional_title_override: body.professional_title_override ?? null, + affiliations: 'One Sky IT', + affiliations_override: body.affiliations_override ?? null, + email: 'scott@oneskyit.com', + email_override: body.email_override ?? null, + location: 'Seattle, WA', + location_override: body.location_override ?? null, + badge_type: 'member', + badge_type_code: body.badge_type_code ?? 'current_member', + badge_type_override: body.badge_type_override ?? null, + badge_type_code_override: body.badge_type_code_override ?? null, + print_count: body.print_count ?? 0, + print_first_datetime: body.print_first_datetime ?? null, + print_last_datetime: body.print_last_datetime ?? null, + default_qry_str: 'scott idem scott@oneskyit.com', + enable: '1', + hide: '0', + created_on: '2026-02-01T10:00:00Z', + updated_on: new Date().toISOString() + } + }) + }); + } + + // Default: empty envelope + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ data: [] }) }); + }); + + // Set up environment with authenticated access + await page.addInitScript( + ({ defaults, event_id, account_id, person_id }) => { + const test_data = { + ...defaults, + account_id: account_id, + authenticated_access: true, + trusted_access: true, + edit_mode: true, + person_id: person_id, + user: { id: person_id }, + mod: { + ...defaults.mod, + events: { + ...defaults.mod.events, + event_id: event_id, + badges: { + qry__remote_first: true, // Force API search instead of local IDB first + fulltext_search_qry_str: '', + search_version: 0 + } + } + } + }; + window.localStorage.setItem('ae_loc', JSON.stringify(test_data)); + }, + { + defaults: ae_app_local_data_defaults, + event_id: event_id, + account_id: testing_account_id, + person_id: testing_person_id + } + ); + + // Navigate to home page and delete IndexedDB for cold-start + await page.goto('/'); + await page.evaluate(() => { + const dbs = ['ae_events_db', 'ae_core_db']; + return Promise.all( + dbs.map((name) => + new Promise((resolve) => { + try { + const req = indexedDB.deleteDatabase(name); + req.onsuccess = () => resolve(true); + req.onerror = () => resolve(false); + req.onblocked = () => resolve(false); + } catch (e) { + resolve(false); + } + }) + ) + ); + }); + + // Wait a moment for IndexedDB to be ready + await page.waitForTimeout(500); + }); + + test('Complete attendee check-in workflow: navigate → search → view → edit → print → return', async ({ page }) => { + // Step 1: Start at home page + await page.goto('/'); + await expect(page).toHaveTitle(/OSIT/); + + // Wait for page to be ready (no "Domain Not Registered" overlay) + await expect(page.locator('text=Domain Not Registered')).not.toBeVisible({ timeout: 3000 }); + + // Step 2: Navigate to Event Badges page + // In a real app, user would click through navigation. For test, go direct to badges page. + await page.goto(`/events/${event_id}/badges`); + + // Wait for badges page to load (search input is a good indicator) + const search_input = page.locator('input[type="search"], input[placeholder*="Search"]').first(); + + // Step 3: Search for attendee by name + await search_input.waitFor({ state: 'visible', timeout: 5000 }); + + // Debug: Check what's on the page before searching + const results_header_before = await page.locator('text=/Results:/i').count(); + console.log(`Results header before search: ${results_header_before}`); + + await search_input.fill('Scott Idem'); + await search_input.press('Enter'); + + // Wait for search to complete (longer timeout for API call + IDB save) + await page.waitForTimeout(2500); + + // Debug: Take screenshot and dump HTML + await page.screenshot({ path: 'test-results/badge-search-results.png', fullPage: true }); + const page_content = await page.content(); + console.log('=== Page HTML (first 2000 chars) ==='); + console.log(page_content.substring(0, 2000)); + + if (!page_content.includes('Scott')) { + console.log('⚠️ Search results may not have loaded. Page content check failed.'); + // Check if there's an error message or loading state + const loading_indicator = await page.locator('text=/Loading|Searching/i').count(); + const error_msg = await page.locator('text=/Error|Failed/i').count(); + console.log(`Loading indicators: ${loading_indicator}, Error messages: ${error_msg}`); + } + + // Step 4: Click on the badge to view details + // Badge link is an anchor with full name text + const badge_link = page.locator('a[href*="/badges/"]').filter({ hasText: /Scott Idem/i }).first(); + + await badge_link.waitFor({ state: 'visible', timeout: 10000 }); + await badge_link.click(); + + // Wait for badge detail page + await expect(page).toHaveURL(new RegExp(`/events/${event_id}/badges/${badge_id}`), { timeout: 5000 }); + + // Verify badge details are visible + await expect(page.locator('text=Scott Idem')).toBeVisible({ timeout: 5000 }); + await expect(page.locator('text=Software Developer')).toBeVisible({ timeout: 5000 }); + + // Step 5: Edit professional title using override field + // Look for edit button or quick edit mode + const edit_button = page.getByRole('button', { name: /Edit|Quick Edit/i }); + if (await edit_button.isVisible({ timeout: 2000 })) { + await edit_button.click(); + } else { + // Check if already in edit mode (review page or #review hash) + console.log('Already in edit mode or no edit button found'); + } + + // Find professional title input (may be labeled "Professional Title Override") + const title_input = page.locator('input[type="text"]').filter({ + has: page.locator('.. >> text=/Professional Title/i') + }).or( + page.locator('label:has-text("Professional Title")').locator('input') + ).first(); + + await title_input.waitFor({ state: 'visible', timeout: 5000 }); + await title_input.clear(); + await title_input.fill('Lead Software Architect'); + + // Step 6: Save changes + const save_button = page.getByRole('button', { name: /Save|Update/i }); + + const [update_request] = await Promise.all([ + page.waitForRequest((r) => + r.url().includes(`/v3/crud/event/${event_id}/event_badge/${badge_id}`) && + (r.method() === 'PATCH' || r.method() === 'PUT'), + { timeout: 10000 } + ), + save_button.click() + ]); + + // Verify the save request included the override field + const post_body = update_request.postData(); + const post_json = post_body ? JSON.parse(post_body) : {}; + expect(post_json.professional_title_override).toBe('Lead Software Architect'); + + // Wait for save to complete + await expect(page.locator('text=Lead Software Architect')).toBeVisible({ timeout: 5000 }); + + // Step 7: Simulate print button (increment print_count) + // For now, we'll just verify the UI shows print count = 0 + // TODO: Add onclick handler to print button that increments count + const print_section = page.locator('text=/Print Count|Printed/i'); + if (await print_section.isVisible({ timeout: 2000 })) { + await expect(print_section).toContainText('0'); + console.log('Print count verified: 0 (print button not yet implemented)'); + } + + // Step 8: Return to badge search for next attendee + const back_button = page.getByRole('button', { name: /Back|Return|Badge/i }).or( + page.getByRole('link', { name: /Badge/i }) + ).first(); + + if (await back_button.isVisible({ timeout: 2000 })) { + await back_button.click(); + await expect(page).toHaveURL(new RegExp(`/events/${event_id}/badges`), { timeout: 5000 }); + } else { + // Navigate directly back + await page.goto(`/events/${event_id}/badges`); + } + + // Verify we're back at the badge search page + await expect(page.locator('input[type="search"], input[placeholder*="Search"]')).toBeVisible({ timeout: 5000 }); + console.log('✅ Returned to badge search - ready for next attendee'); + }); + + test('Future: Attendee review feature (unauthenticated email link)', async ({ page }) => { + // This test documents the future "Review" workflow + // where attendees receive an email link to review/edit their badge + // before arrival or while waiting in line + + test.skip(); + + /* + * Future workflow: + * 1. Attendee searches for their name (unauthenticated) + * 2. Results show "Send Review Link" button instead of direct edit + * 3. Click button → email sent with secure link + * 4. Attendee clicks link → /events/{event_id}/badges/{badge_id}/review + * 5. Can view and edit allowed fields (full_name, professional_title, affiliations, location) + * 6. Save changes → override fields updated + * 7. Changes protected from automated sync overwrites + * + * Security: + * - Time-limited token in URL (expires after 24-48 hours) + * - One-time use or limited use + * - Only allowed fields editable (no email, badge_type) + * - Audit log of self-service changes + */ + }); +});