instance('config', new Repository(['app' => ['timezone' => 'Asia/Shanghai'], 'third_game' => ['player_prefix' => 'u']])); Facade::setFacadeApplication($app); $db = new Manager($app); $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']); $db->setAsGlobal(); $db->bootEloquent(); $app->instance('db', $db->getDatabaseManager()); $app->bind('db.schema', fn () => $db->schema()); DB::connection()->getPdo()->sqliteCreateFunction('MD5', md5(...), 1); DB::statement('CREATE TABLE bot_users (id INTEGER PRIMARY KEY,member_id TEXT,username TEXT,account TEXT,first_name TEXT)'); (require dirname(__DIR__, 2) . '/database/migrations/2026_08_21_120000_create_third_game_orders_table.php')->up(); (require dirname(__DIR__, 2) . '/database/migrations/2026_10_26_140000_create_reporting_tables.php')->up(); $this->commissionMigration()->up(); DB::table('users')->insert([ ['id' => 1, 'member_id' => 'm1', 'username' => 'one', 'first_name' => ''], ['id' => 106, 'member_id' => 'm106', 'username' => 'member106', 'first_name' => ''], ['id' => 1000, 'member_id' => 'm1000', 'username' => 'member1000', 'first_name' => ''], ]); } protected function tearDown(): void { Carbon::setTestNow(); DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown(); } private function lookup(array $playerIds): array { $service = new ThirdGameOrderService(new ThirdGameBalanceService()); return (new \ReflectionMethod($service, 'usersByPlayerId'))->invoke($service, $playerIds); } public function test_new_padded_and_legacy_identifiers_in_one_batch_resolve_to_the_correct_member(): void { $rows = $this->lookup(['u106', 'u0106', 'u0001', 'u1', 'u1000']); $this->assertCount(5, $rows); $this->assertSame(106, $rows['u0106']->id); $this->assertSame(106, $rows['u106']->id); $this->assertSame('m106', $rows['u0106']->member_id); $this->assertSame(1, $rows['u0001']->id); $this->assertSame(1, $rows['u1']->id); $this->assertSame(1000, $rows['u1000']->id); } public function test_noncanonical_zero_padding_and_unknown_accounts_do_not_get_bound_to_another_member(): void { $this->assertSame([], $this->lookup(['u000106', 'u01', 'u00001', 'u01000', 'u0000', 'u0', 'u10000000000', 'u0999', 'a0106', 'U0106', 'p1234567890'])); $this->assertSame([], $this->lookup([])); } public function test_test_namespace_does_not_associate_production_players_with_test_members(): void { config(['third_game.player_prefix' => 'tu']); $rows = $this->lookup(['u106', 'u0106', 'u1000', 'tu106', 'tu0106', 'tu1000']); $this->assertSame(['tu106', 'tu0106', 'tu1000'], array_keys($rows)); $this->assertSame(106, $rows['tu0106']->id); $this->assertSame([], $this->lookup(['u0106', 'tu00106', 'tu0000', 'tu1000000000'])); } public function test_sync_and_listing_keep_namespaces_separate_even_when_an_old_foreign_snapshot_exists(): void { config(['third_game.player_prefix' => 'tu']); $foreign = ['player_id' => 'u0106', 'platform' => 'pg', 'game_order_id' => 'production-order', 'currency' => 'CNY', 'game_type' => 2, 'status' => 1, 'bet_amount' => '999', 'bet_time' => '2026-10-27 10:00:00', 'last_update_time' => '2026-10-27 10:01:00']; $foreignKey = hash('sha256', "pg\0u0106\0production-order"); DB::table('third_game_orders')->insert($foreign + ['order_key' => $foreignKey, 'user_id' => 106, 'member_id' => 'm106', 'username' => 'member106', 'first_name' => '', 'created_at' => '2026-10-27 10:00:00']); $before = (array) DB::table('third_game_orders')->where('order_key', $foreignKey)->first(); $local = array_replace($foreign, ['player_id' => 'tu0106', 'game_order_id' => 'test-order', 'bet_amount' => '10']); $service = new ThirdGameOrderService(new ThirdGameBalanceService()); $persist = new \ReflectionMethod($service, 'persistProviderResult'); $result = $persist->invoke($service, ['ok' => true, 'list' => [$foreign, $local], 'page_no' => 1, 'page_size' => 2, 'total' => 4]); $this->assertSame(2, $result['received']); $this->assertSame(1, $result['synced']); $this->assertSame(1, $result['matched_users']); $this->assertSame(4, $result['total']); $this->assertTrue($result['has_more']); $this->assertSame($before, (array) DB::table('third_game_orders')->where('order_key', $foreignKey)->first()); $this->assertSame(106, DB::table('third_game_orders')->where('player_id', 'tu0106')->value('user_id')); $list = $service->paginate([]); $this->assertSame(1, $list['total']); $this->assertSame('tu0106', $list['list'][0]['player_id']); config(['third_game.player_prefix' => 'u']); $production = $service->paginate([]); $this->assertSame(1, $production['total']); $this->assertSame('u0106', $production['list'][0]['player_id']); $rejected = $persist->invoke($service, ['ok' => true, 'list' => [$local], 'page_no' => 1, 'page_size' => 1, 'total' => 1]); $this->assertSame(0, $rejected['synced']); $this->assertSame(2, DB::table('third_game_orders')->count()); } public function test_order_sync_snapshots_only_losses_and_keeps_the_rate_during_corrections(): void { Carbon::setTestNow(Carbon::parse('2026-11-05 12:00:00', 'Asia/Shanghai')); $this->fakeQuota([ [['platfrom' => 'ag', 'ratio' => '0.09'], ['platfrom' => 'free', 'ratio' => '0']], [['platfrom' => 'ag', 'ratio' => '0.20']], ]); $provider = new class extends ThirdGameBalanceService { public array $orders; public function realtimeOrders(int $page = 1, int $pageSize = 2000): array { return ['ok' => true, 'list' => $this->orders]; } public function historyOrders(string $startTime, string $endTime, int $page = 1, int $pageSize = 2000): array { return ['ok' => true, 'list' => $this->orders]; } }; $service = new ThirdGameOrderService($provider); $loss = $this->order('loss'); $provider->orders = [ $loss, $this->order('win', ['settled_amount' => '20']), $this->order('draw', ['settled_amount' => '0']), $this->order('pending', ['status' => 0]), $this->order('missing-net', ['settled_amount' => null]), $this->order('missing-rate', ['platform' => 'pg']), $this->order('zero-rate', ['platform' => 'free']), $this->order('foreign', ['currency' => 'USD']), ]; $this->assertSame(8, $service->syncRealtime()['synced']); $byOrder = fn () => collect($service->paginate([])['list'])->keyBy('game_order_id'); $rows = $byOrder(); $this->assertSame('loss', $rows['loss']['bet_result']); $this->assertSame('0.0900000000', $rows['loss']['third_game_commission_ratio']); $this->assertSame('9.0000000000', $rows['loss']['third_game_commission']); $this->assertSame('win', $rows['win']['bet_result']); $this->assertSame('draw', $rows['draw']['bet_result']); $this->assertNull($rows['pending']['bet_result']); foreach (['win', 'draw', 'pending', 'zero-rate'] as $order) { $this->assertSame('0.0000000000', $rows[$order]['third_game_commission']); } foreach (['missing-net', 'missing-rate', 'foreign'] as $order) { $this->assertNull($rows[$order]['third_game_commission']); } $this->assertSame('0.0000000000', $rows['zero-rate']['third_game_commission_ratio']); $service->syncRealtime(); Http::assertSentCount(1); Carbon::setTestNow(Carbon::parse('2026-11-06 12:00:00', 'Asia/Shanghai')); $correction = ['last_update_time' => '2026-11-06 10:00:00', 'settled_amount' => '-50'] + $loss; $provider->orders = [$correction, $this->order('new', ['last_update_time' => '2026-11-06 10:00:00'])]; $service->syncHistory('2026-11-06 09:00:00', '2026-11-06 11:00:00'); $this->assertSame('4.5000000000', $byOrder()['loss']['third_game_commission']); $this->assertSame('20.0000000000', $byOrder()['new']['third_game_commission']); $provider->orders = [$loss]; $this->assertSame(0, $service->syncRealtime()['synced']); $this->assertSame('4.5000000000', $byOrder()['loss']['third_game_commission']); $provider->orders = [['status' => 3, 'last_update_time' => '2026-11-06 11:00:00'] + $loss]; $service->syncRealtime(); $this->assertSame('0.0000000000', $byOrder()['loss']['third_game_commission']); $this->assertNull($byOrder()['loss']['bet_result']); $this->assertNull($byOrder()['loss']['third_game_commission_ratio']); Http::assertSentCount(2); } public function test_backfill_freezes_the_launch_range_and_daily_retry_only_fills_new_orders(): void { Carbon::setTestNow(Carbon::parse('2026-11-05 12:00:00', 'Asia/Shanghai')); $migration = $this->commissionMigration(); $migration->down(); $this->insertOrder('old-ag'); $this->insertOrder('old-pg', ['platform' => 'pg']); $this->insertOrder('old-win', ['settled_amount' => '10']); $migration->up(); $this->insertOrder('new-pg', ['platform' => 'pg', 'last_update_time' => '2026-11-05 13:00:00']); $this->fakeQuota([ [['platfrom' => 'ag', 'ratio' => '0.09']], [['platfrom' => 'ag', 'ratio' => '0.20'], ['platfrom' => 'pg', 'ratio' => '0.30']], ]); $service = new ThirdGameCommissionService(); $this->assertSame(['updated' => 1, 'remaining' => 1], $service->backfill(false)); Carbon::setTestNow(Carbon::parse('2026-11-06 12:00:00', 'Asia/Shanghai')); $this->assertSame(['updated' => 0, 'remaining' => 1], $service->backfill(false)); Http::assertSentCount(1); $this->assertSame(['updated' => 1, 'remaining' => 0], $service->backfill(true)); $rows = collect((new ThirdGameOrderService(new ThirdGameBalanceService()))->paginate([])['list'])->keyBy('game_order_id'); $this->assertSame('9.0000000000', $rows['old-ag']['third_game_commission']); $this->assertNull($rows['old-pg']['third_game_commission']); $this->assertSame('0.0000000000', $rows['old-win']['third_game_commission']); $this->assertSame('30.0000000000', $rows['new-pg']['third_game_commission']); Http::assertSentCount(2); } public function test_failed_rate_lookup_is_retried_the_same_day_and_rows_without_update_time_still_update(): void { Carbon::setTestNow(Carbon::parse('2026-11-05 12:00:00', 'Asia/Shanghai')); $this->fakeQuota([[['platfrom' => 'ag', 'ratio' => '0.10']]], [['code' => 500, 'msg' => 'busy']]); $provider = new class extends ThirdGameBalanceService { public array $orders; public function realtimeOrders(int $page = 1, int $pageSize = 2000): array { return ['ok' => true, 'list' => $this->orders]; } }; $service = new ThirdGameOrderService($provider); $byOrder = fn () => collect($service->paginate([])['list'])->keyBy('game_order_id'); $provider->orders = [$this->order('loss'), $this->order('later', ['status' => 0, 'settled_amount' => null])]; $service->syncRealtime(); $this->assertNull($byOrder()['loss']['third_game_commission'], 'rate lookup failed'); Carbon::setTestNow(Carbon::parse('2026-11-05 12:20:00', 'Asia/Shanghai')); $service->syncRealtime(); Http::assertSentCount(1); Carbon::setTestNow(Carbon::parse('2026-11-05 12:31:00', 'Asia/Shanghai')); $provider->orders = [$this->order('loss'), $this->order('later', ['last_update_time' => null])]; $service->syncRealtime(); Http::assertSentCount(2); $this->assertSame('10.0000000000', $byOrder()['loss']['third_game_commission'], 'retried after the failure pause'); $this->assertSame(1, $byOrder()['later']['status'], 'a row without update time is not treated as stale'); $this->assertSame('10.0000000000', $byOrder()['later']['third_game_commission']); // An untimed version cannot be ordered, so it never replaces a finished one. $provider->orders = [$this->order('loss', ['status' => 0, 'settled_amount' => null, 'last_update_time' => null])]; $service->syncRealtime(); $this->assertSame(1, $byOrder()['loss']['status']); $this->assertEquals(-100, $byOrder()['loss']['settled_amount']); $this->assertSame('2026-11-05 10:00:00', $byOrder()['loss']['last_update_time']); } public function test_scheduled_backfill_skips_quietly_until_the_migration_has_run(): void { $migration = $this->commissionMigration(); $migration->down(); $this->assertFalse((new ThirdGameCommissionService())->migrated()); $command = new \App\Console\Commands\ThirdGameCommissionBackfill(); $command->setLaravel(app()); $tester = new \Symfony\Component\Console\Tester\CommandTester($command); $this->assertSame(0, $tester->execute(['--retry-missing' => true])); $this->assertStringContainsString('跳过', $tester->getDisplay()); $migration->up(); $this->assertTrue((new ThirdGameCommissionService())->migrated()); } public function test_backfill_leaves_old_snapshots_of_the_other_environment_untouched(): void { Carbon::setTestNow(Carbon::parse('2026-11-05 12:00:00', 'Asia/Shanghai')); $migration = $this->commissionMigration(); $migration->down(); $this->insertOrder('own'); $this->insertOrder('foreign', ['player_id' => 'tu0106']); $migration->up(); $this->fakeQuota([[['platfrom' => 'ag', 'ratio' => '0.10']]]); $this->assertSame(['updated' => 1, 'remaining' => 0], (new ThirdGameCommissionService())->backfill(false)); $this->assertNull(DB::table('third_game_orders')->where('game_order_id', 'foreign')->value('third_game_commission')); } private function commissionMigration(): object { return require dirname(__DIR__, 2) . '/database/migrations/2026_11_05_150000_add_third_game_order_commission.php'; } private function order(string $id, array $extra = []): array { return $extra + [ 'player_id' => 'u0106', 'game_order_id' => $id, 'platform' => 'AG', 'currency' => 'CNY', 'game_type' => 1, 'status' => 1, 'bet_amount' => '100', 'settled_amount' => '-100', 'bet_time' => '2026-11-05 09:00:00', 'last_update_time' => '2026-11-05 10:00:00', ]; } private function insertOrder(string $id, array $extra = []): void { DB::table('third_game_orders')->insert($this->order($id, $extra) + ['order_key' => hash('sha256', $id), 'user_id' => 106]); } /** @param list $before raw responses (e.g. failures) returned before the daily ratio lists */ private function fakeQuota(array $days, array $before = []): void { app()->instance('cache', new CacheRepository(new ArrayStore())); app()->instance(Factory::class, new Factory()); app()->instance('log', new \Psr\Log\NullLogger()); config(['third_game.api_url' => 'https://supplier.test', 'third_game.sn' => 'test', 'third_game.key' => 'test']); Http::preventStrayRequests(); $sequence = Http::sequence(); foreach ($before as $response) { $sequence->push($response); } foreach ($days as $ratios) { $sequence->push(['code' => 10000, 'data' => ['ratios' => $ratios]]); } Http::fake(['https://supplier.test/api/server/quota' => $sequence]); } }