添加LOTTERY_E2E环境变量来控制E2E测试相关功能, 包括绕过验证码、登录限制和钱包API URL验证, 同时更新composer.json以包含E2E专用的提供者和服务。
This commit is contained in:
46
app/Http/Controllers/Api/V1/E2E/E2ECaptchaPeekController.php
Normal file
46
app/Http/Controllers/Api/V1/E2E/E2ECaptchaPeekController.php
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1\E2E;
|
||||||
|
|
||||||
|
use App\Services\AdminCaptchaService;
|
||||||
|
use App\Support\ApiResponse;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* E2E 辅助:peek captcha bypass 码。
|
||||||
|
*
|
||||||
|
* digest 是 HMAC(app.key) 单向,存进 cache 的就是 digest,无法反查明文。
|
||||||
|
* 改用 e2e 约定:captcha_code == "LOTTERY_E2E_BYPASS" 在 LOTTERY_E2E=true 时视为通过。
|
||||||
|
* 本端点不返回任何"答案",仅返回约定码提示。
|
||||||
|
*/
|
||||||
|
final class E2ECaptchaPeekController extends \App\Http\Controllers\Controller
|
||||||
|
{
|
||||||
|
public function player(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$key = (string) $request->input('captcha_key', '');
|
||||||
|
if ($key === '') {
|
||||||
|
return ApiResponse::error('captcha_key required', 'e2e_invalid_input', null, 422);
|
||||||
|
}
|
||||||
|
return $this->peek($key, AdminCaptchaService::SCOPE_PLAYER);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function admin(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$key = (string) $request->input('captcha_key', '');
|
||||||
|
if ($key === '') {
|
||||||
|
return ApiResponse::error('captcha_key required', 'e2e_invalid_input', null, 422);
|
||||||
|
}
|
||||||
|
return $this->peek($key, AdminCaptchaService::SCOPE_ADMIN);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function peek(string $key, string $scope): JsonResponse
|
||||||
|
{
|
||||||
|
return ApiResponse::success([
|
||||||
|
'bypass_code' => 'LOTTERY_E2E_BYPASS',
|
||||||
|
'scope' => $scope,
|
||||||
|
'key' => $key,
|
||||||
|
'hint' => 'POST captcha_code=LOTTERY_E2E_BYPASS to login when LOTTERY_E2E=true',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
127
app/Http/Controllers/Api/V1/E2E/E2EDrawController.php
Normal file
127
app/Http/Controllers/Api/V1/E2E/E2EDrawController.php
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1\E2E;
|
||||||
|
|
||||||
|
use App\Lottery\DrawStatus;
|
||||||
|
use App\Support\ApiResponse;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* E2E 辅助:期号时间/状态快进。
|
||||||
|
*
|
||||||
|
* 真实生产链路下"等 12 分钟后封盘"是脆的——CI 跑 e2e 不可能等。
|
||||||
|
* 这里允许 e2e 直接 SQL 改 close_time / draw_time,模拟:
|
||||||
|
* - close-now: close_time 改到 1 秒前、status=closed
|
||||||
|
* - reopen: status=open、重置冷却期
|
||||||
|
* - inspect: 返回 draw 完整时间/状态快照
|
||||||
|
*
|
||||||
|
* 严格仅在 LOTTERY_E2E=true 时由 E2EServiceProvider 注册。
|
||||||
|
*/
|
||||||
|
final class E2EDrawController extends \App\Http\Controllers\Controller
|
||||||
|
{
|
||||||
|
public function closeNow(Request $request, string $drawNo): JsonResponse
|
||||||
|
{
|
||||||
|
$draw = DB::table('draws')->where('draw_no', $drawNo)->first();
|
||||||
|
if (! $draw) {
|
||||||
|
return ApiResponse::error('draw not found', 'e2e_draw_missing', null, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('draws')
|
||||||
|
->where('id', $draw->id)
|
||||||
|
->update([
|
||||||
|
'status' => DrawStatus::Closed->value,
|
||||||
|
'close_time' => now()->subSeconds(5),
|
||||||
|
'draw_time' => now()->subSeconds(3),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->inspectById((int) $draw->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将冷静期结束时间改到过去,便于 tick 推进 cooldown → settling。 */
|
||||||
|
public function finishCooldown(Request $request, string $drawNo): JsonResponse
|
||||||
|
{
|
||||||
|
$draw = DB::table('draws')->where('draw_no', $drawNo)->first();
|
||||||
|
if (! $draw) {
|
||||||
|
return ApiResponse::error('draw not found', 'e2e_draw_missing', null, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('draws')
|
||||||
|
->where('id', $draw->id)
|
||||||
|
->update([
|
||||||
|
'cooling_end_time' => now()->subSeconds(5),
|
||||||
|
'draw_time' => now()->subMinutes(2),
|
||||||
|
'close_time' => now()->subMinutes(3),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->inspectById((int) $draw->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reopen(Request $request, string $drawNo): JsonResponse
|
||||||
|
{
|
||||||
|
$draw = DB::table('draws')->where('draw_no', $drawNo)->first();
|
||||||
|
if (! $draw) {
|
||||||
|
return ApiResponse::error('draw not found', 'e2e_draw_missing', null, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('draws')
|
||||||
|
->where('id', $draw->id)
|
||||||
|
->update([
|
||||||
|
'status' => DrawStatus::Open->value,
|
||||||
|
'start_time' => now()->subMinutes(5),
|
||||||
|
'close_time' => now()->addMinutes(20),
|
||||||
|
'draw_time' => now()->addMinutes(21),
|
||||||
|
'cooling_end_time' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->inspectById((int) $draw->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function inspect(Request $request, string $drawNo): JsonResponse
|
||||||
|
{
|
||||||
|
$draw = DB::table('draws')->where('draw_no', $drawNo)->first();
|
||||||
|
if (! $draw) {
|
||||||
|
return ApiResponse::error('draw not found', 'e2e_draw_missing', null, 404);
|
||||||
|
}
|
||||||
|
return $this->inspectById((int) $draw->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 同步跑一次 lottery:draw-tick(不开调度)。返回该 tick 推进的 draw_no 列表。 */
|
||||||
|
public function tick(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$exit = \Illuminate\Support\Facades\Artisan::call('lottery:draw-tick');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return ApiResponse::error(
|
||||||
|
'draw-tick failed: '.$e->getMessage(),
|
||||||
|
'e2e_tick_failed',
|
||||||
|
['trace' => array_slice(explode("\n", $e->getTraceAsString()), 0, 5)],
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApiResponse::success([
|
||||||
|
'exit_code' => $exit,
|
||||||
|
'output' => \Illuminate\Support\Facades\Artisan::output(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function inspectById(int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$d = DB::table('draws')->where('id', $id)->first();
|
||||||
|
return ApiResponse::success([
|
||||||
|
'id' => (int) $d->id,
|
||||||
|
'draw_no' => $d->draw_no,
|
||||||
|
'status' => (string) $d->status,
|
||||||
|
'business_date' => $d->business_date,
|
||||||
|
'start_time' => $d->start_time,
|
||||||
|
'close_time' => $d->close_time,
|
||||||
|
'draw_time' => $d->draw_time,
|
||||||
|
'cooling_end_time' => $d->cooling_end_time,
|
||||||
|
'current_result_version' => (int) $d->current_result_version,
|
||||||
|
'settle_version' => (int) $d->settle_version,
|
||||||
|
'is_reopened' => (bool) $d->is_reopened,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
62
app/Http/Controllers/Api/V1/E2E/E2EInspectController.php
Normal file
62
app/Http/Controllers/Api/V1/E2E/E2EInspectController.php
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1\E2E;
|
||||||
|
|
||||||
|
use App\Support\ApiResponse;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* E2E 辅助:只读探查(用于断言前的"准"快照)。
|
||||||
|
*
|
||||||
|
* - credit-ledger: 玩家最近 N 条 credit_ledger 流水
|
||||||
|
* - wallet-txns: 玩家最近 N 条 wallet_transactions
|
||||||
|
* - ticket-items: 玩家最近 N 条 ticket_items
|
||||||
|
*
|
||||||
|
* 这些是真实表 + 真实 SQL,但只读不写;只用于 e2e 写断言时核对中间态。
|
||||||
|
* 严格仅在 LOTTERY_E2E=true 时由 E2EServiceProvider 注册。
|
||||||
|
*/
|
||||||
|
final class E2EInspectController extends \App\Http\Controllers\Controller
|
||||||
|
{
|
||||||
|
public function creditLedger(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
return $this->tailTable('credit_ledger', $request);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function walletTxns(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
return $this->tailTable('wallet_txns', $request);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ticketItems(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
return $this->tailTable('ticket_items', $request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return JsonResponse
|
||||||
|
*/
|
||||||
|
private function tailTable(string $table, Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$limit = max(1, min(100, (int) $request->input('limit', 20)));
|
||||||
|
$playerId = $request->input('player_id');
|
||||||
|
|
||||||
|
$q = DB::table($table);
|
||||||
|
if ($playerId !== null) {
|
||||||
|
$pid = (int) $playerId;
|
||||||
|
if ($table === 'credit_ledger') {
|
||||||
|
$q->where('owner_type', 'player')->where('owner_id', $pid);
|
||||||
|
} else {
|
||||||
|
$q->where('player_id', $pid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$rows = $q->orderByDesc('id')->limit($limit)->get();
|
||||||
|
|
||||||
|
return ApiResponse::success([
|
||||||
|
'table' => $table,
|
||||||
|
'count' => $rows->count(),
|
||||||
|
'rows' => $rows->map(fn ($r) => (array) $r)->values()->all(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
162
app/Http/Controllers/Api/V1/E2E/E2EPlayerStateController.php
Normal file
162
app/Http/Controllers/Api/V1/E2E/E2EPlayerStateController.php
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1\E2E;
|
||||||
|
|
||||||
|
use App\Support\ApiResponse;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* E2E 辅助:重置/查询玩家状态。
|
||||||
|
*
|
||||||
|
* - reset: 失败计数 / 锁定 / 余额 / 状态 → 全部归位
|
||||||
|
* - set-balance: 强制把 player_wallets.balance 写成指定值(不改 frozen,避开版本锁)
|
||||||
|
* - unlock: 仅清失败计数 + 锁定(不动余额)
|
||||||
|
* - inspect: 返回玩家当前完整状态快照(用于断言前的状态探查)
|
||||||
|
*
|
||||||
|
* 严格仅在 LOTTERY_E2E=true 时由 E2EServiceProvider 注册。
|
||||||
|
*/
|
||||||
|
final class E2EPlayerStateController extends \App\Http\Controllers\Controller
|
||||||
|
{
|
||||||
|
public function reset(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$username = (string) env('E2E_PLAYER_USERNAME', 'demo_player');
|
||||||
|
$siteCode = (string) env('E2E_PLAYER_SITE_CODE', 'demo');
|
||||||
|
$balance = (int) env('DEV_SEED_WALLET_BALANCE_MINOR', 1_250_000);
|
||||||
|
$currency = strtoupper((string) env('LOTTERY_DEFAULT_CURRENCY', config('lottery.default_currency', 'NPR')));
|
||||||
|
|
||||||
|
$playerId = DB::table('players')
|
||||||
|
->where('site_code', $siteCode)
|
||||||
|
->where('username', $username)
|
||||||
|
->value('id');
|
||||||
|
|
||||||
|
if (! $playerId) {
|
||||||
|
return ApiResponse::error('e2e player not found', 'e2e_player_missing', null, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::transaction(function () use ($playerId, $balance, $currency) {
|
||||||
|
DB::table('players')
|
||||||
|
->where('id', $playerId)
|
||||||
|
->update([
|
||||||
|
'login_failed_count' => 0,
|
||||||
|
'login_locked_until' => null,
|
||||||
|
'status' => 0,
|
||||||
|
]);
|
||||||
|
DB::table('player_wallets')
|
||||||
|
->where('player_id', $playerId)
|
||||||
|
->where('wallet_type', 'lottery')
|
||||||
|
->where('currency_code', $currency)
|
||||||
|
->update([
|
||||||
|
'balance' => $balance,
|
||||||
|
'frozen_balance' => 0,
|
||||||
|
'version' => DB::raw('version + 1'),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
return ApiResponse::success([
|
||||||
|
'player_id' => (int) $playerId,
|
||||||
|
'username' => $username,
|
||||||
|
'balance' => $balance,
|
||||||
|
'currency' => $currency,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setBalance(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$username = (string) env('E2E_PLAYER_USERNAME', 'demo_player');
|
||||||
|
$siteCode = (string) env('E2E_PLAYER_SITE_CODE', 'demo');
|
||||||
|
$currency = strtoupper((string) $request->input('currency', config('lottery.default_currency', 'NPR')));
|
||||||
|
$balance = (int) $request->input('balance', -1);
|
||||||
|
|
||||||
|
if ($balance < 0) {
|
||||||
|
return ApiResponse::error('balance must be >= 0', 'e2e_invalid_input', null, 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$playerId = DB::table('players')
|
||||||
|
->where('site_code', $siteCode)
|
||||||
|
->where('username', $username)
|
||||||
|
->value('id');
|
||||||
|
|
||||||
|
if (! $playerId) {
|
||||||
|
return ApiResponse::error('e2e player not found', 'e2e_player_missing', null, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('player_wallets')
|
||||||
|
->where('player_id', $playerId)
|
||||||
|
->where('wallet_type', 'lottery')
|
||||||
|
->where('currency_code', $currency)
|
||||||
|
->update([
|
||||||
|
'balance' => $balance,
|
||||||
|
'frozen_balance' => 0,
|
||||||
|
'version' => DB::raw('version + 1'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return ApiResponse::success([
|
||||||
|
'player_id' => (int) $playerId,
|
||||||
|
'balance' => $balance,
|
||||||
|
'currency' => $currency,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function unlock(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$username = (string) env('E2E_PLAYER_USERNAME', 'demo_player');
|
||||||
|
$siteCode = (string) env('E2E_PLAYER_SITE_CODE', 'demo');
|
||||||
|
|
||||||
|
$affected = DB::table('players')
|
||||||
|
->where('site_code', $siteCode)
|
||||||
|
->where('username', $username)
|
||||||
|
->update([
|
||||||
|
'login_failed_count' => 0,
|
||||||
|
'login_locked_until' => null,
|
||||||
|
'status' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return ApiResponse::success([
|
||||||
|
'username' => $username,
|
||||||
|
'affected_rows' => $affected,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function inspect(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$username = (string) env('E2E_PLAYER_USERNAME', 'demo_player');
|
||||||
|
$siteCode = (string) env('E2E_PLAYER_SITE_CODE', 'demo');
|
||||||
|
|
||||||
|
$player = DB::table('players')
|
||||||
|
->where('site_code', $siteCode)
|
||||||
|
->where('username', $username)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $player) {
|
||||||
|
return ApiResponse::error('e2e player not found', 'e2e_player_missing', null, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$wallets = DB::table('player_wallets')
|
||||||
|
->where('player_id', $player->id)
|
||||||
|
->get(['wallet_type', 'currency_code', 'balance', 'frozen_balance', 'status', 'version']);
|
||||||
|
|
||||||
|
return ApiResponse::success([
|
||||||
|
'player' => [
|
||||||
|
'id' => (int) $player->id,
|
||||||
|
'username' => $player->username,
|
||||||
|
'site_code' => $player->site_code,
|
||||||
|
'auth_source' => $player->auth_source,
|
||||||
|
'funding_mode' => $player->funding_mode,
|
||||||
|
'status' => (int) $player->status,
|
||||||
|
'login_failed_count' => (int) $player->login_failed_count,
|
||||||
|
'login_locked_until' => $player->login_locked_until,
|
||||||
|
'default_currency' => $player->default_currency,
|
||||||
|
],
|
||||||
|
'wallets' => $wallets->map(fn ($w) => [
|
||||||
|
'wallet_type' => $w->wallet_type,
|
||||||
|
'currency_code' => $w->currency_code,
|
||||||
|
'balance' => (int) $w->balance,
|
||||||
|
'frozen_balance' => (int) $w->frozen_balance,
|
||||||
|
'status' => (int) $w->status,
|
||||||
|
'version' => (int) $w->version,
|
||||||
|
])->values()->all(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
214
app/Http/Controllers/Api/V1/E2E/E2EProvisionController.php
Normal file
214
app/Http/Controllers/Api/V1/E2E/E2EProvisionController.php
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1\E2E;
|
||||||
|
|
||||||
|
use App\Models\AdminSite;
|
||||||
|
use App\Models\AgentNode;
|
||||||
|
use App\Models\Player;
|
||||||
|
use App\Models\PlayerWallet;
|
||||||
|
use App\Services\Agent\AgentNodeService;
|
||||||
|
use App\Services\Integration\PartnerSiteConfigResolver;
|
||||||
|
use App\Support\ApiResponse;
|
||||||
|
use App\Support\PlayerAuthSource;
|
||||||
|
use Firebase\JWT\JWT;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* E2E 辅助:信用玩家 / SSO JWT / 主站钱包 mock 配置。
|
||||||
|
*/
|
||||||
|
final class E2EProvisionController extends \App\Http\Controllers\Controller
|
||||||
|
{
|
||||||
|
public function setupCreditPlayer(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$siteCode = (string) ($request->input('site_code') ?: env('E2E_PLAYER_SITE_CODE', 'demo'));
|
||||||
|
$username = (string) ($request->input('username') ?: 'e2e_credit_player');
|
||||||
|
$password = (string) ($request->input('password') ?: env('E2E_PLAYER_PASSWORD', '12345678'));
|
||||||
|
$creditLimit = max(1, (int) $request->input('credit_limit', 50_000));
|
||||||
|
$currency = strtoupper((string) ($request->input('currency') ?: config('lottery.default_currency', 'NPR')));
|
||||||
|
|
||||||
|
$site = AdminSite::query()->where('code', $siteCode)->first();
|
||||||
|
if ($site === null) {
|
||||||
|
return ApiResponse::error('site not found', 'e2e_site_missing', null, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$extra = $site->extra_json ?? [];
|
||||||
|
if (! is_array($extra)) {
|
||||||
|
$extra = json_decode((string) $extra, true);
|
||||||
|
if (! is_array($extra)) {
|
||||||
|
$extra = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$extra['credit_line_mode'] = true;
|
||||||
|
$site->extra_json = $extra;
|
||||||
|
$site->save();
|
||||||
|
|
||||||
|
$rootId = (int) DB::table('agent_nodes')
|
||||||
|
->where('admin_site_id', $site->id)
|
||||||
|
->where('depth', 0)
|
||||||
|
->value('id');
|
||||||
|
|
||||||
|
if ($rootId <= 0) {
|
||||||
|
return ApiResponse::error('root agent missing', 'e2e_agent_missing', null, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
$leafCode = (string) $request->input('agent_code', 'e2e_leaf');
|
||||||
|
$leaf = AgentNode::query()
|
||||||
|
->where('admin_site_id', $site->id)
|
||||||
|
->where('code', $leafCode)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($leaf === null) {
|
||||||
|
$super = \App\Models\AdminUser::query()->where('username', 'admin')->first();
|
||||||
|
if ($super === null) {
|
||||||
|
return ApiResponse::error('admin user missing', 'e2e_admin_missing', null, 500);
|
||||||
|
}
|
||||||
|
$leaf = app(AgentNodeService::class)->createChild($super, [
|
||||||
|
'parent_id' => $rootId,
|
||||||
|
'code' => $leafCode,
|
||||||
|
'name' => 'E2E Leaf Agent',
|
||||||
|
'username' => 'e2e_leaf_agent',
|
||||||
|
'password' => (string) env('E2E_PLAYER_PASSWORD', '12345678'),
|
||||||
|
'total_share_rate' => 25,
|
||||||
|
'credit_limit' => 200_000,
|
||||||
|
'rebate_limit' => 0.01,
|
||||||
|
'default_player_rebate' => 0.005,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var Player $player */
|
||||||
|
$player = Player::query()->updateOrCreate(
|
||||||
|
['site_code' => $siteCode, 'username' => $username],
|
||||||
|
[
|
||||||
|
'site_player_id' => 'e2e-credit-'.substr(md5($username), 0, 8),
|
||||||
|
'password_hash' => Hash::make($password),
|
||||||
|
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
|
||||||
|
'funding_mode' => 'credit',
|
||||||
|
'nickname' => 'E2E Credit',
|
||||||
|
'default_currency' => $currency,
|
||||||
|
'status' => 0,
|
||||||
|
'login_failed_count' => 0,
|
||||||
|
'login_locked_until' => null,
|
||||||
|
'agent_node_id' => $leaf->id,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
PlayerWallet::query()->updateOrCreate(
|
||||||
|
['player_id' => $player->id, 'wallet_type' => 'lottery', 'currency_code' => $currency],
|
||||||
|
[
|
||||||
|
'balance' => 0,
|
||||||
|
'frozen_balance' => 0,
|
||||||
|
'status' => 0,
|
||||||
|
'version' => 0,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
DB::table('player_credit_accounts')->updateOrInsert(
|
||||||
|
['player_id' => $player->id],
|
||||||
|
[
|
||||||
|
'credit_limit' => $creditLimit,
|
||||||
|
'used_credit' => 0,
|
||||||
|
'frozen_credit' => 0,
|
||||||
|
'updated_at' => now(),
|
||||||
|
'created_at' => now(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
DB::table('player_rebate_profiles')->updateOrInsert(
|
||||||
|
['player_id' => $player->id, 'game_type' => '*'],
|
||||||
|
[
|
||||||
|
'rebate_rate' => 0.005,
|
||||||
|
'extra_rebate_rate' => 0,
|
||||||
|
'inherit_from_agent' => true,
|
||||||
|
'updated_at' => now(),
|
||||||
|
'created_at' => now(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return ApiResponse::success([
|
||||||
|
'player_id' => (int) $player->id,
|
||||||
|
'username' => $username,
|
||||||
|
'password' => $password,
|
||||||
|
'site_code' => $siteCode,
|
||||||
|
'admin_site_id' => (int) $site->id,
|
||||||
|
'agent_node_id' => (int) $leaf->id,
|
||||||
|
'credit_limit' => $creditLimit,
|
||||||
|
'currency' => $currency,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mintSsoJwt(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$siteCode = (string) ($request->input('site_code') ?: env('E2E_PLAYER_SITE_CODE', 'demo'));
|
||||||
|
$sitePlayerId = (string) ($request->input('site_player_id') ?: 'e2e-sso-'.bin2hex(random_bytes(4)));
|
||||||
|
|
||||||
|
$site = AdminSite::query()->where('code', $siteCode)->first();
|
||||||
|
if ($site === null) {
|
||||||
|
return ApiResponse::error('site not found', 'e2e_site_missing', null, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$secret = $site->decryptedSsoJwtSecret();
|
||||||
|
if (! is_string($secret) || $secret === '') {
|
||||||
|
return ApiResponse::error('sso secret missing on site', 'e2e_sso_secret_missing', null, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = time();
|
||||||
|
$jwt = JWT::encode([
|
||||||
|
'site_code' => $siteCode,
|
||||||
|
'site_player_id' => $sitePlayerId,
|
||||||
|
'iat' => $now,
|
||||||
|
'exp' => $now + 300,
|
||||||
|
], $secret, 'HS256');
|
||||||
|
|
||||||
|
return ApiResponse::success([
|
||||||
|
'jwt' => $jwt,
|
||||||
|
'site_code' => $siteCode,
|
||||||
|
'site_player_id' => $sitePlayerId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureWalletApi(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$siteCode = (string) ($request->input('site_code') ?: env('E2E_PLAYER_SITE_CODE', 'demo'));
|
||||||
|
$baseUrl = rtrim((string) $request->input('base_url', ''), '/');
|
||||||
|
if ($baseUrl === '') {
|
||||||
|
return ApiResponse::error('base_url required', 'e2e_invalid_input', null, 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$site = AdminSite::query()->where('code', $siteCode)->first();
|
||||||
|
if ($site === null) {
|
||||||
|
return ApiResponse::error('site not found', 'e2e_site_missing', null, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$site->wallet_api_url = $baseUrl;
|
||||||
|
if ($request->filled('wallet_api_key')) {
|
||||||
|
$site->wallet_api_key_encrypted = encrypt((string) $request->input('wallet_api_key'));
|
||||||
|
}
|
||||||
|
$site->save();
|
||||||
|
|
||||||
|
app(PartnerSiteConfigResolver::class)->forgetCache($siteCode);
|
||||||
|
|
||||||
|
return ApiResponse::success([
|
||||||
|
'site_code' => $siteCode,
|
||||||
|
'wallet_api_url' => $baseUrl,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resetWalletApi(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$siteCode = (string) ($request->input('site_code') ?: env('E2E_PLAYER_SITE_CODE', 'demo'));
|
||||||
|
|
||||||
|
$site = AdminSite::query()->where('code', $siteCode)->first();
|
||||||
|
if ($site === null) {
|
||||||
|
return ApiResponse::error('site not found', 'e2e_site_missing', null, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$site->wallet_api_url = null;
|
||||||
|
$site->save();
|
||||||
|
app(PartnerSiteConfigResolver::class)->forgetCache($siteCode);
|
||||||
|
|
||||||
|
return ApiResponse::success(['site_code' => $siteCode, 'wallet_api_url' => null]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,18 +51,34 @@ final class AppServiceProvider extends ServiceProvider
|
|||||||
});
|
});
|
||||||
|
|
||||||
RateLimiter::for('admin-auth-captcha', function (Request $request) {
|
RateLimiter::for('admin-auth-captcha', function (Request $request) {
|
||||||
|
if ((bool) env('LOTTERY_E2E', false)) {
|
||||||
|
return Limit::none();
|
||||||
|
}
|
||||||
|
|
||||||
return Limit::perMinute(45)->by($request->ip());
|
return Limit::perMinute(45)->by($request->ip());
|
||||||
});
|
});
|
||||||
|
|
||||||
RateLimiter::for('admin-auth-login', function (Request $request) {
|
RateLimiter::for('admin-auth-login', function (Request $request) {
|
||||||
|
if ((bool) env('LOTTERY_E2E', false)) {
|
||||||
|
return Limit::none();
|
||||||
|
}
|
||||||
|
|
||||||
return Limit::perMinute(15)->by($request->ip());
|
return Limit::perMinute(15)->by($request->ip());
|
||||||
});
|
});
|
||||||
|
|
||||||
RateLimiter::for('player-auth-captcha', function (Request $request) {
|
RateLimiter::for('player-auth-captcha', function (Request $request) {
|
||||||
|
if ((bool) env('LOTTERY_E2E', false)) {
|
||||||
|
return Limit::none();
|
||||||
|
}
|
||||||
|
|
||||||
return Limit::perMinute(45)->by($request->ip());
|
return Limit::perMinute(45)->by($request->ip());
|
||||||
});
|
});
|
||||||
|
|
||||||
RateLimiter::for('player-auth-login', function (Request $request) {
|
RateLimiter::for('player-auth-login', function (Request $request) {
|
||||||
|
if ((bool) env('LOTTERY_E2E', false)) {
|
||||||
|
return Limit::none();
|
||||||
|
}
|
||||||
|
|
||||||
return Limit::perMinute(15)->by($request->ip());
|
return Limit::perMinute(15)->by($request->ip());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,17 @@ final class AdminCaptchaService
|
|||||||
|
|
||||||
public function verify(?string $captchaKey, ?string $captchaInput, string $scope = self::SCOPE_ADMIN): bool
|
public function verify(?string $captchaKey, ?string $captchaInput, string $scope = self::SCOPE_ADMIN): bool
|
||||||
{
|
{
|
||||||
|
// E2E 旁路:仅在 LOTTERY_E2E=true 时放行,约定 captcha_code == LOTTERY_E2E_BYPASS
|
||||||
|
// 视为通过。生产环境不启用,对应变量在 .env.example 留空。
|
||||||
|
if ($captchaInput !== null && trim($captchaInput) === 'LOTTERY_E2E_BYPASS'
|
||||||
|
&& (bool) env('LOTTERY_E2E', false)) {
|
||||||
|
// 顺便消费掉 key,避免同一 key 被多次复用
|
||||||
|
if ($captchaKey !== null && $captchaKey !== '') {
|
||||||
|
Cache::pull($this->prefix($scope).$captchaKey);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if ($captchaKey === null || $captchaKey === ''
|
if ($captchaKey === null || $captchaKey === ''
|
||||||
|| $captchaInput === null || trim($captchaInput) === '') {
|
|| $captchaInput === null || trim($captchaInput) === '') {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ final class AgentNodeService
|
|||||||
$password = (string) ($payload['password'] ?? '');
|
$password = (string) ($payload['password'] ?? '');
|
||||||
if ($password === '') {
|
if ($password === '') {
|
||||||
if (app()->environment('testing')) {
|
if (app()->environment('testing')) {
|
||||||
$password = 'TestPass1!';
|
$password = '12345678';
|
||||||
} else {
|
} else {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'password' => ['required'],
|
'password' => ['required'],
|
||||||
@@ -179,7 +179,7 @@ final class AgentNodeService
|
|||||||
$password = (string) ($payload['password'] ?? '');
|
$password = (string) ($payload['password'] ?? '');
|
||||||
if ($password === '') {
|
if ($password === '') {
|
||||||
if (app()->environment('testing')) {
|
if (app()->environment('testing')) {
|
||||||
$password = 'TestPass1!';
|
$password = '12345678';
|
||||||
} else {
|
} else {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'password' => ['required'],
|
'password' => ['required'],
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ final class WalletApiUrlSanitizer
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// E2E:允许本地 mock 主站钱包(http://127.0.0.1:port),生产 LOTTERY_E2E 默认 false。
|
||||||
|
if ((bool) env('LOTTERY_E2E', false) && app()->environment(['local', 'testing'])) {
|
||||||
|
if (preg_match('#^https?://127\.0\.0\.1:\d{1,5}$#', rtrim($raw, '/')) === 1) {
|
||||||
|
return rtrim($raw, '/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 允许尾部 /,归一化后移除
|
// 允许尾部 /,归一化后移除
|
||||||
$raw = rtrim($raw, " \t\n\r\0\x0B/");
|
$raw = rtrim($raw, " \t\n\r\0\x0B/");
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Providers\AppServiceProvider;
|
use App\Providers\AppServiceProvider;
|
||||||
|
use E2E\Providers\E2EServiceProvider;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
AppServiceProvider::class,
|
AppServiceProvider::class,
|
||||||
|
// E2E 专用:仅在 LOTTERY_E2E=true 时挂载 /api/v1/_e2e/* 路由
|
||||||
|
E2EServiceProvider::class,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -36,7 +36,9 @@
|
|||||||
},
|
},
|
||||||
"autoload-dev": {
|
"autoload-dev": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
"Tests\\": "tests/"
|
"Tests\\": "tests/",
|
||||||
|
"E2E\\Seeders\\": "e2e/database/seeders/",
|
||||||
|
"E2E\\Providers\\": "e2e/providers/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use App\Support\SuperAdminAccount;
|
|||||||
/**
|
/**
|
||||||
* 后台 RBAC:平台固定角色 super_admin / agent。
|
* 后台 RBAC:平台固定角色 super_admin / agent。
|
||||||
*
|
*
|
||||||
* 演示账号 **admin** / **123456**(仅限非 production)。
|
* 演示账号 **admin** / **12345678**(仅限非 production)。
|
||||||
*/
|
*/
|
||||||
final class AdminRbacAndUserSeeder extends Seeder
|
final class AdminRbacAndUserSeeder extends Seeder
|
||||||
{
|
{
|
||||||
@@ -29,7 +29,7 @@ final class AdminRbacAndUserSeeder extends Seeder
|
|||||||
[
|
[
|
||||||
'name' => '超级管理员',
|
'name' => '超级管理员',
|
||||||
'email' => null,
|
'email' => null,
|
||||||
'password' => '123456',
|
'password' => '12345678',
|
||||||
'status' => 0,
|
'status' => 0,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
8
e2e/.gitignore
vendored
Normal file
8
e2e/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# e2e runtime artifacts
|
||||||
|
logs/
|
||||||
|
artifacts/
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# env overrides
|
||||||
|
.env.e2e
|
||||||
|
.env.*.local
|
||||||
208
e2e/README.md
Normal file
208
e2e/README.md
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
# lotterLaravel E2E 测试
|
||||||
|
|
||||||
|
> 端到端测试,**真实** Postgres + Redis + Laravel + Reverb,与 Feature 测试(SQLite 内存库 + mock)解耦。
|
||||||
|
|
||||||
|
## 与 Feature 测试的边界
|
||||||
|
|
||||||
|
| 维度 | Feature | E2E(这套) |
|
||||||
|
|------|---------|------------|
|
||||||
|
| 数据库 | SQLite `:memory:` + `RefreshDatabase`(事务回滚) | 真 PG(docker compose 15432),事务落库 |
|
||||||
|
| Redis | `array` 驱动(不真连) | 真 Redis(16379),Lua/广播全真实 |
|
||||||
|
| 队列 | `sync`(同步执行) | `redis`(异步,启 `queue:work`) |
|
||||||
|
| 鉴权 | `actingAs` 直接注入 | 真 HTTP 调 `auth/login` 拿 token |
|
||||||
|
| 广播 | `Event::fake()` 拦截 | 真走 Reverb(8080) |
|
||||||
|
| 入口 | `app()->handle($request)` | 真 `php artisan serve`(8000) |
|
||||||
|
| 时延 | 数百 ms | 数十秒(启服务、跑 migrate) |
|
||||||
|
| 跑哪 | 每次 push / PR | 上线前 + 重大改动后 |
|
||||||
|
|
||||||
|
**业务逻辑**交给 Feature;**部署/启动/集成**问题归 e2e。
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
e2e/
|
||||||
|
├── docker-compose.yml # postgres + redis(仅 e2e 用)
|
||||||
|
├── .env.e2e # 模板 .env
|
||||||
|
├── package.json # playwright 依赖
|
||||||
|
├── playwright.config.ts
|
||||||
|
├── tests/ # 测试用例(API mode)
|
||||||
|
│ ├── fixtures.ts # 共享 HTTP 客户端 / login / 拿 draw
|
||||||
|
│ ├── api/
|
||||||
|
│ │ ├── _helper.spec.ts
|
||||||
|
│ │ ├── 01.health.spec.ts
|
||||||
|
│ │ ├── 02.player-auth.spec.ts
|
||||||
|
│ │ ├── 03.wallet-ticket.spec.ts
|
||||||
|
│ │ ├── 04.admin-auth.spec.ts
|
||||||
|
│ │ ├── 05.wallet-transfer.spec.ts # 转账 + 幂等 + 1001/1010
|
||||||
|
│ │ ├── 06.wallet-logs.spec.ts # 流水一致性 + 过滤 + 分页
|
||||||
|
│ │ ├── 07.admin-player.spec.ts # 创建/冻结/解冻玩家
|
||||||
|
│ │ ├── 08.draw-publish-settle.spec.ts # 开奖结算派彩(poll,无 skip)
|
||||||
|
│ │ ├── 09.credit-bet.spec.ts # 信用盘下注
|
||||||
|
│ │ ├── 10.agent-settlement.spec.ts # 代理账期关账
|
||||||
|
│ │ ├── 11.sso-mainsite.spec.ts # SSO + 主站 mock 异常 + happy path
|
||||||
|
│ │ ├── 12.broadcast.spec.ts # Reverb balance.update
|
||||||
|
│ │ ├── 13.credit-settlement-win.spec.ts # 信用盘中奖释额
|
||||||
|
│ │ ├── 14.settlement-payment.spec.ts # 账期 confirm + 收付
|
||||||
|
│ │ ├── 15.reconcile-job.spec.ts # pending_reconcile 扫描
|
||||||
|
│ │ ├── _helper.ts # 共享步骤
|
||||||
|
│ │ └── helpers/draw-settlement.ts # 结算流水线
|
||||||
|
│ └── ui/
|
||||||
|
│ ├── admin-login.spec.ts
|
||||||
|
│ └── front-login-hall.spec.ts
|
||||||
|
├── database/seeders/
|
||||||
|
│ └── E2EPlayerSeeder.php # 建可登录玩家(E2E\Seeders 命名空间)
|
||||||
|
├── routes/e2e.php # 仅 /api/v1/_e2e/* 路由
|
||||||
|
├── providers/
|
||||||
|
│ └── E2EServiceProvider.php # 仅在 LOTTERY_E2E=true 时挂载路由
|
||||||
|
├── scripts/
|
||||||
|
│ ├── run.sh # 一键起 stack + mock + UI + playwright
|
||||||
|
│ └── mock-wallet-server.mjs # 主站钱包 mock(5555)
|
||||||
|
└── README.md # 本文件
|
||||||
|
|
||||||
|
# E2E 控制器(仅路由被挂载才暴露,物理上位于 app/ 下便于 Laravel 自动加载)
|
||||||
|
app/Http/Controllers/Api/V1/E2E/
|
||||||
|
├── E2ECaptchaPeekController.php # captcha bypass 提示
|
||||||
|
├── E2EPlayerStateController.php # 玩家 reset / set-balance / unlock / inspect
|
||||||
|
├── E2EDrawController.php # close-now / finish-cooldown / tick
|
||||||
|
├── E2EInspectController.php # credit-ledger / wallet-txns / ticket-items
|
||||||
|
└── E2EProvisionController.php # credit-player / SSO mint / wallet mock 配置
|
||||||
|
|
||||||
|
# E2E 服务提供者注册入口(仅 E2E 时注册)
|
||||||
|
bootstrap/providers.php # 在末尾追加 E2EServiceProvider
|
||||||
|
|
||||||
|
# 生产代码最小入侵点
|
||||||
|
app/Services/AdminCaptchaService.php # 多了一个 `LOTTERY_E2E_BYPASS` 分支(env 关闭时不生效)
|
||||||
|
composer.json # autoload-dev 加 E2E\Seeders\, E2E\Providers\
|
||||||
|
```
|
||||||
|
|
||||||
|
## 前置依赖
|
||||||
|
|
||||||
|
- Docker(Docker Desktop / OrbStack / Colima 任一)
|
||||||
|
- Node.js 20+(跑 Playwright)
|
||||||
|
- PHP 8.3+、Composer
|
||||||
|
- 第一次跑:`npx playwright install chromium`(自动;本仓库 e2e 用 API 模式不依赖浏览器壳,但安装包仍需)
|
||||||
|
|
||||||
|
## 一键跑(macOS / Linux)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./e2e/scripts/run.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
会自动:
|
||||||
|
|
||||||
|
1. `docker compose up -d`(PG :15432 + Redis :16379)
|
||||||
|
2. 等 PG/Redis health
|
||||||
|
3. `composer install`(缺 vendor 时)
|
||||||
|
4. 复制 `e2e/.env.e2e` → `.env`,注入强随机 `LOTTERY_NATIVE_JWT_SECRET` / `REVERB_APP_SECRET`
|
||||||
|
5. `php artisan key:generate`(缺时)
|
||||||
|
6. **`php artisan lottery:db-init --fresh`** ⚠️ 重建 `lottery_e2e` 库(**只**作用于此库,绝不碰其他库)
|
||||||
|
7. 跑 `E2EPlayerSeeder`(建可登录玩家)
|
||||||
|
8. 起 `php artisan serve`(8000)、`queue:work redis`(默认队列)、`reverb:start`(8080)
|
||||||
|
9. `npx playwright install chromium`(首次)
|
||||||
|
10. `npx playwright test`
|
||||||
|
|
||||||
|
跑完按 Ctrl+C 自动停 serve/queue/reverb。`docker compose down -v` 自行决定(脚本不删 volume,下次跑快)。
|
||||||
|
|
||||||
|
## 单独跑(不重起 stack)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 起 stack(不跑测试)
|
||||||
|
docker compose -f e2e/docker-compose.yml up -d
|
||||||
|
DB_DATABASE=lottery_e2e php artisan serve --port=8000
|
||||||
|
DB_DATABASE=lottery_e2e php artisan queue:work redis
|
||||||
|
DB_DATABASE=lottery_e2e php artisan reverb:start --port=8080
|
||||||
|
|
||||||
|
# 跑测试
|
||||||
|
cd e2e
|
||||||
|
PLAYWRIGHT_API_URL=http://127.0.0.1:8000 \
|
||||||
|
E2E_PLAYER_USERNAME=demo_player E2E_PLAYER_PASSWORD=12345678 \
|
||||||
|
npx playwright test --headed # 想要 UI 调试时
|
||||||
|
```
|
||||||
|
|
||||||
|
## e2e 账号
|
||||||
|
|
||||||
|
| 角色 | 账号 | 密码 |
|
||||||
|
|------|------|------|
|
||||||
|
| 超管 | `admin` | `12345678` |
|
||||||
|
|
||||||
|
## e2e 玩家账号
|
||||||
|
|
||||||
|
| 字段 | 值 |
|
||||||
|
|------|---|
|
||||||
|
| `site_code` | `demo` |
|
||||||
|
| `username` | `demo_player` |
|
||||||
|
| `password` | `12345678` |
|
||||||
|
| `auth_source` | `lottery_native` |
|
||||||
|
| `funding_mode` | `wallet` |
|
||||||
|
| 初始余额 | `1,250,000 minor`(NPR 125.00,由 `DEV_SEED_WALLET_BALANCE_MINOR` 改) |
|
||||||
|
|
||||||
|
## 覆盖矩阵(截至当前)
|
||||||
|
|
||||||
|
| 链路 | spec | 用例数 | 状态 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| 健康/ping/captcha/公开接口 | 01.health | 4 | ✅ |
|
||||||
|
| 玩家登录 / 失败 / 锁定 | 02.player-auth | 4 | ✅ |
|
||||||
|
| 玩家钱包 + 下注 + 幂等 | 03.wallet-ticket | 3 | ✅ |
|
||||||
|
| 超管登录 / dashboard / 401 | 04.admin-auth | 3 | ✅ |
|
||||||
|
| 玩家 transfer-in/out + 1001/1010 | 05.wallet-transfer | 7 (+2 skip) | ✅ |
|
||||||
|
| 钱包流水一致性 + 过滤 + 分页 | 06.wallet-logs | 4 | ✅ |
|
||||||
|
| 超管创建/冻结/解冻/查玩家 | 07.admin-player | 6 | ✅ |
|
||||||
|
| 开奖+结算+派彩 完整链路 | 08.draw-publish-settle | 1 | ✅ 确定性 poll |
|
||||||
|
| 信用盘下注占用授信 | 09.credit-bet | 1 | ✅ |
|
||||||
|
| 代理账期关账出账单 | 10.agent-settlement | 1 | ✅ |
|
||||||
|
| SSO JWT + 主站钱包异常 + happy path | 11.sso-mainsite | 4 | ✅ |
|
||||||
|
| Reverb balance.update | 12.broadcast | 1 | ✅ |
|
||||||
|
| 信用盘中奖释额 game_settlement_win | 13.credit-settlement-win | 1 | ✅ |
|
||||||
|
| 账期 confirm + 登记收付闭环 | 14.settlement-payment | 1 | ✅ |
|
||||||
|
| pending_reconcile → reconcile-jobs | 15.reconcile-job | 1 | ✅ |
|
||||||
|
| 管理端 UI 登录 | ui/admin-login | 1 | ✅ |
|
||||||
|
| 玩家端 UI 登录进大厅 | ui/front-login-hall | 1 | ✅ |
|
||||||
|
|
||||||
|
合计 ~36 个用例覆盖 e2e 关键链路。
|
||||||
|
|
||||||
|
## 已知未覆盖(需外部依赖或重构成本高)
|
||||||
|
|
||||||
|
- **玩家提现链路**:与 transfer-out 重叠度 90%,增量价值低
|
||||||
|
- **报表 / settings 实时生效**:表单字段多,断言脆,价值中
|
||||||
|
|
||||||
|
## 跑特定 spec
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd e2e
|
||||||
|
npx playwright test 05.wallet-transfer.spec.ts --headed
|
||||||
|
```
|
||||||
|
|
||||||
|
## 关键安全设计
|
||||||
|
|
||||||
|
- **生产环境零侵入**:`AdminCaptchaService::verify` 走 `env('LOTTERY_E2E')` 开关,生产 `.env` 留空 → bypass 分支 dead code。
|
||||||
|
- **E2E 路由不挂载到生产**:`E2EServiceProvider::boot` 在 `LOTTERY_E2E !== true` 时直接 return;`/api/v1/_e2e/*` 在生产完全 404。
|
||||||
|
- **数据隔离**:e2e 库名固定 `lottery_e2e`,与 `lottery` 主库**端口不同**(15432 vs 5432)。
|
||||||
|
- **migrate:fresh 是库内操作**:脚本注释里明确"只作用于此 docker 库",并对应 AGENTS.md 规定。
|
||||||
|
- **JWT / Reverb 密钥运行时随机生成**:避免与生产/主站共用 secret。
|
||||||
|
|
||||||
|
## 添加新用例
|
||||||
|
|
||||||
|
1. 在 `e2e/tests/api/` 新建 `XX.something.spec.ts`
|
||||||
|
2. 复用 `fixtures.ts` 的 `playerLogin` / `adminLogin` / `playerCtx` / `adminCtx`
|
||||||
|
3. 任何要复用 e2e-only 路由的辅助,往 `E2EServiceProvider` 挂的路由里加,**绝不**往生产路由加
|
||||||
|
4. 跑:`cd e2e && npx playwright test 05.new.spec.ts`
|
||||||
|
|
||||||
|
## 排障
|
||||||
|
|
||||||
|
- **API 启动失败**:`tail -n 100 e2e/logs/serve.log` / `queue.log` / `reverb.log`
|
||||||
|
- **PG/Redis 起不来**:`docker compose -f e2e/docker-compose.yml logs`
|
||||||
|
- **测试卡住**:脚本的 `cleanup` 钩子 Ctrl+C 会停 artisan/queue/reverb
|
||||||
|
- **player 登录 401**:`curl -X POST http://127.0.0.1:8000/api/v1/_e2e/reset-player` 重置玩家
|
||||||
|
|
||||||
|
## CI 集成(参考)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# .github/workflows/e2e.yml
|
||||||
|
- name: e2e
|
||||||
|
run: ./e2e/scripts/run.sh
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
if: failure()
|
||||||
|
with:
|
||||||
|
name: e2e-artifacts
|
||||||
|
path: e2e/artifacts/
|
||||||
|
```
|
||||||
138
e2e/database/seeders/E2EPlayerSeeder.php
Normal file
138
e2e/database/seeders/E2EPlayerSeeder.php
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace E2E\Seeders;
|
||||||
|
|
||||||
|
use App\Models\AdminSite;
|
||||||
|
use App\Models\Player;
|
||||||
|
use App\Models\PlayerWallet;
|
||||||
|
use App\Support\PlayerAuthSource;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* E2E 专用:建可登录玩家 + demo 接入站(含根代理、SSO 密钥、mock 钱包 URL)。
|
||||||
|
*
|
||||||
|
* 命名空间 E2E\Seeders(不在 Database\Seeders\ 下,避免与生产 seeder 撞类名)。
|
||||||
|
* 由 e2e/scripts/run.sh 在 migrate:fresh --seed 后显式调用。
|
||||||
|
*/
|
||||||
|
final class E2EPlayerSeeder extends Seeder
|
||||||
|
{
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$siteCode = (string) env('E2E_PLAYER_SITE_CODE', 'demo');
|
||||||
|
$username = (string) env('E2E_PLAYER_USERNAME', 'demo_player');
|
||||||
|
$password = (string) env('E2E_PLAYER_PASSWORD', '12345678');
|
||||||
|
$currency = strtoupper((string) env('LOTTERY_DEFAULT_CURRENCY', config('lottery.default_currency', 'NPR')));
|
||||||
|
$balance = (int) env('DEV_SEED_WALLET_BALANCE_MINOR', 1_250_000);
|
||||||
|
$mockWalletPort = (string) env('E2E_MOCK_WALLET_PORT', '5555');
|
||||||
|
|
||||||
|
$this->ensureDemoIntegrationSite($siteCode, $currency, $mockWalletPort);
|
||||||
|
|
||||||
|
// LocalDemoSeeder 可能已建无 password_hash 的 demo_player;合并为一条可登录行。
|
||||||
|
Player::query()
|
||||||
|
->where('site_code', $siteCode)
|
||||||
|
->where('username', $username)
|
||||||
|
->where('site_player_id', '!=', 'e2e-001')
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
/** @var Player $player */
|
||||||
|
$player = Player::query()->updateOrCreate(
|
||||||
|
['site_code' => $siteCode, 'username' => $username],
|
||||||
|
[
|
||||||
|
'site_player_id' => 'e2e-001',
|
||||||
|
'password_hash' => Hash::make($password),
|
||||||
|
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
|
||||||
|
'funding_mode' => 'wallet',
|
||||||
|
'nickname' => 'E2E Player',
|
||||||
|
'default_currency' => $currency,
|
||||||
|
'status' => 0,
|
||||||
|
'login_failed_count' => 0,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
PlayerWallet::query()->updateOrCreate(
|
||||||
|
['player_id' => $player->id, 'wallet_type' => 'lottery', 'currency_code' => $currency],
|
||||||
|
[
|
||||||
|
'balance' => $balance,
|
||||||
|
'frozen_balance' => 0,
|
||||||
|
'status' => 0,
|
||||||
|
'version' => 0,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ensureDemoIntegrationSite(string $siteCode, string $currency, string $mockWalletPort): void
|
||||||
|
{
|
||||||
|
$walletBase = 'http://127.0.0.1:'.trim($mockWalletPort);
|
||||||
|
|
||||||
|
AdminSite::query()->updateOrCreate(
|
||||||
|
['code' => $siteCode],
|
||||||
|
[
|
||||||
|
'name' => 'E2E Demo Site',
|
||||||
|
'currency_code' => $currency,
|
||||||
|
'status' => 1,
|
||||||
|
'is_default' => false,
|
||||||
|
'extra_json' => ['source' => 'e2e'],
|
||||||
|
'wallet_api_url' => $walletBase,
|
||||||
|
'wallet_debit_path' => '/wallet/debit-for-lottery',
|
||||||
|
'wallet_credit_path' => '/wallet/credit-from-lottery',
|
||||||
|
'wallet_balance_path' => '/wallet/balance',
|
||||||
|
'wallet_api_key_encrypted' => encrypt('e2e-mock-key'),
|
||||||
|
'sso_jwt_secret_encrypted' => encrypt('e2e-sso-secret'),
|
||||||
|
'wallet_timeout_seconds' => 10,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$siteId = (int) AdminSite::query()->where('code', $siteCode)->value('id');
|
||||||
|
if ($siteId <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rootId = (int) DB::table('agent_nodes')
|
||||||
|
->where('admin_site_id', $siteId)
|
||||||
|
->where('depth', 0)
|
||||||
|
->value('id');
|
||||||
|
|
||||||
|
if ($rootId <= 0) {
|
||||||
|
$now = now();
|
||||||
|
$rootId = (int) DB::table('agent_nodes')->insertGetId([
|
||||||
|
'admin_site_id' => $siteId,
|
||||||
|
'parent_id' => null,
|
||||||
|
'path' => '/',
|
||||||
|
'depth' => 0,
|
||||||
|
'code' => 'root-'.$siteCode,
|
||||||
|
'name' => 'E2E Root',
|
||||||
|
'status' => 1,
|
||||||
|
'created_by' => null,
|
||||||
|
'extra_json' => null,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
DB::table('agent_nodes')->where('id', $rootId)->update([
|
||||||
|
'path' => '/'.$rootId.'/',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! DB::table('agent_profiles')->where('agent_node_id', $rootId)->exists()) {
|
||||||
|
$defaults = config('agent_line_defaults', []);
|
||||||
|
$now = now();
|
||||||
|
DB::table('agent_profiles')->insert([
|
||||||
|
'agent_node_id' => $rootId,
|
||||||
|
'total_share_rate' => (float) ($defaults['total_share_rate'] ?? 100),
|
||||||
|
'credit_limit' => (int) ($defaults['credit_limit'] ?? 0) > 0
|
||||||
|
? (int) $defaults['credit_limit']
|
||||||
|
: 1_000_000,
|
||||||
|
'allocated_credit' => 0,
|
||||||
|
'used_credit' => 0,
|
||||||
|
'rebate_limit' => (float) ($defaults['rebate_limit'] ?? 0.005),
|
||||||
|
'default_player_rebate' => (float) ($defaults['default_player_rebate'] ?? 0.005),
|
||||||
|
'can_grant_extra_rebate' => true,
|
||||||
|
'can_create_child_agent' => true,
|
||||||
|
'can_create_player' => true,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
55
e2e/docker-compose.yml
Normal file
55
e2e/docker-compose.yml
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
name: lotterlaravel-e2e
|
||||||
|
|
||||||
|
# e2e 测试专用:只起后端依赖(Postgres + Redis)。
|
||||||
|
# Laravel 应用本身由 `e2e/scripts/run.sh` 在宿主机上以 `php artisan serve` 拉起,
|
||||||
|
# 这样保留与生产一致的 PHP-FPM / OPCache / .env 加载路径,也避免在容器里
|
||||||
|
# 重新装一遍 vendor。容器只在需要数据库与缓存时使用。
|
||||||
|
#
|
||||||
|
# 使用方法(在仓库根):
|
||||||
|
# docker compose -f e2e/docker-compose.yml up -d
|
||||||
|
# ./e2e/scripts/run.sh
|
||||||
|
# docker compose -f e2e/docker-compose.yml down -v
|
||||||
|
#
|
||||||
|
# macOS 用户:OrbStack / Colima / Docker Desktop 任一即可。
|
||||||
|
# Apple Silicon 警告:postgis/postgis 镜像仅支持 amd64;用 postgres:16-alpine 即可。
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: lotterlaravel-e2e-pg
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: lottery
|
||||||
|
POSTGRES_PASSWORD: lottery
|
||||||
|
POSTGRES_DB: lottery_e2e
|
||||||
|
ports:
|
||||||
|
- "15432:5432"
|
||||||
|
volumes:
|
||||||
|
- lotterlaravel-e2e-pg:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U lottery -d lottery_e2e"]
|
||||||
|
interval: 2s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 30
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: lotterlaravel-e2e-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "16379:6379"
|
||||||
|
command:
|
||||||
|
- redis-server
|
||||||
|
- --save
|
||||||
|
- ""
|
||||||
|
- --appendonly
|
||||||
|
- "no"
|
||||||
|
# e2e 跑完即清,不做持久化(启动更快,状态可重现)
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 2s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 30
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
lotterlaravel-e2e-pg:
|
||||||
25
e2e/global-setup.ts
Normal file
25
e2e/global-setup.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* Playwright global setup:指向 mock 主站钱包、tick 期号调度。
|
||||||
|
*/
|
||||||
|
import { request as pwRequest } from '@playwright/test';
|
||||||
|
|
||||||
|
export default async function globalSetup(): Promise<void> {
|
||||||
|
const api = process.env.PLAYWRIGHT_API_URL ?? 'http://127.0.0.1:8000';
|
||||||
|
const mockPort = process.env.E2E_MOCK_WALLET_PORT ?? '5555';
|
||||||
|
const ctx = await pwRequest.newContext({ baseURL: api });
|
||||||
|
|
||||||
|
const walletResp = await ctx.post('/api/v1/_e2e/site/wallet-api', {
|
||||||
|
data: {
|
||||||
|
base_url: `http://127.0.0.1:${mockPort}`,
|
||||||
|
wallet_api_key: 'e2e-mock-key',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!walletResp.ok()) {
|
||||||
|
const body = await walletResp.text().catch(() => '');
|
||||||
|
throw new Error(`e2e wallet-api setup failed: ${walletResp.status()} ${body.slice(0, 400)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.post('/api/v1/_e2e/draw/tick', { data: {} });
|
||||||
|
|
||||||
|
await ctx.dispose();
|
||||||
|
}
|
||||||
104
e2e/package-lock.json
generated
Normal file
104
e2e/package-lock.json
generated
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
{
|
||||||
|
"name": "lotterlaravel-e2e",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "lotterlaravel-e2e",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.49.0",
|
||||||
|
"@types/pusher-js": "^4.2.2",
|
||||||
|
"pusher-js": "^8.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.61.0",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/@playwright/test/-/test-1.61.0.tgz",
|
||||||
|
"integrity": "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.61.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/pusher-js": {
|
||||||
|
"version": "4.2.2",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/@types/pusher-js/-/pusher-js-4.2.2.tgz",
|
||||||
|
"integrity": "sha512-LP9isBRAFlNzQohQtySJxJjzmy4zQCcv5xGZD2G3rsDnTWfpEkFKyLw3x9711pFAXwwUl9ZivxKkcnFr8umSAQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.61.0",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/playwright/-/playwright-1.61.0.tgz",
|
||||||
|
"integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.61.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.61.0",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/playwright-core/-/playwright-core-1.61.0.tgz",
|
||||||
|
"integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pusher-js": {
|
||||||
|
"version": "8.5.0",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/pusher-js/-/pusher-js-8.5.0.tgz",
|
||||||
|
"integrity": "sha512-V7uzGi9bqOOOyM/6IkJdpFyjGZj7llz1v0oWnYkZKcYLvbz6VcHVLmzKqkvegjuMumpfIEKGLmWHwFb39XFCpw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tweetnacl": "^1.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tweetnacl": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://mirrors.cloud.tencent.com/npm/tweetnacl/-/tweetnacl-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Unlicense"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
e2e/package.json
Normal file
17
e2e/package.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "lotterlaravel-e2e",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"test": "playwright test",
|
||||||
|
"test:headed": "playwright test --headed",
|
||||||
|
"test:ui": "playwright test --ui",
|
||||||
|
"report": "playwright show-report"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.49.0",
|
||||||
|
"@types/pusher-js": "^4.2.2",
|
||||||
|
"pusher-js": "^8.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
55
e2e/playwright.config.ts
Normal file
55
e2e/playwright.config.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
const API_URL = process.env.PLAYWRIGHT_API_URL ?? 'http://127.0.0.1:8000';
|
||||||
|
const ADMIN_URL = process.env.PLAYWRIGHT_ADMIN_URL ?? 'http://localhost:3801';
|
||||||
|
const FRONT_URL = process.env.PLAYWRIGHT_FRONT_URL ?? 'http://localhost:3800';
|
||||||
|
const ARTIFACT_DIR = 'artifacts';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './tests',
|
||||||
|
globalSetup: './global-setup.ts',
|
||||||
|
fullyParallel: false,
|
||||||
|
workers: 1,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 1 : 0,
|
||||||
|
reporter: [
|
||||||
|
['list'],
|
||||||
|
['json', { outputFile: `${ARTIFACT_DIR}/test-results/results.json` }],
|
||||||
|
['html', { outputFolder: `${ARTIFACT_DIR}/playwright-report`, open: 'never' }],
|
||||||
|
],
|
||||||
|
outputDir: `${ARTIFACT_DIR}/test-output`,
|
||||||
|
timeout: 60_000,
|
||||||
|
expect: { timeout: 10_000 },
|
||||||
|
use: {
|
||||||
|
trace: 'retain-on-failure',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
video: 'retain-on-failure',
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'api',
|
||||||
|
testMatch: 'tests/api/**/*.spec.ts',
|
||||||
|
use: {
|
||||||
|
baseURL: API_URL,
|
||||||
|
extraHTTPHeaders: { 'Accept-Language': 'zh-CN' },
|
||||||
|
...devices['Desktop Chrome'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'admin-ui',
|
||||||
|
testMatch: 'tests/ui/admin*.spec.ts',
|
||||||
|
use: {
|
||||||
|
baseURL: ADMIN_URL,
|
||||||
|
...devices['Desktop Chrome'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'front-ui',
|
||||||
|
testMatch: 'tests/ui/front*.spec.ts',
|
||||||
|
use: {
|
||||||
|
baseURL: FRONT_URL,
|
||||||
|
...devices['Desktop Chrome'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
31
e2e/providers/E2EServiceProvider.php
Normal file
31
e2e/providers/E2EServiceProvider.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace E2E\Providers;
|
||||||
|
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* E2E 专用 ServiceProvider:
|
||||||
|
* - 仅在 LOTTERY_E2E=true 时注册(生产环境不挂载)
|
||||||
|
* - 加载 e2e/routes/e2e.php(仅 /api/v1/_e2e/* 路由)
|
||||||
|
* - 加载 e2e/app/Http/Controllers/Api/V1/E2E/* 控制器
|
||||||
|
*
|
||||||
|
* 由 composer autoload-dev 加载(命名空间 E2E\\),
|
||||||
|
* 运行时由 bootstrap/app.php 检测 LOTTERY_E2E 显式 register。
|
||||||
|
*/
|
||||||
|
final class E2EServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
if (! (bool) env('LOTTERY_E2E', false)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->loadRoutesFrom(__DIR__.'/../routes/e2e.php');
|
||||||
|
}
|
||||||
|
}
|
||||||
74
e2e/routes/e2e.php
Normal file
74
e2e/routes/e2e.php
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use App\Http\Controllers\Api\V1\E2E\E2ECaptchaPeekController;
|
||||||
|
use App\Http\Controllers\Api\V1\E2E\E2EPlayerStateController;
|
||||||
|
use App\Http\Controllers\Api\V1\E2E\E2EDrawController;
|
||||||
|
use App\Http\Controllers\Api\V1\E2E\E2EInspectController;
|
||||||
|
use App\Http\Controllers\Api\V1\E2E\E2EProvisionController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* E2E 专用路由:仅在 LOTTERY_E2E=true 时由 E2EServiceProvider 挂载。
|
||||||
|
*
|
||||||
|
* 设计:
|
||||||
|
* - 完全不污染生产路由表(生产环境 LOTTERY_E2E 默认 false)
|
||||||
|
* - 用 /api/v1/_e2e/* 前缀,与正常业务路由完全独立
|
||||||
|
* - 中间件只挂 throttle:60,1(无 locale 协商,captcha SVG 不依赖)
|
||||||
|
*
|
||||||
|
* 端点分组:
|
||||||
|
* captcha : peek/peek-admin(约定码提示,e2e 用 LOTTERY_E2E_BYPASS)
|
||||||
|
* player : reset / set-balance / unlock / inspect
|
||||||
|
* draw : close-now / reopen / inspect
|
||||||
|
* inspect : credit-ledger / wallet-txns / ticket-items(只读探查)
|
||||||
|
*/
|
||||||
|
Route::prefix('api/v1/_e2e')
|
||||||
|
->middleware((bool) env('LOTTERY_E2E', false) ? [] : ['throttle:60,1'])
|
||||||
|
->group(function (): void {
|
||||||
|
// captcha bypass 提示
|
||||||
|
Route::post('captcha/peek', [E2ECaptchaPeekController::class, 'player'])
|
||||||
|
->name('e2e.captcha.peek.player');
|
||||||
|
Route::post('captcha/peek-admin', [E2ECaptchaPeekController::class, 'admin'])
|
||||||
|
->name('e2e.captcha.peek.admin');
|
||||||
|
|
||||||
|
// 玩家状态:reset 兼容老路径(与 player 控制器方法对应)
|
||||||
|
Route::post('reset-player', [E2EPlayerStateController::class, 'reset'])
|
||||||
|
->name('e2e.reset.player');
|
||||||
|
Route::post('player/reset', [E2EPlayerStateController::class, 'reset'])
|
||||||
|
->name('e2e.player.reset');
|
||||||
|
Route::post('player/set-balance', [E2EPlayerStateController::class, 'setBalance'])
|
||||||
|
->name('e2e.player.set-balance');
|
||||||
|
Route::post('player/unlock', [E2EPlayerStateController::class, 'unlock'])
|
||||||
|
->name('e2e.player.unlock');
|
||||||
|
Route::get('player/inspect', [E2EPlayerStateController::class, 'inspect'])
|
||||||
|
->name('e2e.player.inspect');
|
||||||
|
|
||||||
|
// 期号时间快进
|
||||||
|
Route::post('draw/{drawNo}/close-now', [E2EDrawController::class, 'closeNow'])
|
||||||
|
->name('e2e.draw.close-now');
|
||||||
|
Route::post('draw/{drawNo}/finish-cooldown', [E2EDrawController::class, 'finishCooldown'])
|
||||||
|
->name('e2e.draw.finish-cooldown');
|
||||||
|
Route::post('draw/{drawNo}/reopen', [E2EDrawController::class, 'reopen'])
|
||||||
|
->name('e2e.draw.reopen');
|
||||||
|
Route::get('draw/{drawNo}/inspect', [E2EDrawController::class, 'inspect'])
|
||||||
|
->name('e2e.draw.inspect');
|
||||||
|
Route::post('draw/tick', [E2EDrawController::class, 'tick'])
|
||||||
|
->name('e2e.draw.tick');
|
||||||
|
|
||||||
|
// 只读探查
|
||||||
|
Route::get('inspect/credit-ledger', [E2EInspectController::class, 'creditLedger'])
|
||||||
|
->name('e2e.inspect.credit-ledger');
|
||||||
|
Route::get('inspect/wallet-txns', [E2EInspectController::class, 'walletTxns'])
|
||||||
|
->name('e2e.inspect.wallet-txns');
|
||||||
|
Route::get('inspect/ticket-items', [E2EInspectController::class, 'ticketItems'])
|
||||||
|
->name('e2e.inspect.ticket-items');
|
||||||
|
|
||||||
|
// 信用盘 / SSO / 主站钱包 mock
|
||||||
|
Route::post('credit-player/setup', [E2EProvisionController::class, 'setupCreditPlayer'])
|
||||||
|
->name('e2e.credit-player.setup');
|
||||||
|
Route::post('sso/mint-jwt', [E2EProvisionController::class, 'mintSsoJwt'])
|
||||||
|
->name('e2e.sso.mint-jwt');
|
||||||
|
Route::post('site/wallet-api', [E2EProvisionController::class, 'configureWalletApi'])
|
||||||
|
->name('e2e.site.wallet-api');
|
||||||
|
Route::post('site/wallet-api/reset', [E2EProvisionController::class, 'resetWalletApi'])
|
||||||
|
->name('e2e.site.wallet-api.reset');
|
||||||
|
});
|
||||||
82
e2e/scripts/mock-wallet-server.mjs
Normal file
82
e2e/scripts/mock-wallet-server.mjs
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* E2E 主站钱包 mock:供 transfer-in/out 异常场景(504 / 业务拒绝)。
|
||||||
|
*
|
||||||
|
* 控制端点:
|
||||||
|
* POST /_e2e/mode body: { "mode": "success" | "504" | "reject" }
|
||||||
|
*
|
||||||
|
* 业务端点(与 config lottery.main_site 默认路径一致):
|
||||||
|
* POST /wallet/debit-for-lottery
|
||||||
|
* POST /wallet/credit-from-lottery
|
||||||
|
*/
|
||||||
|
|
||||||
|
import http from 'node:http';
|
||||||
|
|
||||||
|
const PORT = Number(process.env.E2E_MOCK_WALLET_PORT ?? 5555);
|
||||||
|
let mode = process.env.E2E_MOCK_WALLET_MODE ?? 'success';
|
||||||
|
|
||||||
|
function readBody(req) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const chunks = [];
|
||||||
|
req.on('data', (c) => chunks.push(c));
|
||||||
|
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function json(res, status, body) {
|
||||||
|
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
const url = req.url ?? '/';
|
||||||
|
const method = req.method ?? 'GET';
|
||||||
|
|
||||||
|
if (method === 'POST' && url === '/_e2e/mode') {
|
||||||
|
try {
|
||||||
|
const raw = await readBody(req);
|
||||||
|
const parsed = JSON.parse(raw || '{}');
|
||||||
|
if (typeof parsed.mode === 'string') {
|
||||||
|
mode = parsed.mode;
|
||||||
|
}
|
||||||
|
json(res, 200, { mode });
|
||||||
|
} catch {
|
||||||
|
json(res, 400, { error: 'invalid_json' });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'GET' && url === '/_e2e/health') {
|
||||||
|
json(res, 200, { ok: true, mode });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isWallet =
|
||||||
|
method === 'POST' &&
|
||||||
|
(url === '/wallet/debit-for-lottery' || url === '/wallet/credit-from-lottery' || url.startsWith('/wallet/'));
|
||||||
|
|
||||||
|
if (!isWallet) {
|
||||||
|
json(res, 404, { error: 'not_found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === '504') {
|
||||||
|
json(res, 504, { success: false, message: 'gateway_timeout' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'reject') {
|
||||||
|
json(res, 200, { success: false, message: 'credit_denied' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
json(res, 200, {
|
||||||
|
success: true,
|
||||||
|
external_ref: `mock-${Date.now()}`,
|
||||||
|
message: 'ok',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, '127.0.0.1', () => {
|
||||||
|
console.log(`[e2e-mock-wallet] listening on http://127.0.0.1:${PORT} mode=${mode}`);
|
||||||
|
});
|
||||||
253
e2e/scripts/run.sh
Executable file
253
e2e/scripts/run.sh
Executable file
@@ -0,0 +1,253 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# e2e 一键跑通:
|
||||||
|
# 1. 启 docker compose(postgres + redis)
|
||||||
|
# 2. 复制 .env.e2e → .env,注入强随机 JWT 密钥
|
||||||
|
# 3. migrate --seed,跑 LocalDemoSeeder(admin/12345678 + demo_player)
|
||||||
|
# 4. php artisan serve 起应用
|
||||||
|
# 5. 启动 queue:work(异步开奖/广播)
|
||||||
|
# 6. 启动 reverb:start
|
||||||
|
# 7. npx playwright install chromium(首次)
|
||||||
|
# 8. npx playwright test
|
||||||
|
# 9. 失败时打 docker logs + Laravel 日志;结束时清理 compose
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
E2E_DIR="$ROOT_DIR/e2e"
|
||||||
|
COMPOSE_FILE="$E2E_DIR/docker-compose.yml"
|
||||||
|
ENV_FILE="$ROOT_DIR/.env"
|
||||||
|
ENV_E2E="$E2E_DIR/.env.e2e"
|
||||||
|
ARTIFACT_DIR="$E2E_DIR/artifacts"
|
||||||
|
LOG_DIR="$E2E_DIR/logs"
|
||||||
|
|
||||||
|
mkdir -p "$ARTIFACT_DIR" "$LOG_DIR"
|
||||||
|
|
||||||
|
API_URL="http://127.0.0.1:8000"
|
||||||
|
PUBLIC_URL="$API_URL/api/v1"
|
||||||
|
PIDS=()
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
local code=$?
|
||||||
|
trap - INT TERM EXIT
|
||||||
|
echo
|
||||||
|
echo "==> Cleaning up e2e processes (code=$code)…"
|
||||||
|
for pid in "${PIDS[@]:-}"; do
|
||||||
|
[[ -n "$pid" ]] && kill "$pid" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
# docker compose 留给用户决定是否 down,避免误删 pg volume
|
||||||
|
exit "$code"
|
||||||
|
}
|
||||||
|
trap cleanup INT TERM EXIT
|
||||||
|
|
||||||
|
require() {
|
||||||
|
command -v "$1" >/dev/null 2>&1 || { echo "Missing: $1"; exit 2; }
|
||||||
|
}
|
||||||
|
require docker
|
||||||
|
require php
|
||||||
|
require node
|
||||||
|
require npx
|
||||||
|
|
||||||
|
echo "==> [1/8] docker compose up -d"
|
||||||
|
if ! docker image inspect postgres:16-alpine >/dev/null 2>&1 \
|
||||||
|
|| ! docker image inspect redis:7-alpine >/dev/null 2>&1; then
|
||||||
|
echo " pulling postgres/redis images (docker-credential-desktop workaround)"
|
||||||
|
mkdir -p /tmp/docker-e2e-nocreds
|
||||||
|
printf '%s\n' '{"auths":{}}' > /tmp/docker-e2e-nocreds/config.json
|
||||||
|
docker --config /tmp/docker-e2e-nocreds pull postgres:16-alpine
|
||||||
|
docker --config /tmp/docker-e2e-nocreds pull redis:7-alpine
|
||||||
|
fi
|
||||||
|
if ! docker info >/dev/null 2>&1; then
|
||||||
|
cat <<'EOF' >&2
|
||||||
|
!! Docker daemon 未运行。
|
||||||
|
|
||||||
|
启动 Docker Desktop / OrbStack / Colima 后重试。
|
||||||
|
|
||||||
|
macOS Docker Desktop: 打开 Docker.app
|
||||||
|
macOS OrbStack: open -a OrbStack
|
||||||
|
macOS Colima: colima start
|
||||||
|
|
||||||
|
或者不使用 docker,直接本机起 PG(端口 15432)+ Redis(端口 16379),
|
||||||
|
跳过这一步,只跑 [4/8] 起的 .env + 后面步骤。
|
||||||
|
EOF
|
||||||
|
exit 5
|
||||||
|
fi
|
||||||
|
docker compose -f "$COMPOSE_FILE" up -d
|
||||||
|
|
||||||
|
echo "==> [2/8] wait for pg/redis health"
|
||||||
|
for i in {1..30}; do
|
||||||
|
if docker compose -f "$COMPOSE_FILE" ps --format json | grep -q '"Health":"healthy"' \
|
||||||
|
|| docker compose -f "$COMPOSE_FILE" ps | grep -E "(healthy|running)" >/dev/null; then
|
||||||
|
pg_ok=$(docker compose -f "$COMPOSE_FILE" exec -T postgres pg_isready -U lottery -d lottery_e2e 2>/dev/null || true)
|
||||||
|
redis_ok=$(docker compose -f "$COMPOSE_FILE" exec -T redis redis-cli ping 2>/dev/null || true)
|
||||||
|
if [[ "$pg_ok" == *"accepting connections"* && "$redis_ok" == "PONG" ]]; then
|
||||||
|
echo " pg/redis healthy"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
if [[ $i -eq 30 ]]; then
|
||||||
|
echo "!! pg/redis not healthy after 30s; dumping compose logs"
|
||||||
|
docker compose -f "$COMPOSE_FILE" logs --no-color
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "==> [3/8] install composer deps if missing"
|
||||||
|
if [[ ! -d vendor ]]; then
|
||||||
|
composer install --no-interaction --prefer-dist
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> [4/8] prepare .env"
|
||||||
|
if [[ ! -f "$ENV_FILE" ]]; then
|
||||||
|
cp "$ENV_E2E" "$ENV_FILE"
|
||||||
|
fi
|
||||||
|
# 同步关键 e2e 变量(不覆盖已存在的 APP_KEY,避免触发额外 key:generate 流程)
|
||||||
|
python3 - <<PY
|
||||||
|
import os, re
|
||||||
|
src = "$ENV_E2E"
|
||||||
|
dst = "$ENV_FILE"
|
||||||
|
with open(src) as f: new = f.read()
|
||||||
|
with open(dst) as f: cur = f.read()
|
||||||
|
# 用 e2e 模板值覆盖,仅保留 APP_KEY
|
||||||
|
for line in new.splitlines():
|
||||||
|
if not line or line.lstrip().startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
k, v = line.split("=", 1)
|
||||||
|
if k.strip() == "APP_KEY":
|
||||||
|
continue
|
||||||
|
pattern = re.compile(rf"^{re.escape(k.strip())}=.*$", re.M)
|
||||||
|
if pattern.search(cur):
|
||||||
|
cur = pattern.sub(f"{k.strip()}={v}", cur)
|
||||||
|
else:
|
||||||
|
cur += ("\n" if not cur.endswith("\n") else "") + line
|
||||||
|
with open(dst, "w") as f: f.write(cur)
|
||||||
|
PY
|
||||||
|
|
||||||
|
# 强制覆盖关键 secret,避免与主站/生产共用
|
||||||
|
php -r '
|
||||||
|
$env = "'"$ENV_FILE"'";
|
||||||
|
$c = file_get_contents($env);
|
||||||
|
$c = preg_replace("/^LOTTERY_NATIVE_JWT_SECRET=.*$/m", "LOTTERY_NATIVE_JWT_SECRET=" . bin2hex(random_bytes(32)), $c);
|
||||||
|
$c = preg_replace("/^REVERB_APP_SECRET=.*$/m", "REVERB_APP_SECRET=" . bin2hex(random_bytes(16)), $c);
|
||||||
|
file_put_contents($env, $c);
|
||||||
|
'
|
||||||
|
|
||||||
|
if ! grep -q "^APP_KEY=base64:" "$ENV_FILE"; then
|
||||||
|
php artisan key:generate --force
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> [5/8] lottery:db-init --fresh (e2e environment only)"
|
||||||
|
# 注意:AGENTS.md 要求 migrate:fresh 须用户确认;本脚本为 e2e 自动化专用,仅作用于
|
||||||
|
# docker compose 内的 lottery_e2e 库,绝不触及生产。
|
||||||
|
# composer dump-autoload 让 E2EPlayerSeeder(位于 e2e/database/seeders)被发现。
|
||||||
|
composer dump-autoload --quiet
|
||||||
|
# 用统一入口:migrate:fresh + FoundationSeeder + admin-auth-sync + LocalDemoSeeder
|
||||||
|
# lottery:db-init --fresh 内部已对 migrate 传 --force,无需外层加。
|
||||||
|
DB_DATABASE=lottery_e2e php artisan lottery:db-init --fresh
|
||||||
|
# e2e 专用 seeder:建可登录玩家(带 password_hash)。LocalDemoSeeder 不会建可登录玩家
|
||||||
|
DB_DATABASE=lottery_e2e php artisan db:seed --class='E2E\Seeders\E2EPlayerSeeder' --force
|
||||||
|
|
||||||
|
echo "==> [6/10] boot backend processes"
|
||||||
|
php artisan config:clear >/dev/null
|
||||||
|
|
||||||
|
export LOTTERY_NATIVE_JWT_SECRET="$(grep '^LOTTERY_NATIVE_JWT_SECRET=' "$ENV_FILE" | cut -d= -f2-)"
|
||||||
|
if [[ -z "${LOTTERY_NATIVE_JWT_SECRET}" ]]; then
|
||||||
|
LOTTERY_NATIVE_JWT_SECRET="$(openssl rand -hex 32)"
|
||||||
|
if grep -q '^LOTTERY_NATIVE_JWT_SECRET=' "$ENV_FILE"; then
|
||||||
|
perl -i -pe "s/^LOTTERY_NATIVE_JWT_SECRET=.*/LOTTERY_NATIVE_JWT_SECRET=${LOTTERY_NATIVE_JWT_SECRET}/" "$ENV_FILE"
|
||||||
|
else
|
||||||
|
echo "LOTTERY_NATIVE_JWT_SECRET=${LOTTERY_NATIVE_JWT_SECRET}" >>"$ENV_FILE"
|
||||||
|
fi
|
||||||
|
export LOTTERY_NATIVE_JWT_SECRET
|
||||||
|
fi
|
||||||
|
export LOTTERY_E2E=true
|
||||||
|
|
||||||
|
php artisan serve --host=127.0.0.1 --port=8000 >"$LOG_DIR/serve.log" 2>&1 &
|
||||||
|
PIDS+=($!)
|
||||||
|
php artisan queue:work redis --queue=broadcasts:countdown,broadcasts,default --tries=3 --timeout=120 >"$LOG_DIR/queue.log" 2>&1 &
|
||||||
|
PIDS+=($!)
|
||||||
|
php artisan reverb:start --host=127.0.0.1 --hostname=127.0.0.1 --port=8080 >"$LOG_DIR/reverb.log" 2>&1 &
|
||||||
|
PIDS+=($!)
|
||||||
|
|
||||||
|
node "$E2E_DIR/scripts/mock-wallet-server.mjs" >"$LOG_DIR/mock-wallet.log" 2>&1 &
|
||||||
|
PIDS+=($!)
|
||||||
|
|
||||||
|
ADMIN_DIR="$ROOT_DIR/../lotteryadmin"
|
||||||
|
FRONT_DIR="$ROOT_DIR/../lotteryfront"
|
||||||
|
if [[ "${E2E_UI:-1}" == "1" && -d "$ADMIN_DIR" ]]; then
|
||||||
|
echo "==> [7/10] start lotteryadmin (3801)"
|
||||||
|
(cd "$ADMIN_DIR" && LOTTERY_API_UPSTREAM="$API_URL" ALLOWED_DEV_ORIGINS=127.0.0.1 npm run dev) >"$LOG_DIR/admin-ui.log" 2>&1 &
|
||||||
|
PIDS+=($!)
|
||||||
|
fi
|
||||||
|
if [[ "${E2E_UI:-1}" == "1" && -d "$FRONT_DIR" ]]; then
|
||||||
|
echo "==> [7/10] start lotteryfront (3800)"
|
||||||
|
(cd "$FRONT_DIR" && LOTTERY_API_UPSTREAM="$API_URL" NEXT_PUBLIC_PLAYER_SITE_CODE=demo ALLOWED_DEV_ORIGINS=127.0.0.1 npm run dev) >"$LOG_DIR/front-ui.log" 2>&1 &
|
||||||
|
PIDS+=($!)
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> [8/10] wait for API healthy"
|
||||||
|
for i in {1..30}; do
|
||||||
|
if curl -fsS "$PUBLIC_URL/health" >/dev/null 2>&1; then
|
||||||
|
echo " API up"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
if [[ $i -eq 30 ]]; then
|
||||||
|
echo "!! API not healthy; logs:"
|
||||||
|
tail -n 50 "$LOG_DIR"/*.log
|
||||||
|
exit 4
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ "${E2E_UI:-1}" == "1" ]]; then
|
||||||
|
echo "==> [9/10] wait for admin/front UI"
|
||||||
|
for i in {1..60}; do
|
||||||
|
admin_ok=false
|
||||||
|
front_ok=false
|
||||||
|
curl -fsS "http://localhost:3801/admin/login" >/dev/null 2>&1 && admin_ok=true
|
||||||
|
curl -fsS "http://localhost:3800/login" >/dev/null 2>&1 && front_ok=true
|
||||||
|
if [[ "$admin_ok" == true && "$front_ok" == true ]]; then
|
||||||
|
echo " UI up"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
if [[ $i -eq 60 ]]; then
|
||||||
|
echo "!! UI not ready; see admin-ui.log / front-ui.log"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> [10/10] playwright test"
|
||||||
|
cd "$E2E_DIR"
|
||||||
|
if [[ ! -d node_modules ]]; then
|
||||||
|
npm install
|
||||||
|
fi
|
||||||
|
if [[ ! -d node_modules/@playwright/test/.local-browsers ]]; then
|
||||||
|
npx playwright install chromium
|
||||||
|
fi
|
||||||
|
|
||||||
|
PLAYWRIGHT_JWT_SECRET=$(grep '^LOTTERY_NATIVE_JWT_SECRET=' "$ENV_FILE" | cut -d= -f2-) \
|
||||||
|
PLAYWRIGHT_API_URL="$API_URL" \
|
||||||
|
PLAYWRIGHT_ADMIN_URL="http://localhost:3801" \
|
||||||
|
PLAYWRIGHT_FRONT_URL="http://localhost:3800" \
|
||||||
|
REVERB_APP_KEY=$(grep '^REVERB_APP_KEY=' "$ENV_FILE" | cut -d= -f2-) \
|
||||||
|
REVERB_HOST=127.0.0.1 \
|
||||||
|
REVERB_PORT=8080 \
|
||||||
|
E2E_MOCK_WALLET_PORT=5555 \
|
||||||
|
E2E_ADMIN_USERNAME=admin \
|
||||||
|
E2E_ADMIN_PASSWORD=12345678 \
|
||||||
|
E2E_PLAYER_USERNAME=$(grep '^E2E_PLAYER_USERNAME=' "$ENV_E2E" | cut -d= -f2-) \
|
||||||
|
E2E_PLAYER_PASSWORD=$(grep '^E2E_PLAYER_PASSWORD=' "$ENV_E2E" | cut -d= -f2-) \
|
||||||
|
npx playwright test "$@"
|
||||||
|
rc=$?
|
||||||
|
|
||||||
|
if [[ $rc -ne 0 ]]; then
|
||||||
|
echo "==> Playwright failed; saving artifacts"
|
||||||
|
cp -R "$E2E_DIR/test-results" "$ARTIFACT_DIR/test-results-$(date +%s)" 2>/dev/null || true
|
||||||
|
cp -R "$E2E_DIR/playwright-report" "$ARTIFACT_DIR/playwright-report-$(date +%s)" 2>/dev/null || true
|
||||||
|
tail -n 200 "$LOG_DIR"/*.log > "$ARTIFACT_DIR/backend-logs-$(date +%s).log" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit "$rc"
|
||||||
48
e2e/tests/api/01.health.spec.ts
Normal file
48
e2e/tests/api/01.health.spec.ts
Normal 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 是 DrawHallSnapshot,draw_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();
|
||||||
|
});
|
||||||
105
e2e/tests/api/02.player-auth.spec.ts
Normal file
105
e2e/tests/api/02.player-auth.spec.ts
Normal 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_locked(403 状态码也合理)
|
||||||
|
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();
|
||||||
|
});
|
||||||
120
e2e/tests/api/03.wallet-ticket.spec.ts
Normal file
120
e2e/tests/api/03.wallet-ticket.spec.ts
Normal 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 + command,e2e 单独测)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
34
e2e/tests/api/04.admin-auth.spec.ts
Normal file
34
e2e/tests/api/04.admin-auth.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
172
e2e/tests/api/05.wallet-transfer.spec.ts
Normal file
172
e2e/tests/api/05.wallet-transfer.spec.ts
Normal 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 200,code=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();
|
||||||
|
});
|
||||||
134
e2e/tests/api/06.wallet-logs.spec.ts
Normal file
134
e2e/tests/api/06.wallet-logs.spec.ts
Normal 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¤cy=${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¤cy=${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¤cy=${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¤cy=${CURRENCY}`);
|
||||||
|
const r2 = await ctx.get(`/api/v1/wallet/logs?page=2&size=2&type=transfer_in¤cy=${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();
|
||||||
|
});
|
||||||
247
e2e/tests/api/07.admin-player.spec.ts
Normal file
247
e2e/tests/api/07.admin-player.spec.ts
Normal 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 → 玩家登录 403;unfreeze → 又能登录', 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();
|
||||||
|
});
|
||||||
37
e2e/tests/api/08.draw-publish-settle.spec.ts
Normal file
37
e2e/tests/api/08.draw-publish-settle.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
50
e2e/tests/api/09.credit-bet.spec.ts
Normal file
50
e2e/tests/api/09.credit-bet.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
70
e2e/tests/api/10.agent-settlement.spec.ts
Normal file
70
e2e/tests/api/10.agent-settlement.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
130
e2e/tests/api/11.sso-mainsite.spec.ts
Normal file
130
e2e/tests/api/11.sso-mainsite.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
69
e2e/tests/api/12.broadcast.spec.ts
Normal file
69
e2e/tests/api/12.broadcast.spec.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* E2E:Reverb 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();
|
||||||
|
});
|
||||||
63
e2e/tests/api/13.credit-settlement-win.spec.ts
Normal file
63
e2e/tests/api/13.credit-settlement-win.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
108
e2e/tests/api/14.settlement-payment.spec.ts
Normal file
108
e2e/tests/api/14.settlement-payment.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
85
e2e/tests/api/15.reconcile-job.spec.ts
Normal file
85
e2e/tests/api/15.reconcile-job.spec.ts
Normal 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
218
e2e/tests/api/_helper.ts
Normal 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 个 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<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 };
|
||||||
129
e2e/tests/api/helpers/draw-settlement.ts
Normal file
129
e2e/tests/api/helpers/draw-settlement.ts
Normal 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
129
e2e/tests/fixtures.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* e2e 共用 fixture:玩家 / 超管登录、API 客户端、期号工具。
|
||||||
|
*
|
||||||
|
* 设计原则:
|
||||||
|
* - 走真 HTTP(request.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;
|
||||||
|
}
|
||||||
24
e2e/tests/ui/admin-login.spec.ts
Normal file
24
e2e/tests/ui/admin-login.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
24
e2e/tests/ui/front-login-hall.spec.ts
Normal file
24
e2e/tests/ui/front-login-hall.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
@@ -61,7 +61,7 @@ function grantSuperAdminRole(AdminUser $admin): void
|
|||||||
/** Feature 测直调 {@see AgentNodeService::createChild()} 时使用的密码。 */
|
/** Feature 测直调 {@see AgentNodeService::createChild()} 时使用的密码。 */
|
||||||
function agentNodeTestPassword(): string
|
function agentNodeTestPassword(): string
|
||||||
{
|
{
|
||||||
return 'TestPass1!';
|
return '12345678';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param array<string, mixed> $overrides
|
/** @param array<string, mixed> $overrides
|
||||||
|
|||||||
Reference in New Issue
Block a user