- 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.
224 lines
11 KiB
JavaScript
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)
|
|
})
|