From f0e0966a739b2180cf6f7ad51fc6d31ac220e533 Mon Sep 17 00:00:00 2001 From: kang Date: Tue, 7 Jul 2026 13:37:05 +0800 Subject: [PATCH] =?UTF-8?q?fix(lottery):=20=E5=A2=9E=E5=8A=A0=E5=B9=BF?= =?UTF-8?q?=E6=92=AD=E4=BB=BB=E5=8A=A1=E6=97=B6=E6=95=88=E4=BF=9D=E6=8A=A4?= =?UTF-8?q?=E5=B9=B6=E5=BC=BA=E5=8C=96=E5=91=BD=E4=BB=A4=E5=BC=82=E5=B8=B8?= =?UTF-8?q?=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为多个广播事件添加 retryUntil 方法,设置派发后 30 秒内必须执行,否则丢弃 - 修改 LotteryDrawTickCommand handle,添加异常捕获及错误日志,保证失败时返回 FAILURE - 修改 LotteryHallCountdownCommand handle,添加异常捕获及错误日志,超时情况写入警告日志 - 针对 SettlementOrchestrator 和 DrawHallSnapshotBuilder 查询结果添加限制,防止数据量过大 - 调整计划任务 withoutOverlapping 调用,增加 expiresAt 参数避免任务锁死 - 更新 .env.example,完善本地开发与生产环境部署说明及日志配置说明 - 移除 e2e 相关测试代码、配置及依赖,精简项目体积 --- .env.example | 8 +- .../Commands/LotteryDrawTickCommand.php | 30 +- .../Commands/LotteryHallCountdownCommand.php | 25 +- app/Events/BalanceUpdateBroadcast.php | 6 + app/Events/DrawResultPublishedBroadcast.php | 6 + app/Events/DrawStatusChangeBroadcast.php | 6 + app/Events/JackpotBurstBroadcast.php | 6 + app/Events/OddsUpdateBroadcast.php | 6 + app/Events/PlayCatalogUpdatedBroadcast.php | 6 + app/Events/PlayToggleBroadcast.php | 6 + app/Events/RiskSoldOutBroadcast.php | 6 + app/Events/RiskWarningBroadcast.php | 6 + app/Services/Draw/DrawHallSnapshotBuilder.php | 1 + .../Settlement/SettlementOrchestrator.php | 9 + bootstrap/app.php | 4 +- e2e/.gitignore | 8 - e2e/README.md | 225 ------------- e2e/database/seeders/E2EPlayerSeeder.php | 179 ---------- e2e/docker-compose.yml | 55 ---- e2e/global-setup.ts | 27 -- e2e/package-lock.json | 104 ------ e2e/package.json | 17 - e2e/playwright.config.ts | 63 ---- e2e/providers/E2EServiceProvider.php | 31 -- e2e/routes/e2e.php | 78 ----- e2e/scripts/mock-wallet-server.mjs | 82 ----- e2e/scripts/run.sh | 280 ---------------- 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 | 118 ------- 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 | 57 ---- 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 | 95 ------ e2e/tests/api/15.reconcile-job.spec.ts | 85 ----- e2e/tests/api/16.agent-share-bill.spec.ts | 79 ----- .../api/17.settlement-partial-payment.spec.ts | 202 ------------ .../api/18.settlement-permissions.spec.ts | 151 --------- .../19.settlement-bad-debt-partial.spec.ts | 105 ------ e2e/tests/api/_helper.ts | 305 ------------------ e2e/tests/api/helpers/draw-settlement.ts | 132 -------- e2e/tests/fixtures.ts | 129 -------- e2e/tests/ui/admin-login.spec.ts | 24 -- e2e/tests/ui/admin-settlement-center.spec.ts | 29 -- e2e/tests/ui/front-login-hall.spec.ts | 24 -- e2e/tests/ui/front-place-bet.spec.ts | 49 --- scripts/deploy.sh | 0 54 files changed, 105 insertions(+), 3848 deletions(-) delete mode 100644 e2e/.gitignore delete mode 100644 e2e/README.md delete mode 100644 e2e/database/seeders/E2EPlayerSeeder.php delete mode 100644 e2e/docker-compose.yml delete mode 100644 e2e/global-setup.ts delete mode 100644 e2e/package-lock.json delete mode 100644 e2e/package.json delete mode 100644 e2e/playwright.config.ts delete mode 100644 e2e/providers/E2EServiceProvider.php delete mode 100644 e2e/routes/e2e.php delete mode 100644 e2e/scripts/mock-wallet-server.mjs delete mode 100755 e2e/scripts/run.sh delete mode 100644 e2e/tests/api/01.health.spec.ts delete mode 100644 e2e/tests/api/02.player-auth.spec.ts delete mode 100644 e2e/tests/api/03.wallet-ticket.spec.ts delete mode 100644 e2e/tests/api/04.admin-auth.spec.ts delete mode 100644 e2e/tests/api/05.wallet-transfer.spec.ts delete mode 100644 e2e/tests/api/06.wallet-logs.spec.ts delete mode 100644 e2e/tests/api/07.admin-player.spec.ts delete mode 100644 e2e/tests/api/08.draw-publish-settle.spec.ts delete mode 100644 e2e/tests/api/09.credit-bet.spec.ts delete mode 100644 e2e/tests/api/10.agent-settlement.spec.ts delete mode 100644 e2e/tests/api/11.sso-mainsite.spec.ts delete mode 100644 e2e/tests/api/12.broadcast.spec.ts delete mode 100644 e2e/tests/api/13.credit-settlement-win.spec.ts delete mode 100644 e2e/tests/api/14.settlement-payment.spec.ts delete mode 100644 e2e/tests/api/15.reconcile-job.spec.ts delete mode 100644 e2e/tests/api/16.agent-share-bill.spec.ts delete mode 100644 e2e/tests/api/17.settlement-partial-payment.spec.ts delete mode 100644 e2e/tests/api/18.settlement-permissions.spec.ts delete mode 100644 e2e/tests/api/19.settlement-bad-debt-partial.spec.ts delete mode 100644 e2e/tests/api/_helper.ts delete mode 100644 e2e/tests/api/helpers/draw-settlement.ts delete mode 100644 e2e/tests/fixtures.ts delete mode 100644 e2e/tests/ui/admin-login.spec.ts delete mode 100644 e2e/tests/ui/admin-settlement-center.spec.ts delete mode 100644 e2e/tests/ui/front-login-hall.spec.ts delete mode 100644 e2e/tests/ui/front-place-bet.spec.ts create mode 100644 scripts/deploy.sh diff --git a/.env.example b/.env.example index 23e09d7..8459998 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ -# cp .env.example .env && composer install && php artisan key:generate && php artisan migrate -# 生产常驻:queue:work、schedule:work、reverb:start(见 README) +# 本地开发:cp .env.example .env && composer install && php artisan key:generate && php artisan migrate +# 生产部署:bash scripts/deploy.sh(自动切换 .env.production,见 scripts/deploy.sh) +# 生产常驻进程:queue:work、schedule:work、reverb:start(见 README / docs/production-runbook.md) APP_NAME=Lottery APP_ENV=local @@ -9,7 +10,10 @@ APP_URL=http://localhost:8000 APP_BIND_HOST=127.0.0.1 VITE_HOST=0.0.0.0 +# 本地开发:debug 方便排查;生产用 scripts/deploy.sh 自动切换 .env.production(LOG_LEVEL=warning) +LOG_CHANNEL=daily LOG_LEVEL=debug +LOG_DAILY_DAYS=14 DB_CONNECTION=pgsql DB_HOST=127.0.0.1 diff --git a/app/Console/Commands/LotteryDrawTickCommand.php b/app/Console/Commands/LotteryDrawTickCommand.php index ef8e8e4..162070e 100644 --- a/app/Console/Commands/LotteryDrawTickCommand.php +++ b/app/Console/Commands/LotteryDrawTickCommand.php @@ -13,20 +13,26 @@ final class LotteryDrawTickCommand extends Command public function handle(DrawTickService $tickService): int { - $report = $tickService->tick(); + try { + $report = $tickService->tick(); - $statusSum = array_sum($report['status_updates'] ?? []); - $this->info(sprintf( - 'Status rows updated: %d | RNG runs: %d | Planned draws created: %d', - $statusSum, - $report['rng_rung'], - $report['planned']['created'] ?? 0, - )); + $statusSum = array_sum($report['status_updates'] ?? []); + $this->info(sprintf( + 'Status rows updated: %d | RNG runs: %d | Planned draws created: %d', + $statusSum, + $report['rng_rung'], + $report['planned']['created'] ?? 0, + )); - foreach ($report['rng_errors'] as $err) { - $this->warn($err); + foreach ($report['rng_errors'] as $err) { + $this->warn($err); + } + + return self::SUCCESS; + } catch (\Throwable $e) { + report($e); + $this->error('lottery:draw-tick failed: '.$e->getMessage()); + return self::FAILURE; } - - return self::SUCCESS; } } diff --git a/app/Console/Commands/LotteryHallCountdownCommand.php b/app/Console/Commands/LotteryHallCountdownCommand.php index 1da5d1d..e15f791 100644 --- a/app/Console/Commands/LotteryHallCountdownCommand.php +++ b/app/Console/Commands/LotteryHallCountdownCommand.php @@ -14,17 +14,22 @@ final class LotteryHallCountdownCommand extends Command public function handle(LotteryHallRealtimeBroadcaster $broadcaster): int { - $startedAt = hrtime(true); - $broadcaster->countdownPulse(); - $elapsedMs = (int) round((hrtime(true) - $startedAt) / 1_000_000); + try { + $startedAt = hrtime(true); + $broadcaster->countdownPulse(); + $elapsedMs = (int) round((hrtime(true) - $startedAt) / 1_000_000); - if ($elapsedMs >= (int) config('lottery.realtime_hall_countdown_warn_threshold_ms', 800)) { - Log::warning('lottery:hall-countdown exceeded warn threshold', [ - 'elapsed_ms' => $elapsedMs, - 'threshold_ms' => (int) config('lottery.realtime_hall_countdown_warn_threshold_ms', 800), - ]); + if ($elapsedMs >= (int) config('lottery.realtime_hall_countdown_warn_threshold_ms', 800)) { + Log::warning('lottery:hall-countdown exceeded warn threshold', [ + 'elapsed_ms' => $elapsedMs, + 'threshold_ms' => (int) config('lottery.realtime_hall_countdown_warn_threshold_ms', 800), + ]); + } + + return self::SUCCESS; + } catch (\Throwable $e) { + report($e); + return self::FAILURE; } - - return self::SUCCESS; } } diff --git a/app/Events/BalanceUpdateBroadcast.php b/app/Events/BalanceUpdateBroadcast.php index ff8bc61..7edae00 100644 --- a/app/Events/BalanceUpdateBroadcast.php +++ b/app/Events/BalanceUpdateBroadcast.php @@ -27,6 +27,12 @@ final class BalanceUpdateBroadcast implements ShouldBroadcast /** 单任务超时 10 秒 */ public int $timeout = 10; + /** 广播时效性保护:派发后 30 秒内必须开始执行,否则丢弃 */ + public function retryUntil(): \DateTimeInterface + { + return now()->addSeconds(30); + } + /** * @param int $playerId 玩家 ID(用于频道隔离) * @param string $currencyCode 币种代码 diff --git a/app/Events/DrawResultPublishedBroadcast.php b/app/Events/DrawResultPublishedBroadcast.php index 3460d60..69ddd67 100644 --- a/app/Events/DrawResultPublishedBroadcast.php +++ b/app/Events/DrawResultPublishedBroadcast.php @@ -22,6 +22,12 @@ final class DrawResultPublishedBroadcast implements ShouldBroadcast /** 单任务超时 10 秒 */ public int $timeout = 10; + /** 广播时效性保护:派发后 30 秒内必须开始执行,否则丢弃 */ + public function retryUntil(): \DateTimeInterface + { + return now()->addSeconds(30); + } + /** * @param array|null $data 与 GET draw/current 的 data 相同(含 result_items) */ diff --git a/app/Events/DrawStatusChangeBroadcast.php b/app/Events/DrawStatusChangeBroadcast.php index 50eef9d..14c5653 100644 --- a/app/Events/DrawStatusChangeBroadcast.php +++ b/app/Events/DrawStatusChangeBroadcast.php @@ -22,6 +22,12 @@ final class DrawStatusChangeBroadcast implements ShouldBroadcast /** 单任务超时 10 秒 */ public int $timeout = 10; + /** 广播时效性保护:派发后 30 秒内必须开始执行,否则丢弃 */ + public function retryUntil(): \DateTimeInterface + { + return now()->addSeconds(30); + } + /** * @param array|null $data 与 GET draw/current 的 data 相同 */ diff --git a/app/Events/JackpotBurstBroadcast.php b/app/Events/JackpotBurstBroadcast.php index 6efe554..cf0c47a 100644 --- a/app/Events/JackpotBurstBroadcast.php +++ b/app/Events/JackpotBurstBroadcast.php @@ -22,6 +22,12 @@ final class JackpotBurstBroadcast implements ShouldBroadcast /** 单任务超时 10 秒 */ public int $timeout = 10; + /** 广播时效性保护:派发后 30 秒内必须开始执行,否则丢弃 */ + public function retryUntil(): \DateTimeInterface + { + return now()->addSeconds(30); + } + public function __construct( public readonly int $drawId, public readonly string $drawNo, diff --git a/app/Events/OddsUpdateBroadcast.php b/app/Events/OddsUpdateBroadcast.php index 34b0cb5..7d6d060 100644 --- a/app/Events/OddsUpdateBroadcast.php +++ b/app/Events/OddsUpdateBroadcast.php @@ -27,6 +27,12 @@ final class OddsUpdateBroadcast implements ShouldBroadcast /** 单任务超时 10 秒 */ public int $timeout = 10; + /** 广播时效性保护:派发后 30 秒内必须开始执行,否则丢弃 */ + public function retryUntil(): \DateTimeInterface + { + return now()->addSeconds(30); + } + /** * @param int $versionId 新版本 ID * @param string $versionName 版本名称/描述 diff --git a/app/Events/PlayCatalogUpdatedBroadcast.php b/app/Events/PlayCatalogUpdatedBroadcast.php index 2968e06..6166ae3 100644 --- a/app/Events/PlayCatalogUpdatedBroadcast.php +++ b/app/Events/PlayCatalogUpdatedBroadcast.php @@ -27,6 +27,12 @@ final class PlayCatalogUpdatedBroadcast implements ShouldBroadcast /** 单任务超时 10 秒 */ public int $timeout = 10; + /** 广播时效性保护:派发后 30 秒内必须开始执行,否则丢弃 */ + public function retryUntil(): \DateTimeInterface + { + return now()->addSeconds(30); + } + /** * @param string $module play_config|odds|risk_cap * @param array|null $meta diff --git a/app/Events/PlayToggleBroadcast.php b/app/Events/PlayToggleBroadcast.php index 36a545f..61383a4 100644 --- a/app/Events/PlayToggleBroadcast.php +++ b/app/Events/PlayToggleBroadcast.php @@ -27,6 +27,12 @@ final class PlayToggleBroadcast implements ShouldBroadcast /** 单任务超时 10 秒 */ public int $timeout = 10; + /** 广播时效性保护:派发后 30 秒内必须开始执行,否则丢弃 */ + public function retryUntil(): \DateTimeInterface + { + return now()->addSeconds(30); + } + /** * @param string $playCode 玩法代码(如 straight_4d, box_2d 等) * @param bool $enabled 是否启用 diff --git a/app/Events/RiskSoldOutBroadcast.php b/app/Events/RiskSoldOutBroadcast.php index ec68968..b1bdceb 100644 --- a/app/Events/RiskSoldOutBroadcast.php +++ b/app/Events/RiskSoldOutBroadcast.php @@ -27,6 +27,12 @@ final class RiskSoldOutBroadcast implements ShouldBroadcast /** 单任务超时 10 秒 */ public int $timeout = 10; + /** 广播时效性保护:派发后 30 秒内必须开始执行,否则丢弃 */ + public function retryUntil(): \DateTimeInterface + { + return now()->addSeconds(30); + } + /** * @param int $drawId 期号 ID * @param string $drawNo 期号编号(如 20260101-001) diff --git a/app/Events/RiskWarningBroadcast.php b/app/Events/RiskWarningBroadcast.php index 2124c46..e0b3034 100644 --- a/app/Events/RiskWarningBroadcast.php +++ b/app/Events/RiskWarningBroadcast.php @@ -27,6 +27,12 @@ final class RiskWarningBroadcast implements ShouldBroadcast /** 单任务超时 10 秒 */ public int $timeout = 10; + /** 广播时效性保护:派发后 30 秒内必须开始执行,否则丢弃 */ + public function retryUntil(): \DateTimeInterface + { + return now()->addSeconds(30); + } + /** * @param int $drawId 期号 ID * @param string $drawNo 期号编号 diff --git a/app/Services/Draw/DrawHallSnapshotBuilder.php b/app/Services/Draw/DrawHallSnapshotBuilder.php index 722e5e3..491d16c 100644 --- a/app/Services/Draw/DrawHallSnapshotBuilder.php +++ b/app/Services/Draw/DrawHallSnapshotBuilder.php @@ -140,6 +140,7 @@ final class DrawHallSnapshotBuilder }); }) ->orderBy('draw_time') + ->limit(50) ->get(); foreach ($upcoming as $candidate) { diff --git a/app/Services/Settlement/SettlementOrchestrator.php b/app/Services/Settlement/SettlementOrchestrator.php index 34c9cc2..4f98671 100644 --- a/app/Services/Settlement/SettlementOrchestrator.php +++ b/app/Services/Settlement/SettlementOrchestrator.php @@ -111,8 +111,17 @@ final class SettlementOrchestrator ->where('status', 'pending_draw') ->with(['combinations', 'order']) ->orderBy('id') + ->limit(10_000) ->get(); + if ($ticketItems->count() >= 10_000) { + \Illuminate\Support\Facades\Log::warning('SettlementOrchestrator: ticket_items hit safety cap', [ + 'draw_id' => $locked->id, + 'draw_no' => $locked->draw_no, + 'loaded_count' => $ticketItems->count(), + ]); + } + /** @var list $prepared */ $prepared = []; foreach ($ticketItems as $item) { diff --git a/bootstrap/app.php b/bootstrap/app.php index b5a396b..c02c308 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -243,7 +243,7 @@ return Application::configure(basePath: dirname(__DIR__)) /** 开奖时刻后尽快跑 RNG/冷静期,避免大厅在 0:00 卡住最多 1 分钟 */ $schedule->command('lottery:draw-tick') ->everyTenSeconds() - ->withoutOverlapping() + ->withoutOverlapping(expiresAt: 10) ->onOneServer(); $schedule->command('lottery:wallet-transfer-reconcile --lookback-hours=24 --stale-minutes=15 --limit=1000') ->everyTenMinutes() @@ -261,7 +261,7 @@ return Application::configure(basePath: dirname(__DIR__)) if (config('lottery.realtime_hall_countdown', true)) { $schedule->command('lottery:hall-countdown') ->everySecond() - ->withoutOverlapping() + ->withoutOverlapping(expiresAt: 5) ->onOneServer(); } }) diff --git a/e2e/.gitignore b/e2e/.gitignore deleted file mode 100644 index 62530ad..0000000 --- a/e2e/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# e2e runtime artifacts -logs/ -artifacts/ -node_modules/ - -# env overrides -.env.e2e -.env.*.local diff --git a/e2e/README.md b/e2e/README.md deleted file mode 100644 index 2c0114c..0000000 --- a/e2e/README.md +++ /dev/null @@ -1,225 +0,0 @@ -# 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 扫描 -│ │ ├── 16.agent-share-bill.spec.ts # 代理占成账单 -│ │ ├── 17.settlement-partial-payment.spec.ts # 部分收付 -│ │ ├── 18.settlement-permissions.spec.ts # 站点财务/代理权限 -│ │ ├── 19.settlement-bad-debt-partial.spec.ts # 部分收付后坏账 -│ │ ├── _helper.ts # 共享步骤 -│ │ └── helpers/draw-settlement.ts # 结算流水线 -│ └── ui/ -│ ├── admin-login.spec.ts -│ ├── admin-settlement-center.spec.ts -│ ├── front-login-hall.spec.ts -│ └── front-place-bet.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. UI 测试默认用本机 **Google Chrome**(`playwright.config.ts` `channel: 'chrome'`),**无需**下载 Playwright Chromium -10. `npx playwright test`(若要用自带 Chromium:`PLAYWRIGHT_USE_BUNDLED_CHROMIUM=1 ./e2e/scripts/run.sh`) - -跑完按 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 | ✅ | -| 钱包流水一致性 + 过滤 + 分页 | 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 | ✅ | -| 代理占成账单 share_profit | 16.agent-share-bill | 1 | ✅ | -| 部分收付 partial_paid → settled | 17.settlement-partial-payment | 2 | ✅ | -| 站点财务收付 / 绑定代理禁坏账 | 18.settlement-permissions | 1 | ✅ | -| 部分收付后坏账核销 | 19.settlement-bad-debt-partial | 1 | ✅ | -| 管理端 UI 登录 | ui/admin-login | 1 | ✅ | -| 管理端 UI 结算中心 | ui/admin-settlement-center | 1 | ✅ | -| 玩家端 UI 登录进大厅 | ui/front-login-hall | 1 | ✅ | -| 玩家端 UI 下注提交 | ui/front-place-bet | 1 | ✅ | - -合计 ~43 个用例覆盖 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 集成 - -仓库已包含 `.github/workflows/e2e.yml`(push/PR 自动跑 API 项目;`E2E_UI=0` 不启前后端 dev server)。 - -本地跑全量(含 UI): - -```bash -./e2e/scripts/run.sh -``` - -仅 API(与 CI 一致): - -```bash -E2E_UI=0 ./e2e/scripts/run.sh -``` - -失败产物:`e2e/artifacts/`、`e2e/logs/`。 diff --git a/e2e/database/seeders/E2EPlayerSeeder.php b/e2e/database/seeders/E2EPlayerSeeder.php deleted file mode 100644 index fd7bbb6..0000000 --- a/e2e/database/seeders/E2EPlayerSeeder.php +++ /dev/null @@ -1,179 +0,0 @@ -ensureDemoIntegrationSite($siteCode, $currency, $mockWalletPort); - $this->ensureE2ELeafAgent($siteCode, $password); - - // 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, - ]); - } - } - - private function ensureE2ELeafAgent(string $siteCode, string $password): void - { - $siteId = (int) AdminSite::query()->where('code', $siteCode)->value('id'); - if ($siteId <= 0) { - return; - } - - $exists = DB::table('admin_users')->where('username', 'e2e_leaf_agent')->exists(); - if ($exists) { - return; - } - - $rootId = (int) DB::table('agent_nodes') - ->where('admin_site_id', $siteId) - ->where('depth', 0) - ->value('id'); - if ($rootId <= 0) { - return; - } - - $super = AdminUser::query()->where('username', 'admin')->first(); - if ($super === null) { - return; - } - - app(AgentNodeService::class)->createChild($super, [ - 'parent_id' => $rootId, - 'code' => 'e2e_leaf', - 'name' => 'E2E Leaf Agent', - 'username' => 'e2e_leaf_agent', - 'password' => $password, - 'total_share_rate' => 25, - 'credit_limit' => 200_000, - 'rebate_limit' => 0.01, - 'default_player_rebate' => 0.005, - ]); - } -} diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml deleted file mode 100644 index e7c0b32..0000000 --- a/e2e/docker-compose.yml +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100644 index 5b70711..0000000 --- a/e2e/global-setup.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * 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 siteCode = process.env.E2E_PLAYER_SITE_CODE ?? 'demo'; - const walletResp = await ctx.post('/api/v1/_e2e/site/wallet-api', { - data: { - site_code: siteCode, - 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 deleted file mode 100644 index ea8f2e3..0000000 --- a/e2e/package-lock.json +++ /dev/null @@ -1,104 +0,0 @@ -{ - "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 deleted file mode 100644 index b6a3103..0000000 --- a/e2e/package.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "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 deleted file mode 100644 index 6f1f655..0000000 --- a/e2e/playwright.config.ts +++ /dev/null @@ -1,63 +0,0 @@ -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'; - -/** 默认用本机 Google Chrome;设 PLAYWRIGHT_USE_BUNDLED_CHROMIUM=1 则走 ms-playwright 自带 Chromium。 */ -const uiBrowserUse = { - ...devices['Desktop Chrome'], - ...(process.env.PLAYWRIGHT_USE_BUNDLED_CHROMIUM === '1' - ? {} - : { channel: 'chrome' as const }), -}; - -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, - ...uiBrowserUse, - }, - }, - { - name: 'front-ui', - testMatch: 'tests/ui/front*.spec.ts', - use: { - baseURL: FRONT_URL, - ...uiBrowserUse, - }, - }, - ], -}); diff --git a/e2e/providers/E2EServiceProvider.php b/e2e/providers/E2EServiceProvider.php deleted file mode 100644 index 4b46586..0000000 --- a/e2e/providers/E2EServiceProvider.php +++ /dev/null @@ -1,31 +0,0 @@ -loadRoutesFrom(__DIR__.'/../routes/e2e.php'); - } -} diff --git a/e2e/routes/e2e.php b/e2e/routes/e2e.php deleted file mode 100644 index 3fb381b..0000000 --- a/e2e/routes/e2e.php +++ /dev/null @@ -1,78 +0,0 @@ -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('site-operator/setup', [E2EProvisionController::class, 'setupSiteOperator']) - ->name('e2e.site-operator.setup'); - Route::post('settlement/reset', [E2EProvisionController::class, 'resetSiteSettlement']) - ->name('e2e.settlement.reset'); - 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 deleted file mode 100644 index f773aff..0000000 --- a/e2e/scripts/mock-wallet-server.mjs +++ /dev/null @@ -1,82 +0,0 @@ -#!/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 deleted file mode 100755 index a8505a5..0000000 --- a/e2e/scripts/run.sh +++ /dev/null @@ -1,280 +0,0 @@ -#!/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 - -playwright_cache_dir() { - case "$(uname -s)" in - Darwin) echo "$HOME/Library/Caches/ms-playwright" ;; - Linux) echo "${XDG_CACHE_HOME:-$HOME/.cache}/ms-playwright" ;; - MINGW*|MSYS*|CYGWIN*) echo "${LOCALAPPDATA:-$HOME/AppData/Local}/ms-playwright" ;; - *) echo "${XDG_CACHE_HOME:-$HOME/.cache}/ms-playwright" ;; - esac -} - -playwright_chromium_cached() { - local cache_dir - cache_dir="$(playwright_cache_dir)" - # UI 项目依赖 chromium_headless_shell;API 仅 request 时可能不触发,但一并检测。 - compgen -G "${cache_dir}/chromium-"* >/dev/null 2>&1 \ - || compgen -G "${cache_dir}/chromium_headless_shell-"* >/dev/null 2>&1 \ - || [[ -d "$E2E_DIR/node_modules/playwright-core/.local-browsers" ]] -} - -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 -# UI 默认 channel:chrome(本机 Google Chrome);仅显式要求 bundled 时才下载 ms-playwright Chromium。 -if [[ "${PLAYWRIGHT_USE_BUNDLED_CHROMIUM:-0}" == "1" ]]; then - if ! playwright_chromium_cached; then - export PLAYWRIGHT_BROWSERS_PATH="${PLAYWRIGHT_BROWSERS_PATH:-$(playwright_cache_dir)}" - npx playwright install chromium - else - export PLAYWRIGHT_BROWSERS_PATH="${PLAYWRIGHT_BROWSERS_PATH:-$(playwright_cache_dir)}" - fi -fi - -export PLAYWRIGHT_JWT_SECRET="$(grep '^LOTTERY_NATIVE_JWT_SECRET=' "$ENV_FILE" | cut -d= -f2-)" -export PLAYWRIGHT_API_URL="$API_URL" -export PLAYWRIGHT_ADMIN_URL="http://localhost:3801" -export PLAYWRIGHT_FRONT_URL="http://localhost:3800" -export REVERB_APP_KEY="$(grep '^REVERB_APP_KEY=' "$ENV_FILE" | cut -d= -f2-)" -export REVERB_HOST=127.0.0.1 -export REVERB_PORT=8080 -export E2E_MOCK_WALLET_PORT=5555 -export E2E_ADMIN_USERNAME=admin -export E2E_ADMIN_PASSWORD=12345678 -export E2E_PLAYER_USERNAME="$(grep '^E2E_PLAYER_USERNAME=' "$ENV_E2E" | cut -d= -f2-)" -export E2E_PLAYER_PASSWORD="$(grep '^E2E_PLAYER_PASSWORD=' "$ENV_E2E" | cut -d= -f2-)" -if [[ "${E2E_UI:-1}" != "1" ]]; then - set -- --project=api "$@" -fi -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 deleted file mode 100644 index cd628f9..0000000 --- a/e2e/tests/api/01.health.spec.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * 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 deleted file mode 100644 index 1f91d63..0000000 --- a/e2e/tests/api/03.wallet-ticket.spec.ts +++ /dev/null @@ -1,118 +0,0 @@ -/** - * 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, fetchOpenDrawNo } 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 drawNo = await fetchOpenDrawNo(); - - 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: drawNo, - 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: drawNo, - 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 drawNo = await fetchOpenDrawNo(); - - 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: drawNo, - 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 deleted file mode 100644 index dc8f21c..0000000 --- a/e2e/tests/api/04.admin-auth.spec.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * 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 deleted file mode 100644 index 8eabe16..0000000 --- a/e2e/tests/api/05.wallet-transfer.spec.ts +++ /dev/null @@ -1,172 +0,0 @@ -/** - * 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 或改配置才能跑): - * - 主站失败 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 deleted file mode 100644 index f0c6c25..0000000 --- a/e2e/tests/api/06.wallet-logs.spec.ts +++ /dev/null @@ -1,134 +0,0 @@ -/** - * 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 deleted file mode 100644 index df5d0c2..0000000 --- a/e2e/tests/api/07.admin-player.spec.ts +++ /dev/null @@ -1,247 +0,0 @@ -/** - * 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 deleted file mode 100644 index c1d570a..0000000 --- a/e2e/tests/api/08.draw-publish-settle.spec.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * 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 deleted file mode 100644 index f873aef..0000000 --- a/e2e/tests/api/09.credit-bet.spec.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * 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 deleted file mode 100644 index ea7dad5..0000000 --- a/e2e/tests/api/10.agent-settlement.spec.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * E2E:代理账期开账 → 信用注单结算 → 关账出玩家账单。 - */ - -import { test, expect } from '@playwright/test'; -import { - adminLoginViaBypass, - adminCtxOf, - fetchOpenDrawNo, - playerLoginViaBypass, - openSettlementPeriod, - 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 periodId = await openSettlementPeriod(admin, credit.admin_site_id, credit.site_code); - - 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 deleted file mode 100644 index 33c44a4..0000000 --- a/e2e/tests/api/11.sso-mainsite.spec.ts +++ /dev/null @@ -1,130 +0,0 @@ -/** - * 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 deleted file mode 100644 index b0d9879..0000000 --- a/e2e/tests/api/12.broadcast.spec.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * 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 deleted file mode 100644 index cceff6d..0000000 --- a/e2e/tests/api/13.credit-settlement-win.spec.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * 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 deleted file mode 100644 index 61ca581..0000000 --- a/e2e/tests/api/14.settlement-payment.spec.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * E2E:代理账期关账 → confirm → 登记收付 → 账单 settled。 - */ - -import { test, expect } from '@playwright/test'; -import { - adminLoginViaBypass, - adminCtxOf, - fetchOpenDrawNo, - playerLoginViaBypass, - openSettlementPeriod, - 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 periodId = await openSettlementPeriod(admin, credit.admin_site_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 deleted file mode 100644 index d3fe676..0000000 --- a/e2e/tests/api/15.reconcile-job.spec.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * 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/16.agent-share-bill.spec.ts b/e2e/tests/api/16.agent-share-bill.spec.ts deleted file mode 100644 index d2b51d4..0000000 --- a/e2e/tests/api/16.agent-share-bill.spec.ts +++ /dev/null @@ -1,79 +0,0 @@ -/** - * E2E:信用下注结算后关账 → 玩家账单 + 代理占成账单(share_profit)。 - */ - -import { test, expect } from '@playwright/test'; -import { - adminLoginViaBypass, - adminCtxOf, - fetchOpenDrawNo, - playerLoginViaBypass, - openSettlementPeriod, - setupCreditPlayer, -} from './_helper'; -import { runWalletDrawSettlement } from './helpers/draw-settlement'; - -test('关账后生成玩家账单与代理占成账单', async () => { - test.setTimeout(300_000); - - const credit = await setupCreditPlayer({ - credit_limit: 100_000, - username: `e2e_share_${Date.now()}`, - }); - const drawNo = await fetchOpenDrawNo(); - - const adminSession = await adminLoginViaBypass(); - const admin = await adminCtxOf(adminSession.accessToken); - const periodId = await openSettlementPeriod(admin, credit.admin_site_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=50`, - ); - 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(); - - const agentBills = billsBody.data.items.filter((b: any) => b.bill_type === 'agent'); - expect(agentBills.length, '关账后应生成代理占成账单').toBeGreaterThan(0); - - const leafEdge = agentBills.find( - (b: any) => - Number(b.owner_id) === credit.agent_node_id || Number(b.counterparty_id) === credit.agent_node_id, - ); - expect(leafEdge, '应包含直属代理节点的占成边').toBeTruthy(); - - const meta = - typeof leafEdge.meta_json === 'string' - ? JSON.parse(leafEdge.meta_json) - : leafEdge.meta_json ?? {}; - expect( - 'share_profit' in meta || Number(leafEdge.net_amount) !== 0, - '代理账单应含占成信息或非零净额', - ).toBeTruthy(); - - await admin.dispose(); -}); diff --git a/e2e/tests/api/17.settlement-partial-payment.spec.ts b/e2e/tests/api/17.settlement-partial-payment.spec.ts deleted file mode 100644 index 947861f..0000000 --- a/e2e/tests/api/17.settlement-partial-payment.spec.ts +++ /dev/null @@ -1,202 +0,0 @@ -/** - * E2E:账期账单部分收付 → partial_paid → 尾款结清 settled。 - */ - -import { test, expect } from '@playwright/test'; -import { - adminLoginViaBypass, - adminCtxOf, - fetchBillCreditLedgerRows, - fetchOpenDrawNo, - playerLoginViaBypass, - openSettlementPeriod, - setupCreditPlayer, -} from './_helper'; -import { runWalletDrawSettlement } from './helpers/draw-settlement'; - -test('confirm 后分两笔收付 → partial_paid → settled', async () => { - test.setTimeout(300_000); - - const credit = await setupCreditPlayer({ - credit_limit: 100_000, - username: `e2e_partial_${Date.now()}`, - }); - const drawNo = await fetchOpenDrawNo(); - - const adminSession = await adminLoginViaBypass(); - const admin = await adminCtxOf(adminSession.accessToken); - const periodId = await openSettlementPeriod(admin, credit.admin_site_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: '1234', - publishWinNumber: '9999', - betAmount: 10_000, - expectWalletIncrease: false, - }); - - const close = await admin.post(`/api/v1/admin/settlement-periods/${periodId}/close`); - expect(close.ok()).toBeTruthy(); - expect((await close.json()).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; - const playerBill = billsBody.data.items.find( - (b: any) => b.bill_type === 'player' && Number(b.owner_id) === credit.player_id, - ); - expect(playerBill).toBeTruthy(); - expect(Number(playerBill.net_amount)).toBeGreaterThan(0); - - const confirm = await admin.post(`/api/v1/admin/settlement-bills/${playerBill.id}/confirm`); - expect(confirm.ok()).toBeTruthy(); - const confirmBody = (await confirm.json()) as any; - expect(confirmBody.code).toBe(0); - expect(confirmBody.data.status).toBe('confirmed'); - - const billShow = await admin.get(`/api/v1/admin/settlement-bills/${playerBill.id}`); - const billDetail = (await billShow.json()) as any; - const unpaid = Math.abs(Number(billDetail.data.bill.unpaid_amount)); - expect(unpaid).toBeGreaterThan(1); - const firstPay = Math.floor(unpaid / 2); - expect(firstPay).toBeGreaterThan(0); - - const pay1 = await admin.post(`/api/v1/admin/settlement-bills/${playerBill.id}/payments`, { - data: { amount: firstPay, method: 'e2e_partial_1', remark: 'e2e first half' }, - }); - expect(pay1.ok()).toBeTruthy(); - const pay1Body = (await pay1.json()) as any; - expect(pay1Body.code).toBe(0); - expect(pay1Body.data.bill.status).toBe('partial_paid'); - - const ledgerAfterPay1 = await fetchBillCreditLedgerRows( - credit.player_id, - Number(playerBill.id), - 'settlement_confirm', - ); - expect(ledgerAfterPay1).toHaveLength(1); - expect(Number(ledgerAfterPay1[0].amount)).toBe(firstPay); - - const midShow = await admin.get(`/api/v1/admin/settlement-bills/${playerBill.id}`); - const midDetail = (await midShow.json()) as any; - const secondPay = Math.abs(Number(midDetail.data.bill.unpaid_amount)); - expect(secondPay).toBeGreaterThan(0); - - const pay2 = await admin.post(`/api/v1/admin/settlement-bills/${playerBill.id}/payments`, { - data: { amount: secondPay, method: 'e2e_partial_2', remark: 'e2e remainder' }, - }); - const pay2Text = await pay2.text(); - expect(pay2.ok(), `pay2 status=${pay2.status()} body=${pay2Text.slice(0, 400)}`).toBeTruthy(); - const pay2Body = JSON.parse(pay2Text) as any; - expect(pay2Body.data.bill.status).toBe('settled'); - expect(Number(pay2Body.data.bill.unpaid_amount)).toBe(0); - - const ledgerAfterPay2 = await fetchBillCreditLedgerRows( - credit.player_id, - Number(playerBill.id), - 'settlement_confirm', - ); - expect(ledgerAfterPay2).toHaveLength(1); - expect(Number(ledgerAfterPay2[0].amount)).toBe(unpaid); - - const exceed = await admin.post(`/api/v1/admin/settlement-bills/${playerBill.id}/payments`, { - data: { amount: 1, method: 'e2e_over', remark: 'should fail' }, - }); - const exceedBody = (await exceed.json()) as any; - expect(exceedBody.code).not.toBe(0); - - await admin.dispose(); -}); - -test('玩家赢单 confirm 后分两笔收付 → settlement_payout 累计', async () => { - test.setTimeout(300_000); - - const winNumber = `6${String(Date.now()).slice(-3)}`; - const credit = await setupCreditPlayer({ - credit_limit: 100_000, - username: `e2e_partial_win_${Date.now()}`, - }); - const drawNo = await fetchOpenDrawNo(); - - const adminSession = await adminLoginViaBypass(); - const admin = await adminCtxOf(adminSession.accessToken); - const periodId = await openSettlementPeriod(admin, credit.admin_site_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()).toBeTruthy(); - - const bills = await admin.get( - `/api/v1/admin/settlement-bills?settlement_period_id=${periodId}&size=20`, - ); - const billsBody = (await bills.json()) as any; - const playerBill = billsBody.data.items.find( - (b: any) => b.bill_type === 'player' && Number(b.owner_id) === credit.player_id, - ); - expect(playerBill).toBeTruthy(); - expect(Number(playerBill.net_amount)).toBeLessThan(0); - - await admin.post(`/api/v1/admin/settlement-bills/${playerBill.id}/confirm`); - - const billDetail = (await (await admin.get(`/api/v1/admin/settlement-bills/${playerBill.id}`)).json()) as any; - const unpaid = Math.abs(Number(billDetail.data.bill.unpaid_amount)); - expect(unpaid).toBeGreaterThan(1); - const firstPay = Math.floor(unpaid / 2); - expect(firstPay).toBeGreaterThan(0); - - const pay1 = await admin.post(`/api/v1/admin/settlement-bills/${playerBill.id}/payments`, { - data: { amount: firstPay, method: 'e2e_win_partial_1', remark: 'win first half' }, - }); - expect(pay1.ok()).toBeTruthy(); - expect((await pay1.json()).data.bill.status).toBe('partial_paid'); - - const ledgerAfterPay1 = await fetchBillCreditLedgerRows( - credit.player_id, - Number(playerBill.id), - 'settlement_payout', - ); - expect(ledgerAfterPay1).toHaveLength(1); - expect(Number(ledgerAfterPay1[0].amount)).toBe(firstPay); - - const midDetail = (await (await admin.get(`/api/v1/admin/settlement-bills/${playerBill.id}`)).json()) as any; - const secondPay = Math.abs(Number(midDetail.data.bill.unpaid_amount)); - - const pay2 = await admin.post(`/api/v1/admin/settlement-bills/${playerBill.id}/payments`, { - data: { amount: secondPay, method: 'e2e_win_partial_2', remark: 'win remainder' }, - }); - expect(pay2.ok()).toBeTruthy(); - expect((await pay2.json()).data.bill.status).toBe('settled'); - - const ledgerAfterPay2 = await fetchBillCreditLedgerRows( - credit.player_id, - Number(playerBill.id), - 'settlement_payout', - ); - expect(ledgerAfterPay2).toHaveLength(1); - expect(Number(ledgerAfterPay2[0].amount)).toBe(unpaid); - - await admin.dispose(); -}); diff --git a/e2e/tests/api/18.settlement-permissions.spec.ts b/e2e/tests/api/18.settlement-permissions.spec.ts deleted file mode 100644 index e207884..0000000 --- a/e2e/tests/api/18.settlement-permissions.spec.ts +++ /dev/null @@ -1,151 +0,0 @@ -/** - * E2E:结算收付权限 — 站点财务可收付/坏账;绑定代理不可坏账核销。 - */ - -import { test, expect } from '@playwright/test'; -import { - adminCtxOf, - adminLoginWithAccount, - adminLoginViaBypass, - fetchOpenDrawNo, - openSettlementPeriod, - playerLoginViaBypass, - setupCreditPlayer, - setupSiteOperator, -} from './_helper'; -import { runWalletDrawSettlement } from './helpers/draw-settlement'; - -const DEFAULT_PASS = process.env.E2E_PLAYER_PASSWORD ?? '12345678'; - -test('站点财务可 confirm + 收付;绑定代理不可坏账核销', async () => { - test.setTimeout(360_000); - - const credit = await setupCreditPlayer({ - credit_limit: 100_000, - username: `e2e_perm_${Date.now()}`, - }); - const finance = await setupSiteOperator({ - username: `e2e_fin_${Date.now()}`, - password: DEFAULT_PASS, - role_slug: 'site_finance', - }); - const drawNo = await fetchOpenDrawNo(); - const winNumber = `5${String(Date.now()).slice(-3)}`; - - const superSession = await adminLoginViaBypass(); - const superAdmin = await adminCtxOf(superSession.accessToken); - const periodId = await openSettlementPeriod(superAdmin, credit.admin_site_id); - - const playerSession = await playerLoginViaBypass({ - site_code: credit.site_code, - username: credit.username, - password: credit.password, - }); - - await runWalletDrawSettlement({ - adminToken: superSession.accessToken, - playerToken: playerSession.access_token, - drawNo, - betNumber: winNumber, - betAmount: 10_000, - expectWalletIncrease: false, - }); - - const close = await superAdmin.post(`/api/v1/admin/settlement-periods/${periodId}/close`); - expect(close.ok()).toBeTruthy(); - - const bills = await superAdmin.get( - `/api/v1/admin/settlement-bills?settlement_period_id=${periodId}&size=20`, - ); - const billsBody = (await bills.json()) as any; - const playerBill = billsBody.data.items.find( - (b: any) => b.bill_type === 'player' && Number(b.owner_id) === credit.player_id, - ); - expect(playerBill).toBeTruthy(); - const billId = Number(playerBill.id); - const unpaid = Number(playerBill.unpaid_amount); - expect(unpaid).toBeGreaterThan(0); - - const financeSession = await adminLoginWithAccount(finance.username, finance.password); - const financeAdmin = await adminCtxOf(financeSession.accessToken); - - const me = await financeAdmin.get('/api/v1/admin/auth/me'); - expect(me.ok()).toBeTruthy(); - const meBody = (await me.json()) as any; - expect(meBody.data.admin.account_kind).toBe('site_finance'); - - const confirm = await financeAdmin.post(`/api/v1/admin/settlement-bills/${billId}/confirm`); - expect(confirm.ok(), `site_finance confirm status=${confirm.status()}`).toBeTruthy(); - expect((await confirm.json()).code).toBe(0); - - const pay = await financeAdmin.post(`/api/v1/admin/settlement-bills/${billId}/payments`, { - data: { amount: unpaid, method: 'e2e_finance', remark: 'site finance payment' }, - }); - expect(pay.ok(), `site_finance payment status=${pay.status()}`).toBeTruthy(); - expect((await pay.json()).data.bill.status).toBe('settled'); - - await financeAdmin.dispose(); - - const credit2 = await setupCreditPlayer({ - credit_limit: 100_000, - username: `e2e_perm2_${Date.now()}`, - }); - const periodId2 = await openSettlementPeriod(superAdmin, credit2.admin_site_id); - const drawNo2 = await fetchOpenDrawNo(); - const player2 = await playerLoginViaBypass({ - site_code: credit2.site_code, - username: credit2.username, - password: credit2.password, - }); - - await runWalletDrawSettlement({ - adminToken: superSession.accessToken, - playerToken: player2.access_token, - drawNo: drawNo2, - betNumber: `4${String(Date.now()).slice(-3)}`, - betAmount: 10_000, - expectWalletIncrease: false, - }); - - const close2 = await superAdmin.post(`/api/v1/admin/settlement-periods/${periodId2}/close`); - expect(close2.ok()).toBeTruthy(); - - const bills2 = await superAdmin.get( - `/api/v1/admin/settlement-bills?settlement_period_id=${periodId2}&size=20`, - ); - const bill2 = ((await bills2.json()) as any).data.items.find( - (b: any) => b.bill_type === 'player' && Number(b.owner_id) === credit2.player_id, - ); - expect(bill2).toBeTruthy(); - - await superAdmin.post(`/api/v1/admin/settlement-bills/${bill2.id}/confirm`); - - const agentSession = await adminLoginWithAccount('e2e_leaf_agent', DEFAULT_PASS); - const agentAdmin = await adminCtxOf(agentSession.accessToken); - const agentMe = (await (await agentAdmin.get('/api/v1/admin/auth/me')).json()) as any; - expect(agentMe.data.admin.account_kind).toBe('agent_operator'); - - const badDebt = await agentAdmin.post( - `/api/v1/admin/settlement-bills/${bill2.id}/bad-debt-write-off`, - { data: { reason: 'e2e should deny' } }, - ); - expect([403, 404]).toContain(badDebt.status()); - - const financeBadDebt = await setupSiteOperator({ - username: `e2e_fin_bd_${Date.now()}`, - password: DEFAULT_PASS, - role_slug: 'site_finance', - }); - const finance2 = await adminLoginWithAccount(financeBadDebt.username, DEFAULT_PASS); - const finance2Ctx = await adminCtxOf(finance2.accessToken); - const writeOff = await finance2Ctx.post( - `/api/v1/admin/settlement-bills/${bill2.id}/bad-debt-write-off`, - { data: { reason: 'e2e uncollectible' } }, - ); - expect(writeOff.ok(), `site_finance bad debt status=${writeOff.status()}`).toBeTruthy(); - expect((await writeOff.json()).code).toBe(0); - - await agentAdmin.dispose(); - await finance2Ctx.dispose(); - await superAdmin.dispose(); -}); diff --git a/e2e/tests/api/19.settlement-bad-debt-partial.spec.ts b/e2e/tests/api/19.settlement-bad-debt-partial.spec.ts deleted file mode 100644 index 5ed2710..0000000 --- a/e2e/tests/api/19.settlement-bad-debt-partial.spec.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * E2E:部分收付后坏账核销 → settlement_confirm 累计释额至账单全额。 - */ - -import { test, expect } from '@playwright/test'; -import { - adminLoginViaBypass, - adminCtxOf, - adminLoginWithAccount, - fetchBillCreditLedgerRows, - fetchOpenDrawNo, - openSettlementPeriod, - playerLoginViaBypass, - setupCreditPlayer, - setupSiteOperator, -} from './_helper'; -import { runWalletDrawSettlement } from './helpers/draw-settlement'; - -const DEFAULT_PASS = process.env.E2E_PLAYER_PASSWORD ?? '12345678'; - -test('部分收付后坏账核销 → credit_ledger 累计至全额', async () => { - test.setTimeout(360_000); - - const credit = await setupCreditPlayer({ - credit_limit: 100_000, - username: `e2e_bd_partial_${Date.now()}`, - }); - const finance = await setupSiteOperator({ - username: `e2e_fin_bd_${Date.now()}`, - password: DEFAULT_PASS, - role_slug: 'site_finance', - }); - const drawNo = await fetchOpenDrawNo(); - - const superSession = await adminLoginViaBypass(); - const superAdmin = await adminCtxOf(superSession.accessToken); - const periodId = await openSettlementPeriod(superAdmin, credit.admin_site_id); - - const playerSession = await playerLoginViaBypass({ - site_code: credit.site_code, - username: credit.username, - password: credit.password, - }); - - await runWalletDrawSettlement({ - adminToken: superSession.accessToken, - playerToken: playerSession.access_token, - drawNo, - betNumber: '4321', - publishWinNumber: '9999', - betAmount: 10_000, - expectWalletIncrease: false, - }); - - const close = await superAdmin.post(`/api/v1/admin/settlement-periods/${periodId}/close`); - expect(close.ok()).toBeTruthy(); - - const bills = await superAdmin.get( - `/api/v1/admin/settlement-bills?settlement_period_id=${periodId}&size=20`, - ); - const billsBody = (await bills.json()) as any; - const playerBill = billsBody.data.items.find( - (b: any) => b.bill_type === 'player' && Number(b.owner_id) === credit.player_id, - ); - expect(playerBill).toBeTruthy(); - expect(Number(playerBill.net_amount)).toBeGreaterThan(0); - - const billId = Number(playerBill.id); - await superAdmin.post(`/api/v1/admin/settlement-bills/${billId}/confirm`); - - const billDetail = (await (await superAdmin.get(`/api/v1/admin/settlement-bills/${billId}`)).json()) as any; - const unpaid = Math.abs(Number(billDetail.data.bill.unpaid_amount)); - const firstPay = Math.floor(unpaid / 2); - expect(firstPay).toBeGreaterThan(0); - - const financeSession = await adminLoginWithAccount(finance.username, finance.password); - const financeAdmin = await adminCtxOf(financeSession.accessToken); - - const pay1 = await financeAdmin.post(`/api/v1/admin/settlement-bills/${billId}/payments`, { - data: { amount: firstPay, method: 'e2e_bd_partial', remark: 'partial before write-off' }, - }); - expect(pay1.ok()).toBeTruthy(); - expect((await pay1.json()).data.bill.status).toBe('partial_paid'); - - const ledgerAfterPay1 = await fetchBillCreditLedgerRows(credit.player_id, billId, 'settlement_confirm'); - expect(ledgerAfterPay1).toHaveLength(1); - expect(Number(ledgerAfterPay1[0].amount)).toBe(firstPay); - - const writeOff = await financeAdmin.post(`/api/v1/admin/settlement-bills/${billId}/bad-debt-write-off`, { - data: { reason: 'e2e partial then bad debt' }, - }); - expect(writeOff.ok(), `bad debt status=${writeOff.status()}`).toBeTruthy(); - expect((await writeOff.json()).code).toBe(0); - - const settledBill = (await (await superAdmin.get(`/api/v1/admin/settlement-bills/${billId}`)).json()) as any; - expect(settledBill.data.bill.status).toBe('settled'); - expect(Number(settledBill.data.bill.unpaid_amount)).toBe(0); - - const ledgerAfterWriteOff = await fetchBillCreditLedgerRows(credit.player_id, billId, 'settlement_confirm'); - expect(ledgerAfterWriteOff).toHaveLength(1); - expect(Number(ledgerAfterWriteOff[0].amount)).toBe(unpaid); - - await financeAdmin.dispose(); - await superAdmin.dispose(); -}); diff --git a/e2e/tests/api/_helper.ts b/e2e/tests/api/_helper.ts deleted file mode 100644 index 23bf95b..0000000 --- a/e2e/tests/api/_helper.ts +++ /dev/null @@ -1,305 +0,0 @@ -/** - * 共享步骤:取 captcha、登录玩家、登录超管、获取/重置玩家状态。 - */ - -import { test, request as pwRequest, type APIRequestContext, expect } from '@playwright/test'; -import { adminLogin, playerLogin, playerCtx, adminCtx, fetchCurrentDrawNo, e2e } from '../fixtures'; - -export const E2E_TAG = '@e2e'; - -export async function fetchPlayerCaptcha(): Promise<{ captcha_key: string; image_svg: string }> { - const ctx = await pwRequest.newContext(); - const resp = await ctx.get('/api/v1/player/auth/captcha'); - expect(resp.ok(), `GET /player/auth/captcha failed: ${resp.status()}`).toBeTruthy(); - const body = (await resp.json()) as { data: { captcha_key: string; image_svg: string; image_base64: string } }; - await ctx.dispose(); - return { captcha_key: body.data.captcha_key, image_svg: body.data.image_svg }; -} - -export async function fetchAdminCaptcha(): Promise<{ captcha_key: string }> { - const ctx = await pwRequest.newContext(); - const resp = await ctx.get('/api/v1/admin/auth/captcha'); - expect(resp.ok(), `GET /admin/auth/captcha failed: ${resp.status()}`).toBeTruthy(); - const body = (await resp.json()) as { data: { captcha_key: string } }; - await ctx.dispose(); - return { captcha_key: body.data.captcha_key }; -} - -/** 用 LOTTERY_E2E_BYPASS 登录(管理端 captcha 旁路) */ -export async function adminLoginViaBypass(): Promise> { - const captcha = await fetchAdminCaptcha(); - const ctx = await pwRequest.newContext(); - const resp = await ctx.post('/api/v1/admin/auth/login', { - data: { - account: process.env.E2E_ADMIN_USERNAME ?? 'admin', - password: process.env.E2E_ADMIN_PASSWORD ?? '12345678', - captcha_key: captcha.captcha_key, - captcha_code: 'LOTTERY_E2E_BYPASS', - }, - }); - expect(resp.ok(), `admin login failed: ${resp.status()}`).toBeTruthy(); - const body = (await resp.json()) as { data: any }; - await ctx.dispose(); - if (!body.data?.token) throw new Error('admin login returned no token: ' + JSON.stringify(body)); - return { - accessToken: body.data.token, - tokenType: body.data.token_type ?? 'Bearer', - expiresIn: 0, - admin: body.data.admin, - }; -} - -/** 用 LOTTERY_E2E_BYPASS 登录(玩家端 captcha 旁路) */ -export async function playerLoginViaBypass(creds?: { - site_code?: string; - username?: string; - password?: string; -}): Promise> { - const captcha = await fetchPlayerCaptcha(); - const ctx = await pwRequest.newContext(); - const resp = await ctx.post('/api/v1/player/auth/login', { - data: { - site_code: creds?.site_code ?? process.env.E2E_PLAYER_SITE_CODE ?? 'demo', - username: creds?.username ?? process.env.E2E_PLAYER_USERNAME ?? 'demo_player', - password: creds?.password ?? process.env.E2E_PLAYER_PASSWORD ?? '12345678', - captcha_key: captcha.captcha_key, - captcha_code: 'LOTTERY_E2E_BYPASS', - }, - }); - expect(resp.ok(), `player login failed: ${resp.status()}`).toBeTruthy(); - const body = (await resp.json()) as { data: any }; - await ctx.dispose(); - if (!body.data?.access_token) throw new Error('player login returned no token: ' + JSON.stringify(body)); - return body.data; -} - -/** 重置 e2e 玩家(清失败计数、解除锁定、恢复初始余额) */ -export async function resetE2EPlayer(): Promise { - await e2e('POST', '/player/reset', {}); -} - -/** 把期号 close_time 改到过去,status=closed */ -export async function forceCloseDraw(drawNo: string): Promise { - return e2e('POST', `/draw/${encodeURIComponent(drawNo)}/close-now`, {}); -} - -export async function finishDrawCooldown(drawNo: string): Promise { - return e2e('POST', `/draw/${encodeURIComponent(drawNo)}/finish-cooldown`, {}); -} - -export async function tickDraws(): Promise { - return e2e('POST', '/draw/tick', {}); -} - -export async function inspectDraw(drawNo: string): Promise { - return e2e('GET', `/draw/${encodeURIComponent(drawNo)}/inspect`, {}); -} - -export async function waitForDrawStatus( - drawNo: string, - targets: string[], - maxAttempts = 20, - pauseMs = 500, -): Promise { - for (let i = 0; i < maxAttempts; i++) { - const insp = await inspectDraw(drawNo); - const status = String(insp.data?.status ?? insp.status ?? ''); - if (targets.includes(status)) { - return insp; - } - await tickDraws(); - await sleep(pauseMs); - } - const last = await inspectDraw(drawNo); - throw new Error( - `draw ${drawNo} not reached ${targets.join('|')} after ${maxAttempts} ticks; last=${JSON.stringify(last).slice(0, 400)}`, - ); -} - -export function sleep(ms: number): Promise { - return new Promise((r) => setTimeout(r, ms)); -} - -export async function adminCtxOf(token: string): Promise { - return pwRequest.newContext({ - extraHTTPHeaders: { Authorization: `Bearer ${token}` }, - }); -} - -export async function playerCtxOf(token: string): Promise { - return pwRequest.newContext({ - extraHTTPHeaders: { Authorization: `Bearer ${token}` }, - }); -} - -export async function setupCreditPlayer(opts?: { credit_limit?: number; username?: string }): Promise<{ - player_id: number; - username: string; - password: string; - site_code: string; - admin_site_id: number; - agent_node_id: number; -}> { - const data = await e2e('POST', '/credit-player/setup', opts ?? {}); - return data.data; -} - -export async function setupSiteOperator(opts?: { - role_slug?: string; - username?: string; - password?: string; -}): Promise<{ - admin_user_id: number; - username: string; - password: string; - role_slug: string; - admin_site_id: number; - site_code: string; -}> { - const data = await e2e('POST', '/site-operator/setup', opts ?? {}); - return data.data; -} - -export async function adminLoginWithAccount( - account: string, - password: string, -): Promise> { - const captcha = await fetchAdminCaptcha(); - const ctx = await pwRequest.newContext(); - const resp = await ctx.post('/api/v1/admin/auth/login', { - data: { - account, - password, - captcha_key: captcha.captcha_key, - captcha_code: 'LOTTERY_E2E_BYPASS', - }, - }); - expect(resp.ok(), `admin login failed: ${resp.status()}`).toBeTruthy(); - const body = (await resp.json()) as { data: any }; - await ctx.dispose(); - if (!body.data?.token) throw new Error('admin login returned no token: ' + JSON.stringify(body)); - return { - accessToken: body.data.token, - tokenType: body.data.token_type ?? 'Bearer', - expiresIn: 0, - admin: body.data.admin, - }; -} - -/** 以当前时间为中心的 2h 账期窗口;开账前会清掉同站历史账期避免重叠。 */ -export function periodWindowIso(): { start: string; end: string } { - const start = new Date(Date.now() - 3600_000).toISOString().replace('T', ' ').slice(0, 19); - const end = new Date(Date.now() + 3600_000).toISOString().replace('T', ' ').slice(0, 19); - return { start, end }; -} - -export async function resetSiteSettlement(siteCode?: string): Promise { - await e2e('POST', '/settlement/reset', { site_code: siteCode ?? process.env.E2E_PLAYER_SITE_CODE ?? 'demo' }); -} - -export async function fetchBillCreditLedgerRows( - playerId: number, - billId: number, - reason: 'settlement_confirm' | 'settlement_payout', -): Promise> { - const ledger = await e2e('GET', `/inspect/credit-ledger?player_id=${playerId}&limit=50`); - return (ledger.data.rows as any[]).filter( - (row) => - row.reason === reason && - row.ref_type === 'settlement_bill' && - Number(row.ref_id) === billId, - ); -} - -export async function openSettlementPeriod( - admin: APIRequestContext, - adminSiteId: number, - siteCode?: string, -): Promise { - await resetSiteSettlement(siteCode); - const { start, end } = periodWindowIso(); - const open = await admin.post('/api/v1/admin/settlement-periods', { - data: { - admin_site_id: adminSiteId, - period_start: start, - period_end: end, - }, - }); - expect(open.ok(), `open period status=${open.status()}`).toBeTruthy(); - const openBody = (await open.json()) as any; - expect(openBody.code).toBe(0); - return Number(openBody.data.id); -} - -export async function mintSsoJwt(sitePlayerId?: string): Promise<{ jwt: string; site_player_id: string }> { - const data = await e2e('POST', '/sso/mint-jwt', { - site_player_id: sitePlayerId ?? `e2e-sso-${Date.now()}`, - }); - return data.data; -} - -export async function configureWalletMock(baseUrl: string): Promise { - await e2e('POST', '/site/wallet-api', { base_url: baseUrl, wallet_api_key: 'e2e-mock-key' }); -} - -export async function resetWalletMock(): Promise { - await e2e('POST', '/site/wallet-api/reset', {}); -} - -export async function setMockWalletMode(mode: 'success' | '504' | 'reject'): Promise { - const port = process.env.E2E_MOCK_WALLET_PORT ?? '5555'; - const ctx = await pwRequest.newContext({ baseURL: `http://127.0.0.1:${port}` }); - const resp = await ctx.post('/_e2e/mode', { data: { mode } }); - expect(resp.ok(), `mock wallet mode=${mode} failed`).toBeTruthy(); - await ctx.dispose(); -} - -/** 造 23 个 slot:first=winNumber,其余填占位号 */ -export function buildAllResultItems(winNumber: string): any[] { - const items: any[] = []; - items.push({ prize_type: 'first', prize_index: 0, number_4d: winNumber }); - items.push({ prize_type: 'second', prize_index: 0, number_4d: '5678' }); - items.push({ prize_type: 'third', prize_index: 0, number_4d: '0123' }); - for (let i = 0; i < 10; i++) { - items.push({ - prize_type: 'starter', - prize_index: i, - number_4d: String(1000 + i).padStart(4, '0'), - }); - } - for (let i = 0; i < 10; i++) { - items.push({ - prize_type: 'consolation', - prize_index: i, - number_4d: String(2000 + i).padStart(4, '0'), - }); - } - return items; -} - -export async function resolveDrawId(admin: APIRequestContext, drawNo: string): Promise { - const drawsList = await admin.get(`/api/v1/admin/draws?keyword=${encodeURIComponent(drawNo)}&size=10`); - const dl = (await drawsList.json()) as any; - const drawId = - dl.data.items.find((it: any) => it.draw_no === drawNo)?.id ?? dl.data.items[0]?.id; - expect(drawId, `draw id for ${drawNo}`).toBeTruthy(); - return Number(drawId); -} - -export async function fetchOpenDrawNo(): Promise { - const current = await fetchCurrentDrawNo(); - if (!current?.draw_no) { - throw new Error('draw/current returned no draw_no'); - } - const drawNo = current.draw_no; - const insp = await e2e('GET', `/draw/${encodeURIComponent(drawNo)}/inspect`); - const status = String(insp.data?.status ?? ''); - if (status !== 'open') { - await e2e('POST', `/draw/${encodeURIComponent(drawNo)}/reopen`, {}); - const after = await e2e('GET', `/draw/${encodeURIComponent(drawNo)}/inspect`); - expect(String(after.data?.status), `draw ${drawNo} reopen`).toBe('open'); - } - return drawNo; -} - -export { playerLogin, playerCtx, adminLogin, adminCtx, fetchCurrentDrawNo, e2e }; diff --git a/e2e/tests/api/helpers/draw-settlement.ts b/e2e/tests/api/helpers/draw-settlement.ts deleted file mode 100644 index 9a051e9..0000000 --- a/e2e/tests/api/helpers/draw-settlement.ts +++ /dev/null @@ -1,132 +0,0 @@ -/** - * 开奖 → 结算 → 派彩 确定性流水线(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; - /** 公布头奖号码;默认与 betNumber 相同(必中)。传不同值可测玩家输单。 */ - publishWinNumber?: string; - betAmount?: number; - balanceBefore?: number; - expectWalletIncrease?: boolean; -}): Promise { - const drawNo = params.drawNo; - const betNumber = params.betNumber ?? '1234'; - const publishWinNumber = params.publishWinNumber ?? betNumber; - 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(publishWinNumber) }, - }); - 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 deleted file mode 100644 index 6e46c5e..0000000 --- a/e2e/tests/fixtures.ts +++ /dev/null @@ -1,129 +0,0 @@ -/** - * 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 deleted file mode 100644 index 0b3dc83..0000000 --- a/e2e/tests/ui/admin-login.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -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/admin-settlement-center.spec.ts b/e2e/tests/ui/admin-settlement-center.spec.ts deleted file mode 100644 index dec29b9..0000000 --- a/e2e/tests/ui/admin-settlement-center.spec.ts +++ /dev/null @@ -1,29 +0,0 @@ -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(120_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); - await page.locator('img[src^="data:image"]').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 }); - - await page.goto(`${ADMIN_URL}/admin/settlement-center`); - await expect(page.getByText(/结算中心|Settlement center/i).first()).toBeVisible({ - timeout: 60_000, - }); - await expect(page.getByText(/账期管理|Period/i).first()).toBeVisible({ timeout: 30_000 }); - const periodAction = page - .getByRole('button', { name: /开账|Open period|关账|Close period/i }) - .first(); - await expect(periodAction).toBeVisible({ timeout: 30_000 }); -}); diff --git a/e2e/tests/ui/front-login-hall.spec.ts b/e2e/tests/ui/front-login-hall.spec.ts deleted file mode 100644 index 49b247a..0000000 --- a/e2e/tests/ui/front-login-hall.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -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/e2e/tests/ui/front-place-bet.spec.ts b/e2e/tests/ui/front-place-bet.spec.ts deleted file mode 100644 index 22a0fb3..0000000 --- a/e2e/tests/ui/front-place-bet.spec.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { test, expect } from '@playwright/test'; -import { fetchOpenDrawNo, tickDraws } from '../api/_helper'; - -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'; - -async function loginToHall(page: import('@playwright/test').Page): Promise { - 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); - await page.locator('img[src^="data:image"]').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 }); -} - -test('玩家端大厅 → 填写号码金额 → 预览并提交下注', async ({ page }) => { - test.setTimeout(180_000); - - await fetchOpenDrawNo(); - await tickDraws(); - - await loginToHall(page); - - const grid = page.getByRole('section', { name: /下注表格|Betting/i }); - await grid.waitFor({ state: 'visible', timeout: 60_000 }); - - const numberInput = grid.locator('input[inputmode="text"]').first(); - await numberInput.waitFor({ state: 'visible', timeout: 30_000 }); - await numberInput.fill('12'); - - const amountInput = grid.locator('input[inputmode="decimal"]').first(); - await amountInput.fill('10'); - - const submitBtn = page.getByRole('button', { name: /提交下注|Submit bet/i }); - await expect(submitBtn).toBeEnabled({ timeout: 60_000 }); - await submitBtn.click(); - - const confirmBtn = page.getByRole('button', { name: /确认提交|Confirm/i }); - await confirmBtn.waitFor({ state: 'visible', timeout: 30_000 }); - await confirmBtn.click(); - - await expect(page.getByText(/下注成功|Bet placed|订单号|Order/i)).toBeVisible({ - timeout: 45_000, - }); -}); diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100644 index 0000000..e69de29