Files
lotteryLaravel/e2e/tests/api/_helper.ts
kang 3f29229499
Some checks failed
lotterLaravel CI / test (push) Has been cancelled
lotterLaravel E2E / e2e-api (push) Has been cancelled
fix: 部分收付累计释额并扩展结算 E2E 与 CI
账期收付改为按累计已付同步 credit_ledger,修复多笔 partial_paid 与坏账核销重复释额;新增 API E2E(占成、权限、部分收付/坏账)与 GitHub Actions e2e-api job。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 09:48:01 +08:00

306 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 共享步骤:取 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 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<any>('POST', '/site-operator/setup', opts ?? {});
return data.data;
}
export async function adminLoginWithAccount(
account: string,
password: string,
): 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,
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<void> {
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<Array<{ id: number; amount: number; reason: string; ref_type: string; ref_id: number }>> {
const ledger = await e2e<any>('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<number> {
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<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 };