/** * 共享步骤:取 captcha、登录玩家、登录超管、获取/重置玩家状态。 */ import { test, request as pwRequest, type APIRequestContext, expect } from '@playwright/test'; import { adminLogin, playerLogin, playerCtx, adminCtx, fetchCurrentDrawNo, e2e } from '../fixtures'; export const E2E_TAG = '@e2e'; export async function fetchPlayerCaptcha(): Promise<{ captcha_key: string; image_svg: string }> { const ctx = await pwRequest.newContext(); const resp = await ctx.get('/api/v1/player/auth/captcha'); expect(resp.ok(), `GET /player/auth/captcha failed: ${resp.status()}`).toBeTruthy(); const body = (await resp.json()) as { data: { captcha_key: string; image_svg: string; image_base64: string } }; await ctx.dispose(); return { captcha_key: body.data.captcha_key, image_svg: body.data.image_svg }; } export async function fetchAdminCaptcha(): Promise<{ captcha_key: string }> { const ctx = await pwRequest.newContext(); const resp = await ctx.get('/api/v1/admin/auth/captcha'); expect(resp.ok(), `GET /admin/auth/captcha failed: ${resp.status()}`).toBeTruthy(); const body = (await resp.json()) as { data: { captcha_key: string } }; await ctx.dispose(); return { captcha_key: body.data.captcha_key }; } /** 用 LOTTERY_E2E_BYPASS 登录(管理端 captcha 旁路) */ export async function adminLoginViaBypass(): Promise> { const captcha = await fetchAdminCaptcha(); const ctx = await pwRequest.newContext(); const resp = await ctx.post('/api/v1/admin/auth/login', { data: { account: process.env.E2E_ADMIN_USERNAME ?? 'admin', password: process.env.E2E_ADMIN_PASSWORD ?? '12345678', captcha_key: captcha.captcha_key, captcha_code: 'LOTTERY_E2E_BYPASS', }, }); expect(resp.ok(), `admin login failed: ${resp.status()}`).toBeTruthy(); const body = (await resp.json()) as { data: any }; await ctx.dispose(); if (!body.data?.token) throw new Error('admin login returned no token: ' + JSON.stringify(body)); return { accessToken: body.data.token, tokenType: body.data.token_type ?? 'Bearer', expiresIn: 0, admin: body.data.admin, }; } /** 用 LOTTERY_E2E_BYPASS 登录(玩家端 captcha 旁路) */ export async function playerLoginViaBypass(creds?: { site_code?: string; username?: string; password?: string; }): Promise> { const captcha = await fetchPlayerCaptcha(); const ctx = await pwRequest.newContext(); const resp = await ctx.post('/api/v1/player/auth/login', { data: { site_code: creds?.site_code ?? process.env.E2E_PLAYER_SITE_CODE ?? 'demo', username: creds?.username ?? process.env.E2E_PLAYER_USERNAME ?? 'demo_player', password: creds?.password ?? process.env.E2E_PLAYER_PASSWORD ?? '12345678', captcha_key: captcha.captcha_key, captcha_code: 'LOTTERY_E2E_BYPASS', }, }); expect(resp.ok(), `player login failed: ${resp.status()}`).toBeTruthy(); const body = (await resp.json()) as { data: any }; await ctx.dispose(); if (!body.data?.access_token) throw new Error('player login returned no token: ' + JSON.stringify(body)); return body.data; } /** 重置 e2e 玩家(清失败计数、解除锁定、恢复初始余额) */ export async function resetE2EPlayer(): Promise { await e2e('POST', '/player/reset', {}); } /** 把期号 close_time 改到过去,status=closed */ export async function forceCloseDraw(drawNo: string): Promise { return e2e('POST', `/draw/${encodeURIComponent(drawNo)}/close-now`, {}); } export async function finishDrawCooldown(drawNo: string): Promise { return e2e('POST', `/draw/${encodeURIComponent(drawNo)}/finish-cooldown`, {}); } export async function tickDraws(): Promise { return e2e('POST', '/draw/tick', {}); } export async function inspectDraw(drawNo: string): Promise { return e2e('GET', `/draw/${encodeURIComponent(drawNo)}/inspect`, {}); } export async function waitForDrawStatus( drawNo: string, targets: string[], maxAttempts = 20, pauseMs = 500, ): Promise { for (let i = 0; i < maxAttempts; i++) { const insp = await inspectDraw(drawNo); const status = String(insp.data?.status ?? insp.status ?? ''); if (targets.includes(status)) { return insp; } await tickDraws(); await sleep(pauseMs); } const last = await inspectDraw(drawNo); throw new Error( `draw ${drawNo} not reached ${targets.join('|')} after ${maxAttempts} ticks; last=${JSON.stringify(last).slice(0, 400)}`, ); } export function sleep(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)); } export async function adminCtxOf(token: string): Promise { return pwRequest.newContext({ extraHTTPHeaders: { Authorization: `Bearer ${token}` }, }); } export async function playerCtxOf(token: string): Promise { return pwRequest.newContext({ extraHTTPHeaders: { Authorization: `Bearer ${token}` }, }); } export async function setupCreditPlayer(opts?: { credit_limit?: number; username?: string }): Promise<{ player_id: number; username: string; password: string; site_code: string; admin_site_id: number; agent_node_id: number; }> { const data = await e2e('POST', '/credit-player/setup', opts ?? {}); return data.data; } export async function setupSiteOperator(opts?: { role_slug?: string; username?: string; password?: string; }): Promise<{ admin_user_id: number; username: string; password: string; role_slug: string; admin_site_id: number; site_code: string; }> { const data = await e2e('POST', '/site-operator/setup', opts ?? {}); return data.data; } export async function adminLoginWithAccount( account: string, password: string, ): Promise> { const captcha = await fetchAdminCaptcha(); const ctx = await pwRequest.newContext(); const resp = await ctx.post('/api/v1/admin/auth/login', { data: { account, password, captcha_key: captcha.captcha_key, captcha_code: 'LOTTERY_E2E_BYPASS', }, }); expect(resp.ok(), `admin login failed: ${resp.status()}`).toBeTruthy(); const body = (await resp.json()) as { data: any }; await ctx.dispose(); if (!body.data?.token) throw new Error('admin login returned no token: ' + JSON.stringify(body)); return { accessToken: body.data.token, tokenType: body.data.token_type ?? 'Bearer', expiresIn: 0, admin: body.data.admin, }; } /** 以当前时间为中心的 2h 账期窗口;开账前会清掉同站历史账期避免重叠。 */ export function periodWindowIso(): { start: string; end: string } { const start = new Date(Date.now() - 3600_000).toISOString().replace('T', ' ').slice(0, 19); const end = new Date(Date.now() + 3600_000).toISOString().replace('T', ' ').slice(0, 19); return { start, end }; } export async function resetSiteSettlement(siteCode?: string): Promise { await e2e('POST', '/settlement/reset', { site_code: siteCode ?? process.env.E2E_PLAYER_SITE_CODE ?? 'demo' }); } export async function fetchBillCreditLedgerRows( playerId: number, billId: number, reason: 'settlement_confirm' | 'settlement_payout', ): Promise> { const ledger = await e2e('GET', `/inspect/credit-ledger?player_id=${playerId}&limit=50`); return (ledger.data.rows as any[]).filter( (row) => row.reason === reason && row.ref_type === 'settlement_bill' && Number(row.ref_id) === billId, ); } export async function openSettlementPeriod( admin: APIRequestContext, adminSiteId: number, siteCode?: string, ): Promise { await resetSiteSettlement(siteCode); const { start, end } = periodWindowIso(); const open = await admin.post('/api/v1/admin/settlement-periods', { data: { admin_site_id: adminSiteId, period_start: start, period_end: end, }, }); expect(open.ok(), `open period status=${open.status()}`).toBeTruthy(); const openBody = (await open.json()) as any; expect(openBody.code).toBe(0); return Number(openBody.data.id); } export async function mintSsoJwt(sitePlayerId?: string): Promise<{ jwt: string; site_player_id: string }> { const data = await e2e('POST', '/sso/mint-jwt', { site_player_id: sitePlayerId ?? `e2e-sso-${Date.now()}`, }); return data.data; } export async function configureWalletMock(baseUrl: string): Promise { await e2e('POST', '/site/wallet-api', { base_url: baseUrl, wallet_api_key: 'e2e-mock-key' }); } export async function resetWalletMock(): Promise { await e2e('POST', '/site/wallet-api/reset', {}); } export async function setMockWalletMode(mode: 'success' | '504' | 'reject'): Promise { const port = process.env.E2E_MOCK_WALLET_PORT ?? '5555'; const ctx = await pwRequest.newContext({ baseURL: `http://127.0.0.1:${port}` }); const resp = await ctx.post('/_e2e/mode', { data: { mode } }); expect(resp.ok(), `mock wallet mode=${mode} failed`).toBeTruthy(); await ctx.dispose(); } /** 造 23 个 slot:first=winNumber,其余填占位号 */ export function buildAllResultItems(winNumber: string): any[] { const items: any[] = []; items.push({ prize_type: 'first', prize_index: 0, number_4d: winNumber }); items.push({ prize_type: 'second', prize_index: 0, number_4d: '5678' }); items.push({ prize_type: 'third', prize_index: 0, number_4d: '0123' }); for (let i = 0; i < 10; i++) { items.push({ prize_type: 'starter', prize_index: i, number_4d: String(1000 + i).padStart(4, '0'), }); } for (let i = 0; i < 10; i++) { items.push({ prize_type: 'consolation', prize_index: i, number_4d: String(2000 + i).padStart(4, '0'), }); } return items; } export async function resolveDrawId(admin: APIRequestContext, drawNo: string): Promise { const drawsList = await admin.get(`/api/v1/admin/draws?keyword=${encodeURIComponent(drawNo)}&size=10`); const dl = (await drawsList.json()) as any; const drawId = dl.data.items.find((it: any) => it.draw_no === drawNo)?.id ?? dl.data.items[0]?.id; expect(drawId, `draw id for ${drawNo}`).toBeTruthy(); return Number(drawId); } export async function fetchOpenDrawNo(): Promise { const current = await fetchCurrentDrawNo(); if (!current?.draw_no) { throw new Error('draw/current returned no draw_no'); } const drawNo = current.draw_no; const insp = await e2e('GET', `/draw/${encodeURIComponent(drawNo)}/inspect`); const status = String(insp.data?.status ?? ''); if (status !== 'open') { await e2e('POST', `/draw/${encodeURIComponent(drawNo)}/reopen`, {}); const after = await e2e('GET', `/draw/${encodeURIComponent(drawNo)}/inspect`); expect(String(after.data?.status), `draw ${drawNo} reopen`).toBe('open'); } return drawNo; } export { playerLogin, playerCtx, adminLogin, adminCtx, fetchCurrentDrawNo, e2e };