Files
sales-trainer/frontend/tests/e2e/training.spec.js
Macky 3c22d88bcd feat: demo SaaS + training flow security hardening (8/8 review gate passed)
- Demo accounts: super_admin-only provisioning into isolated DEMO_ORG_ID tenant,
  30-day UTC trial on first login, revocable, one-time credential delivery via
  optional SES/webhook (never persisted). Adds boto3 dependency.
- Analytics/report/export/privacy: shared bounded scan budget across users/groups/
  sessions, tenant-consistent session/user/group joins, scalar-only CSV export
  (no nested persisted-value stringification).
- Ownership/tenant isolation: canonical owner-tenant predicate for list/read/chat;
  client sees is_owned only, never owner_user_id.
- Lifecycle/races: status transition validation, analyzing is an in-progress gate
  (no duplicate reanalysis), structured-ready publication, stale-variant revalidation.
- Auth/setup/consent/JWT/OAuth/config: fail-closed consent, bounded JWT lifetime,
  provider-subject atomic OAuth identity, repeated-secret rejection, strict Persona
  trait validation.
- Chat/session/privacy: pre-seller opener redaction, corrupt-session recovery,
  role-aware completed-chat dashboard routing.
- Frontend: Training→product→personas→practice flow, demo/role/demo guards,
  is_owned-based ownership display, 320×568 and 500×768 responsive E2E.
- 8 independent exact-five-key review scopes passed; backend 509, frontend 26,
  production build 1775 modules, isolated E2E 15.
2026-08-25 06:39:06 +07:00

224 lines
11 KiB
JavaScript

import { expect, test } from '@playwright/test'
const admin = {
id: 'admin',
username: 'admin',
name: 'Fixture Admin',
role: 'admin',
must_setup: false,
accepted_terms: true,
}
const persona = {
id: 'p1',
name: 'Acme decision maker',
profession: 'Operations lead',
age_group: '35-44',
channel: 'facebook',
initiation_mode: 'customer',
}
async function installFixtureApi(page, { chat = false, groupStatus = 'ready' } = {}) {
await page.addInitScript(() => localStorage.setItem('st_token', 'fixture-token'))
let sendCalls = 0
let currentGroupStatus = groupStatus
await page.route('**/api/**', async (route) => {
const request = route.request()
const url = new URL(request.url())
const method = request.method()
const path = url.pathname
if (!path.startsWith('/api/')) return route.continue()
if (method === 'GET' && path === '/api/auth/me') {
return route.fulfill({ json: { user: admin } })
}
if (method === 'GET' && path === '/api/groups') {
return route.fulfill({ json: { groups: [{ id: 'g1', title: 'CRM assistant', status: currentGroupStatus, persona_count: 15, input: { product: 'CRM assistant' } }] } })
}
if (method === 'GET' && path === '/api/groups/g1') {
return route.fulfill({ json: { group: { id: 'g1', title: 'CRM assistant', status: currentGroupStatus, visibility: 'public' } } })
}
if (method === 'POST' && path === '/api/groups/g1/analyze') {
currentGroupStatus = 'ready'
return route.fulfill({ json: { group: { id: 'g1', title: 'CRM assistant', status: currentGroupStatus, visibility: 'public' } } })
}
if (method === 'GET' && path === '/api/groups/g1/personas') {
return route.fulfill({ json: { personas: [{ ...persona, tier: 'A', difficulty: 1, location: 'Bangkok', personality: 'Practical', my_outcome: 'not_tried' }] } })
}
if (method === 'GET' && path === '/api/analytics') {
return route.fulfill({
json: {
overall: { total_sessions: 1, wins: 1, losses: 0, avg_score: 82, close_rate: 100 },
trainee_count: 1,
hardest_personas: [{ persona_name: 'Acme decision maker', wins: 1, losses: 0, plays: 1, avg_score: 82 }],
},
})
}
if (method === 'GET' && path === '/api/analytics/export/token') {
return route.fulfill({ json: { url: '/api/analytics/export?token=fixture' } })
}
if (method === 'GET' && path === '/api/analytics/export') {
return route.fulfill({ contentType: 'text/csv', body: 'persona,outcome\nAcme decision maker,won\n' })
}
if (method === 'GET' && path === '/api/groups/g1/personas/p1') {
return route.fulfill({ json: { persona } })
}
if (chat && method === 'GET' && path === '/api/chat/g1/personas/p1/chat/resume') {
return route.fulfill({ status: 404, json: { error: 'no active session' } })
}
if (chat && method === 'GET' && path === '/api/chat/sessions') {
return route.fulfill({ json: { sessions: [] } })
}
if (chat && method === 'POST' && path === '/api/chat/g1/personas/p1/chat/start') {
return route.fulfill({ json: { session: { id: 's1', messages: [] } } })
}
if (chat && method === 'POST' && path === '/api/chat/g1/personas/p1/chat/send') {
sendCalls += 1
return route.fulfill({
json: {
session: { id: 's1', messages: [{ role: 'customer', text: 'Tell me more.' }], status: 'finished' },
debrief: { outcome: 'won', score: 82, pain: 'manual work', why: 'clear value', coaching: [] },
finished: true,
outcome: 'won',
messages: [{ role: 'customer', text: 'Tell me more.' }],
},
})
}
return route.fulfill({ status: 404, json: { error: `unmocked fixture route: ${method} ${path}` } })
})
return () => sendCalls
}
async function installUserJourneyApi(page) {
await page.addInitScript(() => localStorage.setItem('st_token', 'fixture-user-token'))
await page.route('**/api/**', async (route) => {
const request = route.request()
const url = new URL(request.url())
const method = request.method()
const path = url.pathname
if (!path.startsWith('/api/')) return route.continue()
if (method === 'GET' && path === '/api/auth/me') {
return route.fulfill({ json: { user: { id: 'trainee', username: 'trainee', name: 'Fixture Trainee', role: 'user', must_setup: false, accepted_terms: true } } })
}
if (method === 'GET' && path === '/api/groups') {
return route.fulfill({
json: {
groups: [{
id: 'g-user',
title: 'CRM assistant',
status: 'ready',
persona_count: 1,
input: { product: 'CRM assistant' },
is_owned: true,
}],
},
})
}
if (method === 'GET' && path === '/api/groups/g-user') {
return route.fulfill({ json: { group: { id: 'g-user', title: 'CRM assistant', status: 'ready', visibility: 'private', is_owned: true } } })
}
if (method === 'GET' && path === '/api/groups/g-user/personas') {
return route.fulfill({ json: { personas: [{ ...persona, tier: 'A', difficulty: 1, location: 'Bangkok', product_context: 'CRM assistant', my_outcome: 'not_tried' }] } })
}
if (method === 'GET' && path === '/api/groups/g-user/personas/p1') {
return route.fulfill({ json: { persona: { ...persona, tier: 'A', difficulty: 1, location: 'Bangkok', product_context: 'CRM assistant' } } })
}
if (method === 'GET' && path === '/api/chat/g-user/personas/p1/chat/resume') {
return route.fulfill({ status: 404, json: { error: 'no active session' } })
}
if (method === 'POST' && path === '/api/chat/g-user/personas/p1/chat/start') {
return route.fulfill({ json: { session: { id: 'user-s1', messages: [] } } })
}
if (method === 'GET' && path === '/api/me/board') {
return route.fulfill({ json: { board: [{ id: 'p1', persona_name: 'Acme decision maker', my_outcome: 'won' }] } })
}
if (method === 'GET' && path === '/api/chat/sessions') {
return route.fulfill({ json: { sessions: [{ id: 's1', persona_name: 'Acme decision maker', status: 'finished', outcome: 'won' }] } })
}
if (method === 'GET' && path === '/api/chat/sessions/s1') {
return route.fulfill({ json: { session: { id: 's1', persona_name: 'Acme decision maker', status: 'finished', outcome: 'won', messages: [{ role: 'customer', text: 'Tell me more.' }], debrief: { outcome: 'won', score: 82, pain: 'manual work', why: 'clear value', failurePoints: [], coaching: [], revealed_persona: { name: 'Acme decision maker', tier: 'A', secret_formula: 'internal formula', pains: ['hidden pain'] } } } } })
}
return route.fulfill({ status: 404, json: { error: `unmocked fixture route: ${method} ${path}` } })
})
}
test('admin can open the training list and manage product personas', async ({ page }) => {
await installFixtureApi(page)
await page.goto('/training')
await expect(page.getByRole('link', { name: 'CRM assistant', exact: true })).toBeVisible()
await expect(page.getByText(/15 persona(s)?|15 บุคคลต้นแบบ/)).toBeVisible()
await page.goto('/admin/groups/g1/edit')
await expect(page.getByRole('heading', { name: /จัดการ persona|Manage personas/ })).toBeVisible()
await expect(page.getByText('CRM assistant')).toBeVisible()
await expect(page.getByTestId('group-visibility-panel')).toBeVisible()
await page.goto('/')
await expect(page.getByText(/Sessions|ครั้งที่ฝึก/, { exact: true })).toBeVisible()
const exportResponse = page.waitForResponse((response) => response.url().includes('/api/analytics/export?token=fixture'))
await page.getByRole('button', { name: 'CSV' }).click()
await expect((await exportResponse).status()).toBe(200)
const widthState = await page.evaluate(() => ({ width: window.innerWidth, scrollWidth: document.documentElement.scrollWidth }))
expect(widthState.scrollWidth).toBeLessThanOrEqual(widthState.width)
})
test('admin can retry a failed product analysis', async ({ page }) => {
await installFixtureApi(page, { groupStatus: 'failed' })
await page.goto('/training')
await page.getByRole('link', { name: 'CRM assistant', exact: true }).click()
await expect(page).toHaveURL(/\/admin\/groups\/g1\/edit$/)
await expect(page.getByTestId('retry-analysis')).toBeVisible()
await page.getByTestId('retry-analysis').click()
await expect(page.getByTestId('retry-analysis')).toHaveCount(0)
})
test('chat closes automatically after the customer makes a decision', async ({ page }) => {
const getSendCalls = await installFixtureApi(page, { chat: true })
await page.goto('/groups/g1/chat/p1')
await expect(page.getByText(/Choose a scenario|เลือกสถานการณ์/)).toBeVisible()
await page.getByRole('button', { name: /เริ่มต้น|Start/ }).click()
await expect(page.getByText('Acme decision maker')).toBeVisible()
await page.locator('input[placeholder="ส่ง"], input[placeholder="Send"]').fill('I can help.')
await page.getByRole('button', { name: /ส่ง|Send/ }).click()
await expect(page.locator('.result-banner strong')).toHaveText(/Won|ชนะ|ปิดการขายได้/)
expect(getSendCalls()).toBe(1)
await expect(page.locator('input[placeholder="ส่ง"], input[placeholder="Send"]')).toHaveCount(0)
})
test('trainee can move from board to product personas and start training', async ({ page }) => {
await installUserJourneyApi(page)
await page.goto('/my/board')
await expect(page.getByText('Acme decision maker')).toBeVisible()
await page.getByRole('link', { name: /ดูรายละเอียด|View details/ }).click()
await expect(page.getByText('Tell me more.')).toBeVisible()
await page.locator('summary').click()
await expect(page.locator('.reveal-grid .rv').filter({ hasText: 'Acme decision maker' })).toBeVisible()
await expect(page.getByText('internal formula')).toHaveCount(0)
await expect(page.getByText('hidden pain')).toHaveCount(0)
await page.goto('/training')
await expect(page.getByRole('link', { name: 'CRM assistant', exact: true }).first()).toBeVisible()
await page.getByRole('link', { name: 'CRM assistant', exact: true }).first().click()
await expect(page).toHaveURL(/\/groups\/g-user\/personas$/)
await expect(page.getByText('Acme decision maker')).toBeVisible()
await page.getByRole('button', { name: /แชท|Chat/ }).click()
await expect(page).toHaveURL(/\/groups\/g-user\/chat\/p1$/)
await expect(page.getByText(/Choose a scenario|เลือกสถานการณ์/)).toBeVisible()
await page.getByRole('button', { name: /เริ่มต้น|Start/ }).click()
await expect(page.locator('input[placeholder="ส่ง"], input[placeholder="Send"]')).toBeVisible()
const widthState = await page.evaluate(() => ({ width: window.innerWidth, scrollWidth: document.documentElement.scrollWidth }))
expect(widthState.scrollWidth).toBeLessThanOrEqual(widthState.width)
})