fix(leads): disable sign-in submit until exhibit loads; add licensed-user auth tests

Prevents silent no-op when user clicks submit before lq__exhibit_obj is ready
(exhibit not yet written to Dexie). Button now shows 'Loading...' spinner while
the exhibit record is resolving, eliminating the two-tap workaround needed on
first page load.

Also adds 7 Playwright tests for licensed user sign-in (leads_licensed_signin.test.ts)
covering success path, wrong credentials, email/identity tagging on captured leads,
identity isolation between staff members, and returning-session bypass.

Helpers: attach_leads_routes/setup_leads_test_page now accept exhibit_overrides
(e.g. license_li_json) to inject licensed users into mocked API responses.
seed_leads_loc import added to leads_auth.test.ts multi-exhibit test.

Total leads test coverage: 29 tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-04-06 17:04:57 -04:00
parent d340bbbe94
commit f95243a9c7
4 changed files with 323 additions and 5 deletions

View File

@@ -213,8 +213,13 @@ function complete_signin(key: string, type: string) {
<button
type="submit"
class="btn btn-lg preset-filled-primary shadow-primary-500/20 group w-full font-bold shadow-lg"
disabled={status === 'submitting'}>
{#if status === 'submitting'}
disabled={status === 'submitting' || !$lq__exhibit_obj}>
{#if !$lq__exhibit_obj}
<!-- Exhibit record still loading from Dexie/API — block submission
until it's ready so handle_signin() doesn't return silently. -->
<LoaderCircle size="1.5em" class="mr-2 animate-spin" />
Loading...
{:else if status === 'submitting'}
<LoaderCircle size="1.5em" class="mr-2 animate-spin" />
Signing In...
{:else if status === 'success'}

View File

@@ -350,6 +350,7 @@ export async function attach_leads_routes(
opts: {
staff_passcode?: string;
exhibit_name?: string;
exhibit_overrides?: Record<string, unknown>;
tracking_li?: any[];
badge_li?: any[];
event_data_overrides?: Record<string, any>;
@@ -358,6 +359,7 @@ export async function attach_leads_routes(
const {
staff_passcode = exhibit_staff_passcode,
exhibit_name = 'Test Booth — ACME Corp',
exhibit_overrides = {} as Record<string, unknown>,
tracking_li = [],
badge_li = [minimal_badge()],
event_data_overrides = {},
@@ -387,7 +389,7 @@ export async function attach_leads_routes(
status: 200,
contentType: 'application/json',
body: JSON.stringify(
minimal_exhibit(exhibit_id, { staff_passcode, name: exhibit_name })
minimal_exhibit(exhibit_id, { staff_passcode, name: exhibit_name, ...exhibit_overrides })
),
});
}
@@ -492,6 +494,7 @@ export async function setup_leads_test_page(
leads_overrides?: Record<string, any>;
staff_passcode?: string;
exhibit_name?: string;
exhibit_overrides?: Record<string, unknown>;
tracking_li?: any[];
badge_li?: any[];
event_data_overrides?: Record<string, any>;
@@ -499,10 +502,18 @@ export async function setup_leads_test_page(
): Promise<void> {
const { access = {}, auth_kv = {}, leads_overrides = {}, ...route_opts } = opts;
// Build auth_exhibit_kv with timestamps for ae_leads_loc (the current store).
// seed_events_loc also seeds the old ae_events_loc for backwards compatibility,
// but is_signed_in now reads from leads_loc (ae_leads_loc) exclusively.
const auth_exhibit_kv_with_ts: Record<string, { key: string; type: string; updated_on: string }> = {};
for (const [eid, entry] of Object.entries(auth_kv)) {
auth_exhibit_kv_with_ts[eid] = { ...entry, updated_on: new Date().toISOString() };
}
page.on('pageerror', (err) => console.error(`BROWSER ERROR: ${err.message}`));
await attach_leads_routes(page, event_id, exhibit_id, route_opts);
await seed_ae_loc(page, access);
await seed_leads_loc(page, leads_overrides);
await seed_leads_loc(page, { ...leads_overrides, auth_exhibit_kv: auth_exhibit_kv_with_ts });
await seed_events_loc(page, auth_kv, leads_overrides);
}

View File

@@ -7,6 +7,7 @@ import {
setup_leads_test_page,
attach_leads_routes,
seed_ae_loc,
seed_leads_loc,
seed_events_loc,
minimal_exhibit,
} from './_helpers/leads_helpers';
@@ -219,7 +220,14 @@ test.describe('Leads — Auth Gate', () => {
// 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
// Pre-seed BOTH exhibits as authorized — independent KV entries.
// Must seed ae_leads_loc (new PersistedState store) in addition to ae_events_loc.
await seed_leads_loc(page, {
auth_exhibit_kv: {
[exhibit_id]: { key: exhibit_staff_passcode, type: 'shared', updated_on: new Date().toISOString() },
[exhibit_id_b]: { key: 'BOOTHB99', type: 'shared', updated_on: new Date().toISOString() },
},
});
await seed_events_loc(page, {
[exhibit_id]: { key: exhibit_staff_passcode, type: 'shared' },
[exhibit_id_b]: { key: 'BOOTHB99', type: 'shared' },

View File

@@ -0,0 +1,294 @@
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}`;
// ---------------------------------------------------------------------------
// Test license entries
// ---------------------------------------------------------------------------
/** A licensed staff member whose leads should be tagged with their email. */
const licensed_user = {
full_name: 'Alice Staff',
email: 'alice@acmecorp.com',
passcode: 'ALICE99',
};
/** A second licensed user — verifies identity isolation between staff members. */
const licensed_user_b = {
full_name: 'Bob Staff',
email: 'bob@acmecorp.com',
passcode: 'BOB88',
};
/**
* license_li_json as a JSON string (matches how the API returns it and how
* the sign-in component parses it inside handle_signin).
*/
const license_li_json = JSON.stringify([licensed_user, licensed_user_b]);
// Exhibit overrides that inject the license list into the mocked API response.
const exhibit_with_licenses = { license_li_json };
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const signin_form = (page: Parameters<typeof expect>[0]) =>
(page as import('@playwright/test').Page).locator('.exhibit-signin');
const header_action_btn = (page: Parameters<typeof expect>[0]) =>
(page as import('@playwright/test').Page).locator(
'header button.preset-filled-primary'
);
const passcode_input = (page: Parameters<typeof expect>[0]) =>
(page as import('@playwright/test').Page).locator(
'input[placeholder="Enter shared code..."]'
);
const search_mode_seed = { tab_add_mode: { [exhibit_id]: 'search' } };
/**
* Navigate to the exhibit URL and wait for lq__exhibit_obj to be live in the
* sign-in component before returning.
*
* The sign-in component's handle_signin() returns early if lq__exhibit_obj is
* null. trusted_access causes handle_signin to auto-fill the shared passcode
* input once lq__exhibit_obj loads — this is the same readiness signal used
* in the shared passcode sign-in tests.
*
* Note: trusted_access does NOT bypass the auth gate (only manager_access
* does), so the sign-in form will still appear for licensed user tests.
*/
async function goto_and_wait_for_exhibit_ready(
page: import('@playwright/test').Page
): Promise<void> {
await page.goto(exhibit_url);
// Auto-fill fires once lq__exhibit_obj is live — this is our readiness signal.
await expect(passcode_input(page)).toHaveValue(exhibit_staff_passcode, {
timeout: 10_000,
});
}
// ---------------------------------------------------------------------------
// Test suite
// ---------------------------------------------------------------------------
test.describe('Leads — Licensed User Sign-In', () => {
// -----------------------------------------------------------------------
// 1. Licensed user tab is visible on the sign-in form
// -----------------------------------------------------------------------
test('sign-in form has a Licensed User tab', async ({ page }) => {
await setup_leads_test_page(page, event_id, exhibit_id, {
exhibit_overrides: exhibit_with_licenses,
access: { allow_access: true, trusted_access: true },
});
await page.goto(exhibit_url);
await expect(signin_form(page)).toBeVisible({ timeout: 10_000 });
await expect(page.locator('button:has-text("Licensed User")')).toBeVisible({
timeout: 5_000,
});
});
// -----------------------------------------------------------------------
// 2. Correct email + passcode signs in successfully
// -----------------------------------------------------------------------
test('correct email and passcode signs in — form disappears, lead list shown', async ({ page }) => {
await setup_leads_test_page(page, event_id, exhibit_id, {
exhibit_overrides: exhibit_with_licenses,
access: { allow_access: true, trusted_access: true },
});
await goto_and_wait_for_exhibit_ready(page);
// Switch to Licensed User tab
await page.locator('button:has-text("Licensed User")').click();
await page.locator('input[type="email"]').fill(licensed_user.email);
await page.locator('input[placeholder="Your code..."]').fill(licensed_user.passcode);
await page.locator('button[type="submit"]').click();
// After the 800 ms UX delay + reactivity, form should disappear
await expect(signin_form(page)).not.toBeVisible({ timeout: 5_000 });
await expect(header_action_btn(page)).toBeVisible({ timeout: 5_000 });
});
// -----------------------------------------------------------------------
// 3. Wrong passcode shows error, form stays open
// -----------------------------------------------------------------------
test('wrong passcode shows error message, form stays visible', async ({ page }) => {
await setup_leads_test_page(page, event_id, exhibit_id, {
exhibit_overrides: exhibit_with_licenses,
access: { allow_access: true, trusted_access: true },
});
await goto_and_wait_for_exhibit_ready(page);
await page.locator('button:has-text("Licensed User")').click();
await page.locator('input[type="email"]').fill(licensed_user.email);
await page.locator('input[placeholder="Your code..."]').fill('WRONGCODE');
await page.locator('button[type="submit"]').click();
await expect(
page.locator('text=Invalid email or personal passcode')
).toBeVisible({ timeout: 5_000 });
await expect(signin_form(page)).toBeVisible({ timeout: 3_000 });
await expect(header_action_btn(page)).not.toBeVisible();
});
// -----------------------------------------------------------------------
// 4. Unknown email shows error, form stays open
// -----------------------------------------------------------------------
test('unknown email shows error message, form stays visible', async ({ page }) => {
await setup_leads_test_page(page, event_id, exhibit_id, {
exhibit_overrides: exhibit_with_licenses,
access: { allow_access: true, trusted_access: true },
});
await goto_and_wait_for_exhibit_ready(page);
await page.locator('button:has-text("Licensed User")').click();
await page.locator('input[type="email"]').fill('unknown@notregistered.com');
await page.locator('input[placeholder="Your code..."]').fill('ANYCODE');
await page.locator('button[type="submit"]').click();
await expect(
page.locator('text=Invalid email or personal passcode')
).toBeVisible({ timeout: 5_000 });
await expect(signin_form(page)).toBeVisible({ timeout: 3_000 });
});
// -----------------------------------------------------------------------
// 5. Licensed user captures a lead — external_person_id = their email
//
// This is the core business rule: every lead captured by a licensed user
// must be tagged with their email so "My Leads" filtering works per staff
// member. The POST body is inspected directly to verify the identity field.
// -----------------------------------------------------------------------
test('lead captured by licensed user is tagged with their email address', async ({ page }) => {
await setup_leads_test_page(page, event_id, exhibit_id, {
exhibit_overrides: exhibit_with_licenses,
access: { allow_access: true, trusted_access: true },
leads_overrides: search_mode_seed,
});
await goto_and_wait_for_exhibit_ready(page);
// Sign in as licensed user
await page.locator('button:has-text("Licensed User")').click();
await page.locator('input[type="email"]').fill(licensed_user.email);
await page.locator('input[placeholder="Your code..."]').fill(licensed_user.passcode);
await page.locator('button[type="submit"]').click();
await expect(signin_form(page)).not.toBeVisible({ timeout: 5_000 });
// Navigate to Add Lead tab
await header_action_btn(page).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();
await expect(page.locator('text=Scott Idem')).toBeVisible({ timeout: 8_000 });
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();
const create_req = await create_promise;
const body = JSON.parse(create_req.postData() ?? '{}');
// The lead must be tagged with the licensed user's email, not 'shared_passcode'
expect(body.external_person_id).toBe(licensed_user.email);
expect(body.group).toBe(licensed_user.email);
});
// -----------------------------------------------------------------------
// 6. Two different licensed users get different external_person_id values
//
// Regression guard: Bob's leads must be tagged with his email, not Alice's
// and not 'shared_passcode'.
// -----------------------------------------------------------------------
test('different licensed users are tagged with their own email addresses', async ({ page }) => {
await setup_leads_test_page(page, event_id, exhibit_id, {
exhibit_overrides: exhibit_with_licenses,
access: { allow_access: true, trusted_access: true },
leads_overrides: search_mode_seed,
});
await goto_and_wait_for_exhibit_ready(page);
// Sign in as Bob (the second licensed user)
await page.locator('button:has-text("Licensed User")').click();
await page.locator('input[type="email"]').fill(licensed_user_b.email);
await page.locator('input[placeholder="Your code..."]').fill(licensed_user_b.passcode);
await page.locator('button[type="submit"]').click();
await expect(signin_form(page)).not.toBeVisible({ timeout: 5_000 });
await header_action_btn(page).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();
await expect(page.locator('text=Scott Idem')).toBeVisible({ timeout: 8_000 });
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();
const create_req = await create_promise;
const body = JSON.parse(create_req.postData() ?? '{}');
// Must be Bob's email, not Alice's and not 'shared_passcode'
expect(body.external_person_id).toBe(licensed_user_b.email);
expect(body.external_person_id).not.toBe(licensed_user.email);
expect(body.external_person_id).not.toBe('shared_passcode');
});
// -----------------------------------------------------------------------
// 7. Pre-seeded licensed auth skips sign-in (returning user)
//
// A returning user whose session is already in auth_exhibit_kv with
// type='licensed' should see the lead list directly — no sign-in form.
// -----------------------------------------------------------------------
test('pre-seeded licensed auth skips sign-in form', async ({ page }) => {
await setup_leads_test_page(page, event_id, exhibit_id, {
exhibit_overrides: exhibit_with_licenses,
auth_kv: {
// Simulate a returning session: already signed in as Alice
[exhibit_id]: { key: licensed_user.email, type: 'licensed' },
},
});
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 });
});
});