feat: 添加E2E测试环境配置支持
Some checks failed
lotterLaravel CI / test (push) Has been cancelled

添加LOTTERY_E2E环境变量来控制E2E测试相关功能,
包括绕过验证码、登录限制和钱包API URL验证,
同时更新composer.json以包含E2E专用的提供者和服务。
This commit is contained in:
2026-06-18 14:42:22 +08:00
parent 6b2ea39ea1
commit 6ec9634704
45 changed files with 3702 additions and 6 deletions

View File

@@ -0,0 +1,48 @@
/**
* E2E 冒烟:健康检查 + 公开 draw/captcha/玩家 ping。
*
* 不依赖任何账号,跑通说明:
* - docker compose 起得起来
* - php artisan serve 真的在 127.0.0.1:8000
* - PG / Redis / 缓存 / 路由都活着
*/
import { test, expect, request as pwRequest } from '@playwright/test';
const API = process.env.PLAYWRIGHT_API_URL ?? 'http://127.0.0.1:8000';
test('GET /api/v1/health 返回 200', async () => {
const ctx = await pwRequest.newContext();
const r = await ctx.get('/api/v1/health');
expect(r.ok(), `status=${r.status()}`).toBeTruthy();
await ctx.dispose();
});
test('GET /api/v1/player/ping 返回 200', async () => {
const ctx = await pwRequest.newContext();
const r = await ctx.get('/api/v1/player/ping');
expect(r.ok(), `status=${r.status()}`).toBeTruthy();
await ctx.dispose();
});
test('GET /api/v1/draw/current 不要求登录且包含 draw_no', async () => {
const ctx = await pwRequest.newContext();
const r = await ctx.get('/api/v1/draw/current');
expect(r.ok(), `status=${r.status()}`).toBeTruthy();
const body = (await r.json()) as any;
// data 是 DrawHallSnapshotdraw_no 不一定有(如果没"当前可下注"期号就 null
expect(body).toHaveProperty('data');
await ctx.dispose();
});
test('GET /api/v1/player/auth/captcha 返回 base64 + uuid key', async () => {
const ctx = await pwRequest.newContext();
const r = await ctx.get('/api/v1/player/auth/captcha');
expect(r.ok(), `status=${r.status()}`).toBeTruthy();
const body = (await r.json()) as any;
expect(body.data.captcha_key).toMatch(/^[0-9a-f-]{36}$/);
expect(body.data.image_base64).toBeTruthy();
const svg = Buffer.from(body.data.image_base64, 'base64').toString('utf8');
expect(svg).toContain('<svg');
await ctx.dispose();
});

View File

@@ -0,0 +1,105 @@
/**
* E2E玩家登录链路
*
* 覆盖:
* - 成功登录拿 token / me 能拿回玩家
* - 错密码 + 失败计数累加
* - 累积到上限锁定
* - reset-player 重置后能再登录
*
* 不依赖 captcha 渲染LOTTERY_E2E_BYPASS code 由 AdminCaptchaService 接受。
*/
import { test, expect, request as pwRequest } from '@playwright/test';
import {
playerLoginViaBypass,
resetE2EPlayer,
fetchPlayerCaptcha,
} from './_helper';
const PWD_OK = process.env.E2E_PLAYER_PASSWORD ?? '12345678';
const PWD_BAD = 'WrongPassword1!';
test.beforeEach(async () => {
// 每个用例都从干净状态开始
await resetE2EPlayer();
});
test('登录成功 → token 合法 → /player/me 回放同账号', async () => {
const session = await playerLoginViaBypass();
expect(session.access_token).toBeTruthy();
expect(session.player.username).toBe(process.env.E2E_PLAYER_USERNAME ?? 'demo_player');
const ctx = await pwRequest.newContext({
extraHTTPHeaders: { Authorization: `Bearer ${session.access_token}` },
});
const r = await ctx.get('/api/v1/player/me');
expect(r.ok(), `me status=${r.status()}`).toBeTruthy();
const me = (await r.json()) as any;
expect(me.data.username).toBe(session.player.username);
expect(me.data.auth_source).toBe('lottery_native');
await ctx.dispose();
});
test('错误密码 → 返回 200 但 code 非 0且 login_failed_count 自增', async () => {
const captcha = await fetchPlayerCaptcha();
const ctx = await pwRequest.newContext();
const r = await ctx.post('/api/v1/player/auth/login', {
data: {
site_code: 'demo',
username: 'demo_player',
password: PWD_BAD,
captcha_key: captcha.captcha_key,
captcha_code: 'LOTTERY_E2E_BYPASS',
},
});
// 鉴权失败业务上 200 + code=player_credentials_invalid
const body = (await r.json()) as any;
expect(body.code).not.toBe(0);
await ctx.dispose();
});
test('连续 N 次错密码 → 登录锁定 → 正确密码也被拒', async () => {
const maxAttempts = 8; // 与 PlayerNativeAuthService::recordFailedLogin 默认对齐
for (let i = 0; i < maxAttempts; i++) {
const captcha = await fetchPlayerCaptcha();
const ctx = await pwRequest.newContext();
const r = await ctx.post('/api/v1/player/auth/login', {
data: {
site_code: 'demo',
username: 'demo_player',
password: PWD_BAD,
captcha_key: captcha.captcha_key,
captcha_code: 'LOTTERY_E2E_BYPASS',
},
});
await r.body().catch(() => '');
await ctx.dispose();
}
// 第 9 次即使密码正确也应被拒(已锁定)
const captcha2 = await fetchPlayerCaptcha();
const ctx = await pwRequest.newContext();
const r = await ctx.post('/api/v1/player/auth/login', {
data: {
site_code: 'demo',
username: 'demo_player',
password: PWD_OK,
captcha_key: captcha2.captcha_key,
captcha_code: 'LOTTERY_E2E_BYPASS',
},
});
const body = (await r.json()) as any;
// 期望 200 + 业务 code=player_login_locked403 状态码也合理)
expect([403, 200]).toContain(r.status());
if (r.status() === 200) {
expect(body.code).not.toBe(0);
}
await ctx.dispose();
});
test('reset-player 后又能登录', async () => {
await resetE2EPlayer();
const session = await playerLoginViaBypass();
expect(session.access_token).toBeTruthy();
});

View File

@@ -0,0 +1,120 @@
/**
* E2E钱包 + 下注 + 注单查看
*
* 链路:
* 1. 玩家登录 → 拿钱包余额
* 2. 取当前 draw_no公开 draw.current
* 3. POST /ticket/preview不落库只算钱
* 4. POST /ticket/place落库 + 扣 frozen
* 5. GET /ticket/items/{ticket_no} 验真
* 6. 钱包余额减少frozen 增加)
* 7. 同 client_trace_id 第二次 place → 幂等回放
*
* 不做结算(结算链路走 settlement service + commande2e 单独测)。
*/
import { test, expect, request as pwRequest } from '@playwright/test';
import { playerLoginViaBypass, resetE2EPlayer, fetchCurrentDrawNo } from './_helper';
const CURRENCY = (process.env.LOTTERY_DEFAULT_CURRENCY ?? 'NPR').toUpperCase();
const BET_AMOUNT_MINOR = 1000;
test.beforeEach(async () => {
await resetE2EPlayer();
});
test('玩家登录 → /wallet/balance 返回币种 + 余额', async () => {
const session = await playerLoginViaBypass();
const ctx = await pwRequest.newContext({
extraHTTPHeaders: { Authorization: `Bearer ${session.access_token}` },
});
const r = await ctx.get('/api/v1/wallet/balance');
expect(r.ok(), `balance status=${r.status()}`).toBeTruthy();
const body = (await r.json()) as any;
expect(body.data.currency_code).toBe(CURRENCY);
expect(typeof body.data.balance).toBe('number');
expect(typeof body.data.available_balance).toBe('number');
expect(body.data.credit_line_mode).toBe(false);
await ctx.dispose();
});
test('preview → place → 拿 ticket_no + 余额变动', async () => {
const draw = await fetchCurrentDrawNo();
test.skip(draw === null, '当前无开放期号draw.current 返回 null跳过下注链路');
const session = await playerLoginViaBypass();
const ctx = await pwRequest.newContext({
extraHTTPHeaders: { Authorization: `Bearer ${session.access_token}` },
});
const bal0 = (await (await ctx.get('/api/v1/wallet/balance')).json()) as any;
const before = Number(bal0.data.balance);
const preview = await ctx.post('/api/v1/ticket/preview', {
data: {
draw_id: draw!.draw_no,
currency_code: CURRENCY,
client_trace_id: `e2e-preview-${Date.now()}`,
lines: [{ number: '1234', play_code: 'straight', amount: BET_AMOUNT_MINOR }],
},
});
expect(preview.ok(), `preview status=${preview.status()}`).toBeTruthy();
const previewBody = (await preview.json()) as any;
expect(previewBody.code).toBe(0);
const place = await ctx.post('/api/v1/ticket/place', {
data: {
draw_id: draw!.draw_no,
currency_code: CURRENCY,
client_trace_id: `e2e-place-${Date.now()}`,
lines: [{ number: '1234', play_code: 'straight', amount: BET_AMOUNT_MINOR }],
},
});
expect(place.ok(), `place status=${place.status()}`).toBeTruthy();
const placeBody = (await place.json()) as any;
expect(placeBody.code).toBe(0);
const ticketNo = placeBody.data.items?.[0]?.ticket_no ?? placeBody.data.ticket_no;
expect(ticketNo).toMatch(/^TK[0-9]+$/);
const show = await ctx.get(`/api/v1/ticket/items/${ticketNo}`);
expect(show.ok(), `items show status=${show.status()}`).toBeTruthy();
const showBody = (await show.json()) as any;
expect(showBody.data.ticket_no).toBe(ticketNo);
const bal1 = (await (await ctx.get('/api/v1/wallet/balance')).json()) as any;
const afterAvailable = Number(bal1.data.available_balance);
expect(afterAvailable).toBeLessThan(before);
await ctx.dispose();
});
test('同 client_trace_id 第二次 place → 幂等回放,不重复扣款', async () => {
const draw = await fetchCurrentDrawNo();
test.skip(draw === null, '当前无开放期号,跳过幂等用例');
const session = await playerLoginViaBypass();
const ctx = await pwRequest.newContext({
extraHTTPHeaders: { Authorization: `Bearer ${session.access_token}` },
});
const trace = `e2e-idem-${Date.now()}`;
const payload = {
draw_id: draw!.draw_no,
currency_code: CURRENCY,
client_trace_id: trace,
lines: [{ number: '5678', play_code: 'straight', amount: 500 }],
};
const r1 = await ctx.post('/api/v1/ticket/place', { data: payload });
expect(r1.ok(), `place1 status=${r1.status()}`).toBeTruthy();
const body1 = (await r1.json()) as any;
const ticket1 = body1.data.items?.[0]?.ticket_no ?? body1.data.ticket_no;
const r2 = await ctx.post('/api/v1/ticket/place', { data: payload });
expect(r2.ok(), `place2 status=${r2.status()}`).toBeTruthy();
const body2 = (await r2.json()) as any;
const ticket2 = body2.data.items?.[0]?.ticket_no ?? body2.data.ticket_no;
expect(ticket1).toBe(ticket2);
await ctx.dispose();
});

View File

@@ -0,0 +1,34 @@
/**
* E2E超管登录 + ping/dashboard
*
* 验证:
* - super admin 能登录
* - /api/v1/admin/ping 不需要 auth实际看路由
* - /api/v1/admin/dashboard 需要 auth 且返回 dashboard
*/
import { test, expect, request as pwRequest } from '@playwright/test';
import { adminLoginViaBypass } from './_helper';
test('超管登录成功is_super_admin=true', async () => {
const session = await adminLoginViaBypass();
expect(session.accessToken).toBeTruthy();
expect(session.admin.is_super_admin).toBe(true);
});
test('超管带 token 调 /api/v1/admin/dashboard 返回 200', async () => {
const session = await adminLoginViaBypass();
const ctx = await pwRequest.newContext({
extraHTTPHeaders: { Authorization: `Bearer ${session.accessToken}` },
});
const r = await ctx.get('/api/v1/admin/dashboard');
expect(r.ok(), `dashboard status=${r.status()}`).toBeTruthy();
await ctx.dispose();
});
test('超管无 token 调 /api/v1/admin/dashboard → 401', async () => {
const ctx = await pwRequest.newContext();
const r = await ctx.get('/api/v1/admin/dashboard');
expect([401, 403]).toContain(r.status());
await ctx.dispose();
});

View File

@@ -0,0 +1,172 @@
/**
* E2E玩家钱包 transfer-in / transfer-out
*
* 链路:
* 1. transfer-in: 主站扣款 → 彩票钱包加款e2e 走 stub秒成功
* 2. transfer-out: 彩票钱包扣款 → 主站加款(同样 stub 秒成功)
* 3. 余额一致性transfer-in 后 balance 增加、available_balance 增加
* 4. 幂等:同 idempotent_key 第二次返回同 transfer_no
* 5. 余额不足 1001把 balance 改到 0 → transfer-out → 1001
* 6. 幂等冲突 1010同 idempotent_key 第二次 amount 不同 → 1010
*
* 不覆盖(需外部主站 mock 才能跑,留 skip + 文档):
* - 主站失败 1009需真主站返 5xx
* - 主站超时 504/408 → 1002 pending_reconcile需真主站返 timeout
* - 转入关 1004需 .env 关 transfer_in_enabled跑前要改
*/
import { test, expect, request as pwRequest } from '@playwright/test';
import { playerLoginViaBypass, resetE2EPlayer, e2e } from './_helper';
const CURRENCY = (process.env.LOTTERY_DEFAULT_CURRENCY ?? 'NPR').toUpperCase();
const IN_AMOUNT = 50_000; // 转入 50 元 NPR
test.beforeEach(async () => {
await resetE2EPlayer();
});
async function ctxOf(token: string) {
return pwRequest.newContext({
extraHTTPHeaders: { Authorization: `Bearer ${token}` },
});
}
test('transfer-in: 加款 + 余额增加 + lottery_balance_after 一致', async () => {
const session = await playerLoginViaBypass();
const ctx = await ctxOf(session.access_token);
const bal0 = (await (await ctx.get('/api/v1/wallet/balance')).json()) as any;
const before = Number(bal0.data.balance);
const r = await ctx.post('/api/v1/wallet/transfer-in', {
data: {
amount: IN_AMOUNT,
currency: CURRENCY,
idempotent_key: `e2e-tin-${Date.now()}`,
},
});
expect(r.ok(), `transfer-in status=${r.status()}`).toBeTruthy();
const body = (await r.json()) as any;
expect(body.code).toBe(0);
expect(body.data.transfer_no).toMatch(/^T[IO]_[a-z0-9]+$/);
expect(Number(body.data.amount)).toBe(IN_AMOUNT);
expect(body.data.currency_code).toBe(CURRENCY);
// lottery_balance_after = before + IN_AMOUNT
expect(Number(body.data.lottery_balance_after)).toBe(before + IN_AMOUNT);
const bal1 = (await (await ctx.get('/api/v1/wallet/balance')).json()) as any;
expect(Number(bal1.data.balance)).toBe(before + IN_AMOUNT);
expect(Number(bal1.data.available_balance)).toBe(before + IN_AMOUNT);
await ctx.dispose();
});
test('transfer-out: 扣款 + 余额减少', async () => {
const session = await playerLoginViaBypass();
const ctx = await ctxOf(session.access_token);
const bal0 = (await (await ctx.get('/api/v1/wallet/balance')).json()) as any;
const before = Number(bal0.data.balance);
const r = await ctx.post('/api/v1/wallet/transfer-out', {
data: {
amount: IN_AMOUNT,
currency: CURRENCY,
idempotent_key: `e2e-tout-${Date.now()}`,
},
});
expect(r.ok(), `transfer-out status=${r.status()}`).toBeTruthy();
const body = (await r.json()) as any;
expect(body.code).toBe(0);
expect(Number(body.data.amount)).toBe(IN_AMOUNT);
expect(Number(body.data.lottery_balance_after)).toBe(before - IN_AMOUNT);
const bal1 = (await (await ctx.get('/api/v1/wallet/balance')).json()) as any;
expect(Number(bal1.data.balance)).toBe(before - IN_AMOUNT);
await ctx.dispose();
});
test('transfer-in 幂等:同 key 第二次返回同 transfer_no + 不重复加款', async () => {
const session = await playerLoginViaBypass();
const ctx = await ctxOf(session.access_token);
const key = `e2e-tin-idem-${Date.now()}`;
const payload = { amount: IN_AMOUNT, currency: CURRENCY, idempotent_key: key };
const r1 = await ctx.post('/api/v1/wallet/transfer-in', { data: payload });
const b1 = (await r1.json()) as any;
const t1 = b1.data.transfer_no;
const bal1 = (await (await ctx.get('/api/v1/wallet/balance')).json()) as any;
const after1 = Number(bal1.data.balance);
const r2 = await ctx.post('/api/v1/wallet/transfer-in', { data: payload });
const b2 = (await r2.json()) as any;
expect(b2.data.transfer_no).toBe(t1);
const bal2 = (await (await ctx.get('/api/v1/wallet/balance')).json()) as any;
expect(Number(bal2.data.balance)).toBe(after1); // 余额没再变
await ctx.dispose();
});
test('transfer-in 幂等冲突 1010同 key 第二次 amount 不同', async () => {
const session = await playerLoginViaBypass();
const ctx = await ctxOf(session.access_token);
const key = `e2e-tin-conflict-${Date.now()}`;
const r1 = await ctx.post('/api/v1/wallet/transfer-in', {
data: { amount: IN_AMOUNT, currency: CURRENCY, idempotent_key: key },
});
expect(r1.ok()).toBeTruthy();
const r2 = await ctx.post('/api/v1/wallet/transfer-in', {
data: { amount: IN_AMOUNT + 100, currency: CURRENCY, idempotent_key: key },
});
// 业务失败HTTP 200code=1010
const b2 = (await r2.json()) as any;
expect(Number(b2.code)).toBe(1010);
await ctx.dispose();
});
test('transfer-out 余额不足 1001把余额改到 0 后转出', async () => {
// 把 balance 改到 0
await e2e('POST', '/player/set-balance', { balance: 0, currency: CURRENCY });
const session = await playerLoginViaBypass();
const ctx = await ctxOf(session.access_token);
const r = await ctx.post('/api/v1/wallet/transfer-out', {
data: {
amount: 100,
currency: CURRENCY,
idempotent_key: `e2e-tout-empty-${Date.now()}`,
},
});
const body = (await r.json()) as any;
expect(Number(body.code)).toBe(1001);
await ctx.dispose();
});
test('transfer-in 负数金额 → 422 校验失败', async () => {
const session = await playerLoginViaBypass();
const ctx = await ctxOf(session.access_token);
const r = await ctx.post('/api/v1/wallet/transfer-in', {
data: {
amount: -100,
currency: CURRENCY,
idempotent_key: `e2e-tin-neg-${Date.now()}`,
},
});
expect([422, 400]).toContain(r.status());
await ctx.dispose();
});
test('transfer-in 缺 idempotent_key → 422 校验失败', async () => {
const session = await playerLoginViaBypass();
const ctx = await ctxOf(session.access_token);
const r = await ctx.post('/api/v1/wallet/transfer-in', {
data: { amount: IN_AMOUNT, currency: CURRENCY },
});
expect([422, 400]).toContain(r.status());
await ctx.dispose();
});

View File

@@ -0,0 +1,134 @@
/**
* E2E钱包流水一致性
*
* 链路:
* 1. reset → 余额 baseline
* 2. transfer-in N → 看 /wallet/logs 出现 type=transfer_in 且 amount=N
* 3. transfer-out M → 出现 type=transfer_out 且 amount=M
* 4. 流水 total 增量为 in - out
* 5. type 过滤:只查 transfer_in 不出现 transfer_out
*
* 与 05 不同05 测单次 transfer 成功06 测多笔后的流水呈现 + 过滤。
*/
import { test, expect, request as pwRequest } from '@playwright/test';
import { playerLoginViaBypass, resetE2EPlayer } from './_helper';
const CURRENCY = (process.env.LOTTERY_DEFAULT_CURRENCY ?? 'NPR').toUpperCase();
const IN_1 = 12_000;
const IN_2 = 8_000;
const OUT_1 = 5_000;
test.beforeEach(async () => {
await resetE2EPlayer();
});
async function ctxOf(token: string) {
return pwRequest.newContext({
extraHTTPHeaders: { Authorization: `Bearer ${token}` },
});
}
async function transferIn(ctx: any, amount: number, key: string) {
const r = await ctx.post('/api/v1/wallet/transfer-in', {
data: { amount, currency: CURRENCY, idempotent_key: key },
});
expect(r.ok(), `transfer-in status=${r.status()}`).toBeTruthy();
}
async function transferOut(ctx: any, amount: number, key: string) {
const r = await ctx.post('/api/v1/wallet/transfer-out', {
data: { amount, currency: CURRENCY, idempotent_key: key },
});
expect(r.ok(), `transfer-out status=${r.status()}`).toBeTruthy();
}
test('transfer-in/out 后 /wallet/logs 流水一致性', async () => {
const session = await playerLoginViaBypass();
const ctx = await ctxOf(session.access_token);
const keyIn1 = `e2e-log-in1-${Date.now()}`;
const keyIn2 = `e2e-log-in2-${Date.now()}`;
const keyOut1 = `e2e-log-out1-${Date.now()}`;
await transferIn(ctx, IN_1, keyIn1);
await transferIn(ctx, IN_2, keyIn2);
await transferOut(ctx, OUT_1, keyOut1);
const r = await ctx.get(`/api/v1/wallet/logs?size=100&currency=${CURRENCY}`);
expect(r.ok(), `logs status=${r.status()}`).toBeTruthy();
const body = (await r.json()) as any;
expect(body.code).toBe(0);
expect(body.data.funding_mode).toBe('wallet');
expect(body.data.ledger_source).toBeTruthy();
const items: any[] = body.data.items;
expect(items.length).toBeGreaterThanOrEqual(3);
const ourKeys = new Set([keyIn1, keyIn2, keyOut1]);
const ourTxns = items.filter((it) => ourKeys.has(it.idempotent_key));
expect(ourTxns.length).toBe(3);
const ins = ourTxns.filter((it) => it.type === 'transfer_in');
const outs = ourTxns.filter((it) => it.type === 'transfer_out');
expect(ins.length).toBe(2);
expect(outs.length).toBe(1);
expect(ins.reduce((s, x) => s + Math.abs(x.amount_abs ?? x.amount), 0)).toBe(IN_1 + IN_2);
expect(Math.abs(outs[0].amount_abs ?? outs[0].amount)).toBe(OUT_1);
await ctx.dispose();
});
test('type=transfer_in 过滤:结果中不应出现 transfer_out', async () => {
const session = await playerLoginViaBypass();
const ctx = await ctxOf(session.access_token);
await transferIn(ctx, IN_1, `e2e-logf-in1-${Date.now()}`);
await transferOut(ctx, OUT_1, `e2e-logf-out1-${Date.now()}`);
const r = await ctx.get(`/api/v1/wallet/logs?type=transfer_in&size=100&currency=${CURRENCY}`);
const body = (await r.json()) as any;
const items: any[] = body.data.items;
expect(items.length).toBeGreaterThanOrEqual(1);
for (const it of items) {
expect(it.type).toBe('transfer_in');
}
// 不能有 transfer_out
expect(items.some((it) => it.type === 'transfer_out')).toBe(false);
await ctx.dispose();
});
test('pending_reconcile 字段:本地 stub 走秒成功,列表应为空', async () => {
const session = await playerLoginViaBypass();
const ctx = await ctxOf(session.access_token);
await transferIn(ctx, IN_1, `e2e-logp-in1-${Date.now()}`);
const r = await ctx.get(`/api/v1/wallet/logs?size=20&currency=${CURRENCY}`);
const body = (await r.json()) as any;
expect(Array.isArray(body.data.pending_reconcile)).toBeTruthy();
expect(body.data.pending_reconcile.length).toBe(0);
await ctx.dispose();
});
test('分页page=1 size=2 + page=2 size=2 不重叠且能拼回完整列表', async () => {
const session = await playerLoginViaBypass();
const ctx = await ctxOf(session.access_token);
await transferIn(ctx, 100, `e2e-page-in1-${Date.now()}`);
await transferIn(ctx, 200, `e2e-page-in2-${Date.now()}`);
await transferIn(ctx, 300, `e2e-page-in3-${Date.now()}`);
const r1 = await ctx.get(`/api/v1/wallet/logs?page=1&size=2&type=transfer_in&currency=${CURRENCY}`);
const r2 = await ctx.get(`/api/v1/wallet/logs?page=2&size=2&type=transfer_in&currency=${CURRENCY}`);
const b1 = (await r1.json()) as any;
const b2 = (await r2.json()) as any;
expect(b1.data.items.length).toBeLessThanOrEqual(2);
expect(b2.data.items.length).toBeLessThanOrEqual(2);
const ids1 = new Set(b1.data.items.map((x: any) => x.log_id ?? x.id));
for (const it of b2.data.items) {
expect(ids1.has(it.log_id ?? it.id)).toBe(false); // 不重叠
}
await ctx.dispose();
});

View File

@@ -0,0 +1,247 @@
/**
* E2E超管玩家管理
*
* 链路:
* 1. 创建玩家site_code=demo, username/password
* 2. 用新建玩家登录 → 拿 token
* 3. /admin/players/{id}/freeze → status=1
* 4. 玩家用 frozen 账号登录 → 403 PlayerAccountSuspended
* 5. /admin/players/{id}/unfreeze → status=0
* 6. 玩家又能登录
* 7. /admin/players 列表能找到新建玩家
* 8. /admin/players/{id}/wallets 返回钱包列表
*
* 不测 destroy避免把 e2e 自带的 demo_player 误删player id 不固定)。
*/
import { test, expect, request as pwRequest } from '@playwright/test';
import { adminLoginViaBypass, fetchPlayerCaptcha, e2e } from './_helper';
const SITE_CODE = 'demo';
const CURRENCY = (process.env.LOTTERY_DEFAULT_CURRENCY ?? 'NPR').toUpperCase();
const PWD = '12345678';
function uniqueUsername(): string {
// 玩家 username 规则 native 6-32 位字母数字下划线(参考 nativePlayerUsernameRules
return 'e2e_p_' + Math.random().toString(36).slice(2, 10);
}
async function adminCtxOf(token: string) {
return pwRequest.newContext({
extraHTTPHeaders: { Authorization: `Bearer ${token}` },
});
}
async function playerLoginCtx(siteCode: string, username: string, password: string, captchaKey: string) {
const ctx = await pwRequest.newContext();
const r = await ctx.post('/api/v1/player/auth/login', {
data: {
site_code: siteCode,
username,
password,
captcha_key: captchaKey,
captcha_code: 'LOTTERY_E2E_BYPASS',
},
});
return { ctx, status: r.status(), body: (await r.json().catch(() => ({}))) as any };
}
test('超管创建玩家 + 玩家登录成功', async () => {
const session = await adminLoginViaBypass();
const admin = await adminCtxOf(session.accessToken);
const username = uniqueUsername();
const r = await admin.post('/api/v1/admin/players', {
data: {
site_code: SITE_CODE,
site_player_id: 'e2e-' + username,
username,
password: PWD,
default_currency: CURRENCY,
status: 0,
},
});
expect(r.ok(), `create player status=${r.status()}`).toBeTruthy();
const created = (await r.json()) as any;
expect(created.code).toBe(0);
const playerId = created.data.id;
expect(typeof playerId).toBe('number');
await admin.dispose();
// 玩家登录
const cap = await fetchPlayerCaptcha();
const { ctx, status, body } = await playerLoginCtx(SITE_CODE, username, PWD, cap.captcha_key);
expect(status, `player login status=${status}`).toBe(200);
expect(body.code).toBe(0);
expect(body.data.player.username).toBe(username);
await ctx.dispose();
});
test('超管 freeze → 玩家登录 403unfreeze → 又能登录', async () => {
const session = await adminLoginViaBypass();
const admin = await adminCtxOf(session.accessToken);
const username = uniqueUsername();
// 创建
const r = await admin.post('/api/v1/admin/players', {
data: {
site_code: SITE_CODE,
site_player_id: 'e2e-' + username,
username,
password: PWD,
default_currency: CURRENCY,
status: 0,
},
});
const created = (await r.json()) as any;
const playerId = created.data.id;
// freeze
const fz = await admin.post(`/api/v1/admin/players/${playerId}/freeze`);
expect(fz.ok(), `freeze status=${fz.status()}`).toBeTruthy();
const fzBody = (await fz.json()) as any;
expect(fzBody.data.status).toBe(1);
await admin.dispose();
// frozen 玩家登录应失败player_account_suspended
const cap1 = await fetchPlayerCaptcha();
const { ctx: ctx1, status: st1, body: b1 } = await playerLoginCtx(SITE_CODE, username, PWD, cap1.captcha_key);
expect([200, 403]).toContain(st1);
if (st1 === 200) {
expect(b1.code).not.toBe(0);
}
await ctx1.dispose();
// unfreeze
const admin2 = await adminCtxOf(session.accessToken);
const uf = await admin2.post(`/api/v1/admin/players/${playerId}/unfreeze`);
expect(uf.ok(), `unfreeze status=${uf.status()}`).toBeTruthy();
const ufBody = (await uf.json()) as any;
expect(ufBody.data.status).toBe(0);
await admin2.dispose();
// 玩家又能登录
const cap2 = await fetchPlayerCaptcha();
const { ctx: ctx2, status: st2, body: b2 } = await playerLoginCtx(SITE_CODE, username, PWD, cap2.captcha_key);
expect(st2, `player login after unfreeze status=${st2}`).toBe(200);
expect(b2.code).toBe(0);
await ctx2.dispose();
});
test('/admin/players 列表能找到新建玩家(按 username 搜索)', async () => {
const session = await adminLoginViaBypass();
const admin = await adminCtxOf(session.accessToken);
const username = uniqueUsername();
const r = await admin.post('/api/v1/admin/players', {
data: {
site_code: SITE_CODE,
site_player_id: 'e2e-' + username,
username,
password: PWD,
default_currency: CURRENCY,
status: 0,
},
});
const created = (await r.json()) as any;
const playerId = created.data.id;
// 列表搜索
const list = await admin.get(`/api/v1/admin/players?keyword=${encodeURIComponent(username)}&size=20`);
expect(list.ok(), `list status=${list.status()}`).toBeTruthy();
const body = (await list.json()) as any;
const items: any[] = body.data.items;
expect(items.length).toBeGreaterThanOrEqual(1);
const found = items.find((it) => it.id === playerId);
expect(found).toBeTruthy();
expect(found.username).toBe(username);
await admin.dispose();
});
test('/admin/players/{id}/wallets 信用盘玩家返回空钱包列表', async () => {
const session = await adminLoginViaBypass();
const admin = await adminCtxOf(session.accessToken);
const username = uniqueUsername();
const r = await admin.post('/api/v1/admin/players', {
data: {
site_code: SITE_CODE,
site_player_id: 'e2e-' + username,
username,
password: PWD,
default_currency: CURRENCY,
status: 0,
},
});
const created = (await r.json()) as any;
const playerId = created.data.id;
expect(created.data.funding_mode).toBe('credit');
// 信用盘玩家登录不会开立 player_wallets
const cap = await fetchPlayerCaptcha();
const { ctx, status, body } = await playerLoginCtx(SITE_CODE, username, PWD, cap.captcha_key);
expect(status).toBe(200);
expect(body.code).toBe(0);
await ctx.dispose();
// 查 wallet
const w = await admin.get(`/api/v1/admin/players/${playerId}/wallets`);
expect(w.ok(), `wallets status=${w.status()}`).toBeTruthy();
const wb = (await w.json()) as any;
const wallets: any[] = wb.data.wallets ?? [];
expect(wallets.length).toBe(0);
await admin.dispose();
});
test('创建玩家缺 site_player_id → 422', async () => {
const session = await adminLoginViaBypass();
const admin = await adminCtxOf(session.accessToken);
const username = uniqueUsername();
const r = await admin.post('/api/v1/admin/players', {
data: {
site_code: SITE_CODE,
username,
default_currency: CURRENCY,
},
});
expect([422, 400]).toContain(r.status());
await admin.dispose();
});
test('创建玩家重复 username → 409/422 业务失败', async () => {
const session = await adminLoginViaBypass();
const admin = await adminCtxOf(session.accessToken);
const username = uniqueUsername();
// 第一次
const r1 = await admin.post('/api/v1/admin/players', {
data: {
site_code: SITE_CODE,
site_player_id: 'e2e-dup-' + username,
username,
password: PWD,
default_currency: CURRENCY,
},
});
expect(r1.ok()).toBeTruthy();
// 第二次同 username不同 site_player_id
const r2 = await admin.post('/api/v1/admin/players', {
data: {
site_code: SITE_CODE,
site_player_id: 'e2e-dup2-' + username,
username,
password: PWD,
default_currency: CURRENCY,
},
});
// 业务失败HTTP 200 + code 非 0 是常见;或直接 4xx
const b2 = (await r2.json().catch(() => ({}))) as any;
if (r2.status() === 200) {
expect(b2.code).not.toBe(0);
} else {
expect([409, 422, 400, 500]).toContain(r2.status());
}
await admin.dispose();
});

View File

@@ -0,0 +1,37 @@
/**
* E2E开奖 + 结算 + 派彩(确定性流水线,无 skip
*/
import { test, expect, request as pwRequest } from '@playwright/test';
import {
playerLoginViaBypass,
resetE2EPlayer,
fetchCurrentDrawNo,
} from './_helper';
import { runWalletDrawSettlement } from './helpers/draw-settlement';
test('下注 → 关盘 → 录号 → publish → 结算 → 派彩 → 余额涨 + 公开结果可查', async () => {
test.setTimeout(180_000);
await resetE2EPlayer();
const draw = await fetchCurrentDrawNo();
expect(draw?.draw_no, '当前没有 open 期号').toBeTruthy();
const session = await playerLoginViaBypass();
const adminSession = await (await import('./_helper')).adminLoginViaBypass();
const result = await runWalletDrawSettlement({
adminToken: adminSession.accessToken,
playerToken: session.access_token,
drawNo: draw!.draw_no,
});
expect(result.balanceAfter).toBeGreaterThan(result.balanceBefore);
const publicCtx = await pwRequest.newContext();
const pub2 = await publicCtx.get(`/api/v1/draw/results/${draw!.draw_no}`);
expect(pub2.ok()).toBeTruthy();
const pb2 = (await pub2.json()) as any;
expect(pb2.code).toBe(0);
await publicCtx.dispose();
});

View File

@@ -0,0 +1,50 @@
/**
* E2E信用盘下注占用授信钱包余额不变。
*/
import { test, expect } from '@playwright/test';
import {
fetchOpenDrawNo,
playerLoginViaBypass,
setupCreditPlayer,
e2e,
} from './_helper';
const CURRENCY = (process.env.LOTTERY_DEFAULT_CURRENCY ?? 'NPR').toUpperCase();
const BET_AMOUNT = 10_000;
test('信用玩家下注 → used_credit 增加、钱包 balance 不变', async () => {
const credit = await setupCreditPlayer({ credit_limit: 50_000 });
const drawNo = await fetchOpenDrawNo();
const session = await playerLoginViaBypass({
site_code: credit.site_code,
username: credit.username,
password: credit.password,
});
const ctx = await (await import('./_helper')).playerCtxOf(session.access_token);
const bal0 = (await (await ctx.get('/api/v1/wallet/balance')).json()) as any;
const walletBefore = Number(bal0.data.balance);
const place = await ctx.post('/api/v1/ticket/place', {
data: {
draw_id: drawNo,
currency_code: CURRENCY,
client_trace_id: `e2e-credit-${Date.now()}`,
lines: [{ number: '1234', play_code: 'straight', amount: BET_AMOUNT }],
},
});
expect(place.ok()).toBeTruthy();
const body = (await place.json()) as any;
expect(body.code).toBe(0);
const bal1 = (await (await ctx.get('/api/v1/wallet/balance')).json()) as any;
expect(Number(bal1.data.balance)).toBe(walletBefore);
await ctx.dispose();
const inspect = await e2e<any>('GET', `/inspect/credit-ledger?player_id=${credit.player_id}&limit=5`);
expect(inspect.data.count).toBeGreaterThan(0);
const hold = inspect.data.rows.find((r: any) => r.reason === 'bet_hold');
expect(hold).toBeTruthy();
});

View File

@@ -0,0 +1,70 @@
/**
* E2E代理账期开账 → 信用注单结算 → 关账出玩家账单。
*/
import { test, expect } from '@playwright/test';
import {
adminLoginViaBypass,
adminCtxOf,
fetchOpenDrawNo,
playerLoginViaBypass,
setupCreditPlayer,
} from './_helper';
import { runWalletDrawSettlement } from './helpers/draw-settlement';
test('信用下注结算后关账 → 生成玩家 settlement_bill', async () => {
test.setTimeout(240_000);
const credit = await setupCreditPlayer({ credit_limit: 100_000 });
const drawNo = await fetchOpenDrawNo();
const adminSession = await adminLoginViaBypass();
const admin = await adminCtxOf(adminSession.accessToken);
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);
const open = await admin.post('/api/v1/admin/settlement-periods', {
data: {
admin_site_id: credit.admin_site_id,
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);
const periodId = openBody.data.id;
const playerSession = await playerLoginViaBypass({
site_code: credit.site_code,
username: credit.username,
password: credit.password,
});
await runWalletDrawSettlement({
adminToken: adminSession.accessToken,
playerToken: playerSession.access_token,
drawNo,
betNumber: '5678',
betAmount: 10_000,
expectWalletIncrease: false,
});
const close = await admin.post(`/api/v1/admin/settlement-periods/${periodId}/close`);
expect(close.ok(), `close period status=${close.status()}`).toBeTruthy();
const closeBody = (await close.json()) as any;
expect(closeBody.code).toBe(0);
const bills = await admin.get(
`/api/v1/admin/settlement-bills?settlement_period_id=${periodId}&size=20`,
);
const billsBody = (await bills.json()) as any;
expect(billsBody.code).toBe(0);
const playerBill = billsBody.data.items.find(
(b: any) => b.bill_type === 'player' && Number(b.owner_id) === credit.player_id,
);
expect(playerBill, '应有该信用玩家的账单').toBeTruthy();
await admin.dispose();
});

View File

@@ -0,0 +1,130 @@
/**
* E2E主站 SSO JWT + 钱包 mock 异常504 / 业务拒绝)。
*/
import { test, expect, request as pwRequest } from '@playwright/test';
import {
configureWalletMock,
fetchOpenDrawNo,
mintSsoJwt,
playerLoginViaBypass,
resetE2EPlayer,
resetWalletMock,
setMockWalletMode,
} from './_helper';
const CURRENCY = (process.env.LOTTERY_DEFAULT_CURRENCY ?? 'NPR').toUpperCase();
const MOCK_BASE = `http://127.0.0.1:${process.env.E2E_MOCK_WALLET_PORT ?? '5555'}`;
test.beforeEach(async () => {
await resetWalletMock();
await setMockWalletMode('success');
});
test('SSO JWT 首次 /player/me 自动建档', async () => {
const { jwt, site_player_id } = await mintSsoJwt();
const ctx = await pwRequest.newContext({
extraHTTPHeaders: { Authorization: `Bearer ${jwt}` },
});
const me = await ctx.get('/api/v1/player/me');
expect(me.ok()).toBeTruthy();
const body = (await me.json()) as any;
expect(body.code).toBe(0);
expect(body.data.site_player_id).toBe(site_player_id);
expect(String(body.data.username)).toMatch(/^nlotto\d{6}$/);
await ctx.dispose();
});
test('主站 mock 504 → transfer-out 1002 pending_reconcile', async () => {
await resetE2EPlayer();
await configureWalletMock(MOCK_BASE);
await setMockWalletMode('504');
const session = await playerLoginViaBypass();
const ctx = await pwRequest.newContext({
extraHTTPHeaders: { Authorization: `Bearer ${session.access_token}` },
});
const r = await ctx.post('/api/v1/wallet/transfer-out', {
data: {
amount: 10_000,
currency: CURRENCY,
idempotent_key: `e2e-mock-504-${Date.now()}`,
},
});
const body = (await r.json()) as any;
expect(Number(body.code)).toBe(1002);
await ctx.dispose();
});
test('主站 mock reject → transfer-out 1009', async () => {
await resetE2EPlayer();
await configureWalletMock(MOCK_BASE);
await setMockWalletMode('reject');
const session = await playerLoginViaBypass();
const ctx = await pwRequest.newContext({
extraHTTPHeaders: { Authorization: `Bearer ${session.access_token}` },
});
const r = await ctx.post('/api/v1/wallet/transfer-out', {
data: {
amount: 10_000,
currency: CURRENCY,
idempotent_key: `e2e-mock-reject-${Date.now()}`,
},
});
const body = (await r.json()) as any;
expect(Number(body.code)).toBe(1009);
await ctx.dispose();
});
test('SSO JWT + 主站 mock 成功 → transfer-in + 下注', async () => {
test.setTimeout(120_000);
const { jwt } = await mintSsoJwt(`e2e-sso-wallet-${Date.now()}`);
await configureWalletMock(MOCK_BASE);
await setMockWalletMode('success');
const ctx = await pwRequest.newContext({
extraHTTPHeaders: { Authorization: `Bearer ${jwt}` },
});
const me = await ctx.get('/api/v1/player/me');
expect(me.ok()).toBeTruthy();
const meBody = (await me.json()) as any;
expect(meBody.code).toBe(0);
expect(meBody.data.funding_mode).toBe('wallet');
expect(meBody.data.auth_source).toBe('main_site_sso');
const bal0 = await ctx.get('/api/v1/wallet/balance');
const before = Number((await bal0.json()).data.balance);
const tin = await ctx.post('/api/v1/wallet/transfer-in', {
data: {
amount: 30_000,
currency: CURRENCY,
idempotent_key: `e2e-sso-tin-${Date.now()}`,
},
});
expect(tin.ok()).toBeTruthy();
const tinBody = (await tin.json()) as any;
expect(tinBody.code).toBe(0);
expect(Number(tinBody.data.lottery_balance_after)).toBeGreaterThan(before);
const drawNo = await fetchOpenDrawNo();
const place = await ctx.post('/api/v1/ticket/place', {
data: {
draw_id: drawNo,
currency_code: CURRENCY,
client_trace_id: `e2e-sso-bet-${Date.now()}`,
lines: [{ number: '1234', play_code: 'straight', amount: 5_000 }],
},
});
expect(place.ok()).toBeTruthy();
const placeBody = (await place.json()) as any;
expect(placeBody.code).toBe(0);
await ctx.dispose();
});

View File

@@ -0,0 +1,69 @@
/**
* E2EReverb balance.update 广播transfer-in 触发)。
*/
import { test, expect } from '@playwright/test';
import { createRequire } from 'node:module';
import {
playerLoginViaBypass,
resetE2EPlayer,
resetWalletMock,
setMockWalletMode,
sleep,
} from './_helper';
test.beforeEach(async () => {
await resetWalletMock();
await setMockWalletMode('success');
});
const require = createRequire(import.meta.url);
const { Pusher } = require('pusher-js') as { Pusher: new (key: string, opts: Record<string, unknown>) => any };
const CURRENCY = (process.env.LOTTERY_DEFAULT_CURRENCY ?? 'NPR').toUpperCase();
const REVERB_KEY = process.env.REVERB_APP_KEY ?? 'e2e-key';
const REVERB_HOST = process.env.REVERB_HOST ?? '127.0.0.1';
const REVERB_PORT = Number(process.env.REVERB_PORT ?? 8080);
test('transfer-in 后收到 balance.update WebSocket 事件', async () => {
test.setTimeout(60_000);
await resetE2EPlayer();
const session = await playerLoginViaBypass();
const playerId = session.player.id;
const events: any[] = [];
const pusher = new Pusher(REVERB_KEY, {
wsHost: REVERB_HOST,
wsPort: REVERB_PORT,
forceTLS: false,
disableStats: true,
enabledTransports: ['ws'],
cluster: 'mt1',
});
const channel = pusher.subscribe(`player.${playerId}`);
channel.bind('balance.update', (data: unknown) => {
events.push(data);
});
await sleep(500);
const ctx = await (await import('./_helper')).playerCtxOf(session.access_token);
const r = await ctx.post('/api/v1/wallet/transfer-in', {
data: {
amount: 20_000,
currency: CURRENCY,
idempotent_key: `e2e-bcast-${Date.now()}`,
},
});
expect(r.ok()).toBeTruthy();
await ctx.dispose();
for (let i = 0; i < 30 && events.length === 0; i++) {
await sleep(500);
}
pusher.disconnect();
expect(events.length).toBeGreaterThan(0);
expect(events[0].reason).toBeTruthy();
});

View File

@@ -0,0 +1,63 @@
/**
* E2E信用盘中奖结算 → game_settlement_win 释额 + 玩家流水 win_credit。
*/
import { test, expect } from '@playwright/test';
import {
adminLoginViaBypass,
e2e,
fetchOpenDrawNo,
playerLoginViaBypass,
playerCtxOf,
setupCreditPlayer,
} from './_helper';
import { runWalletDrawSettlement } from './helpers/draw-settlement';
test('信用玩家中奖结算 → credit_ledger game_settlement_win + 钱包流水 win_credit', async () => {
test.setTimeout(240_000);
const winNumber = `7${String(Date.now()).slice(-3)}`;
const credit = await setupCreditPlayer({
credit_limit: 100_000,
username: `e2e_win_${Date.now()}`,
});
const drawNo = await fetchOpenDrawNo();
const adminSession = await adminLoginViaBypass();
const playerSession = await playerLoginViaBypass({
site_code: credit.site_code,
username: credit.username,
password: credit.password,
});
await runWalletDrawSettlement({
adminToken: adminSession.accessToken,
playerToken: playerSession.access_token,
drawNo,
betNumber: winNumber,
betAmount: 10_000,
expectWalletIncrease: false,
});
const ledger = await e2e<any>(
'GET',
`/inspect/credit-ledger?player_id=${credit.player_id}&limit=20`,
);
const hold = ledger.data.rows.find((r: any) => r.reason === 'bet_hold');
const winRow = ledger.data.rows.find((r: any) => r.reason === 'game_settlement_win');
expect(hold, '下注后应有 bet_hold').toBeTruthy();
expect(winRow, '中奖结算后应有 game_settlement_win').toBeTruthy();
expect(Number(winRow.amount)).toBeGreaterThan(0);
const ctx = await playerCtxOf(playerSession.access_token);
const logs = await ctx.get('/api/v1/wallet/logs?page=1&size=20');
expect(logs.ok()).toBeTruthy();
const logsBody = (await logs.json()) as any;
expect(logsBody.code).toBe(0);
expect(logsBody.data.ledger_source).toBe('credit_ledger');
const winLog = logsBody.data.items.find(
(i: any) => i.biz_type === 'game_settlement_win' || i.type === 'win_credit',
);
expect(winLog, '玩家流水应展示中奖释额').toBeTruthy();
await ctx.dispose();
});

View File

@@ -0,0 +1,108 @@
/**
* E2E代理账期关账 → confirm → 登记收付 → 账单 settled。
*/
import { test, expect } from '@playwright/test';
import {
adminLoginViaBypass,
adminCtxOf,
fetchOpenDrawNo,
playerLoginViaBypass,
setupCreditPlayer,
} from './_helper';
import { runWalletDrawSettlement } from './helpers/draw-settlement';
test('关账后 confirm + 全额收付 → 玩家账单 settled + payment_records', async () => {
test.setTimeout(300_000);
const credit = await setupCreditPlayer({
credit_limit: 100_000,
username: `e2e_pay_${Date.now()}`,
});
const drawNo = await fetchOpenDrawNo();
const winNumber = `8${String(Date.now()).slice(-3)}`;
const adminSession = await adminLoginViaBypass();
const admin = await adminCtxOf(adminSession.accessToken);
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);
const open = await admin.post('/api/v1/admin/settlement-periods', {
data: {
admin_site_id: credit.admin_site_id,
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);
const periodId = openBody.data.id;
const playerSession = await playerLoginViaBypass({
site_code: credit.site_code,
username: credit.username,
password: credit.password,
});
await runWalletDrawSettlement({
adminToken: adminSession.accessToken,
playerToken: playerSession.access_token,
drawNo,
betNumber: winNumber,
betAmount: 10_000,
expectWalletIncrease: false,
});
const close = await admin.post(`/api/v1/admin/settlement-periods/${periodId}/close`);
expect(close.ok(), `close period status=${close.status()}`).toBeTruthy();
const closeBody = (await close.json()) as any;
expect(closeBody.code).toBe(0);
const bills = await admin.get(
`/api/v1/admin/settlement-bills?settlement_period_id=${periodId}&size=20`,
);
const billsBody = (await bills.json()) as any;
expect(billsBody.code).toBe(0);
const playerBill = billsBody.data.items.find(
(b: any) => b.bill_type === 'player' && Number(b.owner_id) === credit.player_id,
);
expect(playerBill, '应有该信用玩家的账单').toBeTruthy();
expect(playerBill.status).toBe('pending_confirm');
const unpaid = Number(playerBill.unpaid_amount);
expect(unpaid).toBeGreaterThan(0);
const confirm = await admin.post(`/api/v1/admin/settlement-bills/${playerBill.id}/confirm`);
expect(confirm.ok(), `confirm status=${confirm.status()}`).toBeTruthy();
const confirmBody = (await confirm.json()) as any;
expect(confirmBody.code).toBe(0);
expect(confirmBody.data.status).toBe('confirmed');
const pay = await admin.post(`/api/v1/admin/settlement-bills/${playerBill.id}/payments`, {
data: {
amount: unpaid,
method: 'e2e_cash',
remark: 'e2e full payment',
},
});
expect(pay.ok(), `payment status=${pay.status()}`).toBeTruthy();
const payBody = (await pay.json()) as any;
expect(payBody.code).toBe(0);
expect(payBody.data.bill.status).toBe('settled');
expect(Number(payBody.data.bill.paid_amount)).toBe(unpaid);
expect(Number(payBody.data.bill.unpaid_amount)).toBe(0);
const payments = await admin.get(
`/api/v1/admin/settlement-payments?settlement_period_id=${periodId}&size=20`,
);
const paymentsBody = (await payments.json()) as any;
expect(paymentsBody.code).toBe(0);
const recorded = paymentsBody.data.items.find(
(p: any) => Number(p.settlement_bill_id) === Number(playerBill.id),
);
expect(recorded, '收付台账应有该账单记录').toBeTruthy();
expect(Number(recorded.amount)).toBe(unpaid);
await admin.dispose();
});

View File

@@ -0,0 +1,85 @@
/**
* E2E主站超时 pending_reconcile → 后台 reconcile-jobs 扫描检出。
*/
import { test, expect } from '@playwright/test';
import {
adminLoginViaBypass,
adminCtxOf,
configureWalletMock,
playerCtxOf,
playerLoginViaBypass,
resetE2EPlayer,
resetWalletMock,
setMockWalletMode,
} from './_helper';
const CURRENCY = (process.env.LOTTERY_DEFAULT_CURRENCY ?? 'NPR').toUpperCase();
const MOCK_BASE = `http://127.0.0.1:${process.env.E2E_MOCK_WALLET_PORT ?? '5555'}`;
function isoDateOffset(days: number): string {
const d = new Date();
d.setDate(d.getDate() + days);
return d.toISOString().slice(0, 10);
}
test.beforeEach(async () => {
await resetWalletMock();
await setMockWalletMode('success');
});
test('transfer-out 504 pending_reconcile → reconcile-jobs 扫描到差异项', async () => {
test.setTimeout(120_000);
await resetE2EPlayer();
await configureWalletMock(MOCK_BASE);
await setMockWalletMode('504');
const session = await playerLoginViaBypass();
const playerId = session.player.id;
const idemKey = `e2e-reconcile-${Date.now()}`;
const player = await playerCtxOf(session.access_token);
const tout = await player.post('/api/v1/wallet/transfer-out', {
data: {
amount: 10_000,
currency: CURRENCY,
idempotent_key: idemKey,
},
});
const toutBody = (await tout.json()) as any;
expect(Number(toutBody.code)).toBe(1002);
await player.dispose();
const adminSession = await adminLoginViaBypass();
const admin = await adminCtxOf(adminSession.accessToken);
const scan = await admin.post('/api/v1/admin/reconcile-jobs', {
data: {
reconcile_type: 'wallet_transfer',
date_from: isoDateOffset(-1),
date_to: isoDateOffset(0),
player_id: playerId,
},
});
expect(scan.ok(), `reconcile-jobs create status=${scan.status()}`).toBeTruthy();
const scanBody = (await scan.json()) as any;
expect(scanBody.code).toBe(0);
expect(Number(scanBody.data.item_count)).toBeGreaterThanOrEqual(1);
const jobId = scanBody.data.id;
const items = await admin.get(`/api/v1/admin/reconcile-jobs/${jobId}/items?size=20`);
expect(items.ok()).toBeTruthy();
const itemsBody = (await items.json()) as any;
expect(itemsBody.code).toBe(0);
expect(itemsBody.data.items.length).toBeGreaterThanOrEqual(1);
const hit = itemsBody.data.items.find(
(it: any) =>
String(it.side_a_ref ?? '').startsWith('TO_') ||
String(it.side_b_ref ?? '').startsWith('TO_'),
);
expect(hit, '扫描结果应包含 pending_reconcile 转账单引用').toBeTruthy();
await admin.dispose();
});

218
e2e/tests/api/_helper.ts Normal file
View File

@@ -0,0 +1,218 @@
/**
* 共享步骤:取 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<ReturnType<typeof adminLogin>> {
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<ReturnType<typeof playerLogin>> {
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<void> {
await e2e('POST', '/player/reset', {});
}
/** 把期号 close_time 改到过去status=closed */
export async function forceCloseDraw(drawNo: string): Promise<any> {
return e2e('POST', `/draw/${encodeURIComponent(drawNo)}/close-now`, {});
}
export async function finishDrawCooldown(drawNo: string): Promise<any> {
return e2e('POST', `/draw/${encodeURIComponent(drawNo)}/finish-cooldown`, {});
}
export async function tickDraws(): Promise<any> {
return e2e('POST', '/draw/tick', {});
}
export async function inspectDraw(drawNo: string): Promise<any> {
return e2e('GET', `/draw/${encodeURIComponent(drawNo)}/inspect`, {});
}
export async function waitForDrawStatus(
drawNo: string,
targets: string[],
maxAttempts = 20,
pauseMs = 500,
): Promise<any> {
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<void> {
return new Promise((r) => setTimeout(r, ms));
}
export async function adminCtxOf(token: string): Promise<APIRequestContext> {
return pwRequest.newContext({
extraHTTPHeaders: { Authorization: `Bearer ${token}` },
});
}
export async function playerCtxOf(token: string): Promise<APIRequestContext> {
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<any>('POST', '/credit-player/setup', opts ?? {});
return data.data;
}
export async function mintSsoJwt(sitePlayerId?: string): Promise<{ jwt: string; site_player_id: string }> {
const data = await e2e<any>('POST', '/sso/mint-jwt', {
site_player_id: sitePlayerId ?? `e2e-sso-${Date.now()}`,
});
return data.data;
}
export async function configureWalletMock(baseUrl: string): Promise<void> {
await e2e('POST', '/site/wallet-api', { base_url: baseUrl, wallet_api_key: 'e2e-mock-key' });
}
export async function resetWalletMock(): Promise<void> {
await e2e('POST', '/site/wallet-api/reset', {});
}
export async function setMockWalletMode(mode: 'success' | '504' | 'reject'): Promise<void> {
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 个 slotfirst=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<number> {
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<string> {
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<any>('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<any>('GET', `/draw/${encodeURIComponent(drawNo)}/inspect`);
expect(String(after.data?.status), `draw ${drawNo} reopen`).toBe('open');
}
return drawNo;
}
export { playerLogin, playerCtx, adminLogin, adminCtx, fetchCurrentDrawNo, e2e };

View File

@@ -0,0 +1,129 @@
/**
* 开奖 → 结算 → 派彩 确定性流水线poll + tick避免 test.skip
*/
import { expect, type APIRequestContext } from '@playwright/test';
import {
buildAllResultItems,
finishDrawCooldown,
forceCloseDraw,
resolveDrawId,
tickDraws,
waitForDrawStatus,
} from '../_helper';
const CURRENCY = (process.env.LOTTERY_DEFAULT_CURRENCY ?? 'NPR').toUpperCase();
export interface DrawSettlementResult {
drawNo: string;
drawId: number;
batchId: number;
balanceBefore?: number;
balanceAfter?: number;
}
export async function runWalletDrawSettlement(params: {
adminToken: string;
playerToken: string;
drawNo: string;
betNumber?: string;
betAmount?: number;
balanceBefore?: number;
expectWalletIncrease?: boolean;
}): Promise<DrawSettlementResult> {
const drawNo = params.drawNo;
const betNumber = params.betNumber ?? '1234';
const betAmount = params.betAmount ?? 10_000;
const player = await pwPlayerCtx(params.playerToken);
let balanceBefore: number | undefined;
if (params.expectWalletIncrease !== false) {
balanceBefore =
params.balanceBefore ??
Number((await (await player.get('/api/v1/wallet/balance')).json()).data.balance);
}
const place = await player.post('/api/v1/ticket/place', {
data: {
draw_id: drawNo,
currency_code: CURRENCY,
client_trace_id: `e2e-settle-${Date.now()}`,
lines: [{ number: betNumber, play_code: 'straight', amount: betAmount }],
},
});
expect(place.ok(), `place status=${place.status()}`).toBeTruthy();
const placeBody = (await place.json()) as any;
expect(placeBody.code).toBe(0);
await player.dispose();
await forceCloseDraw(drawNo);
await waitForDrawStatus(drawNo, ['closed', 'review', 'cooldown', 'settling', 'settled'], 15);
const admin = await pwAdminCtx(params.adminToken);
const drawId = await resolveDrawId(admin, drawNo);
const store = await admin.post(`/api/v1/admin/draws/${drawId}/result-batches`, {
data: { items: buildAllResultItems(betNumber) },
});
expect(store.ok(), `store batch status=${store.status()}`).toBeTruthy();
const storeBody = (await store.json()) as any;
expect(storeBody.code).toBe(0);
const batchId: number = storeBody.data.batch.id;
const pub = await admin.post(`/api/v1/admin/draws/${drawId}/result-batches/${batchId}/publish`);
expect(pub.ok(), `publish status=${pub.status()}`).toBeTruthy();
const pubBody = (await pub.json()) as any;
expect(pubBody.code).toBe(0);
await finishDrawCooldown(drawNo);
await waitForDrawStatus(drawNo, ['settling', 'settled'], 25);
const insp = await waitForDrawStatus(drawNo, ['settled'], 25);
if (String(insp.data?.status ?? insp.status) !== 'settled') {
const settle = await admin.post(`/api/v1/admin/draws/${drawId}/settlement/run`);
const settleBody = (await settle.json()) as any;
if (settleBody.code !== 0) {
await tickDraws();
}
await waitForDrawStatus(drawNo, ['settled'], 25);
}
const list = await admin.get(`/api/v1/admin/settlement-batches?size=20`);
const lb = (await list.json()) as any;
const ourBatch = lb.data.items.find((b: any) => b.draw_id === drawId);
if (ourBatch && ourBatch.status === 'pending_review') {
await admin.post(`/api/v1/admin/settlement-batches/${ourBatch.id}/approve`, {
data: { remark: 'e2e approve' },
});
await admin.post(`/api/v1/admin/settlement-batches/${ourBatch.id}/payout`);
} else {
await tickDraws();
await waitForDrawStatus(drawNo, ['settled'], 15);
}
await admin.dispose();
let balanceAfter: number | undefined;
if (params.expectWalletIncrease !== false) {
const player2 = await pwPlayerCtx(params.playerToken);
const bal1 = (await (await player2.get('/api/v1/wallet/balance')).json()) as any;
balanceAfter = Number(bal1.data.balance);
await player2.dispose();
}
return { drawNo, drawId, batchId, balanceBefore, balanceAfter };
}
async function pwAdminCtx(token: string): Promise<APIRequestContext> {
const { request: pwRequest } = await import('@playwright/test');
return pwRequest.newContext({
extraHTTPHeaders: { Authorization: `Bearer ${token}` },
});
}
async function pwPlayerCtx(token: string): Promise<APIRequestContext> {
const { request: pwRequest } = await import('@playwright/test');
return pwRequest.newContext({
extraHTTPHeaders: { Authorization: `Bearer ${token}` },
});
}

129
e2e/tests/fixtures.ts Normal file
View File

@@ -0,0 +1,129 @@
/**
* e2e 共用 fixture玩家 / 超管登录、API 客户端、期号工具。
*
* 设计原则:
* - 走真 HTTPrequest.newContext不绕开鉴权
* - token 由各用例按需获取(每个 spec 自带 reset不跨测试串味
* - 任何 SQL 写操作走真 PG不允许 Http::fake / Bus::fake
*/
import { request as pwRequest, type APIRequestContext, type APIResponse } from '@playwright/test';
export interface PlayerSession {
accessToken: string;
expiresIn: number;
player: { id: number; site_code: string; username: string; funding_mode: string; auth_source: string };
}
export interface AdminSession {
accessToken: string;
tokenType: string;
expiresIn: number;
admin: { id: number; username: string; is_super_admin: boolean };
}
const API = process.env.PLAYWRIGHT_API_URL ?? 'http://127.0.0.1:8000';
export function apiUrl(path: string): string {
return new URL(path, API + '/').toString();
}
export async function expectOk<T = any>(resp: APIResponse, hint = ''): Promise<T> {
if (!resp.ok()) {
const body = await resp.text().catch(() => '');
throw new Error(`HTTP ${resp.status()} ${hint}\nURL: ${resp.url()}\nBody: ${body.slice(0, 800)}`);
}
return resp.json() as Promise<T>;
}
export async function playerLogin(password?: string): Promise<PlayerSession> {
const ctx = await pwRequest.newContext({ baseURL: API });
const resp = await ctx.post('/api/v1/player/auth/login', {
data: {
site_code: process.env.E2E_PLAYER_SITE_CODE ?? 'demo',
username: process.env.E2E_PLAYER_USERNAME ?? 'demo_player',
password: password ?? process.env.E2E_PLAYER_PASSWORD ?? '12345678',
},
});
const data = await expectOk<{ code: number; data: PlayerSession; msg?: string }>(resp, 'player login');
await ctx.dispose();
if (!data.data?.access_token) throw new Error('login ok but no access_token: ' + JSON.stringify(data));
return data.data;
}
export async function adminLogin(): Promise<AdminSession> {
const ctx = await pwRequest.newContext({ baseURL: API });
const captchaResp = await ctx.get('/api/v1/admin/auth/captcha');
const captchaBody = (await captchaResp.json()) as { data: { captcha_key: string } };
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: captchaBody.data.captcha_key,
captcha_code: 'LOTTERY_E2E_BYPASS',
},
});
const data = await expectOk<{ code: number; data: AdminSession; msg?: string }>(resp, 'admin login');
await ctx.dispose();
if (!data.data?.access_token) throw new Error('admin login ok but no access_token: ' + JSON.stringify(data));
return data.data;
}
export async function playerCtx(token: string): Promise<APIRequestContext> {
return pwRequest.newContext({
baseURL: API,
extraHTTPHeaders: { Authorization: `Bearer ${token}` },
});
}
export async function adminCtx(token: string): Promise<APIRequestContext> {
return pwRequest.newContext({
baseURL: API,
extraHTTPHeaders: { Authorization: `Bearer ${token}` },
});
}
/** 取当前开放、最近尚未开奖的期号(公开接口,无需登录)。
* 返回 null 表示当前没有可下注期号(大厅空),调用方应跳过下注用例。
*/
export async function fetchCurrentDrawNo(): Promise<{ draw_no: string; status: string | number; close_time?: string } | null> {
const ctx = await pwRequest.newContext({ baseURL: API });
const resp = await ctx.get('/api/v1/draw/current');
const body = await expectOk<{
code: number;
data: {
server_now_ms?: number;
data: { draw_no?: string; status?: string | number; close_time?: string } | null;
};
}>(resp, 'draw/current');
await ctx.dispose();
const snapshot = body.data?.data;
if (!snapshot?.draw_no) return null;
return snapshot as { draw_no: string; status: string | number; close_time?: string };
}
/** 取玩法/赔率/位元e2e 验证玩家下注时拼 bet payload 正确) */
export async function fetchPlayEffective(): Promise<any> {
const ctx = await pwRequest.newContext({ baseURL: API });
const resp = await ctx.get('/api/v1/play/effective');
const data = await expectOk<{ code: number; data: any }>(resp, 'play/effective');
await ctx.dispose();
return data.data;
}
/** 通过 /api/v1/_e2e/* 调一个 e2e 辅助端点POST/GET 通用) */
export async function e2e<T = any>(
method: 'GET' | 'POST',
path: string,
body?: Record<string, any>,
): Promise<T> {
const ctx = await pwRequest.newContext({ baseURL: API });
const resp = await ctx.fetch(`/api/v1/_e2e${path}`, {
method,
data: body ?? {},
headers: { 'Content-Type': 'application/json' },
});
const data = await expectOk<T>(resp, `e2e ${method} ${path}`);
await ctx.dispose();
return data;
}

View File

@@ -0,0 +1,24 @@
import { test, expect } from '@playwright/test';
const ADMIN_URL = process.env.PLAYWRIGHT_ADMIN_URL ?? 'http://localhost:3801';
const ADMIN_ACCOUNT = process.env.E2E_ADMIN_USERNAME ?? 'admin';
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? '12345678';
test('管理端登录页 → 登录成功进入后台', async ({ page }) => {
test.setTimeout(90_000);
await page.goto(`${ADMIN_URL}/admin/login`);
const account = page.locator('#admin-account');
await account.waitFor({ state: 'visible', timeout: 60_000 });
await account.fill(ADMIN_ACCOUNT);
await page.locator('#admin-password').fill(ADMIN_PASSWORD);
const captchaImg = page.locator('img[src^="data:image"]');
await captchaImg.waitFor({ state: 'visible', timeout: 30_000 });
await page.locator('#admin-captcha').fill('LOTTERY_E2E_BYPASS');
await page.getByRole('button', { name: /^登录$|Sign in|submit/i }).click();
await page.waitForURL(/\/admin(?!\/login)/, { timeout: 45_000 });
expect(page.url()).toContain('/admin');
});

View File

@@ -0,0 +1,24 @@
import { test, expect } from '@playwright/test';
const FRONT_URL = process.env.PLAYWRIGHT_FRONT_URL ?? 'http://localhost:3800';
const USER = process.env.E2E_PLAYER_USERNAME ?? 'demo_player';
const PASS = process.env.E2E_PLAYER_PASSWORD ?? '12345678';
test('玩家端登录 → 进入大厅', async ({ page }) => {
test.setTimeout(90_000);
await page.goto(`${FRONT_URL}/login`);
const userInput = page.locator('#login-user');
await userInput.waitFor({ state: 'visible', timeout: 60_000 });
await userInput.fill(USER);
await page.locator('#login-pass').fill(PASS);
const captchaImg = page.locator('img[src^="data:image"]');
await captchaImg.waitFor({ state: 'visible', timeout: 30_000 });
await page.locator('#login-captcha').fill('LOTTERY_E2E_BYPASS');
await page.getByRole('button', { name: /^登录$|Sign in|submit/i }).click();
await page.waitForURL(/\/hall/, { timeout: 45_000 });
expect(page.url()).toContain('/hall');
});