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>
304 lines
14 KiB
TypeScript
304 lines
14 KiB
TypeScript
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_leads_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.
|
|
// 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' },
|
|
});
|
|
|
|
// 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();
|
|
});
|
|
});
|