app = new Application(dirname(__DIR__, 2)); $this->app->instance('config', new Repository(['app' => ['locale' => 'zh', 'timezone' => 'Asia/Shanghai']])); Facade::setFacadeApplication($this->app); $db = new Manager($this->app); $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']); $db->setAsGlobal(); $db->bootEloquent(); $this->app->instance('db', $db->getDatabaseManager()); $translator = new Translator(new ArrayLoader(), 'zh'); $this->app->instance('translator', $translator); $this->app->instance('validator', new Factory($translator, $this->app)); $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new \Tests\Support\JsonResponseFactory()); Request::macro('validate', function (array $rules) { return app('validator')->make($this->all(), $rules)->validate(); }); foreach ([ 'users' => 'id INTEGER PRIMARY KEY, user_id TEXT, member_id TEXT, account TEXT, first_name TEXT, "from" INTEGER DEFAULT 1, created_at TEXT', 'wallets' => 'id INTEGER PRIMARY KEY, member_id TEXT, available_balance NUMERIC DEFAULT 0', 'balance_logs' => 'id INTEGER PRIMARY KEY, member_id TEXT, change_type TEXT, amount NUMERIC, created_at TEXT', 'third_game_orders' => 'id INTEGER PRIMARY KEY, user_id INTEGER, member_id TEXT, game_type INTEGER, status INTEGER DEFAULT 1, bet_amount NUMERIC DEFAULT 10, bet_time TEXT', 'sport_order' => 'id INTEGER PRIMARY KEY, user_id TEXT, amount NUMERIC DEFAULT 10, status INTEGER DEFAULT 1, pay_status INTEGER DEFAULT 1, return_status INTEGER DEFAULT 0, is_faker INTEGER DEFAULT 0, create_time INTEGER', 'sport_game_order' => 'id INTEGER PRIMARY KEY, member_id TEXT, amount NUMERIC DEFAULT 10, status INTEGER DEFAULT 1, created_at TEXT', 'lhc_order' => 'id INTEGER PRIMARY KEY, member_id TEXT, amount NUMERIC DEFAULT 10, lottery_status INTEGER DEFAULT 1, is_faker INTEGER DEFAULT 0, created_at INTEGER', 'bets' => 'id INTEGER PRIMARY KEY, member_id TEXT, amount NUMERIC DEFAULT 10, status INTEGER DEFAULT 2, created_at TEXT', 'jisu_game_order' => 'id INTEGER PRIMARY KEY, member_id TEXT, amount NUMERIC DEFAULT 10, status INTEGER DEFAULT 1, created_at TEXT', ] as $table => $columns) { DB::statement('CREATE TABLE bot_' . $table . ' (' . $columns . ')'); } foreach ([['100', 'alice', 1], ['200', 'bob', 1], ['300', 'robot', 2]] as $i => [$member, $account, $from]) { DB::table('users')->insert(['id' => $i + 1, 'user_id' => $member, 'member_id' => $member, 'account' => $account, 'first_name' => $account, 'from' => $from, 'created_at' => '2026-11-0' . ($i + 1) . ' 12:00:00']); DB::table('wallets')->insert(['member_id' => $member, 'available_balance' => 88]); } } protected function tearDown(): void { DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown(); } private function response(array $params = []): array { $this->app->instance('request', Request::create('/admin/operation/exchangeList', 'GET', $params)); return (new Operation())->exchangeList()->getData(true); } private function rows(array $params = []): array { $body = $this->response($params); $this->assertSame(0, $body['code'], $body['msg']); return array_column($body['data']['data'], null, 'member_id'); } private function order(string $table, string $member = '100', array $extra = [], string $at = '2026-11-04 12:00:00'): void { $timestamp = in_array($table, ['sport_order', 'lhc_order'], true); $memberColumn = $table === 'sport_order' ? 'user_id' : 'member_id'; $timeColumn = $table === 'sport_order' ? 'create_time' : ($table === 'third_game_orders' ? 'bet_time' : 'created_at'); DB::table($table)->insert($extra + [$memberColumn => $member, $timeColumn => $timestamp ? Carbon::parse($at, 'Asia/Shanghai')->timestamp : $at]); } private function funds(string $type, string $amount, string $member = '100', string $at = '2026-11-04 12:00:00'): void { DB::table('balance_logs')->insert(['member_id' => $member, 'change_type' => $type, 'amount' => $amount, 'created_at' => $at]); } public function test_all_nine_commission_categories_are_deduplicated_and_old_orders_are_included(): void { foreach (range(1, 7) as $type) $this->order('third_game_orders', extra: ['game_type' => $type]); $this->order('third_game_orders', extra: ['game_type' => 1]); $this->order('sport_order'); $this->order('sport_game_order'); // No sharing facts, child orders or legacy ID cutoff are needed to recognize historical play. $this->order('lhc_order', extra: ['id' => 1]); $this->order('bets'); $this->order('jisu_game_order'); $rows = $this->rows(); $this->assertSame(ShareDefaults::categories(), array_column($rows['100']['played_games'], 'label', 'category')); $this->assertSame([], $rows['200']['played_games']); $this->assertArrayNotHasKey('300', $rows); } public function test_cancelled_unpaid_fake_zero_stake_and_unknown_games_do_not_count_as_play(): void { $this->order('third_game_orders', extra: ['game_type' => 1, 'status' => 2]); $this->order('third_game_orders', extra: ['game_type' => 2, 'status' => 3]); $this->order('third_game_orders', extra: ['game_type' => 8]); $this->order('third_game_orders', extra: ['game_type' => 3, 'bet_amount' => 0]); $this->order('sport_order', extra: ['pay_status' => 0]); $this->order('sport_order', extra: ['return_status' => 2]); $this->order('sport_order', extra: ['is_faker' => 1]); $this->order('sport_game_order', extra: ['status' => 3]); $this->order('lhc_order', extra: ['lottery_status' => 3]); $this->order('lhc_order', extra: ['lottery_status' => 4]); $this->order('lhc_order', extra: ['is_faker' => 1]); $this->order('bets', extra: ['status' => 3]); $this->order('jisu_game_order', extra: ['status' => 3]); $this->assertSame([], $this->rows()['100']['played_games']); } public function test_pending_bets_count_without_waiting_for_commission_settlement(): void { $this->order('third_game_orders', extra: ['game_type' => 1, 'status' => 0]); $this->order('sport_order'); $this->order('sport_game_order', extra: ['status' => 0]); $this->order('lhc_order', extra: ['lottery_status' => 0]); $this->order('bets', extra: ['status' => 1]); $this->order('jisu_game_order', extra: ['status' => 0]); $this->assertSame(['third:1', 'self:sport', 'self:lottery'], array_column($this->rows()['100']['played_games'], 'category')); } public static function sourcesWithoutFakeFlag(): array { return [ 'lottery' => ['lhc_order', 'self:lottery'], 'sport' => ['sport_order', 'self:sport'], ]; } #[DataProvider('sourcesWithoutFakeFlag')] public function test_missing_optional_fake_flag_does_not_break_the_list_or_date_filters(string $table, string $category): void { DB::statement('ALTER TABLE bot_' . $table . ' DROP COLUMN is_faker'); $this->order($table); $this->order($table, '200', at: '2026-11-03 23:59:59'); $this->order($table, '300'); $this->funds('充值', '100'); $this->funds('提现', '-25'); $rows = $this->rows(['start_time' => '2026-11-04', 'end_time' => '2026-11-04', 'sort_field' => 'recharge']); $this->assertSame(['100', '200'], array_column($rows, 'member_id')); $this->assertSame([$category], array_column($rows['100']['played_games'], 'category')); $this->assertSame('75.00', $rows['100']['withdraw_recharge_difference']); $this->assertSame([], $rows['200']['played_games']); $this->assertArrayNotHasKey('300', $rows); $this->assertSame([$category], array_column($this->rows()['200']['played_games'], 'category')); } public static function absentGameTables(): array { $cases = []; foreach (['jisu_game_order', 'bets', 'sport_game_order', 'lhc_order', 'third_game_orders'] as $table) { $cases[$table] = [$table]; } return $cases; } #[DataProvider('absentGameTables')] public function test_absent_optional_game_table_does_not_break_the_list_or_other_categories(string $table): void { $this->order('third_game_orders', extra: ['game_type' => 1]); $this->order('sport_order'); $this->order('sport_game_order'); $this->order('lhc_order'); $this->order('bets'); $this->order('jisu_game_order'); DB::statement('DROP TABLE bot_' . $table); $this->funds('充值', '100'); $this->funds('提现', '-25'); $this->funds('充值', '200', '200'); $rows = $this->rows(['start_time' => '2026-11-04', 'end_time' => '2026-11-04', 'sort_field' => 'withdraw_recharge_difference', 'sort_order' => 'desc']); $this->assertSame(['200', '100'], array_column($rows, 'member_id')); $expected = $table === 'third_game_orders' ? ['self:sport', 'self:lottery'] : ['third:1', 'self:sport', 'self:lottery']; $this->assertSame($expected, array_column($rows['100']['played_games'], 'category')); $this->assertSame('75.00', $rows['100']['withdraw_recharge_difference']); $this->assertSame([], $rows['200']['played_games']); } public function test_absent_jisu_table_and_lottery_fake_flag_are_supported_together(): void { DB::statement('DROP TABLE bot_jisu_game_order'); DB::statement('ALTER TABLE bot_lhc_order DROP COLUMN is_faker'); $this->order('lhc_order'); $this->assertSame(['self:lottery'], array_column($this->rows()['100']['played_games'], 'category')); } public function test_no_game_tables_returns_no_played_categories(): void { foreach (['third_game_orders', 'sport_order', 'sport_game_order', 'lhc_order', 'bets', 'jisu_game_order'] as $table) { DB::statement('DROP TABLE bot_' . $table); } $this->assertSame([], PlayedGameService::forMembers(['100'])); } public function test_third_party_identity_supports_user_id_only_and_does_not_mix_members(): void { $this->order('third_game_orders', extra: ['user_id' => 1, 'member_id' => null, 'game_type' => 1]); $this->order('third_game_orders', '200', ['user_id' => 2, 'game_type' => 2]); $this->order('third_game_orders', '100', ['user_id' => 2, 'game_type' => 3]); // conflicting identity $rows = $this->rows(); $this->assertSame(['third:1'], array_column($rows['100']['played_games'], 'category')); $this->assertSame(['third:2'], array_column($rows['200']['played_games'], 'category')); } public static function gameSources(): array { return array_map(fn ($table) => [$table], ['third_game_orders', 'sport_order', 'sport_game_order', 'lhc_order', 'bets', 'jisu_game_order']); } #[DataProvider('gameSources')] public function test_each_game_source_respects_the_selected_dates(string $table): void { $extra = $table === 'third_game_orders' ? ['game_type' => 1] : []; $this->order($table, extra: $extra, at: '2026-11-03 23:59:59'); $this->order($table, extra: $extra, at: '2026-11-05 00:00:00'); $params = ['start_time' => '2026-11-04', 'end_time' => '2026-11-04']; $this->assertSame([], $this->rows($params)['100']['played_games']); $this->order($table, extra: $extra, at: '2026-11-04 00:00:00'); $this->assertCount(1, $this->rows($params)['100']['played_games']); } public function test_profit_is_recharge_minus_withdraw_and_deducts_refunded_withdrawals(): void { $this->funds('充值', '1000.11'); $this->funds('人工充值', '200.22'); $this->funds('三方充值', '300.33'); $this->funds('提现', '-2000.44'); $this->funds('三方提现', '-500.55'); $this->funds('三方提现', '500.55'); // failed withdrawal returned in full $this->funds('优惠活动', '9999'); $this->funds('人工扣款', '-9999'); $this->funds('充值', '8888', '300'); $row = $this->rows()['100']; $this->assertSame('1,500.66', $row['recharge']); $this->assertSame('2,000.44', $row['withdraw']); $this->assertSame('-499.78', $row['withdraw_recharge_difference']); $this->funds('提现', '-100', '200'); $this->funds('充值', '500', '200'); $this->assertSame('400.00', $this->rows()['200']['withdraw_recharge_difference']); } public function test_date_filters_apply_to_money_categories_and_existing_order_amount_at_day_boundaries(): void { foreach (['2026-11-04 00:00:00', '2026-11-04 23:59:59'] as $at) { $this->order('sport_order', at: $at); $this->order('lhc_order', at: $at); $this->funds('充值', '100', at: $at); } $this->order('third_game_orders', extra: ['game_type' => 1], at: '2026-11-03 23:59:59'); $this->order('third_game_orders', extra: ['game_type' => 2], at: '2026-11-05 00:00:00'); $this->order('sport_order', at: '2026-11-03 23:59:59'); $this->order('sport_order', at: '2026-11-05 00:00:00'); $this->funds('充值', '999', at: '2026-11-03 23:59:59'); $this->funds('提现', '-999', at: '2026-11-05 00:00:00'); $this->funds('提现', '-80'); $row = $this->rows(['start_time' => '2026-11-04', 'end_time' => '2026-11-04'])['100']; $this->assertSame('200.00', $row['recharge']); $this->assertSame('80.00', $row['withdraw']); $this->assertSame('120.00', $row['withdraw_recharge_difference']); $this->assertSame('20.00', $row['order_amount']); $this->assertSame(['self:sport', 'self:lottery'], array_column($row['played_games'], 'category')); $this->assertCount(4, $this->rows()['100']['played_games']); } public function test_empty_users_default_to_zero_and_filters_and_pagination_keep_the_response_shape(): void { $row = $this->rows(['account' => 'lic'])['100']; foreach (['recharge', 'withdraw', 'withdraw_recharge_difference', 'order_amount'] as $key) $this->assertSame('0.00', $row[$key]); $this->assertSame([], $row['played_games']); $this->assertSame([], $this->rows(['account' => 'missing'])); $body = $this->response(['page' => 2, 'limit' => 1]); $this->assertSame(2, $body['data']['total']); $this->assertSame(['100'], array_column($body['data']['data'], 'member_id')); $this->assertNotSame(0, $this->response(['start_time' => '2026-11-04'])['code']); } private function sortingMembers(): void { foreach (['400' => 'carol', '500' => 'dave'] as $member => $account) { DB::table('users')->insert(['user_id' => (string) $member, 'member_id' => (string) $member, 'account' => $account, 'first_name' => $account, 'created_at' => '2026-11-04 12:00:00']); DB::table('wallets')->insert(['member_id' => (string) $member]); } $this->funds('充值', '2000.10'); $this->funds('提现', '-50.10'); $this->funds('三方充值', '100.00', '200'); $this->funds('三方提现', '-1500.01', '200'); $this->funds('三方提现', '-9000', '200'); $this->funds('三方提现', '9000', '200'); // refunded, must not raise the withdrawal ranking $this->funds('人工充值', '900', '400'); $this->funds('提现', '-100', '400'); $this->funds('充值', '999999', '300'); // virtual user foreach (['100' => '9', '200' => '1000', '400' => '90', '500' => '0'] as $member => $balance) { DB::table('wallets')->where('member_id', (string) $member)->update(['available_balance' => $balance]); } } public static function amountSorts(): array { $cases = []; foreach ([ 'recharge' => ['500', '200', '400', '100'], 'withdraw' => ['500', '100', '400', '200'], 'withdraw_recharge_difference' => ['200', '500', '400', '100'], 'money' => ['500', '100', '400', '200'], ] as $field => $ascending) { $cases[$field . '_asc'] = [$field, 'asc', $ascending]; $cases[$field . '_desc'] = [$field, 'desc', array_reverse($ascending)]; } return $cases; } #[DataProvider('amountSorts')] public function test_amount_sorting_is_numeric_and_precedes_pagination(string $field, string $direction, array $expected): void { $this->sortingMembers(); $actual = []; foreach ([1, 2] as $page) { $body = $this->response(['sort_field' => $field, 'sort_order' => $direction, 'limit' => 2, 'page' => $page]); $this->assertSame(0, $body['code'], $body['msg']); $this->assertSame(4, $body['data']['total']); array_push($actual, ...array_column($body['data']['data'], 'member_id')); } $this->assertSame($expected, $actual); } public function test_amount_sorting_uses_selected_dates_and_member_filters(): void { $this->sortingMembers(); $this->funds('充值', '100000', '200', '2026-11-03 12:00:00'); $this->funds('提现', '-100000', '100', '2026-11-05 12:00:00'); $dates = ['start_time' => '2026-11-04', 'end_time' => '2026-11-04']; foreach (['recharge' => '100', 'withdraw' => '200', 'withdraw_recharge_difference' => '100'] as $field => $member) { $body = $this->response($dates + ['sort_field' => $field, 'limit' => 1]); $this->assertSame(0, $body['code'], $body['msg']); $this->assertSame($member, $body['data']['data'][0]['member_id']); } $body = $this->response(['sort_field' => 'recharge', 'limit' => 1]); $this->assertSame('200', $body['data']['data'][0]['member_id']); $this->assertSame(['400'], array_column($this->response($dates + ['sort_field' => 'recharge', 'account' => 'car'])['data']['data'], 'member_id')); } public function test_equal_amounts_have_stable_order_across_pages_and_default_order_is_preserved(): void { DB::table('users')->insert(['id' => 4, 'user_id' => '400', 'member_id' => '400', 'account' => 'carol', 'first_name' => 'carol', 'created_at' => '2026-11-02 12:00:00']); DB::table('wallets')->insert(['member_id' => '400', 'available_balance' => 88]); foreach (['recharge', 'withdraw', 'withdraw_recharge_difference', 'money'] as $field) { $members = []; foreach ([1, 2, 3] as $page) { $body = $this->response(['sort_field' => $field, 'sort_order' => 'asc', 'limit' => 1, 'page' => $page]); $this->assertSame(0, $body['code'], $body['msg']); $members[] = $body['data']['data'][0]['member_id']; } $this->assertSame(['400', '200', '100'], $members); } $this->assertSame(['400', '200', '100'], array_column($this->response()['data']['data'], 'member_id')); } public static function invalidSorts(): array { return [ [['sort_field' => 'created_at']], [['sort_field' => 'money desc, users.id']], [['sort_field' => ['money']]], [['sort_field' => 'money', 'sort_order' => 'descending']], [['sort_field' => 'money', 'sort_order' => ['asc']]], ]; } #[DataProvider('invalidSorts')] public function test_invalid_sort_fields_and_directions_are_rejected(array $params): void { $this->assertNotSame(0, $this->response($params)['code']); } }