Prevents silent no-op when user clicks submit before lq__exhibit_obj is ready (exhibit not yet written to Dexie). Button now shows 'Loading...' spinner while the exhibit record is resolving, eliminating the two-tap workaround needed on first page load. Also adds 7 Playwright tests for licensed user sign-in (leads_licensed_signin.test.ts) covering success path, wrong credentials, email/identity tagging on captured leads, identity isolation between staff members, and returning-session bypass. Helpers: attach_leads_routes/setup_leads_test_page now accept exhibit_overrides (e.g. license_li_json) to inject licensed users into mocked API responses. seed_leads_loc import added to leads_auth.test.ts multi-exhibit test. Total leads test coverage: 29 tests. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
534 lines
20 KiB
TypeScript
534 lines
20 KiB
TypeScript
/**
|
|
* tests/_helpers/leads_helpers.ts
|
|
*
|
|
* Playwright test helpers for the Exhibitor Leads module.
|
|
*
|
|
* Provides:
|
|
* - Demo ID constants
|
|
* - Data factories (exhibit, tracking, event-with-leads)
|
|
* - localStorage seeders (ae_loc, ae_events_loc)
|
|
* - V3 API route mocks for the exhibit page
|
|
* - One-call beforeEach helper: setup_leads_test_page()
|
|
*
|
|
* Auth model:
|
|
* ae_loc.manager_access = true → bypasses sign-in entirely (admin shortcut)
|
|
* ae_events_loc.leads.auth_exhibit_kv[exhibit_id] → exhibit-level auth
|
|
* { key: passcode_or_email, type: 'shared' | 'licensed', updated_on }
|
|
*
|
|
* Multiple exhibits can be authorized simultaneously — auth_exhibit_kv is keyed
|
|
* by exhibit_id, so signing into booth A and booth B are independent entries.
|
|
*
|
|
* API routes mocked (all matching pattern `v3/...`):
|
|
* GET /v3/crud/event_exhibit/{exhibit_id} → exhibit data (loads into Dexie)
|
|
* GET /v3/crud/event/{event_id} → event data (mod_exhibits_json)
|
|
* POST site_domain/search → mock site domain
|
|
* POST exhibit_tracking/search → tracking list (empty by default)
|
|
* any everything else → { data: [] }
|
|
*/
|
|
|
|
import type { Page } from '@playwright/test';
|
|
import { mock_site_domain, testing_account_id, testing_event_id } from './env';
|
|
import { ae_app_local_data_defaults } from './ae_defaults';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Demo IDs — real demo-DB rows under event 'pjrcghqwert' (1) "Demo One Sky IT Conference"
|
|
// IDs are documented in tests/README.md under "Events Modules".
|
|
// ---------------------------------------------------------------------------
|
|
/** Primary demo exhibit — (1) "One Sky's Awesome Exhibit". */
|
|
export const testing_exhibit_id = 'xK_9yEj1bQY';
|
|
/** Secondary demo exhibit — (3) "Exhibit for Precon Events". Used in multi-exhibit auth tests. */
|
|
export const testing_exhibit_id_b = 'acHCkrCDaYs';
|
|
/** Shared staff passcode used in mocked API responses (not a real DB value). */
|
|
export const exhibit_staff_passcode = 'BOOTH2026';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Data factories
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Minimal event object with mod_exhibits_json set for Leads. */
|
|
export const minimal_event_for_leads = (
|
|
event_id: string,
|
|
overrides: Record<string, any> = {}
|
|
) => ({
|
|
data: {
|
|
id: event_id,
|
|
event_id: event_id,
|
|
name: 'Test Event (leads)',
|
|
cfg_json: {},
|
|
mod_pres_mgmt_json: {},
|
|
mod_badges_json: {},
|
|
mod_abstracts_json: {},
|
|
mod_exhibits_json: {
|
|
leads_require_payment: true,
|
|
stripe_publishable_key: null,
|
|
stripe_btn_1_license: null,
|
|
stripe_btn_3_license: null,
|
|
stripe_btn_6_license: null,
|
|
stripe_btn_10_license: null,
|
|
},
|
|
mod_meetings_json: {},
|
|
...overrides,
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Minimal exhibit object.
|
|
*
|
|
* Includes staff_passcode so the sign-in form can validate against it.
|
|
* The API response is wrapped in { data: ... } to match V3 format.
|
|
*/
|
|
export const minimal_exhibit = (
|
|
exhibit_id: string,
|
|
overrides: Record<string, any> = {}
|
|
) => ({
|
|
data: {
|
|
id: exhibit_id,
|
|
event_exhibit_id: exhibit_id,
|
|
event_id: testing_event_id,
|
|
name: 'Test Booth — ACME Corp',
|
|
code: 'ACME',
|
|
staff_passcode: exhibit_staff_passcode,
|
|
license_max: 3,
|
|
license_li_json: '[]',
|
|
leads_api_access: true,
|
|
leads_custom_questions_json: '[]',
|
|
leads_device_sm_qty: 0,
|
|
leads_device_lg_qty: 0,
|
|
enable: '1',
|
|
hide: '0',
|
|
priority: '0',
|
|
sort: '0',
|
|
group: null,
|
|
notes: null,
|
|
cfg_json: null,
|
|
data_json: null,
|
|
created_on: '2026-01-01T00:00:00',
|
|
updated_on: '2026-01-01T00:00:00',
|
|
...overrides,
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Minimal event badge for use in badge search results.
|
|
*
|
|
* allow_tracking MUST be true for the "Add" button to appear.
|
|
* Uses the real demo badge ID from tests/README.md.
|
|
*/
|
|
export const minimal_badge = (overrides: Record<string, any> = {}) => ({
|
|
event_badge_id: 'UIJT-73-63-61', // demo badge (37163) "Scott Idem"
|
|
event_badge_id_random: 'UIJT-73-63-61',
|
|
full_name: 'Scott Idem',
|
|
email: 'scott@demo.oneskyit.com',
|
|
affiliations: 'One Sky IT',
|
|
allow_tracking: true, // required for Add button to render
|
|
badge_type: 'attendee',
|
|
...overrides,
|
|
});
|
|
|
|
/** Minimal exhibit tracking record (a captured lead). */
|
|
export const minimal_tracking = (
|
|
exhibit_id: string,
|
|
tracking_id: string,
|
|
overrides: Record<string, any> = {}
|
|
) => ({
|
|
id: tracking_id,
|
|
event_exhibit_tracking_id: tracking_id,
|
|
event_exhibit_id: exhibit_id,
|
|
event_badge_id: 'BADGE001',
|
|
event_badge_full_name: 'Jane Doe',
|
|
event_badge_email: 'jane@example.com',
|
|
external_person_id: 'shared_passcode', // shared booth staff capture identity
|
|
exhibitor_notes: '',
|
|
enable: 1,
|
|
hide: false,
|
|
created_on: new Date().toISOString(),
|
|
updated_on: new Date().toISOString(),
|
|
...overrides,
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// localStorage seeders
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Seed ae_loc with the correct __version (2 = AE_LOC_VERSION) and optional
|
|
* access flag overrides.
|
|
*
|
|
* Must be called via addInitScript so it runs before store_versions.ts wipes
|
|
* stale data. If __version doesn't match AE_LOC_VERSION, the store is wiped
|
|
* and auth state is lost — this helper ensures version is always correct.
|
|
*
|
|
* Common access overrides:
|
|
* { manager_access: true } → bypasses Leads sign-in (admin shortcut)
|
|
* { trusted_access: true } → triggers passcode auto-fill in sign-in form
|
|
*/
|
|
export async function seed_ae_loc(
|
|
page: Page,
|
|
overrides: Record<string, any> = {}
|
|
): Promise<void> {
|
|
await page.addInitScript(
|
|
([defaults, ovrd]: [typeof ae_app_local_data_defaults, Record<string, any>]) => {
|
|
const data = { ...defaults, ...ovrd, __version: 2 };
|
|
window.localStorage.setItem('ae_loc', JSON.stringify(data));
|
|
},
|
|
[ae_app_local_data_defaults, overrides] as [
|
|
typeof ae_app_local_data_defaults,
|
|
Record<string, any>
|
|
]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Seed ae_leads_loc (new Svelte-5 PersistedState store) with defaults and __version.
|
|
* Must be called via addInitScript so it runs before store_versions.ts wipes stale data.
|
|
*/
|
|
export async function seed_leads_loc(
|
|
page: Page,
|
|
overrides: Record<string, any> = {}
|
|
): Promise<void> {
|
|
await page.addInitScript(([ovrd]: [Record<string, any>]) => {
|
|
const defaults = {
|
|
__version: 1,
|
|
show_option__paid_tab: true,
|
|
show_content__scan_alert: true,
|
|
show_content__scan_requirements: true,
|
|
show_content__custom_question_descriptions: true,
|
|
show_content__email_link_warning: true,
|
|
default_to_scan: true,
|
|
default__external_registration_id: '2024_Annual Meeting',
|
|
auto_view: true,
|
|
auto_hide_on_sign_in: true,
|
|
show_hidden: false,
|
|
show_not_enabled: false,
|
|
refresh_interval__tracking_li: 30000,
|
|
refresh_interval_sec: 25,
|
|
search_version: 0,
|
|
qry__remote_first: false,
|
|
qry__search_text: '',
|
|
qry__sort_order: 'name_asc',
|
|
tracking__search_version: 0,
|
|
tracking__qry__remote_first: false,
|
|
tracking__qry__search_text: '',
|
|
tracking__qry__sort_order: 'created_desc',
|
|
tracking__qry__licensee_email: 'all',
|
|
entered_passcode: null,
|
|
auth_exhibit_kv: {},
|
|
edit_license_li: false,
|
|
tab: {},
|
|
tab_add_mode: {},
|
|
tab_scan_qualify: {}
|
|
};
|
|
const data = { ...defaults, ...ovrd, __version: 1 };
|
|
window.localStorage.setItem('ae_leads_loc', JSON.stringify(data));
|
|
}, [overrides] as [Record<string, any>]);
|
|
}
|
|
|
|
/**
|
|
* Seed ae_events_loc with the correct __version (1 = AE_EVENTS_LOC_VERSION)
|
|
* and the given per-exhibit auth entries.
|
|
*
|
|
* auth_kv shape: { [exhibit_id]: { key: string; type: 'shared' | 'licensed' } }
|
|
*
|
|
* Multiple exhibit IDs can be passed simultaneously — each becomes an
|
|
* independent entry in auth_exhibit_kv, so a user can be signed into
|
|
* Booth A and Booth B at the same time (separate auth scopes).
|
|
*
|
|
* An empty auth_kv (default) means no exhibits are authorized → sign-in
|
|
* form is shown.
|
|
*
|
|
* @param leads_overrides Extra fields merged into the `leads` namespace, e.g.
|
|
* `{ tab_add_mode: { [exhibit_id]: 'search' } }` to start in manual search mode.
|
|
*/
|
|
export async function seed_events_loc(
|
|
page: Page,
|
|
auth_kv: Record<string, { key: string; type: string }> = {},
|
|
leads_overrides: Record<string, any> = {}
|
|
): Promise<void> {
|
|
await page.addInitScript(
|
|
([kv, lo]: [Record<string, { key: string; type: string }>, Record<string, any>]) => {
|
|
// Build auth_exhibit_kv with timestamps added server-side
|
|
const auth_exhibit_kv: Record<
|
|
string,
|
|
{ key: string; type: string; updated_on: string }
|
|
> = {};
|
|
for (const [eid, entry] of Object.entries(kv)) {
|
|
auth_exhibit_kv[eid] = { ...entry, updated_on: new Date().toISOString() };
|
|
}
|
|
|
|
const events_loc = {
|
|
__version: 1, // Must match AE_EVENTS_LOC_VERSION in store_versions.ts
|
|
|
|
// Deployment stamp — must match events_sess.ver to avoid "new version available" banner.
|
|
ver: '2025-10-16_2139',
|
|
|
|
name: 'Aether - Events',
|
|
title: "OSIT's Æ Events",
|
|
ds: {},
|
|
events_cfg_json: {},
|
|
event_id: null,
|
|
qry__enabled: 'enabled',
|
|
qry__hidden: 'not_hidden',
|
|
qry__limit: 20,
|
|
qry__offset: 0,
|
|
show_details: false,
|
|
auth__person: {},
|
|
auth__kv: {
|
|
event: {},
|
|
exhibit: {},
|
|
location: {},
|
|
session: {},
|
|
presentation: {},
|
|
presenter: {},
|
|
person: {},
|
|
},
|
|
// Non-leads modules: empty — tests only exercise leads routes
|
|
badges: {},
|
|
launcher: {},
|
|
pres_mgmt: {},
|
|
|
|
leads: {
|
|
// Leads loc defaults (inlined — avoids SvelteKit module resolution in Node test runner)
|
|
show_option__paid_tab: true,
|
|
show_content__scan_alert: true,
|
|
show_content__scan_requirements: true,
|
|
show_content__custom_question_descriptions: true,
|
|
show_content__email_link_warning: true,
|
|
default_to_scan: true,
|
|
default__external_registration_id: '2024_Annual Meeting',
|
|
auto_view: true,
|
|
auto_hide_on_sign_in: true,
|
|
show_hidden: false,
|
|
show_not_enabled: false,
|
|
refresh_interval__tracking_li: 30000,
|
|
search_version: 0,
|
|
qry__remote_first: false,
|
|
qry__search_text: '',
|
|
qry__sort_order: 'name_asc',
|
|
tracking__search_version: 0,
|
|
tracking__qry__remote_first: false,
|
|
tracking__qry__search_text: '',
|
|
tracking__qry__sort_order: 'created_desc',
|
|
tracking__qry__licensee_email: 'all',
|
|
entered_passcode: null,
|
|
edit_license_li: false,
|
|
tab: {},
|
|
tab_add_mode: {},
|
|
tab_scan_qualify: {},
|
|
// The auth entries for this test session
|
|
auth_exhibit_kv,
|
|
// Any extra leads fields (e.g. tab_add_mode, tab_scan_qualify)
|
|
...lo,
|
|
},
|
|
};
|
|
|
|
window.localStorage.setItem('ae_events_loc', JSON.stringify(events_loc));
|
|
}, [auth_kv, leads_overrides] as [Record<string, { key: string; type: string }>, Record<string, any>]);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Route mocks
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Attach V3 API route mocks for the exhibit page to the given Playwright page.
|
|
*
|
|
* Intercepts all requests matching the `v3/` pattern and fulfills them with minimal mock data so
|
|
* the app can initialize (site_domain → ae_loc hydration, event → event record,
|
|
* event_exhibit → exhibit record written into Dexie for sign-in form).
|
|
*
|
|
* @param tracking_li Pre-existing leads to include in tracking/search responses.
|
|
* @param badge_li Badges returned by event_badge/search. Defaults to one badge
|
|
* with allow_tracking=true (the demo "Scott Idem" badge).
|
|
* @param event_data_overrides Merged into the `data` object of the event response.
|
|
* Use to override mod_exhibits_json, e.g.
|
|
* `{ mod_exhibits_json: { leads_require_payment: false } }`.
|
|
*/
|
|
export async function attach_leads_routes(
|
|
page: Page,
|
|
event_id: string,
|
|
exhibit_id: string,
|
|
opts: {
|
|
staff_passcode?: string;
|
|
exhibit_name?: string;
|
|
exhibit_overrides?: Record<string, unknown>;
|
|
tracking_li?: any[];
|
|
badge_li?: any[];
|
|
event_data_overrides?: Record<string, any>;
|
|
} = {}
|
|
): Promise<void> {
|
|
const {
|
|
staff_passcode = exhibit_staff_passcode,
|
|
exhibit_name = 'Test Booth — ACME Corp',
|
|
exhibit_overrides = {} as Record<string, unknown>,
|
|
tracking_li = [],
|
|
badge_li = [minimal_badge()],
|
|
event_data_overrides = {},
|
|
} = opts;
|
|
|
|
// In-memory state for newly created tracking records within this session
|
|
let created_tracking: any = null;
|
|
|
|
await page.route('**/v3/**', async (route) => {
|
|
const req = route.request();
|
|
const url = req.url();
|
|
const method = req.method();
|
|
|
|
// Site domain init — required for ae_loc hydration in the root layout
|
|
if (url.includes('site_domain/search')) {
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ data: [mock_site_domain] }),
|
|
});
|
|
}
|
|
|
|
// Exhibit record — loaded by +layout.ts into Dexie so the sign-in form
|
|
// can check $lq__exhibit_obj.staff_passcode
|
|
if (url.includes(`/v3/crud/event_exhibit/${exhibit_id}`) && method === 'GET') {
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(
|
|
minimal_exhibit(exhibit_id, { staff_passcode, name: exhibit_name, ...exhibit_overrides })
|
|
),
|
|
});
|
|
}
|
|
|
|
// Event record — needed for mod_exhibits_json (leads_require_payment, Stripe config)
|
|
if (url.includes(`/v3/crud/event/${event_id}`) && method === 'GET') {
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(minimal_event_for_leads(event_id, event_data_overrides)),
|
|
});
|
|
}
|
|
|
|
// Badge search — returns badge_li for any event_badge search
|
|
// URL: POST /v3/crud/event/{event_id}/event_badge/search
|
|
if (url.includes('event_badge') && url.includes('search') && method === 'POST') {
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ data: badge_li }),
|
|
});
|
|
}
|
|
|
|
// Tracking create — POST /v3/crud/event_exhibit/{exhibit_id}/event_exhibit_tracking/
|
|
// Returns a new tracking record; also adds it to the in-session list so subsequent
|
|
// tracking searches include it.
|
|
if (
|
|
url.includes(`event_exhibit/${exhibit_id}/event_exhibit_tracking`) &&
|
|
method === 'POST'
|
|
) {
|
|
const post = await req.postData();
|
|
const body = post ? JSON.parse(post) : {};
|
|
created_tracking = {
|
|
id: 'TRK-TEST-001',
|
|
event_exhibit_tracking_id: 'TRK-TEST-001',
|
|
event_exhibit_tracking_id_random: 'TRK-TEST-001',
|
|
event_exhibit_id: exhibit_id,
|
|
event_badge_id: body.event_badge_id ?? 'UIJT-73-63-61',
|
|
event_badge_full_name: 'Scott Idem',
|
|
event_badge_email: 'scott@demo.oneskyit.com',
|
|
external_person_id: body.external_person_id ?? 'shared_passcode',
|
|
group: body.group ?? 'shared_passcode',
|
|
exhibitor_notes: '',
|
|
enable: 1,
|
|
hide: false,
|
|
created_on: new Date().toISOString(),
|
|
updated_on: new Date().toISOString(),
|
|
};
|
|
return route.fulfill({
|
|
status: 201,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ data: created_tracking }),
|
|
});
|
|
}
|
|
|
|
// Tracking search — returns pre-populated list + any newly created record
|
|
if (url.includes('exhibit_tracking') && url.includes('search') && method === 'POST') {
|
|
const all = created_tracking
|
|
? [...tracking_li, created_tracking]
|
|
: tracking_li;
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ data: all }),
|
|
});
|
|
}
|
|
|
|
// Fallback: return empty success for any other V3 request (tracking list, etc.)
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ data: [] }),
|
|
});
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// One-call beforeEach
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Canonical beforeEach helper for leads exhibit page tests.
|
|
*
|
|
* Wires up in order (order matters — addInitScript runs in insertion order):
|
|
* 1. page.on('pageerror') → stderr
|
|
* 2. attach_leads_routes() — intercept all /v3/ API calls
|
|
* 3. seed_ae_loc() — seed ae_loc (correct __version + access flags)
|
|
* 4. seed_events_loc() — seed ae_events_loc (optional exhibit auth entries)
|
|
*
|
|
* @param access ae_loc overrides e.g. { manager_access: true }
|
|
* @param auth_kv Per-exhibit auth — pass {} for no auth (shows sign-in form)
|
|
* @param leads_overrides Extra fields merged into ae_events_loc.leads namespace,
|
|
* e.g. { tab_add_mode: { [exhibit_id]: 'search' } }
|
|
*/
|
|
export async function setup_leads_test_page(
|
|
page: Page,
|
|
event_id: string,
|
|
exhibit_id: string,
|
|
opts: {
|
|
access?: Record<string, any>;
|
|
auth_kv?: Record<string, { key: string; type: string }>;
|
|
leads_overrides?: Record<string, any>;
|
|
staff_passcode?: string;
|
|
exhibit_name?: string;
|
|
exhibit_overrides?: Record<string, unknown>;
|
|
tracking_li?: any[];
|
|
badge_li?: any[];
|
|
event_data_overrides?: Record<string, any>;
|
|
} = {}
|
|
): Promise<void> {
|
|
const { access = {}, auth_kv = {}, leads_overrides = {}, ...route_opts } = opts;
|
|
|
|
// Build auth_exhibit_kv with timestamps for ae_leads_loc (the current store).
|
|
// seed_events_loc also seeds the old ae_events_loc for backwards compatibility,
|
|
// but is_signed_in now reads from leads_loc (ae_leads_loc) exclusively.
|
|
const auth_exhibit_kv_with_ts: Record<string, { key: string; type: string; updated_on: string }> = {};
|
|
for (const [eid, entry] of Object.entries(auth_kv)) {
|
|
auth_exhibit_kv_with_ts[eid] = { ...entry, updated_on: new Date().toISOString() };
|
|
}
|
|
|
|
page.on('pageerror', (err) => console.error(`BROWSER ERROR: ${err.message}`));
|
|
await attach_leads_routes(page, event_id, exhibit_id, route_opts);
|
|
await seed_ae_loc(page, access);
|
|
await seed_leads_loc(page, { ...leads_overrides, auth_exhibit_kv: auth_exhibit_kv_with_ts });
|
|
await seed_events_loc(page, auth_kv, leads_overrides);
|
|
}
|
|
|
|
export default {
|
|
testing_exhibit_id,
|
|
testing_exhibit_id_b,
|
|
exhibit_staff_passcode,
|
|
minimal_event_for_leads,
|
|
minimal_exhibit,
|
|
minimal_badge,
|
|
minimal_tracking,
|
|
seed_ae_loc,
|
|
seed_leads_loc,
|
|
seed_events_loc,
|
|
attach_leads_routes,
|
|
setup_leads_test_page,
|
|
};
|