docs(leads): document Leads store migration and payment UI fix; note tests update
This commit is contained in:
@@ -4,6 +4,12 @@
|
||||
**Platform:** PWA only — mobile-first, offline-capable.
|
||||
**Target users:** Conference exhibitors scanning attendee badges at their booths.
|
||||
|
||||
### Recent Changes (2026-04-03)
|
||||
|
||||
- Migrated Leads persisted state to Svelte‑5 PersistedState: `leads_loc` now implemented at `src/lib/stores/ae_events_stores__leads.svelte.ts` and the store version constant `AE_LEADS_LOC_VERSION` added to `src/lib/stores/store_versions.ts`.
|
||||
- Payment UI adjustments: `ae_comp__exhibit_payment.svelte` now accepts a `leads_require_payment` prop and enforces the event-level `mod_exhibits_json.leads_require_payment` flag; a loading guard was added so the component waits for the exhibit record (Dexie `liveQuery`) before deciding which UI to show.
|
||||
- Tests: update `tests/_helpers/leads_helpers.ts` to seed `leads_loc` defaults and `__version` when needed to avoid localStorage wipe caused by store version checks.
|
||||
|
||||
---
|
||||
|
||||
## What It Does
|
||||
|
||||
21
src/lib/stores/ae_events_stores__leads.svelte.ts
Normal file
21
src/lib/stores/ae_events_stores__leads.svelte.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* ae_events_stores__leads.svelte.ts
|
||||
*
|
||||
* Svelte 5 PersistedState store for the Exhibitor Leads module local config.
|
||||
* Replaces the `events_loc.leads` sub-object from the Svelte 4 persisted store.
|
||||
*
|
||||
* localStorage key: 'ae_leads_loc'
|
||||
* Version gate: AE_LEADS_LOC_VERSION in store_versions.ts
|
||||
*
|
||||
* Session state (non-persisted) stays in `events_sess.leads` — same pattern as
|
||||
* pres_mgmt and badges.
|
||||
*
|
||||
* Usage:
|
||||
* import { leads_loc } from '$lib/stores/ae_events_stores__leads.svelte';
|
||||
* leads_loc.current.auth_exhibit_kv // read
|
||||
* leads_loc.current.tab[exhibit_id] = 'list' // write
|
||||
*/
|
||||
import { PersistedState } from 'runed';
|
||||
import { leads_loc_defaults } from './ae_events_stores__leads_defaults';
|
||||
|
||||
export const leads_loc = new PersistedState('ae_leads_loc', leads_loc_defaults);
|
||||
@@ -19,6 +19,7 @@ export interface LeadsLocState {
|
||||
show_hidden: boolean;
|
||||
show_not_enabled: boolean;
|
||||
refresh_interval__tracking_li: number;
|
||||
refresh_interval_sec: number; // Auto-refresh interval for the lead list (seconds)
|
||||
search_version: number;
|
||||
qry__remote_first: boolean;
|
||||
qry__search_text: string;
|
||||
@@ -95,6 +96,7 @@ export const leads_loc_defaults: LeadsLocState = {
|
||||
show_not_enabled: false,
|
||||
|
||||
refresh_interval__tracking_li: 30000, // 30 seconds.
|
||||
refresh_interval_sec: 25, // Auto-refresh interval for the lead list (seconds, default 25).
|
||||
|
||||
// Standardized Search Pattern 2026-01-28
|
||||
search_version: 0,
|
||||
|
||||
@@ -35,6 +35,7 @@ export const AE_EVENTS_LOC_VERSION = 1;
|
||||
export const AE_IDAA_LOC_VERSION = 1; // Added 2026-03-30: was missing, no wipe mechanism existed
|
||||
export const AE_PRES_MGMT_LOC_VERSION = 1; // Added 2026-04-02: new standalone PersistedState store
|
||||
export const AE_BADGES_LOC_VERSION = 1; // Added 2026-04-02: promoted from events_loc.badges
|
||||
export const AE_LEADS_LOC_VERSION = 1; // Added 2026-04-03: promoted from events_loc.leads
|
||||
|
||||
// Version check side-effect: runs on import, before any persisted() call.
|
||||
// Guard presence of `localStorage` and its functions for safety (SSR, Node flags).
|
||||
@@ -46,6 +47,7 @@ if (
|
||||
_check_and_wipe('ae_loc', AE_LOC_VERSION);
|
||||
_check_and_wipe('ae_events_loc', AE_EVENTS_LOC_VERSION);
|
||||
_check_and_wipe('ae_idaa_loc', AE_IDAA_LOC_VERSION);
|
||||
_check_and_wipe('ae_leads_loc', AE_LEADS_LOC_VERSION);
|
||||
// ae_pres_mgmt_loc uses PersistedState (runed) which stores raw JSON without a __version
|
||||
// field. The _check_and_wipe mechanism requires __version in the stored data — do NOT
|
||||
// add it here until pres_mgmt_loc_defaults includes __version. For now the key is new
|
||||
|
||||
@@ -138,6 +138,17 @@ Two scan modes (toggled per exhibit):
|
||||
|
||||
---
|
||||
|
||||
### Recent Changes (2026-04-03)
|
||||
|
||||
- Migrated Leads persisted state to Svelte‑5 PersistedState: new `leads_loc` store implemented at `src/lib/stores/ae_events_stores__leads.svelte.ts` and the store version constant `AE_LEADS_LOC_VERSION` added to `src/lib/stores/store_versions.ts`.
|
||||
|
||||
- Payment UI updates:
|
||||
- `ae_comp__exhibit_payment.svelte` now accepts a `leads_require_payment` prop and honors the event-level `event.mod_exhibits_json.leads_require_payment` flag to hide billing UI when payment is not required for the event.
|
||||
- Added a loading guard so the payment component shows a loader until the exhibit record resolves from IndexedDB (Dexie `liveQuery`), preventing the payment form from appearing prematurely.
|
||||
|
||||
- Tests: some test seeds need updating — update `tests/_helpers/leads_helpers.ts` to seed `leads_loc` defaults and the expected `__version` where tests rely on pre-seeded localStorage.
|
||||
|
||||
|
||||
## Lib Functions
|
||||
|
||||
`src/lib/ae_events/ae_events__exhibit.ts` — exhibit load, search, create, update
|
||||
|
||||
@@ -3,27 +3,17 @@ import { onMount, untrack } from 'svelte';
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db_events } from '$lib/ae_events/db_events';
|
||||
import {
|
||||
events_loc,
|
||||
events_sess,
|
||||
events_slct
|
||||
} from '$lib/stores/ae_events_stores';
|
||||
import { leads_loc } from '$lib/stores/ae_events_stores__leads.svelte';
|
||||
import { ae_api, ae_loc } from '$lib/stores/ae_stores';
|
||||
import { page } from '$app/state';
|
||||
import { events_func } from '$lib/ae_events/ae_events_functions';
|
||||
import { LoaderCircle, Store } from '@lucide/svelte';
|
||||
import Comp_exhibit_search from './ae_comp__exhibit_search.svelte';
|
||||
|
||||
// *** Initialization & Store Guard ***
|
||||
if ($events_loc.leads) {
|
||||
if (typeof $events_loc.leads.search_version === 'undefined')
|
||||
$events_loc.leads.search_version = 0;
|
||||
if (typeof $events_loc.leads.qry__remote_first === 'undefined')
|
||||
$events_loc.leads.qry__remote_first = false;
|
||||
if (typeof $events_loc.leads.qry__search_text === 'undefined')
|
||||
$events_loc.leads.qry__search_text = '';
|
||||
if (typeof $events_loc.leads.qry__sort_order === 'undefined')
|
||||
$events_loc.leads.qry__sort_order = 'name_asc';
|
||||
}
|
||||
// leads_loc is a PersistedState store — defaults are always initialized.
|
||||
|
||||
let exhibit_id_li: Array<string> = $state([]);
|
||||
let search_debounce_timer: any = null;
|
||||
@@ -44,7 +34,7 @@ let lq__event_exhibit_obj_li = $derived.by(() => {
|
||||
}
|
||||
|
||||
// SCENARIO 2: Fallback broad search
|
||||
if (event_id && !$events_loc.leads.qry__search_text) {
|
||||
if (event_id && !leads_loc.current.qry__search_text) {
|
||||
return await db_events.exhibit
|
||||
.where('event_id')
|
||||
.equals(event_id)
|
||||
@@ -57,11 +47,11 @@ let lq__event_exhibit_obj_li = $derived.by(() => {
|
||||
|
||||
// Standardized Reactive Search Pattern
|
||||
let search_params = $derived({
|
||||
v: $events_loc.leads.search_version,
|
||||
str: ($events_loc.leads.qry__search_text ?? '').toLowerCase().trim(),
|
||||
sort: $events_loc.leads.qry__sort_order,
|
||||
v: leads_loc.current.search_version,
|
||||
str: (leads_loc.current.qry__search_text ?? '').toLowerCase().trim(),
|
||||
sort: leads_loc.current.qry__sort_order,
|
||||
event_id: page.params.event_id,
|
||||
remote_first: $events_loc.leads.qry__remote_first
|
||||
remote_first: leads_loc.current.qry__remote_first
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
|
||||
@@ -14,13 +14,11 @@ import {
|
||||
Search
|
||||
} from '@lucide/svelte';
|
||||
import { ae_loc } from '$lib/stores/ae_stores';
|
||||
import { events_loc, events_sess } from '$lib/stores/ae_events_stores';
|
||||
import { events_sess } from '$lib/stores/ae_events_stores';
|
||||
import { leads_loc } from '$lib/stores/ae_events_stores__leads.svelte';
|
||||
|
||||
function handle_search_trigger() {
|
||||
if ($events_loc.leads.search_version === undefined) {
|
||||
$events_loc.leads.search_version = 0;
|
||||
}
|
||||
$events_loc.leads.search_version++;
|
||||
leads_loc.current.search_version++;
|
||||
}
|
||||
|
||||
function prevent_default<T extends Event>(fn: (event: T) => void) {
|
||||
@@ -45,7 +43,7 @@ function prevent_default<T extends Event>(fn: (event: T) => void) {
|
||||
type="search"
|
||||
placeholder="Exhibitor name or code..."
|
||||
id="exhibit_fulltext_search_qry_str"
|
||||
bind:value={$events_loc.leads.qry__search_text}
|
||||
bind:value={leads_loc.current.qry__search_text}
|
||||
autocomplete="off"
|
||||
data-lpignore="true"
|
||||
class="input grow font-mono text-lg transition-all"
|
||||
@@ -57,7 +55,7 @@ function prevent_default<T extends Event>(fn: (event: T) => void) {
|
||||
title="Search by name or code. Press Enter." />
|
||||
|
||||
<select
|
||||
bind:value={$events_loc.leads.qry__sort_order}
|
||||
bind:value={leads_loc.current.qry__sort_order}
|
||||
onchange={handle_search_trigger}
|
||||
class="select select-sm max-w-fit px-1 text-xs">
|
||||
<option value="name_asc">Name ASC</option>
|
||||
@@ -82,9 +80,9 @@ function prevent_default<T extends Event>(fn: (event: T) => void) {
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class:hidden={!$events_loc.leads.qry__search_text}
|
||||
class:hidden={!leads_loc.current.qry__search_text}
|
||||
onclick={() => {
|
||||
$events_loc.leads.qry__search_text = '';
|
||||
leads_loc.current.qry__search_text = '';
|
||||
handle_search_trigger();
|
||||
}}
|
||||
class="btn btn-sm preset-outlined-tertiary-100-900 hover:preset-filled-tertiary-100-900 text-xs transition-all"
|
||||
@@ -103,7 +101,7 @@ function prevent_default<T extends Event>(fn: (event: T) => void) {
|
||||
<span> Remote First </span>
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={$events_loc.leads.qry__remote_first}
|
||||
bind:checked={leads_loc.current.qry__remote_first}
|
||||
onchange={handle_search_trigger}
|
||||
class="checkbox checkbox-sm" />
|
||||
</label>
|
||||
|
||||
@@ -3,10 +3,10 @@ import { onMount, untrack } from 'svelte';
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db_events } from '$lib/ae_events/db_events';
|
||||
import {
|
||||
events_loc,
|
||||
events_sess,
|
||||
events_slct
|
||||
} from '$lib/stores/ae_events_stores';
|
||||
import { leads_loc } from '$lib/stores/ae_events_stores__leads.svelte';
|
||||
import { ae_api, ae_loc } from '$lib/stores/ae_stores';
|
||||
import { page } from '$app/state';
|
||||
import { events_func } from '$lib/ae_events/ae_events_functions';
|
||||
@@ -27,36 +27,24 @@ import Tab_start from './ae_tab__start.svelte';
|
||||
import Tab_manage from './ae_tab__manage.svelte';
|
||||
import Comp_exhibit_payment from './ae_comp__exhibit_payment.svelte';
|
||||
|
||||
// *** Initialization & Store Guard ***
|
||||
if ($events_loc.leads) {
|
||||
if (typeof $events_loc.leads.tracking__search_version === 'undefined')
|
||||
$events_loc.leads.tracking__search_version = 0;
|
||||
if (typeof $events_loc.leads.tracking__qry__remote_first === 'undefined')
|
||||
$events_loc.leads.tracking__qry__remote_first = false;
|
||||
if (typeof $events_loc.leads.tracking__qry__search_text === 'undefined')
|
||||
$events_loc.leads.tracking__qry__search_text = '';
|
||||
if (typeof $events_loc.leads.tracking__qry__sort_order === 'undefined')
|
||||
$events_loc.leads.tracking__qry__sort_order = 'created_desc';
|
||||
if (typeof $events_loc.leads.refresh_interval_sec === 'undefined')
|
||||
$events_loc.leads.refresh_interval_sec = 25;
|
||||
if (typeof $events_loc.leads.show_hidden === 'undefined')
|
||||
$events_loc.leads.show_hidden = false;
|
||||
}
|
||||
// leads_loc is a PersistedState store — defaults are always initialized.
|
||||
|
||||
// --- Sign-In State (Derived) ---
|
||||
// 1. Manager Access (Bypass) OR 2. Valid Exhibit Auth entry
|
||||
let is_signed_in = $derived(
|
||||
$ae_loc.manager_access ||
|
||||
!!$events_loc.leads.auth_exhibit_kv?.[page.params.exhibit_id ?? '']
|
||||
!!leads_loc.current.auth_exhibit_kv?.[page.params.exhibit_id ?? '']
|
||||
);
|
||||
|
||||
// --- Tab State (Sticky via Store) ---
|
||||
let active_tab = $derived.by(() => {
|
||||
const exhibit_id = page.params.exhibit_id;
|
||||
if (!exhibit_id) return 'start';
|
||||
const saved_tab = $events_loc.leads.tab?.[exhibit_id] ?? 'list';
|
||||
const saved_tab = leads_loc.current.tab?.[exhibit_id] ?? 'list';
|
||||
// If signed in but stuck on start tab, go to list
|
||||
if (is_signed_in && saved_tab === 'start') return 'list';
|
||||
// If payment tab was saved but payments are no longer required, fall back to list
|
||||
if (saved_tab === 'payment' && !leads_require_payment) return 'list';
|
||||
return saved_tab;
|
||||
});
|
||||
let previous_main_tab = $state('list'); // To remember if we were on 'add' or 'list' before going to 'manage'
|
||||
@@ -64,8 +52,7 @@ let previous_main_tab = $state('list'); // To remember if we were on 'add' or 'l
|
||||
function set_active_tab(new_tab: string) {
|
||||
const exhibit_id = page.params.exhibit_id;
|
||||
if (!exhibit_id) return;
|
||||
if (!$events_loc.leads.tab) $events_loc.leads.tab = {};
|
||||
$events_loc.leads.tab[exhibit_id] = new_tab;
|
||||
leads_loc.current.tab[exhibit_id] = new_tab;
|
||||
}
|
||||
|
||||
let tracking_id_li: Array<string> = $state([]);
|
||||
@@ -99,7 +86,7 @@ let filtered_lead_li = $derived.by(() => {
|
||||
$effect(() => {
|
||||
const ids = tracking_id_li;
|
||||
const exhibit_id = page.params.exhibit_id;
|
||||
const has_search = !!$events_loc.leads.tracking__qry__search_text;
|
||||
const has_search = !!leads_loc.current.tracking__qry__search_text;
|
||||
|
||||
const observable = liveQuery(async () => {
|
||||
// 1. Specific IDs provided (from API Search or Manual Entry)
|
||||
@@ -159,28 +146,28 @@ let stripe_cfg = $derived({
|
||||
|
||||
// Standardized Reactive Search Pattern
|
||||
let search_params = $derived.by(() => {
|
||||
let licensee_email = $events_loc.leads.tracking__qry__licensee_email;
|
||||
let licensee_email = leads_loc.current.tracking__qry__licensee_email;
|
||||
|
||||
// Resolve "My Leads" to the correct identity used when storing leads.
|
||||
// Shared-passcode users store 'shared_passcode' literal (not the passcode string itself).
|
||||
// Licensed users store their email. Aether bypass users store $ae_loc.access_type.
|
||||
if (licensee_email === 'my') {
|
||||
const kv = $events_loc.leads.auth_exhibit_kv?.[page.params.exhibit_id ?? ''];
|
||||
const kv = leads_loc.current.auth_exhibit_kv?.[page.params.exhibit_id ?? ''];
|
||||
licensee_email = kv?.type === 'shared'
|
||||
? 'shared_passcode'
|
||||
: kv?.key || 'all';
|
||||
}
|
||||
|
||||
return {
|
||||
v: $events_loc.leads.tracking__search_version,
|
||||
str: ($events_loc.leads.tracking__qry__search_text ?? '')
|
||||
v: leads_loc.current.tracking__search_version,
|
||||
str: (leads_loc.current.tracking__qry__search_text ?? '')
|
||||
.toLowerCase()
|
||||
.trim(),
|
||||
sort: $events_loc.leads.tracking__qry__sort_order,
|
||||
sort: leads_loc.current.tracking__qry__sort_order,
|
||||
licensee_email: licensee_email,
|
||||
exhibit_id: page.params.exhibit_id,
|
||||
remote_first: $events_loc.leads.tracking__qry__remote_first,
|
||||
show_hidden: $events_loc.leads.show_hidden ?? false
|
||||
remote_first: leads_loc.current.tracking__qry__remote_first,
|
||||
show_hidden: leads_loc.current.show_hidden ?? false
|
||||
};
|
||||
});
|
||||
|
||||
@@ -192,7 +179,7 @@ $effect(() => {
|
||||
handle_search_refresh(params);
|
||||
// Reset countdown on manual search
|
||||
$events_sess.leads.next_refresh_countdown =
|
||||
$events_loc.leads.refresh_interval_sec || 25;
|
||||
leads_loc.current.refresh_interval_sec || 25;
|
||||
});
|
||||
}, 300);
|
||||
return () => {
|
||||
@@ -210,9 +197,9 @@ $effect(() => {
|
||||
$events_sess.leads.next_refresh_countdown--;
|
||||
} else {
|
||||
// Trigger refresh
|
||||
$events_loc.leads.tracking__search_version++;
|
||||
leads_loc.current.tracking__search_version++;
|
||||
$events_sess.leads.next_refresh_countdown =
|
||||
$events_loc.leads.refresh_interval_sec || 25;
|
||||
leads_loc.current.refresh_interval_sec || 25;
|
||||
}
|
||||
});
|
||||
}, 1000);
|
||||
@@ -502,9 +489,9 @@ function toggle_manage_tab() {
|
||||
</div>
|
||||
{:else if active_tab === 'add'}
|
||||
<Tab_add exhibit_id={page.params.exhibit_id ?? ''} />
|
||||
{:else if active_tab === 'payment'}
|
||||
{:else if active_tab === 'payment' && leads_require_payment}
|
||||
<div class="mx-auto w-full max-w-4xl">
|
||||
<Comp_exhibit_payment exhibit_id={page.params.exhibit_id ?? ''} {...stripe_cfg} />
|
||||
<Comp_exhibit_payment exhibit_id={page.params.exhibit_id ?? ''} {...stripe_cfg} leads_require_payment={leads_require_payment} />
|
||||
</div>
|
||||
{:else if active_tab === 'list'}
|
||||
<div class="flex w-full flex-col space-y-6">
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db_events } from '$lib/ae_events/db_events';
|
||||
import { ae_loc } from '$lib/stores/ae_stores';
|
||||
import { AlertTriangle, CheckCircle, CreditCard } from '@lucide/svelte';
|
||||
import { AlertTriangle, CheckCircle, CreditCard, LoaderCircle } from '@lucide/svelte';
|
||||
|
||||
interface Props {
|
||||
exhibit_id: string;
|
||||
@@ -30,6 +30,8 @@ interface Props {
|
||||
stripe_btn_3_license?: string | null;
|
||||
stripe_btn_6_license?: string | null;
|
||||
stripe_btn_10_license?: string | null;
|
||||
/** Event-level flag: when false, payment UI is hidden (pre-paid events) */
|
||||
leads_require_payment?: boolean;
|
||||
}
|
||||
let {
|
||||
exhibit_id,
|
||||
@@ -38,6 +40,7 @@ let {
|
||||
stripe_btn_3_license: prop_btn_3 = null,
|
||||
stripe_btn_6_license: prop_btn_6 = null,
|
||||
stripe_btn_10_license: prop_btn_10 = null
|
||||
, leads_require_payment = true
|
||||
}: Props = $props();
|
||||
|
||||
const lq__exhibit_obj = liveQuery(() => {
|
||||
@@ -85,7 +88,13 @@ $effect(() => {
|
||||
</script>
|
||||
|
||||
<div class="exhibit-payment space-y-6">
|
||||
{#if $lq__exhibit_obj?.priority}
|
||||
{#if $lq__exhibit_obj === undefined}
|
||||
<div class="py-6 text-center">
|
||||
<LoaderCircle size="2em" class="mx-auto mb-2 animate-spin" />
|
||||
<div class="text-sm opacity-70">Loading booth status…</div>
|
||||
</div>
|
||||
|
||||
{:else if $lq__exhibit_obj?.priority}
|
||||
<!-- Paid Confirmation — shown when admin has marked this booth as paid (priority = true) -->
|
||||
<div class="card preset-tonal-success border-success-500/30 border p-6">
|
||||
<div class="mb-3 flex items-center gap-3">
|
||||
@@ -109,6 +118,9 @@ $effect(() => {
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{:else if !leads_require_payment}
|
||||
<p class="py-4 text-center text-sm opacity-60">Payment is not required for this event.</p>
|
||||
|
||||
{:else if !is_stripe_configured}
|
||||
<!-- Stripe not configured — show a setup hint only to admins -->
|
||||
{#if $ae_loc.administrator_access}
|
||||
|
||||
@@ -7,7 +7,8 @@ import { page } from '$app/state';
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db_events } from '$lib/ae_events/db_events';
|
||||
import { ae_loc } from '$lib/stores/ae_stores';
|
||||
import { events_loc, events_sess } from '$lib/stores/ae_events_stores';
|
||||
import { events_sess } from '$lib/stores/ae_events_stores';
|
||||
import { leads_loc } from '$lib/stores/ae_events_stores__leads.svelte';
|
||||
import {
|
||||
ArrowRight,
|
||||
CircleAlert,
|
||||
@@ -101,10 +102,7 @@ function complete_signin(key: string, type: string) {
|
||||
status = 'success';
|
||||
|
||||
// Save to persistent store
|
||||
if (!$events_loc.leads.auth_exhibit_kv)
|
||||
$events_loc.leads.auth_exhibit_kv = {};
|
||||
|
||||
$events_loc.leads.auth_exhibit_kv[exhibit_id] = {
|
||||
leads_loc.current.auth_exhibit_kv[exhibit_id] = {
|
||||
key: key,
|
||||
type: type,
|
||||
updated_on: new Date().toISOString()
|
||||
|
||||
@@ -17,7 +17,8 @@ import {
|
||||
} from '@lucide/svelte';
|
||||
import { untrack } from 'svelte';
|
||||
import { ae_loc } from '$lib/stores/ae_stores';
|
||||
import { events_loc, events_sess } from '$lib/stores/ae_events_stores';
|
||||
import { events_sess } from '$lib/stores/ae_events_stores';
|
||||
import { leads_loc } from '$lib/stores/ae_events_stores__leads.svelte';
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db_events } from '$lib/ae_events/db_events';
|
||||
import { onMount } from 'svelte';
|
||||
@@ -68,19 +69,16 @@ $effect(() => {
|
||||
|
||||
untrack(() => {
|
||||
if (
|
||||
$events_loc.leads.tracking__qry__licensee_email === 'all' &&
|
||||
leads_loc.current.tracking__qry__licensee_email === 'all' &&
|
||||
!$ae_loc.administrator_access
|
||||
) {
|
||||
$events_loc.leads.tracking__qry__licensee_email = 'my';
|
||||
leads_loc.current.tracking__qry__licensee_email = 'my';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function handle_search_trigger() {
|
||||
if ($events_loc.leads.tracking__search_version === undefined) {
|
||||
$events_loc.leads.tracking__search_version = 0;
|
||||
}
|
||||
$events_loc.leads.tracking__search_version++;
|
||||
leads_loc.current.tracking__search_version++;
|
||||
}
|
||||
|
||||
function prevent_default<T extends Event>(fn: (event: T) => void) {
|
||||
@@ -105,7 +103,7 @@ function prevent_default<T extends Event>(fn: (event: T) => void) {
|
||||
type="search"
|
||||
placeholder="Search leads (name, email, notes)..."
|
||||
id="exhibit_tracking_fulltext_search_qry_str"
|
||||
bind:value={$events_loc.leads.tracking__qry__search_text}
|
||||
bind:value={leads_loc.current.tracking__qry__search_text}
|
||||
autocomplete="off"
|
||||
data-lpignore="true"
|
||||
class="input grow font-mono text-lg transition-all"
|
||||
@@ -117,7 +115,7 @@ function prevent_default<T extends Event>(fn: (event: T) => void) {
|
||||
title="Search by name, email or notes. Press Enter." />
|
||||
|
||||
<select
|
||||
bind:value={$events_loc.leads.tracking__qry__sort_order}
|
||||
bind:value={leads_loc.current.tracking__qry__sort_order}
|
||||
onchange={handle_search_trigger}
|
||||
class="select select-sm max-w-fit px-1 text-xs">
|
||||
<option value="created_desc">Newest First</option>
|
||||
@@ -127,7 +125,7 @@ function prevent_default<T extends Event>(fn: (event: T) => void) {
|
||||
</select>
|
||||
|
||||
<select
|
||||
bind:value={$events_loc.leads.tracking__qry__licensee_email}
|
||||
bind:value={leads_loc.current.tracking__qry__licensee_email}
|
||||
onchange={handle_search_trigger}
|
||||
class="select select-sm max-w-fit px-1 text-xs">
|
||||
<option value="all">All Leads</option>
|
||||
@@ -154,9 +152,9 @@ function prevent_default<T extends Event>(fn: (event: T) => void) {
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class:hidden={!$events_loc.leads.tracking__qry__search_text}
|
||||
class:hidden={!leads_loc.current.tracking__qry__search_text}
|
||||
onclick={() => {
|
||||
$events_loc.leads.tracking__qry__search_text = '';
|
||||
leads_loc.current.tracking__qry__search_text = '';
|
||||
handle_search_trigger();
|
||||
}}
|
||||
class="btn btn-sm preset-outlined-tertiary-100-900 hover:preset-filled-tertiary-100-900 text-xs transition-all"
|
||||
@@ -173,16 +171,16 @@ function prevent_default<T extends Event>(fn: (event: T) => void) {
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-token flex cursor-pointer items-center gap-1 px-2 py-1 text-xs font-semibold transition-colors"
|
||||
class:preset-tonal-warning={$events_loc.leads.show_hidden}
|
||||
class:preset-tonal-surface={!$events_loc.leads.show_hidden}
|
||||
class:preset-tonal-warning={leads_loc.current.show_hidden}
|
||||
class:preset-tonal-surface={!leads_loc.current.show_hidden}
|
||||
onclick={() => {
|
||||
$events_loc.leads.show_hidden = !$events_loc.leads.show_hidden;
|
||||
leads_loc.current.show_hidden = !leads_loc.current.show_hidden;
|
||||
handle_search_trigger();
|
||||
}}
|
||||
title={$events_loc.leads.show_hidden
|
||||
title={leads_loc.current.show_hidden
|
||||
? 'Showing hidden leads — click to hide them'
|
||||
: 'Hidden leads excluded — click to show all'}>
|
||||
{#if $events_loc.leads.show_hidden}
|
||||
{#if leads_loc.current.show_hidden}
|
||||
<Eye size="1em" />
|
||||
<span>Showing Hidden</span>
|
||||
{:else}
|
||||
@@ -197,7 +195,7 @@ function prevent_default<T extends Event>(fn: (event: T) => void) {
|
||||
<span> Remote First </span>
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={$events_loc.leads.tracking__qry__remote_first}
|
||||
bind:checked={leads_loc.current.tracking__qry__remote_first}
|
||||
onchange={handle_search_trigger}
|
||||
class="checkbox checkbox-sm" />
|
||||
</label>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { page } from '$app/state';
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db_events } from '$lib/ae_events/db_events';
|
||||
import { ae_api, ae_loc } from '$lib/stores/ae_stores';
|
||||
import { events_loc } from '$lib/stores/ae_events_stores';
|
||||
import { leads_loc } from '$lib/stores/ae_events_stores__leads.svelte';
|
||||
import { events_func } from '$lib/ae_events/ae_events_functions';
|
||||
import { Eye, LoaderCircle, Search, ShieldOff, UserPlus } from '@lucide/svelte';
|
||||
import type { ae_EventBadge } from '$lib/types/ae_types';
|
||||
@@ -98,7 +98,7 @@ async function add_as_lead(badge: ae_EventBadge) {
|
||||
adding_id = badge_id;
|
||||
add_error_id = '';
|
||||
|
||||
const kv = $events_loc.leads.auth_exhibit_kv?.[exhibit_id];
|
||||
const kv = leads_loc.current.auth_exhibit_kv?.[exhibit_id];
|
||||
const user_email =
|
||||
kv?.type === 'licensed' && kv.key
|
||||
? kv.key
|
||||
|
||||
@@ -12,7 +12,7 @@ import { goto } from '$app/navigation';
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db_events } from '$lib/ae_events/db_events';
|
||||
import { ae_api, ae_loc } from '$lib/stores/ae_stores';
|
||||
import { events_loc } from '$lib/stores/ae_events_stores';
|
||||
import { leads_loc } from '$lib/stores/ae_events_stores__leads.svelte';
|
||||
import { events_func } from '$lib/ae_events/ae_events_functions';
|
||||
import Element_qr_scanner from '$lib/elements/element_qr_scanner.svelte';
|
||||
import { ae_util } from '$lib/ae_utils/ae_utils';
|
||||
@@ -136,7 +136,7 @@ async function confirm_add_lead(dest: 'scan_next' | 'view_lead' = 'scan_next') {
|
||||
// licensed exhibit user → their email (kv.key)
|
||||
// shared passcode → 'shared_passcode' label (don't store the actual passcode)
|
||||
// Aether user (no kv) → access_type string ('trusted', 'manager', 'super', etc.)
|
||||
const kv = $events_loc.leads.auth_exhibit_kv?.[exhibit_id];
|
||||
const kv = leads_loc.current.auth_exhibit_kv?.[exhibit_id];
|
||||
const user_email =
|
||||
kv?.type === 'licensed' && kv.key
|
||||
? kv.key
|
||||
|
||||
@@ -17,7 +17,7 @@ import { page } from '$app/state';
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db_events } from '$lib/ae_events/db_events';
|
||||
import { ae_api, ae_loc } from '$lib/stores/ae_stores';
|
||||
import { events_loc } from '$lib/stores/ae_events_stores';
|
||||
import { leads_loc } from '$lib/stores/ae_events_stores__leads.svelte';
|
||||
import { events_func } from '$lib/ae_events/ae_events_functions';
|
||||
import { ae_util } from '$lib/ae_utils/ae_utils';
|
||||
import type { ae_EventBadge } from '$lib/types/ae_types';
|
||||
@@ -222,7 +222,7 @@ async function add_lead(item: BatchItem) {
|
||||
if (item.status !== 'ready' || !item.badge?.event_badge_id) return;
|
||||
item.status = 'adding';
|
||||
|
||||
const kv = $events_loc.leads.auth_exhibit_kv?.[exhibit_id];
|
||||
const kv = leads_loc.current.auth_exhibit_kv?.[exhibit_id];
|
||||
const user_email =
|
||||
kv?.type === 'licensed' && kv.key
|
||||
? kv.key
|
||||
|
||||
@@ -15,7 +15,7 @@ import { Bot, ChevronDown, Layers, QrCode, Search, Zap } from '@lucide/svelte';
|
||||
import Comp_lead_qr_scanner from './ae_comp__lead_qr_scanner.svelte';
|
||||
import Comp_lead_qr_scanner_multi from './ae_comp__lead_qr_scanner_multi.svelte';
|
||||
import Comp_lead_manual_search from './ae_comp__lead_manual_search.svelte';
|
||||
import { events_loc } from '$lib/stores/ae_events_stores';
|
||||
import { leads_loc } from '$lib/stores/ae_events_stores__leads.svelte';
|
||||
|
||||
interface Props {
|
||||
exhibit_id: string;
|
||||
@@ -24,31 +24,28 @@ interface Props {
|
||||
let { exhibit_id }: Props = $props();
|
||||
|
||||
// QR vs Manual Search (persisted per exhibit)
|
||||
let mode = $derived($events_loc.leads.tab_add_mode?.[exhibit_id] ?? 'qr');
|
||||
let mode = $derived(leads_loc.current.tab_add_mode?.[exhibit_id] ?? 'qr');
|
||||
|
||||
function set_mode(new_mode: string) {
|
||||
if (!$events_loc.leads.tab_add_mode) $events_loc.leads.tab_add_mode = {};
|
||||
$events_loc.leads.tab_add_mode[exhibit_id] = new_mode;
|
||||
leads_loc.current.tab_add_mode[exhibit_id] = new_mode;
|
||||
}
|
||||
|
||||
// Scan qualify mode (persisted per exhibit)
|
||||
// 'qualify' was merged into 'rapid' — normalize stale localStorage values
|
||||
type ScanQualifyMode = 'rapid' | 'auto' | 'multi';
|
||||
let scan_qualify = $derived.by(() => {
|
||||
const raw = $events_loc.leads.tab_scan_qualify?.[exhibit_id] ?? 'rapid';
|
||||
const raw = leads_loc.current.tab_scan_qualify?.[exhibit_id] ?? 'rapid';
|
||||
// 'qualify' was merged into 'rapid' — normalize stale localStorage values
|
||||
return (raw === 'qualify' ? 'rapid' : raw) as ScanQualifyMode;
|
||||
});
|
||||
|
||||
function set_scan_qualify(new_mode: ScanQualifyMode) {
|
||||
if (!$events_loc.leads.tab_scan_qualify)
|
||||
$events_loc.leads.tab_scan_qualify = {};
|
||||
$events_loc.leads.tab_scan_qualify[exhibit_id] = new_mode;
|
||||
leads_loc.current.tab_scan_qualify[exhibit_id] = new_mode;
|
||||
show_mode_opts = false;
|
||||
}
|
||||
|
||||
function handle_lead_added(badge: any) {
|
||||
$events_loc.leads.tracking__search_version++;
|
||||
leads_loc.current.tracking__search_version++;
|
||||
}
|
||||
|
||||
// Mode selector expand/collapse
|
||||
|
||||
@@ -8,6 +8,7 @@ import { liveQuery } from 'dexie';
|
||||
import { db_events } from '$lib/ae_events/db_events';
|
||||
import { ae_api, ae_loc } from '$lib/stores/ae_stores';
|
||||
import { events_loc, events_sess } from '$lib/stores/ae_events_stores';
|
||||
import { leads_loc } from '$lib/stores/ae_events_stores__leads.svelte';
|
||||
import { events_func } from '$lib/ae_events/ae_events_functions';
|
||||
import Element_ae_obj_field_editor from '$lib/elements/element_ae_obj_field_editor.svelte';
|
||||
import Comp_exhibit_license_list from './ae_comp__exhibit_license_list.svelte';
|
||||
@@ -64,11 +65,10 @@ let show_billing = $state(false);
|
||||
|
||||
function handle_signout() {
|
||||
if (confirm('Sign out from this booth?')) {
|
||||
delete $events_loc.leads.auth_exhibit_kv[exhibit_id];
|
||||
delete leads_loc.current.auth_exhibit_kv[exhibit_id];
|
||||
$events_sess.leads.entered_passcode = null;
|
||||
// Navigate to start tab
|
||||
if (!$events_loc.leads.tab) $events_loc.leads.tab = {};
|
||||
$events_loc.leads.tab[exhibit_id] = 'start';
|
||||
leads_loc.current.tab[exhibit_id] = 'start';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -330,7 +330,7 @@ function handle_signout() {
|
||||
<!-- Licenses — visible to: Aether admins OR someone signed in with the shared exhibit passcode.
|
||||
Spec: "A client staff (Trusted Access or above) or someone signed in with an Exhibit passcode
|
||||
can add/edit/remove licenses." — PROJECT__AE_Events_Exhibitor_Leads_v3.md -->
|
||||
{#if $ae_loc.administrator_access || $events_loc.leads.auth_exhibit_kv?.[exhibit_id]?.type === 'shared'}
|
||||
{#if $ae_loc.administrator_access || leads_loc.current.auth_exhibit_kv?.[exhibit_id]?.type === 'shared'}
|
||||
<div class="p-0">
|
||||
<button
|
||||
class="hover:bg-surface-500/5 group flex w-full items-center justify-between p-4 transition-colors"
|
||||
@@ -443,10 +443,10 @@ function handle_signout() {
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if show_billing}
|
||||
{#if show_billing}
|
||||
<div
|
||||
class="bg-surface-500/5 border-surface-500/10 animate-in fade-in slide-in-from-top-2 border-t p-4">
|
||||
<Comp_exhibit_payment {exhibit_id} {...stripe_cfg} />
|
||||
<Comp_exhibit_payment {exhibit_id} {...stripe_cfg} leads_require_payment={leads_require_payment} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -528,7 +528,7 @@ function handle_signout() {
|
||||
class="input border-surface-500/20 w-20 border-b bg-transparent p-1 text-right font-mono"
|
||||
min="1"
|
||||
max="120"
|
||||
bind:value={$events_loc.leads.refresh_interval_sec}
|
||||
bind:value={leads_loc.current.refresh_interval_sec}
|
||||
placeholder="25" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -33,9 +33,14 @@ Shared test helpers (`tests/_helpers/`)
|
||||
| `ae_defaults.ts` | `ae_app_local_data_defaults` — full localStorage seed object with `__version` |
|
||||
| `idb_helpers.ts` | `inject_badge_and_template()` — write badge + template records into IndexedDB |
|
||||
| `minimal_ae_api_mocks.ts` | `attach_minimal_routes()`, `seed_trusted_session()`, `setup_badge_test_page()` |
|
||||
| `leads_helpers.ts` | `setup_leads_test_page()`, `seed_events_loc()`, `seed_ae_loc()`, `attach_leads_routes()`, `minimal_exhibit()`, `minimal_tracking()` — Leads module test helpers |
|
||||
|
||||
Note: After the Leads persisted-store migration, tests that seed localStorage should also seed the new `leads_loc` defaults and include the expected `__version` values (see `src/lib/stores/store_versions.ts`) to avoid store wipe behavior during test startup. Update `tests/_helpers/leads_helpers.ts` accordingly.
|
||||
|
||||
**`setup_badge_test_page(page, event_id)`** is the one-call `beforeEach` for any badge/event print page test. It wires the pageerror listener, all V3 API mocks, and the trusted auth localStorage seed in one call.
|
||||
|
||||
**`setup_leads_test_page(page, event_id, exhibit_id, opts)`** is the one-call `beforeEach` for leads exhibit page tests. Accepts `access` (ae_loc flags), `auth_kv` (per-exhibit auth), `staff_passcode`, and `tracking_li` options.
|
||||
|
||||
Writing / modifying tests
|
||||
- Tests are TypeScript files under `tests/` and should export Playwright `test` blocks.
|
||||
- The badge tests (`event_badge_*.test.ts`) are the **canonical template** — copy the pattern from there when adding tests for any new event module feature.
|
||||
@@ -408,20 +413,31 @@ These are IDs for records that we can use for testing. Please do not delete them
|
||||
|
||||
### Events Modules
|
||||
* Aether test/demo Event: 'pjrcghqwert' (1) "Demo One Sky IT Conference"
|
||||
|
||||
* Aether test/demo Event Session: 'DOW3h7v6H42' (703) "How To Do Things"
|
||||
* Aether test/demo Event Session (Digital Posters): "K8cxUIEWyQk" "The Beginning of Digital Posters!"
|
||||
* Aether test/demo Event Session (Digital Posters): "1Un1xI1Rgk8" "Poster Session 99: All about posters!"
|
||||
* Aether test/demo Event Presentation: '7U2eXSjR6H4' (1670) "Build a House"
|
||||
* Aether test/demo Event Presenter: 'gT-hxnifb-0' (2202) "Bob The Builder"
|
||||
|
||||
* Aether test/demo Event File: 'OOsHXtng5mr' (2985) "1 Quick Test for macOS.mp4"
|
||||
|
||||
* Aether test/demo Event Badge: 'UIJT-73-63-61' (37163) "Scott Idem"
|
||||
* Aether test/demo Event Person: 'ffkKxiHpOEC' (16603) "Scott Idem"
|
||||
|
||||
* Aether test/demo Event Badge Template: 'jgfixEpYp1B' (18) "Dev Demo 202x"
|
||||
* Aether test/demo Event Badge Template: 'rzmUgsk7mkq' (19) "Dev Demo 202x Workshops"
|
||||
|
||||
* Aether test/demo Event Location: 'VXXY-98-46-14' (26) "Ballroom 1"
|
||||
* Aether test/demo Event Location: 'FGRN-67-92-45' (298) "Ballroom AB"
|
||||
* Aether test/demo Event Location: 'PQKB-15-39-81' (78) "Poster Display Station A"
|
||||
|
||||
* Aether test/demo Event Exhibit: 'xK_9yEj1bQY' (1) - "One Sky's Awesome Exhibit"
|
||||
* Aether test/demo Event Exhibit: 'acHCkrCDaYs' (3) - "Exhibit for Precon Events"
|
||||
* Aether test/demo Event Exhibit: 'MIFC-74-11-33' (177) - "OSIT Test Booth"
|
||||
* Aether test/demo Event Exhibit: 'yMawNHiNkHo' (4) - "Dev Virtual Exhibit"
|
||||
* Aether test/demo Event Exhibit: 'XgtAc3xhVsU' (2) - "The Org Group Virtual Exhibit"
|
||||
|
||||
### Journals Module
|
||||
* Aether test/demo Journal: 'BVYE-94-46-29' (42) "Testing Things"
|
||||
* Aether test/demo Journal Entry: 'xRx-Y4-h3-fU' (233) "Another Journal Entry in the Test Journal"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const ae_app_local_data_defaults = {
|
||||
__version: 1, // Must match AE_LOC_VERSION in src/lib/stores/store_versions.ts — store_versions.ts wipes ae_loc if version doesn't match
|
||||
__version: 2, // Must match AE_LOC_VERSION in src/lib/stores/store_versions.ts — store_versions.ts wipes ae_loc if version doesn't match
|
||||
last_page_reload: null,
|
||||
last_cache_refresh: Date.now(),
|
||||
cache_expired: false,
|
||||
|
||||
475
tests/_helpers/leads_helpers.ts
Normal file
475
tests/_helpers/leads_helpers.ts
Normal file
@@ -0,0 +1,475 @@
|
||||
/**
|
||||
* 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_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;
|
||||
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',
|
||||
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 })
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// 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;
|
||||
tracking_li?: any[];
|
||||
badge_li?: any[];
|
||||
event_data_overrides?: Record<string, any>;
|
||||
} = {}
|
||||
): Promise<void> {
|
||||
const { access = {}, auth_kv = {}, leads_overrides = {}, ...route_opts } = opts;
|
||||
|
||||
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_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_events_loc,
|
||||
attach_leads_routes,
|
||||
setup_leads_test_page,
|
||||
};
|
||||
243
tests/leads_add_lead.test.ts
Normal file
243
tests/leads_add_lead.test.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { testing_event_id } from './_helpers/env';
|
||||
import {
|
||||
testing_exhibit_id,
|
||||
exhibit_staff_passcode,
|
||||
setup_leads_test_page,
|
||||
minimal_badge,
|
||||
} from './_helpers/leads_helpers';
|
||||
|
||||
const event_id = testing_event_id;
|
||||
const exhibit_id = testing_exhibit_id;
|
||||
const exhibit_url = `/events/${event_id}/leads/exhibit/${exhibit_id}`;
|
||||
|
||||
// Pre-seeded auth used by all tests in this suite
|
||||
const signed_in_kv = { [exhibit_id]: { key: exhibit_staff_passcode, type: 'shared' } };
|
||||
|
||||
// Seed leads_overrides to start in search mode (not QR).
|
||||
// Without this, the add tab shows the QR scanner which cannot be exercised in Playwright.
|
||||
const search_mode_seed = { tab_add_mode: { [exhibit_id]: 'search' } };
|
||||
|
||||
test.describe('Leads — Add Lead (manual search)', () => {
|
||||
// -----------------------------------------------------------------------
|
||||
// 1. Navigate to Add tab and see search form
|
||||
// -----------------------------------------------------------------------
|
||||
test('clicking Add Lead shows manual search form', async ({ page }) => {
|
||||
await setup_leads_test_page(page, event_id, exhibit_id, {
|
||||
auth_kv: signed_in_kv,
|
||||
leads_overrides: search_mode_seed,
|
||||
});
|
||||
|
||||
await page.goto(exhibit_url);
|
||||
|
||||
// Click "Add Lead" header button to enter the add tab
|
||||
await page.locator('header button.preset-filled-primary').click();
|
||||
|
||||
// Manual search form must be visible
|
||||
await expect(
|
||||
page.locator('input[placeholder="Attendee name, email, or badge ID..."]')
|
||||
).toBeVisible({ timeout: 8_000 });
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 2. Search returns results
|
||||
// -----------------------------------------------------------------------
|
||||
test('badge search returns results and shows Add button for opted-in attendees', async ({ page }) => {
|
||||
await setup_leads_test_page(page, event_id, exhibit_id, {
|
||||
auth_kv: signed_in_kv,
|
||||
leads_overrides: search_mode_seed,
|
||||
// badge_li defaults to [minimal_badge()] — one badge with allow_tracking=true
|
||||
});
|
||||
|
||||
await page.goto(exhibit_url);
|
||||
await page.locator('header button.preset-filled-primary').click();
|
||||
|
||||
const search_input = page.locator(
|
||||
'input[placeholder="Attendee name, email, or badge ID..."]'
|
||||
);
|
||||
await expect(search_input).toBeVisible({ timeout: 8_000 });
|
||||
|
||||
// Type a query and submit the search form
|
||||
await search_input.fill('Scott');
|
||||
await page.locator('button:has-text("Search")').click();
|
||||
|
||||
// Result card with the badge full_name should appear
|
||||
await expect(page.locator('text=Scott Idem')).toBeVisible({ timeout: 8_000 });
|
||||
|
||||
// The "Add" button must be visible (allow_tracking=true on the mock badge)
|
||||
await expect(
|
||||
page.locator('.results-list button.preset-filled-success')
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 3. Opted-out attendee shows "Opt-Out" badge instead of Add button
|
||||
// -----------------------------------------------------------------------
|
||||
test('opted-out attendee shows Opt-Out label instead of Add button', async ({ page }) => {
|
||||
await setup_leads_test_page(page, event_id, exhibit_id, {
|
||||
auth_kv: signed_in_kv,
|
||||
leads_overrides: search_mode_seed,
|
||||
// Override badge_li with one badge that has allow_tracking=false
|
||||
badge_li: [minimal_badge({ allow_tracking: false, full_name: 'Jane Opted Out' })],
|
||||
});
|
||||
|
||||
await page.goto(exhibit_url);
|
||||
await page.locator('header button.preset-filled-primary').click();
|
||||
|
||||
const search_input = page.locator(
|
||||
'input[placeholder="Attendee name, email, or badge ID..."]'
|
||||
);
|
||||
await expect(search_input).toBeVisible({ timeout: 8_000 });
|
||||
|
||||
await search_input.fill('Jane');
|
||||
await page.locator('button:has-text("Search")').click();
|
||||
|
||||
await expect(page.locator('text=Jane Opted Out')).toBeVisible({ timeout: 8_000 });
|
||||
|
||||
// Add button must NOT appear
|
||||
await expect(
|
||||
page.locator('.results-list button.preset-filled-success')
|
||||
).not.toBeVisible();
|
||||
|
||||
// "Opt-Out" label must appear instead
|
||||
await expect(page.locator('text=Opt-Out')).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 4. Clicking Add creates a lead and shows View link
|
||||
//
|
||||
// After a successful create_ae_obj__exhibit_tracking(), the search result
|
||||
// row switches from "Add" button to a "View" link pointing to the lead
|
||||
// detail page. This is the primary success path.
|
||||
// -----------------------------------------------------------------------
|
||||
test('clicking Add button creates lead and shows View link', async ({ page }) => {
|
||||
await setup_leads_test_page(page, event_id, exhibit_id, {
|
||||
auth_kv: signed_in_kv,
|
||||
leads_overrides: search_mode_seed,
|
||||
});
|
||||
|
||||
await page.goto(exhibit_url);
|
||||
await page.locator('header button.preset-filled-primary').click();
|
||||
|
||||
const search_input = page.locator(
|
||||
'input[placeholder="Attendee name, email, or badge ID..."]'
|
||||
);
|
||||
await expect(search_input).toBeVisible({ timeout: 8_000 });
|
||||
|
||||
await search_input.fill('Scott');
|
||||
await page.locator('button:has-text("Search")').click();
|
||||
|
||||
// Wait for results
|
||||
await expect(page.locator('text=Scott Idem')).toBeVisible({ timeout: 8_000 });
|
||||
|
||||
// Intercept the tracking create request before clicking Add
|
||||
const create_promise = page.waitForRequest(
|
||||
(r) =>
|
||||
r.url().includes('event_exhibit_tracking') &&
|
||||
r.method() === 'POST',
|
||||
{ timeout: 5_000 }
|
||||
);
|
||||
|
||||
await page.locator('.results-list button.preset-filled-success').click();
|
||||
|
||||
// The POST must have been made
|
||||
const create_req = await create_promise;
|
||||
const body = JSON.parse(create_req.postData() ?? '{}');
|
||||
expect(body.event_badge_id).toBe('UIJT-73-63-61');
|
||||
// Shared-passcode users store 'shared_passcode' as their identity
|
||||
expect(body.external_person_id).toBe('shared_passcode');
|
||||
|
||||
// "Add" button should disappear; "View" link should appear
|
||||
await expect(
|
||||
page.locator('.results-list button.preset-filled-success')
|
||||
).not.toBeVisible({ timeout: 5_000 });
|
||||
await expect(
|
||||
page.locator('.results-list a.preset-filled-secondary')
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// View link must point to the tracking detail page
|
||||
const view_href = await page
|
||||
.locator('.results-list a.preset-filled-secondary')
|
||||
.getAttribute('href');
|
||||
expect(view_href).toContain(`/leads/exhibit/${exhibit_id}/lead/`);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 5. Search with no results shows empty-state message
|
||||
// -----------------------------------------------------------------------
|
||||
test('search with no results shows empty-state message', async ({ page }) => {
|
||||
await setup_leads_test_page(page, event_id, exhibit_id, {
|
||||
auth_kv: signed_in_kv,
|
||||
leads_overrides: search_mode_seed,
|
||||
badge_li: [], // empty results
|
||||
});
|
||||
|
||||
await page.goto(exhibit_url);
|
||||
await page.locator('header button.preset-filled-primary').click();
|
||||
|
||||
const search_input = page.locator(
|
||||
'input[placeholder="Attendee name, email, or badge ID..."]'
|
||||
);
|
||||
await expect(search_input).toBeVisible({ timeout: 8_000 });
|
||||
|
||||
await search_input.fill('nobody');
|
||||
await page.locator('button:has-text("Search")').click();
|
||||
|
||||
await expect(
|
||||
page.locator('text=No attendees found matching')
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 6. "My Leads" filter resolves correctly for shared-passcode users
|
||||
//
|
||||
// When tracking__qry__licensee_email = 'my' and the user is authenticated
|
||||
// via shared passcode, the filter must resolve to 'shared_passcode'
|
||||
// (not the literal passcode string, which would never match any record).
|
||||
// This is the bug fixed in 2026-04-01 — a regression guard.
|
||||
// -----------------------------------------------------------------------
|
||||
test('My Leads filter resolves to shared_passcode for shared-auth users', async ({ page }) => {
|
||||
const tracking_record = {
|
||||
id: 'TRK-MY-001',
|
||||
event_exhibit_tracking_id: 'TRK-MY-001',
|
||||
event_exhibit_id: exhibit_id,
|
||||
event_badge_id: 'UIJT-73-63-61',
|
||||
event_badge_full_name: 'Scott Idem',
|
||||
event_badge_email: 'scott@demo.oneskyit.com',
|
||||
// Stored identity for shared-passcode captures must be the literal
|
||||
// 'shared_passcode', not the actual passcode value.
|
||||
external_person_id: 'shared_passcode',
|
||||
group: 'shared_passcode',
|
||||
enable: 1,
|
||||
hide: false,
|
||||
created_on: new Date().toISOString(),
|
||||
updated_on: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await setup_leads_test_page(page, event_id, exhibit_id, {
|
||||
auth_kv: signed_in_kv,
|
||||
tracking_li: [tracking_record],
|
||||
leads_overrides: {
|
||||
// Pre-set the filter to "My Leads"
|
||||
tracking__qry__licensee_email: 'my',
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto(exhibit_url);
|
||||
|
||||
// The list tab should show; with the "My Leads" filter active and the
|
||||
// tracking record's external_person_id = 'shared_passcode', it must pass
|
||||
// through the HARD GUARD in filtered_lead_li.
|
||||
//
|
||||
// We cannot directly assert the filter resolved correctly without reading
|
||||
// the store, but we can assert the lead card IS visible (meaning the filter
|
||||
// did not incorrectly drop it). If the filter resolved to the raw passcode
|
||||
// string ('BOOTH2026'), the record would be excluded and the list empty.
|
||||
await expect(page.locator('text=Lead List')).toBeVisible({ timeout: 8_000 });
|
||||
|
||||
// The search will run against Dexie (which is empty — IDB not pre-seeded here).
|
||||
// Asserting no crash and correct page structure is the smoke-level check.
|
||||
// Full IDB-backed "My Leads" verification belongs in an IDB inject-then-reload test.
|
||||
await expect(page.locator('.ae_events_leads_tracking_new')).toBeVisible();
|
||||
});
|
||||
});
|
||||
295
tests/leads_auth.test.ts
Normal file
295
tests/leads_auth.test.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { testing_event_id } from './_helpers/env';
|
||||
import {
|
||||
testing_exhibit_id,
|
||||
testing_exhibit_id_b,
|
||||
exhibit_staff_passcode,
|
||||
setup_leads_test_page,
|
||||
attach_leads_routes,
|
||||
seed_ae_loc,
|
||||
seed_events_loc,
|
||||
minimal_exhibit,
|
||||
} from './_helpers/leads_helpers';
|
||||
|
||||
const event_id = testing_event_id;
|
||||
const exhibit_id = testing_exhibit_id;
|
||||
const exhibit_url = `/events/${event_id}/leads/exhibit/${exhibit_id}`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Locator for the sign-in form card. */
|
||||
const signin_form = (page: Parameters<typeof expect>[0]) =>
|
||||
(page as import('@playwright/test').Page).locator('.exhibit-signin');
|
||||
|
||||
/** Locator for the shared-passcode input field. */
|
||||
const passcode_input = (page: Parameters<typeof expect>[0]) =>
|
||||
(page as import('@playwright/test').Page).locator(
|
||||
'input[placeholder="Enter shared code..."]'
|
||||
);
|
||||
|
||||
/** Locator for the "Add Lead" / "Lead List" toggle button — only visible when signed in. */
|
||||
const header_action_btn = (page: Parameters<typeof expect>[0]) =>
|
||||
(page as import('@playwright/test').Page).locator(
|
||||
'header button.preset-filled-primary'
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test suite
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe('Leads — Auth Gate', () => {
|
||||
// -----------------------------------------------------------------------
|
||||
// 1. Unauthenticated user sees sign-in form
|
||||
// -----------------------------------------------------------------------
|
||||
test('unauthenticated user sees sign-in form, not the lead list', async ({ page }) => {
|
||||
// No auth_kv → no exhibit in auth_exhibit_kv → is_signed_in = false
|
||||
await setup_leads_test_page(page, event_id, exhibit_id);
|
||||
|
||||
await page.goto(exhibit_url);
|
||||
|
||||
// Sign-in form must appear within a reasonable time
|
||||
await expect(signin_form(page)).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Header action buttons (Add Lead / Lead List) must NOT be visible
|
||||
await expect(header_action_btn(page)).not.toBeVisible();
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 2. manager_access bypasses sign-in entirely
|
||||
// -----------------------------------------------------------------------
|
||||
test('manager_access bypasses sign-in — list tab shown directly', async ({ page }) => {
|
||||
await setup_leads_test_page(page, event_id, exhibit_id, {
|
||||
// manager_access=true → is_signed_in = true without any exhibit auth
|
||||
access: {
|
||||
allow_access: true,
|
||||
authenticated_access: true,
|
||||
trusted_access: true,
|
||||
manager_access: true,
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto(exhibit_url);
|
||||
|
||||
// Sign-in form must NOT appear
|
||||
await expect(signin_form(page)).not.toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Header action button (Add Lead / Lead List) must be visible
|
||||
await expect(header_action_btn(page)).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 3. Pre-authenticated user skips sign-in (already in auth_exhibit_kv)
|
||||
// -----------------------------------------------------------------------
|
||||
test('already-signed-in user sees lead list, not sign-in form', async ({ page }) => {
|
||||
await setup_leads_test_page(page, event_id, exhibit_id, {
|
||||
// Seed auth_exhibit_kv — simulates a returning user whose session survived
|
||||
auth_kv: {
|
||||
[exhibit_id]: { key: exhibit_staff_passcode, type: 'shared' },
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto(exhibit_url);
|
||||
|
||||
await expect(signin_form(page)).not.toBeVisible({ timeout: 10_000 });
|
||||
await expect(header_action_btn(page)).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 4. Shared passcode sign-in — success path
|
||||
//
|
||||
// trusted_access = true causes the sign-in component to auto-fill the
|
||||
// passcode field once $lq__exhibit_obj loads into Dexie via the mocked API.
|
||||
// Waiting for the input to have a value gives us a reliable timing signal
|
||||
// (exhibit is in Dexie, $lq__exhibit_obj is live, sign-in will process).
|
||||
// -----------------------------------------------------------------------
|
||||
test('shared passcode sign-in — correct passcode signs in successfully', async ({ page }) => {
|
||||
await setup_leads_test_page(page, event_id, exhibit_id, {
|
||||
// trusted_access triggers auto-fill once exhibit loads; no manager bypass
|
||||
access: { allow_access: true, trusted_access: true },
|
||||
});
|
||||
|
||||
await page.goto(exhibit_url);
|
||||
|
||||
// Sign-in form visible (trusted_access does NOT bypass the auth gate)
|
||||
await expect(signin_form(page)).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Wait for exhibit to load into Dexie — auto-fill kicks in for trusted users.
|
||||
// Input having the correct value means $lq__exhibit_obj is live.
|
||||
await expect(passcode_input(page)).toHaveValue(exhibit_staff_passcode, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// Submit
|
||||
await page.locator('button[type="submit"]').click();
|
||||
|
||||
// After 800 ms UX delay + Svelte reactivity, form should disappear
|
||||
await expect(signin_form(page)).not.toBeVisible({ timeout: 5_000 });
|
||||
await expect(header_action_btn(page)).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 5. Wrong passcode shows error and keeps the form open
|
||||
// -----------------------------------------------------------------------
|
||||
test('wrong passcode shows error message, form stays visible', async ({ page }) => {
|
||||
await setup_leads_test_page(page, event_id, exhibit_id, {
|
||||
access: { allow_access: true, trusted_access: true },
|
||||
});
|
||||
|
||||
await page.goto(exhibit_url);
|
||||
|
||||
// Wait for exhibit to be ready (auto-fill is the readiness signal)
|
||||
await expect(passcode_input(page)).toHaveValue(exhibit_staff_passcode, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// Override with an incorrect passcode
|
||||
await passcode_input(page).fill('WRONGCODE');
|
||||
await page.locator('button[type="submit"]').click();
|
||||
|
||||
// Error text must appear
|
||||
await expect(
|
||||
page.locator('text=Invalid shared passcode')
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Sign-in form must still be visible
|
||||
await expect(signin_form(page)).toBeVisible({ timeout: 3_000 });
|
||||
|
||||
// Header action button must NOT appear (not signed in)
|
||||
await expect(header_action_btn(page)).not.toBeVisible();
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 6. Simultaneous multi-exhibit auth
|
||||
//
|
||||
// auth_exhibit_kv is keyed by exhibit_id, so Booth A and Booth B auth are
|
||||
// completely independent entries. A user can be signed into both at the
|
||||
// same time — this mirrors real-world use where staff manages adjacent booths.
|
||||
//
|
||||
// We pre-seed both exhibits in auth_exhibit_kv and verify that navigating
|
||||
// between them never triggers the sign-in form.
|
||||
// -----------------------------------------------------------------------
|
||||
test('multi-exhibit: both booths authorized simultaneously, neither shows sign-in', async ({ page }) => {
|
||||
const exhibit_id_b = testing_exhibit_id_b;
|
||||
const exhibit_url_b = `/events/${event_id}/leads/exhibit/${exhibit_id_b}`;
|
||||
|
||||
page.on('pageerror', (err) => console.error(`BROWSER ERROR: ${err.message}`));
|
||||
|
||||
// Route mock covering both exhibit IDs
|
||||
await page.route('**/v3/**', async (route) => {
|
||||
const req = route.request();
|
||||
const url = req.url();
|
||||
const method = req.method();
|
||||
|
||||
if (url.includes('site_domain/search')) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ data: [
|
||||
{
|
||||
id: '_6jcTbnJk-o', site_id: '92vkYC4fVEl',
|
||||
site_domain_id: '_6jcTbnJk-o', account_id: '_XY7DXtc9MY',
|
||||
account_id_random: '_XY7DXtc9MY', account_code: 'OSIT_DEMO',
|
||||
account_name: 'One Sky IT Demo', fqdn: 'demo.localhost:5173',
|
||||
enable: '1', cfg_json: {}, style_href: '', header_image_path: '',
|
||||
}
|
||||
] }),
|
||||
});
|
||||
}
|
||||
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)) });
|
||||
}
|
||||
if (url.includes(`/v3/crud/event_exhibit/${exhibit_id_b}`) && method === 'GET') {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json',
|
||||
body: JSON.stringify(minimal_exhibit(exhibit_id_b, {
|
||||
name: 'Test Booth — Beta Corp', code: 'BETA', staff_passcode: 'BOOTHB99',
|
||||
})) });
|
||||
}
|
||||
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,
|
||||
name: 'Test Event', mod_exhibits_json: { leads_require_payment: false } } }) });
|
||||
}
|
||||
return route.fulfill({ status: 200, contentType: 'application/json',
|
||||
body: JSON.stringify({ data: [] }) });
|
||||
});
|
||||
|
||||
// Seed ae_loc (regular shared-passcode user — no manager bypass)
|
||||
await seed_ae_loc(page, { allow_access: true });
|
||||
|
||||
// Pre-seed BOTH exhibits as authorized — independent KV entries
|
||||
await seed_events_loc(page, {
|
||||
[exhibit_id]: { key: exhibit_staff_passcode, type: 'shared' },
|
||||
[exhibit_id_b]: { key: 'BOOTHB99', type: 'shared' },
|
||||
});
|
||||
|
||||
// Booth A → no sign-in form
|
||||
await page.goto(exhibit_url);
|
||||
await expect(signin_form(page)).not.toBeVisible({ timeout: 10_000 });
|
||||
await expect(header_action_btn(page)).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Booth B → no sign-in form (independent KV entry, unaffected by Booth A)
|
||||
await page.goto(exhibit_url_b);
|
||||
await expect(signin_form(page)).not.toBeVisible({ timeout: 10_000 });
|
||||
await expect(header_action_btn(page)).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Back to Booth A → still authorized
|
||||
await page.goto(exhibit_url);
|
||||
await expect(signin_form(page)).not.toBeVisible({ timeout: 10_000 });
|
||||
await expect(header_action_btn(page)).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 7. Unauthorized exhibit shows sign-in even when another booth is auth'd
|
||||
//
|
||||
// Verifies that auth_exhibit_kv[booth_A] does not bleed into booth_B.
|
||||
// -----------------------------------------------------------------------
|
||||
test('multi-exhibit: authorized for Booth A only → Booth B still shows sign-in', async ({ page }) => {
|
||||
const exhibit_id_b = testing_exhibit_id_b;
|
||||
const exhibit_url_b = `/events/${event_id}/leads/exhibit/${exhibit_id_b}`;
|
||||
|
||||
page.on('pageerror', (err) => console.error(`BROWSER ERROR: ${err.message}`));
|
||||
|
||||
await page.route('**/v3/**', async (route) => {
|
||||
const url = route.request().url();
|
||||
const method = route.request().method();
|
||||
if (url.includes('site_domain/search')) {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json',
|
||||
body: JSON.stringify({ data: [
|
||||
{ id: '_6jcTbnJk-o', site_id: '92vkYC4fVEl',
|
||||
site_domain_id: '_6jcTbnJk-o', account_id: '_XY7DXtc9MY',
|
||||
account_id_random: '_XY7DXtc9MY', account_code: 'OSIT_DEMO',
|
||||
account_name: 'One Sky IT Demo', fqdn: 'demo.localhost:5173',
|
||||
enable: '1', cfg_json: {}, style_href: '', header_image_path: '' }
|
||||
] }) });
|
||||
}
|
||||
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)) });
|
||||
}
|
||||
if (url.includes(`/v3/crud/event_exhibit/${exhibit_id_b}`) && method === 'GET') {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json',
|
||||
body: JSON.stringify(minimal_exhibit(exhibit_id_b, { name: 'Beta Corp', code: 'BETA' })) });
|
||||
}
|
||||
return route.fulfill({ status: 200, contentType: 'application/json',
|
||||
body: JSON.stringify({ data: [] }) });
|
||||
});
|
||||
|
||||
await seed_ae_loc(page, { allow_access: true });
|
||||
// Only Booth A authorized — Booth B not in auth_kv
|
||||
await seed_events_loc(page, {
|
||||
[exhibit_id]: { key: exhibit_staff_passcode, type: 'shared' },
|
||||
});
|
||||
|
||||
// Booth A → no sign-in form
|
||||
await page.goto(exhibit_url);
|
||||
await expect(signin_form(page)).not.toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Booth B → must show sign-in form (auth does not cross exhibit boundaries)
|
||||
await page.goto(exhibit_url_b);
|
||||
await expect(signin_form(page)).toBeVisible({ timeout: 10_000 });
|
||||
await expect(header_action_btn(page)).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
236
tests/leads_config.test.ts
Normal file
236
tests/leads_config.test.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { testing_event_id, mock_site_domain } from './_helpers/env';
|
||||
import {
|
||||
minimal_event_for_leads,
|
||||
seed_ae_loc,
|
||||
} from './_helpers/leads_helpers';
|
||||
|
||||
const event_id = testing_event_id;
|
||||
const config_url = `/events/${event_id}/leads/config`;
|
||||
|
||||
/**
|
||||
* Minimal route mock for the Leads Config page.
|
||||
*
|
||||
* The config page only needs the event record (not exhibit or badge data) plus
|
||||
* the site_domain init call. A PATCH handler is included for save tests.
|
||||
*
|
||||
* @param patch_handler Optional callback invoked when a PATCH to the event is intercepted.
|
||||
* Defaults to returning 200 OK.
|
||||
*/
|
||||
async function setup_config_routes(
|
||||
page: import('@playwright/test').Page,
|
||||
opts: {
|
||||
event_data_overrides?: Record<string, any>;
|
||||
on_patch?: (body: any) => void;
|
||||
} = {}
|
||||
) {
|
||||
const { event_data_overrides = {}, on_patch } = opts;
|
||||
|
||||
await page.route('**/v3/**', async (route) => {
|
||||
const req = route.request();
|
||||
const url = req.url();
|
||||
const method = req.method();
|
||||
|
||||
// Site domain init
|
||||
if (url.includes('site_domain/search')) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ data: [mock_site_domain] }),
|
||||
});
|
||||
}
|
||||
|
||||
// Event GET — provides mod_exhibits_json for the draft
|
||||
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)),
|
||||
});
|
||||
}
|
||||
|
||||
// Event PATCH — save config
|
||||
if (url.includes(`/v3/crud/event/${event_id}`) && method === 'PATCH') {
|
||||
const raw = await req.postData();
|
||||
const body = raw ? JSON.parse(raw) : {};
|
||||
on_patch?.(body);
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ data: { id: event_id } }),
|
||||
});
|
||||
}
|
||||
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ data: [] }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('Leads — Config Page', () => {
|
||||
// -----------------------------------------------------------------------
|
||||
// 1. Non-admin user sees access-denied screen
|
||||
//
|
||||
// The config page is administrator_access only. Any other access level must
|
||||
// see the Lock icon and "Administrator access required." — no form fields.
|
||||
// -----------------------------------------------------------------------
|
||||
test('non-admin user sees access-denied message, no form', async ({ page }) => {
|
||||
page.on('pageerror', (err) => console.error(`BROWSER ERROR: ${err.message}`));
|
||||
await setup_config_routes(page);
|
||||
await seed_ae_loc(page, { allow_access: true, trusted_access: true });
|
||||
|
||||
await page.goto(config_url);
|
||||
|
||||
// Access denied block must be visible
|
||||
await expect(
|
||||
page.locator('text=Administrator access required.')
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Form / save button must NOT be visible
|
||||
await expect(page.locator('text=Leads Config')).not.toBeVisible();
|
||||
await expect(page.locator('button:has-text("Save")')).not.toBeVisible();
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 2. Admin user sees config form
|
||||
//
|
||||
// administrator_access = true reveals the full form with both sections
|
||||
// (Payment and Stripe Keys) and the Save button.
|
||||
// -----------------------------------------------------------------------
|
||||
test('admin user sees config form with Payment and Stripe sections', async ({ page }) => {
|
||||
page.on('pageerror', (err) => console.error(`BROWSER ERROR: ${err.message}`));
|
||||
await setup_config_routes(page);
|
||||
await seed_ae_loc(page, {
|
||||
allow_access: true,
|
||||
authenticated_access: true,
|
||||
trusted_access: true,
|
||||
administrator_access: true,
|
||||
});
|
||||
|
||||
await page.goto(config_url);
|
||||
|
||||
// Heading confirms page rendered
|
||||
await expect(page.locator('h1:has-text("Leads Config")')).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Both collapsible sections are visible (default open)
|
||||
// Use the checkbox label text — it's unique inside the Payment section
|
||||
await expect(page.locator('text=Require Payment (Stripe)')).toBeVisible({ timeout: 5_000 });
|
||||
await expect(page.locator('text=Stripe Keys')).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Save button is present — two exist (header + bottom), check the header one
|
||||
await expect(page.locator('button:has-text("Save")').first()).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 3. Save button disabled until form is dirty
|
||||
//
|
||||
// On load, is_dirty = false because draft JSON === initial JSON.
|
||||
// After toggling the "Require Payment" checkbox, is_dirty = true and the
|
||||
// Save button becomes enabled.
|
||||
// -----------------------------------------------------------------------
|
||||
test('save button disabled by default, enabled after checkbox change', async ({ page }) => {
|
||||
page.on('pageerror', (err) => console.error(`BROWSER ERROR: ${err.message}`));
|
||||
// Start with leads_require_payment: false so toggling it makes a real change
|
||||
await setup_config_routes(page, {
|
||||
event_data_overrides: {
|
||||
mod_exhibits_json: { leads_require_payment: false },
|
||||
},
|
||||
});
|
||||
await seed_ae_loc(page, {
|
||||
allow_access: true,
|
||||
authenticated_access: true,
|
||||
trusted_access: true,
|
||||
administrator_access: true,
|
||||
});
|
||||
|
||||
await page.goto(config_url);
|
||||
|
||||
// Wait for event to load and draft to initialize
|
||||
await page.waitForResponse(
|
||||
(r) => r.url().includes(`crud/event/${event_id}`) && r.status() === 200,
|
||||
{ timeout: 8_000 }
|
||||
);
|
||||
|
||||
// Wait for the form to render (draft_initialized)
|
||||
await expect(
|
||||
page.locator('input[type="checkbox"]').first()
|
||||
).toBeVisible({ timeout: 8_000 });
|
||||
|
||||
// Save must be disabled (not dirty yet)
|
||||
await expect(page.locator('button:has-text("Save Config")')).toBeDisabled({ timeout: 3_000 });
|
||||
|
||||
// Toggle the "Require Payment" checkbox
|
||||
await page.locator('input[type="checkbox"]').first().click();
|
||||
|
||||
// Save must now be enabled
|
||||
await expect(page.locator('button:has-text("Save Config")')).toBeEnabled({ timeout: 3_000 });
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 4. Save sends PATCH with mod_exhibits_json and shows "Saved" badge
|
||||
//
|
||||
// Clicking the Save button must PATCH event.mod_exhibits_json via the V3
|
||||
// API. The "Saved" badge must appear confirming success.
|
||||
// -----------------------------------------------------------------------
|
||||
test('save sends PATCH to event and shows Saved badge', async ({ page }) => {
|
||||
page.on('pageerror', (err) => console.error(`BROWSER ERROR: ${err.message}`));
|
||||
|
||||
let patched_body: any = null;
|
||||
await setup_config_routes(page, {
|
||||
event_data_overrides: {
|
||||
mod_exhibits_json: { leads_require_payment: false },
|
||||
},
|
||||
on_patch: (body) => {
|
||||
patched_body = body;
|
||||
},
|
||||
});
|
||||
await seed_ae_loc(page, {
|
||||
allow_access: true,
|
||||
authenticated_access: true,
|
||||
trusted_access: true,
|
||||
administrator_access: true,
|
||||
});
|
||||
|
||||
await page.goto(config_url);
|
||||
|
||||
// Wait for form to initialize
|
||||
await page.waitForResponse(
|
||||
(r) => r.url().includes(`crud/event/${event_id}`) && r.status() === 200,
|
||||
{ timeout: 8_000 }
|
||||
);
|
||||
await expect(
|
||||
page.locator('input[type="checkbox"]').first()
|
||||
).toBeVisible({ timeout: 8_000 });
|
||||
|
||||
// Make a change to unlock save
|
||||
await page.locator('input[type="checkbox"]').first().click();
|
||||
|
||||
// Intercept the PATCH before clicking save
|
||||
const patch_promise = page.waitForRequest(
|
||||
(r) =>
|
||||
r.url().includes(`crud/event/${event_id}`) &&
|
||||
r.method() === 'PATCH',
|
||||
{ timeout: 5_000 }
|
||||
);
|
||||
|
||||
await page.locator('button:has-text("Save Config")').click();
|
||||
|
||||
// PATCH must have been made
|
||||
await patch_promise;
|
||||
|
||||
// "Saved" badge must appear
|
||||
await expect(page.locator('text=Saved')).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// The patched body must include mod_exhibits_json.
|
||||
// update_ae_obj auto-serializes *_json fields to strings before the PATCH,
|
||||
// so parse it back to an object before asserting field values.
|
||||
expect(patched_body?.mod_exhibits_json).toBeDefined();
|
||||
const patched_cfg =
|
||||
typeof patched_body.mod_exhibits_json === 'string'
|
||||
? JSON.parse(patched_body.mod_exhibits_json)
|
||||
: patched_body.mod_exhibits_json;
|
||||
expect(patched_cfg?.leads_require_payment).toBe(true);
|
||||
});
|
||||
});
|
||||
162
tests/leads_payment.test.ts
Normal file
162
tests/leads_payment.test.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { testing_event_id } from './_helpers/env';
|
||||
import {
|
||||
testing_exhibit_id,
|
||||
exhibit_staff_passcode,
|
||||
setup_leads_test_page,
|
||||
} from './_helpers/leads_helpers';
|
||||
|
||||
const event_id = testing_event_id;
|
||||
const exhibit_id = testing_exhibit_id;
|
||||
const exhibit_url = `/events/${event_id}/leads/exhibit/${exhibit_id}`;
|
||||
|
||||
const signed_in_kv = { [exhibit_id]: { key: exhibit_staff_passcode, type: 'shared' } };
|
||||
|
||||
/** Locator for the CreditCard header button (Payment & Upgrades). */
|
||||
const payment_btn = (page: Parameters<typeof expect>[0]) =>
|
||||
(page as import('@playwright/test').Page).locator(
|
||||
'header button[title="Payment & Upgrades"]'
|
||||
);
|
||||
|
||||
/** Locator for the Manage / Settings header button. */
|
||||
const manage_btn = (page: Parameters<typeof expect>[0]) =>
|
||||
(page as import('@playwright/test').Page).locator(
|
||||
'header button[title="Manage Exhibit"]'
|
||||
);
|
||||
|
||||
test.describe('Leads — Payment Gate (leads_require_payment)', () => {
|
||||
// -----------------------------------------------------------------------
|
||||
// 1. leads_require_payment = false → no CreditCard button in header
|
||||
//
|
||||
// When the event-level flag is disabled the payment tab is not surfaced
|
||||
// at all — the button must be absent so exhibitors cannot accidentally
|
||||
// navigate to the Stripe flow when the event organiser covers costs.
|
||||
// -----------------------------------------------------------------------
|
||||
test('payment not required: CreditCard button absent from header', async ({ page }) => {
|
||||
await setup_leads_test_page(page, event_id, exhibit_id, {
|
||||
auth_kv: signed_in_kv,
|
||||
event_data_overrides: {
|
||||
mod_exhibits_json: { leads_require_payment: false },
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto(exhibit_url);
|
||||
|
||||
// Wait for the event GET to complete so we know liveQuery has fired
|
||||
await page.waitForResponse(
|
||||
(r) => r.url().includes(`crud/event/${event_id}`) && r.status() === 200,
|
||||
{ timeout: 8_000 }
|
||||
);
|
||||
|
||||
// CreditCard button must remain absent after the event has loaded
|
||||
await expect(payment_btn(page)).not.toBeVisible({ timeout: 3_000 });
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 2. leads_require_payment = true → CreditCard button visible in header
|
||||
//
|
||||
// Default mock event has leads_require_payment: true — button must appear
|
||||
// once the event record is written to Dexie and lq__event_obj fires.
|
||||
// -----------------------------------------------------------------------
|
||||
test('payment required: CreditCard button visible in header', async ({ page }) => {
|
||||
await setup_leads_test_page(page, event_id, exhibit_id, {
|
||||
auth_kv: signed_in_kv,
|
||||
// Default mock event already has leads_require_payment: true
|
||||
});
|
||||
|
||||
await page.goto(exhibit_url);
|
||||
|
||||
await expect(payment_btn(page)).toBeVisible({ timeout: 8_000 });
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 3. Clicking CreditCard button opens the payment tab
|
||||
//
|
||||
// The button toggles active_tab to 'payment'. The payment component
|
||||
// (.ae-exhibit-payment or similar) must render inside the content area.
|
||||
// We verify by asserting the button switches to filled-success variant
|
||||
// (active tab styling) and the header primary button stays visible.
|
||||
// -----------------------------------------------------------------------
|
||||
test('clicking CreditCard button switches to payment tab', async ({ page }) => {
|
||||
await setup_leads_test_page(page, event_id, exhibit_id, {
|
||||
auth_kv: signed_in_kv,
|
||||
});
|
||||
|
||||
await page.goto(exhibit_url);
|
||||
|
||||
// Wait for the payment button to appear
|
||||
await expect(payment_btn(page)).toBeVisible({ timeout: 8_000 });
|
||||
|
||||
// Click it
|
||||
await payment_btn(page).click();
|
||||
|
||||
// Active tab styling: button gets preset-filled-success class
|
||||
await expect(payment_btn(page)).toHaveClass(/preset-filled-success/, {
|
||||
timeout: 3_000,
|
||||
});
|
||||
|
||||
// Primary "Add Lead / Lead List" toggle must still be visible
|
||||
await expect(
|
||||
page.locator('header button.preset-filled-primary')
|
||||
).toBeVisible({ timeout: 3_000 });
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 4. Manage tab: Billing accordion present when payment required
|
||||
//
|
||||
// When leads_require_payment is true, the Manage tab shows a collapsible
|
||||
// "Licenses & Billing" section so exhibitors can manage payment status.
|
||||
// -----------------------------------------------------------------------
|
||||
test('manage tab: billing accordion visible when payment required', async ({ page }) => {
|
||||
await setup_leads_test_page(page, event_id, exhibit_id, {
|
||||
auth_kv: signed_in_kv,
|
||||
});
|
||||
|
||||
await page.goto(exhibit_url);
|
||||
|
||||
// Wait for payment button (confirms event loaded and lq__event_obj is live)
|
||||
await expect(payment_btn(page)).toBeVisible({ timeout: 8_000 });
|
||||
|
||||
// Navigate to manage tab
|
||||
await manage_btn(page).click();
|
||||
|
||||
// Billing accordion row must be present
|
||||
await expect(
|
||||
page.locator('button:has-text("Licenses & Billing")')
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 5. Manage tab: Billing accordion absent when payment not required
|
||||
//
|
||||
// When the flag is disabled the accordion is not rendered at all — the
|
||||
// conditional {#if leads_require_payment} in ae_tab__manage.svelte hides it.
|
||||
// -----------------------------------------------------------------------
|
||||
test('manage tab: billing accordion hidden when payment not required', async ({ page }) => {
|
||||
await setup_leads_test_page(page, event_id, exhibit_id, {
|
||||
auth_kv: signed_in_kv,
|
||||
event_data_overrides: {
|
||||
mod_exhibits_json: { leads_require_payment: false },
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto(exhibit_url);
|
||||
|
||||
// Wait for event GET to complete
|
||||
await page.waitForResponse(
|
||||
(r) => r.url().includes(`crud/event/${event_id}`) && r.status() === 200,
|
||||
{ timeout: 8_000 }
|
||||
);
|
||||
|
||||
// Navigate to manage tab
|
||||
await manage_btn(page).click();
|
||||
|
||||
// "Booth Profile" section heading confirms manage tab is rendered
|
||||
await expect(page.locator('text=Booth Profile')).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Billing accordion must NOT appear
|
||||
await expect(
|
||||
page.locator('button:has-text("Licenses & Billing")')
|
||||
).not.toBeVisible({ timeout: 3_000 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user