From 6ec9634704a85e6d006dc4cb390884d6f7cb1391 Mon Sep 17 00:00:00 2001 From: kang Date: Thu, 18 Jun 2026 14:42:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0E2E=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E7=8E=AF=E5=A2=83=E9=85=8D=E7=BD=AE=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加LOTTERY_E2E环境变量来控制E2E测试相关功能, 包括绕过验证码、登录限制和钱包API URL验证, 同时更新composer.json以包含E2E专用的提供者和服务。 --- .../Api/V1/E2E/E2ECaptchaPeekController.php | 46 ++++ .../Api/V1/E2E/E2EDrawController.php | 127 +++++++++ .../Api/V1/E2E/E2EInspectController.php | 62 +++++ .../Api/V1/E2E/E2EPlayerStateController.php | 162 +++++++++++ .../Api/V1/E2E/E2EProvisionController.php | 214 +++++++++++++++ app/Providers/AppServiceProvider.php | 16 ++ app/Services/AdminCaptchaService.php | 11 + app/Services/Agent/AgentNodeService.php | 4 +- .../Integration/WalletApiUrlSanitizer.php | 7 + bootstrap/providers.php | 3 + composer.json | 4 +- database/seeders/AdminRbacAndUserSeeder.php | 4 +- e2e/.gitignore | 8 + e2e/README.md | 208 ++++++++++++++ e2e/database/seeders/E2EPlayerSeeder.php | 138 ++++++++++ e2e/docker-compose.yml | 55 ++++ e2e/global-setup.ts | 25 ++ e2e/package-lock.json | 104 +++++++ e2e/package.json | 17 ++ e2e/playwright.config.ts | 55 ++++ e2e/providers/E2EServiceProvider.php | 31 +++ e2e/routes/e2e.php | 74 +++++ e2e/scripts/mock-wallet-server.mjs | 82 ++++++ e2e/scripts/run.sh | 253 ++++++++++++++++++ e2e/tests/api/01.health.spec.ts | 48 ++++ e2e/tests/api/02.player-auth.spec.ts | 105 ++++++++ e2e/tests/api/03.wallet-ticket.spec.ts | 120 +++++++++ e2e/tests/api/04.admin-auth.spec.ts | 34 +++ e2e/tests/api/05.wallet-transfer.spec.ts | 172 ++++++++++++ e2e/tests/api/06.wallet-logs.spec.ts | 134 ++++++++++ e2e/tests/api/07.admin-player.spec.ts | 247 +++++++++++++++++ e2e/tests/api/08.draw-publish-settle.spec.ts | 37 +++ e2e/tests/api/09.credit-bet.spec.ts | 50 ++++ e2e/tests/api/10.agent-settlement.spec.ts | 70 +++++ e2e/tests/api/11.sso-mainsite.spec.ts | 130 +++++++++ e2e/tests/api/12.broadcast.spec.ts | 69 +++++ .../api/13.credit-settlement-win.spec.ts | 63 +++++ e2e/tests/api/14.settlement-payment.spec.ts | 108 ++++++++ e2e/tests/api/15.reconcile-job.spec.ts | 85 ++++++ e2e/tests/api/_helper.ts | 218 +++++++++++++++ e2e/tests/api/helpers/draw-settlement.ts | 129 +++++++++ e2e/tests/fixtures.ts | 129 +++++++++ e2e/tests/ui/admin-login.spec.ts | 24 ++ e2e/tests/ui/front-login-hall.spec.ts | 24 ++ tests/Pest.php | 2 +- 45 files changed, 3702 insertions(+), 6 deletions(-) create mode 100644 app/Http/Controllers/Api/V1/E2E/E2ECaptchaPeekController.php create mode 100644 app/Http/Controllers/Api/V1/E2E/E2EDrawController.php create mode 100644 app/Http/Controllers/Api/V1/E2E/E2EInspectController.php create mode 100644 app/Http/Controllers/Api/V1/E2E/E2EPlayerStateController.php create mode 100644 app/Http/Controllers/Api/V1/E2E/E2EProvisionController.php create mode 100644 e2e/.gitignore create mode 100644 e2e/README.md create mode 100644 e2e/database/seeders/E2EPlayerSeeder.php create mode 100644 e2e/docker-compose.yml create mode 100644 e2e/global-setup.ts create mode 100644 e2e/package-lock.json create mode 100644 e2e/package.json create mode 100644 e2e/playwright.config.ts create mode 100644 e2e/providers/E2EServiceProvider.php create mode 100644 e2e/routes/e2e.php create mode 100644 e2e/scripts/mock-wallet-server.mjs create mode 100755 e2e/scripts/run.sh create mode 100644 e2e/tests/api/01.health.spec.ts create mode 100644 e2e/tests/api/02.player-auth.spec.ts create mode 100644 e2e/tests/api/03.wallet-ticket.spec.ts create mode 100644 e2e/tests/api/04.admin-auth.spec.ts create mode 100644 e2e/tests/api/05.wallet-transfer.spec.ts create mode 100644 e2e/tests/api/06.wallet-logs.spec.ts create mode 100644 e2e/tests/api/07.admin-player.spec.ts create mode 100644 e2e/tests/api/08.draw-publish-settle.spec.ts create mode 100644 e2e/tests/api/09.credit-bet.spec.ts create mode 100644 e2e/tests/api/10.agent-settlement.spec.ts create mode 100644 e2e/tests/api/11.sso-mainsite.spec.ts create mode 100644 e2e/tests/api/12.broadcast.spec.ts create mode 100644 e2e/tests/api/13.credit-settlement-win.spec.ts create mode 100644 e2e/tests/api/14.settlement-payment.spec.ts create mode 100644 e2e/tests/api/15.reconcile-job.spec.ts create mode 100644 e2e/tests/api/_helper.ts create mode 100644 e2e/tests/api/helpers/draw-settlement.ts create mode 100644 e2e/tests/fixtures.ts create mode 100644 e2e/tests/ui/admin-login.spec.ts create mode 100644 e2e/tests/ui/front-login-hall.spec.ts diff --git a/app/Http/Controllers/Api/V1/E2E/E2ECaptchaPeekController.php b/app/Http/Controllers/Api/V1/E2E/E2ECaptchaPeekController.php new file mode 100644 index 0000000..eccab35 --- /dev/null +++ b/app/Http/Controllers/Api/V1/E2E/E2ECaptchaPeekController.php @@ -0,0 +1,46 @@ +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', + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/E2E/E2EDrawController.php b/app/Http/Controllers/Api/V1/E2E/E2EDrawController.php new file mode 100644 index 0000000..b3f0c1e --- /dev/null +++ b/app/Http/Controllers/Api/V1/E2E/E2EDrawController.php @@ -0,0 +1,127 @@ +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, + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/E2E/E2EInspectController.php b/app/Http/Controllers/Api/V1/E2E/E2EInspectController.php new file mode 100644 index 0000000..86f8a37 --- /dev/null +++ b/app/Http/Controllers/Api/V1/E2E/E2EInspectController.php @@ -0,0 +1,62 @@ +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(), + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/E2E/E2EPlayerStateController.php b/app/Http/Controllers/Api/V1/E2E/E2EPlayerStateController.php new file mode 100644 index 0000000..d7bec9f --- /dev/null +++ b/app/Http/Controllers/Api/V1/E2E/E2EPlayerStateController.php @@ -0,0 +1,162 @@ +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(), + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/E2E/E2EProvisionController.php b/app/Http/Controllers/Api/V1/E2E/E2EProvisionController.php new file mode 100644 index 0000000..fd483de --- /dev/null +++ b/app/Http/Controllers/Api/V1/E2E/E2EProvisionController.php @@ -0,0 +1,214 @@ +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]); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index c31c7b6..7900848 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -51,18 +51,34 @@ final class AppServiceProvider extends ServiceProvider }); RateLimiter::for('admin-auth-captcha', function (Request $request) { + if ((bool) env('LOTTERY_E2E', false)) { + return Limit::none(); + } + return Limit::perMinute(45)->by($request->ip()); }); RateLimiter::for('admin-auth-login', function (Request $request) { + if ((bool) env('LOTTERY_E2E', false)) { + return Limit::none(); + } + return Limit::perMinute(15)->by($request->ip()); }); RateLimiter::for('player-auth-captcha', function (Request $request) { + if ((bool) env('LOTTERY_E2E', false)) { + return Limit::none(); + } + return Limit::perMinute(45)->by($request->ip()); }); RateLimiter::for('player-auth-login', function (Request $request) { + if ((bool) env('LOTTERY_E2E', false)) { + return Limit::none(); + } + return Limit::perMinute(15)->by($request->ip()); }); } diff --git a/app/Services/AdminCaptchaService.php b/app/Services/AdminCaptchaService.php index 6745e85..6772f3a 100644 --- a/app/Services/AdminCaptchaService.php +++ b/app/Services/AdminCaptchaService.php @@ -50,6 +50,17 @@ final class AdminCaptchaService 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 === '' || $captchaInput === null || trim($captchaInput) === '') { return false; diff --git a/app/Services/Agent/AgentNodeService.php b/app/Services/Agent/AgentNodeService.php index a63fe4e..9ac1c56 100644 --- a/app/Services/Agent/AgentNodeService.php +++ b/app/Services/Agent/AgentNodeService.php @@ -52,7 +52,7 @@ final class AgentNodeService $password = (string) ($payload['password'] ?? ''); if ($password === '') { if (app()->environment('testing')) { - $password = 'TestPass1!'; + $password = '12345678'; } else { throw ValidationException::withMessages([ 'password' => ['required'], @@ -179,7 +179,7 @@ final class AgentNodeService $password = (string) ($payload['password'] ?? ''); if ($password === '') { if (app()->environment('testing')) { - $password = 'TestPass1!'; + $password = '12345678'; } else { throw ValidationException::withMessages([ 'password' => ['required'], diff --git a/app/Support/Integration/WalletApiUrlSanitizer.php b/app/Support/Integration/WalletApiUrlSanitizer.php index 113d818..b8fb04c 100644 --- a/app/Support/Integration/WalletApiUrlSanitizer.php +++ b/app/Support/Integration/WalletApiUrlSanitizer.php @@ -26,6 +26,13 @@ final class WalletApiUrlSanitizer 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/"); diff --git a/bootstrap/providers.php b/bootstrap/providers.php index fc94ae6..b38f166 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -1,7 +1,10 @@ '超级管理员', 'email' => null, - 'password' => '123456', + 'password' => '12345678', 'status' => 0, ], ); diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 0000000..62530ad --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,8 @@ +# e2e runtime artifacts +logs/ +artifacts/ +node_modules/ + +# env overrides +.env.e2e +.env.*.local diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 0000000..5396527 --- /dev/null +++ b/e2e/README.md @@ -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/ +``` diff --git a/e2e/database/seeders/E2EPlayerSeeder.php b/e2e/database/seeders/E2EPlayerSeeder.php new file mode 100644 index 0000000..c09ef1f --- /dev/null +++ b/e2e/database/seeders/E2EPlayerSeeder.php @@ -0,0 +1,138 @@ +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, + ]); + } + } +} diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml new file mode 100644 index 0000000..e7c0b32 --- /dev/null +++ b/e2e/docker-compose.yml @@ -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: diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts new file mode 100644 index 0000000..37c61d6 --- /dev/null +++ b/e2e/global-setup.ts @@ -0,0 +1,25 @@ +/** + * Playwright global setup:指向 mock 主站钱包、tick 期号调度。 + */ +import { request as pwRequest } from '@playwright/test'; + +export default async function globalSetup(): Promise { + 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(); +} diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 0000000..ea8f2e3 --- /dev/null +++ b/e2e/package-lock.json @@ -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" + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..b6a3103 --- /dev/null +++ b/e2e/package.json @@ -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" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 0000000..8e21485 --- /dev/null +++ b/e2e/playwright.config.ts @@ -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'], + }, + }, + ], +}); diff --git a/e2e/providers/E2EServiceProvider.php b/e2e/providers/E2EServiceProvider.php new file mode 100644 index 0000000..4b46586 --- /dev/null +++ b/e2e/providers/E2EServiceProvider.php @@ -0,0 +1,31 @@ +loadRoutesFrom(__DIR__.'/../routes/e2e.php'); + } +} diff --git a/e2e/routes/e2e.php b/e2e/routes/e2e.php new file mode 100644 index 0000000..cff8f79 --- /dev/null +++ b/e2e/routes/e2e.php @@ -0,0 +1,74 @@ +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'); + }); diff --git a/e2e/scripts/mock-wallet-server.mjs b/e2e/scripts/mock-wallet-server.mjs new file mode 100644 index 0000000..f773aff --- /dev/null +++ b/e2e/scripts/mock-wallet-server.mjs @@ -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}`); +}); diff --git a/e2e/scripts/run.sh b/e2e/scripts/run.sh new file mode 100755 index 0000000..cbf2970 --- /dev/null +++ b/e2e/scripts/run.sh @@ -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 - < [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" diff --git a/e2e/tests/api/01.health.spec.ts b/e2e/tests/api/01.health.spec.ts new file mode 100644 index 0000000..cd628f9 --- /dev/null +++ b/e2e/tests/api/01.health.spec.ts @@ -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(' { + // 每个用例都从干净状态开始 + 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(); +}); diff --git a/e2e/tests/api/03.wallet-ticket.spec.ts b/e2e/tests/api/03.wallet-ticket.spec.ts new file mode 100644 index 0000000..d744e74 --- /dev/null +++ b/e2e/tests/api/03.wallet-ticket.spec.ts @@ -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(); +}); diff --git a/e2e/tests/api/04.admin-auth.spec.ts b/e2e/tests/api/04.admin-auth.spec.ts new file mode 100644 index 0000000..dc8f21c --- /dev/null +++ b/e2e/tests/api/04.admin-auth.spec.ts @@ -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(); +}); diff --git a/e2e/tests/api/05.wallet-transfer.spec.ts b/e2e/tests/api/05.wallet-transfer.spec.ts new file mode 100644 index 0000000..f29a3e4 --- /dev/null +++ b/e2e/tests/api/05.wallet-transfer.spec.ts @@ -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(); +}); diff --git a/e2e/tests/api/06.wallet-logs.spec.ts b/e2e/tests/api/06.wallet-logs.spec.ts new file mode 100644 index 0000000..f0c6c25 --- /dev/null +++ b/e2e/tests/api/06.wallet-logs.spec.ts @@ -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(); +}); diff --git a/e2e/tests/api/07.admin-player.spec.ts b/e2e/tests/api/07.admin-player.spec.ts new file mode 100644 index 0000000..df5d0c2 --- /dev/null +++ b/e2e/tests/api/07.admin-player.spec.ts @@ -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(); +}); diff --git a/e2e/tests/api/08.draw-publish-settle.spec.ts b/e2e/tests/api/08.draw-publish-settle.spec.ts new file mode 100644 index 0000000..c1d570a --- /dev/null +++ b/e2e/tests/api/08.draw-publish-settle.spec.ts @@ -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(); +}); diff --git a/e2e/tests/api/09.credit-bet.spec.ts b/e2e/tests/api/09.credit-bet.spec.ts new file mode 100644 index 0000000..f873aef --- /dev/null +++ b/e2e/tests/api/09.credit-bet.spec.ts @@ -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('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(); +}); diff --git a/e2e/tests/api/10.agent-settlement.spec.ts b/e2e/tests/api/10.agent-settlement.spec.ts new file mode 100644 index 0000000..1012c6b --- /dev/null +++ b/e2e/tests/api/10.agent-settlement.spec.ts @@ -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(); +}); diff --git a/e2e/tests/api/11.sso-mainsite.spec.ts b/e2e/tests/api/11.sso-mainsite.spec.ts new file mode 100644 index 0000000..33c44a4 --- /dev/null +++ b/e2e/tests/api/11.sso-mainsite.spec.ts @@ -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(); +}); diff --git a/e2e/tests/api/12.broadcast.spec.ts b/e2e/tests/api/12.broadcast.spec.ts new file mode 100644 index 0000000..b0d9879 --- /dev/null +++ b/e2e/tests/api/12.broadcast.spec.ts @@ -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) => 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(); +}); diff --git a/e2e/tests/api/13.credit-settlement-win.spec.ts b/e2e/tests/api/13.credit-settlement-win.spec.ts new file mode 100644 index 0000000..cceff6d --- /dev/null +++ b/e2e/tests/api/13.credit-settlement-win.spec.ts @@ -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( + '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(); +}); diff --git a/e2e/tests/api/14.settlement-payment.spec.ts b/e2e/tests/api/14.settlement-payment.spec.ts new file mode 100644 index 0000000..5efea35 --- /dev/null +++ b/e2e/tests/api/14.settlement-payment.spec.ts @@ -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(); +}); diff --git a/e2e/tests/api/15.reconcile-job.spec.ts b/e2e/tests/api/15.reconcile-job.spec.ts new file mode 100644 index 0000000..d3fe676 --- /dev/null +++ b/e2e/tests/api/15.reconcile-job.spec.ts @@ -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(); +}); diff --git a/e2e/tests/api/_helper.ts b/e2e/tests/api/_helper.ts new file mode 100644 index 0000000..1c03619 --- /dev/null +++ b/e2e/tests/api/_helper.ts @@ -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> { + const captcha = await fetchAdminCaptcha(); + const ctx = await pwRequest.newContext(); + const resp = await ctx.post('/api/v1/admin/auth/login', { + data: { + account: process.env.E2E_ADMIN_USERNAME ?? 'admin', + password: process.env.E2E_ADMIN_PASSWORD ?? '12345678', + captcha_key: captcha.captcha_key, + captcha_code: 'LOTTERY_E2E_BYPASS', + }, + }); + expect(resp.ok(), `admin login failed: ${resp.status()}`).toBeTruthy(); + const body = (await resp.json()) as { data: any }; + await ctx.dispose(); + if (!body.data?.token) throw new Error('admin login returned no token: ' + JSON.stringify(body)); + return { + accessToken: body.data.token, + tokenType: body.data.token_type ?? 'Bearer', + expiresIn: 0, + admin: body.data.admin, + }; +} + +/** 用 LOTTERY_E2E_BYPASS 登录(玩家端 captcha 旁路) */ +export async function playerLoginViaBypass(creds?: { + site_code?: string; + username?: string; + password?: string; +}): Promise> { + const captcha = await fetchPlayerCaptcha(); + const ctx = await pwRequest.newContext(); + const resp = await ctx.post('/api/v1/player/auth/login', { + data: { + site_code: creds?.site_code ?? process.env.E2E_PLAYER_SITE_CODE ?? 'demo', + username: creds?.username ?? process.env.E2E_PLAYER_USERNAME ?? 'demo_player', + password: creds?.password ?? process.env.E2E_PLAYER_PASSWORD ?? '12345678', + captcha_key: captcha.captcha_key, + captcha_code: 'LOTTERY_E2E_BYPASS', + }, + }); + expect(resp.ok(), `player login failed: ${resp.status()}`).toBeTruthy(); + const body = (await resp.json()) as { data: any }; + await ctx.dispose(); + if (!body.data?.access_token) throw new Error('player login returned no token: ' + JSON.stringify(body)); + return body.data; +} + +/** 重置 e2e 玩家(清失败计数、解除锁定、恢复初始余额) */ +export async function resetE2EPlayer(): Promise { + await e2e('POST', '/player/reset', {}); +} + +/** 把期号 close_time 改到过去,status=closed */ +export async function forceCloseDraw(drawNo: string): Promise { + return e2e('POST', `/draw/${encodeURIComponent(drawNo)}/close-now`, {}); +} + +export async function finishDrawCooldown(drawNo: string): Promise { + return e2e('POST', `/draw/${encodeURIComponent(drawNo)}/finish-cooldown`, {}); +} + +export async function tickDraws(): Promise { + return e2e('POST', '/draw/tick', {}); +} + +export async function inspectDraw(drawNo: string): Promise { + return e2e('GET', `/draw/${encodeURIComponent(drawNo)}/inspect`, {}); +} + +export async function waitForDrawStatus( + drawNo: string, + targets: string[], + maxAttempts = 20, + pauseMs = 500, +): Promise { + for (let i = 0; i < maxAttempts; i++) { + const insp = await inspectDraw(drawNo); + const status = String(insp.data?.status ?? insp.status ?? ''); + if (targets.includes(status)) { + return insp; + } + await tickDraws(); + await sleep(pauseMs); + } + const last = await inspectDraw(drawNo); + throw new Error( + `draw ${drawNo} not reached ${targets.join('|')} after ${maxAttempts} ticks; last=${JSON.stringify(last).slice(0, 400)}`, + ); +} + +export function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +export async function adminCtxOf(token: string): Promise { + return pwRequest.newContext({ + extraHTTPHeaders: { Authorization: `Bearer ${token}` }, + }); +} + +export async function playerCtxOf(token: string): Promise { + return pwRequest.newContext({ + extraHTTPHeaders: { Authorization: `Bearer ${token}` }, + }); +} + +export async function setupCreditPlayer(opts?: { credit_limit?: number; username?: string }): Promise<{ + player_id: number; + username: string; + password: string; + site_code: string; + admin_site_id: number; + agent_node_id: number; +}> { + const data = await e2e('POST', '/credit-player/setup', opts ?? {}); + return data.data; +} + +export async function mintSsoJwt(sitePlayerId?: string): Promise<{ jwt: string; site_player_id: string }> { + const data = await e2e('POST', '/sso/mint-jwt', { + site_player_id: sitePlayerId ?? `e2e-sso-${Date.now()}`, + }); + return data.data; +} + +export async function configureWalletMock(baseUrl: string): Promise { + await e2e('POST', '/site/wallet-api', { base_url: baseUrl, wallet_api_key: 'e2e-mock-key' }); +} + +export async function resetWalletMock(): Promise { + await e2e('POST', '/site/wallet-api/reset', {}); +} + +export async function setMockWalletMode(mode: 'success' | '504' | 'reject'): Promise { + const port = process.env.E2E_MOCK_WALLET_PORT ?? '5555'; + const ctx = await pwRequest.newContext({ baseURL: `http://127.0.0.1:${port}` }); + const resp = await ctx.post('/_e2e/mode', { data: { mode } }); + expect(resp.ok(), `mock wallet mode=${mode} failed`).toBeTruthy(); + await ctx.dispose(); +} + +/** 造 23 个 slot:first=winNumber,其余填占位号 */ +export function buildAllResultItems(winNumber: string): any[] { + const items: any[] = []; + items.push({ prize_type: 'first', prize_index: 0, number_4d: winNumber }); + items.push({ prize_type: 'second', prize_index: 0, number_4d: '5678' }); + items.push({ prize_type: 'third', prize_index: 0, number_4d: '0123' }); + for (let i = 0; i < 10; i++) { + items.push({ + prize_type: 'starter', + prize_index: i, + number_4d: String(1000 + i).padStart(4, '0'), + }); + } + for (let i = 0; i < 10; i++) { + items.push({ + prize_type: 'consolation', + prize_index: i, + number_4d: String(2000 + i).padStart(4, '0'), + }); + } + return items; +} + +export async function resolveDrawId(admin: APIRequestContext, drawNo: string): Promise { + const drawsList = await admin.get(`/api/v1/admin/draws?keyword=${encodeURIComponent(drawNo)}&size=10`); + const dl = (await drawsList.json()) as any; + const drawId = + dl.data.items.find((it: any) => it.draw_no === drawNo)?.id ?? dl.data.items[0]?.id; + expect(drawId, `draw id for ${drawNo}`).toBeTruthy(); + return Number(drawId); +} + +export async function fetchOpenDrawNo(): Promise { + const current = await fetchCurrentDrawNo(); + if (!current?.draw_no) { + throw new Error('draw/current returned no draw_no'); + } + const drawNo = current.draw_no; + const insp = await e2e('GET', `/draw/${encodeURIComponent(drawNo)}/inspect`); + const status = String(insp.data?.status ?? ''); + if (status !== 'open') { + await e2e('POST', `/draw/${encodeURIComponent(drawNo)}/reopen`, {}); + const after = await e2e('GET', `/draw/${encodeURIComponent(drawNo)}/inspect`); + expect(String(after.data?.status), `draw ${drawNo} reopen`).toBe('open'); + } + return drawNo; +} + +export { playerLogin, playerCtx, adminLogin, adminCtx, fetchCurrentDrawNo, e2e }; diff --git a/e2e/tests/api/helpers/draw-settlement.ts b/e2e/tests/api/helpers/draw-settlement.ts new file mode 100644 index 0000000..30d634e --- /dev/null +++ b/e2e/tests/api/helpers/draw-settlement.ts @@ -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 { + 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 { + const { request: pwRequest } = await import('@playwright/test'); + return pwRequest.newContext({ + extraHTTPHeaders: { Authorization: `Bearer ${token}` }, + }); +} + +async function pwPlayerCtx(token: string): Promise { + const { request: pwRequest } = await import('@playwright/test'); + return pwRequest.newContext({ + extraHTTPHeaders: { Authorization: `Bearer ${token}` }, + }); +} diff --git a/e2e/tests/fixtures.ts b/e2e/tests/fixtures.ts new file mode 100644 index 0000000..6e46c5e --- /dev/null +++ b/e2e/tests/fixtures.ts @@ -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(resp: APIResponse, hint = ''): Promise { + 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; +} + +export async function playerLogin(password?: string): Promise { + 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 { + 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 { + return pwRequest.newContext({ + baseURL: API, + extraHTTPHeaders: { Authorization: `Bearer ${token}` }, + }); +} + +export async function adminCtx(token: string): Promise { + 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 { + 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( + method: 'GET' | 'POST', + path: string, + body?: Record, +): Promise { + 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(resp, `e2e ${method} ${path}`); + await ctx.dispose(); + return data; +} diff --git a/e2e/tests/ui/admin-login.spec.ts b/e2e/tests/ui/admin-login.spec.ts new file mode 100644 index 0000000..0b3dc83 --- /dev/null +++ b/e2e/tests/ui/admin-login.spec.ts @@ -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'); +}); diff --git a/e2e/tests/ui/front-login-hall.spec.ts b/e2e/tests/ui/front-login-hall.spec.ts new file mode 100644 index 0000000..49b247a --- /dev/null +++ b/e2e/tests/ui/front-login-hall.spec.ts @@ -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'); +}); diff --git a/tests/Pest.php b/tests/Pest.php index e5a5033..865a1e6 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -61,7 +61,7 @@ function grantSuperAdminRole(AdminUser $admin): void /** Feature 测直调 {@see AgentNodeService::createChild()} 时使用的密码。 */ function agentNodeTestPassword(): string { - return 'TestPass1!'; + return '12345678'; } /** @param array $overrides