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:
@@ -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'}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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' },
|
||||
|
||||
294
tests/leads_licensed_signin.test.ts
Normal file
294
tests/leads_licensed_signin.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user