originalTimezone = date_default_timezone_get(); date_default_timezone_set('Asia/Shanghai'); Carbon::setTestNow(Carbon::parse('2026-10-26 12:00:00', 'Asia/Shanghai')); Facade::clearResolvedInstances(); $this->app = new Application(dirname(__DIR__, 2)); $this->app->instance('config', new Repository(['app' => ['locale' => 'zh', 'timezone' => 'Asia/Shanghai'], 'third_game' => ['currency' => 'CNY']])); 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()); $this->app->bind('db.schema', fn () => $db->schema()); $this->app->instance('translator', new Translator(new ArrayLoader(), 'zh')); $this->app->instance('validator', new Factory($this->app['translator'], $this->app)); $this->app->instance('log', new \Psr\Log\NullLogger()); $this->app->instance('request', Request::create('/api/promotion/progress')); $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new class { public function json($data, $status = 200, $headers = [], $options = 0) { return new \Illuminate\Http\JsonResponse($data, $status, $headers, $options); } }); Request::macro('validate', function ($rules) { return app('validator')->make($this->all(), $rules)->validate(); }); foreach ([ 'activity_rewards' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,title TEXT,sub_title TEXT,type INTEGER,status INTEGER,start_time INTEGER,end_time INTEGER,lang TEXT,params TEXT,detail_image TEXT,pc_image TEXT,detail_banner TEXT,pc_banner TEXT,content TEXT,created_at TEXT,updated_at TEXT', 'users' => 'id INTEGER PRIMARY KEY,member_id TEXT UNIQUE,user_id TEXT,language TEXT,"from" INTEGER,level INTEGER DEFAULT 0,recharge_channel_group_id INTEGER DEFAULT 1,created_at TEXT,updated_at TEXT', 'wallets' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,member_id TEXT UNIQUE,available_balance TEXT DEFAULT "0",frozen_balance TEXT DEFAULT "0",created_at TEXT,updated_at TEXT', 'balance_logs' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,member_id TEXT,type INTEGER DEFAULT 1,amount TEXT,change_type TEXT,before_balance TEXT,after_balance TEXT,related_id INTEGER,remark TEXT,frozen_status INTEGER,created_at TEXT,updated_at TEXT', 'share_members' => 'user_id INTEGER PRIMARY KEY,parent_user_id INTEGER,bound_at TEXT', 'share_policies' => 'id INTEGER PRIMARY KEY,effective_date TEXT,valid_member_min_deposit TEXT,valid_member_min_effective_bet TEXT', 'share_bet_facts' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,user_id INTEGER,source TEXT,source_id TEXT,bet_at TEXT,bet_amount TEXT,net_profit TEXT,effective_amount TEXT,currency TEXT,settled INTEGER DEFAULT 1,voided INTEGER DEFAULT 0', 'user_login' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,user_id TEXT,status INTEGER,platform TEXT,created_at TEXT', 'reporting_activity' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,user_id INTEGER,session_hash TEXT,observed_at TEXT', 'menus' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,parent_id INTEGER,title TEXT,icon TEXT,uri TEXT,permission_name TEXT,sort INTEGER,status INTEGER,type INTEGER,created_at TEXT,updated_at TEXT', ] as $table => $columns) DB::statement('CREATE TABLE bot_' . $table . ' (' . $columns . ')'); foreach (['2026_10_13_120000_add_activity_eligibility_fields.php', '2026_10_14_120000_add_activity_reward_quotas.php', '2026_10_25_120000_add_game_category_to_activity_rewards.php', '2026_10_26_160000_create_promotion_activities.php', '2026_10_27_130000_initialize_fixed_promotion_activities.php', '2026_10_27_140000_add_promotion_activity_translations.php'] as $migration) (require dirname(__DIR__, 2) . '/database/migrations/' . $migration)->up(); $this->member(1); $this->member(2, 1); DB::table('share_policies')->insert(['id' => 1, 'effective_date' => '2026-10-01', 'valid_member_min_deposit' => '200', 'valid_member_min_effective_bet' => '300']); $this->service = new PromotionService(); } protected function tearDown(): void { Carbon::setTestNow(); date_default_timezone_set($this->originalTimezone); DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown(); } private function travel(string $time): void { Carbon::setTestNow(Carbon::parse($time, 'Asia/Shanghai')); } private function member(int $id, ?int $parent = null, string $at = '2026-10-01 00:00:00'): void { DB::table('users')->insert(['id' => $id, 'member_id' => 'm' . $id, 'user_id' => 'u' . $id, 'language' => 'zh', 'from' => 1, 'created_at' => $at, 'updated_at' => $at]); DB::table('wallets')->insert(['member_id' => 'm' . $id, 'available_balance' => '0', 'frozen_balance' => '0']); if ($parent) DB::table('share_members')->insert(['user_id' => $id, 'parent_user_id' => $parent, 'bound_at' => $at]); } private function activity(int $type = 9, array $params = [], int $status = 1): int { $input = ['translations' => $this->texts(), 'type' => $type, 'status' => $status, 'start_time' => '2026-10-01 00:00:00', 'end_time' => '2026-12-31 23:59:59', 'lang' => 'zh', 'params' => $params]; if (in_array($type, [7, 9], true)) $input['id'] = (int) DB::table('promotion_activities')->where('type', $type)->value('id'); return (int) (new PromotionAdminService())->save($input)['id']; } private function texts(): array { return ['zh' => ['title' => '测试活动', 'sub_title' => '规则', 'content' => '
中文活动正文
'], 'en' => ['title' => 'Test promotion', 'sub_title' => 'English subtitle', 'content' => 'English content
'], 'vi' => ['title' => 'Khuyến mãi', 'sub_title' => 'Phụ đề', 'content' => 'Nội dung
']]; } private function deposit(int $id, string $amount, ?string $time = null, string $type = '充值'): BalanceLog { return BalanceLog::create(['member_id' => 'm' . $id, 'type' => 1, 'amount' => $amount, 'change_type' => $type, 'created_at' => $time ?? PromotionFacts::now()]); } private function rawDeposit(int $id, string $amount, string $time, string $type = '充值'): int { return DB::table('balance_logs')->insertGetId(['member_id' => 'm' . $id, 'type' => 1, 'amount' => $amount, 'change_type' => $type, 'created_at' => $time, 'updated_at' => $time]); } private function bet(int $id, string $stake, string $profit, string $time, array $extra = []): int { $next = (int)DB::table('share_bet_facts')->max('id') + 1; return DB::table('share_bet_facts')->insertGetId($extra + ['user_id' => $id, 'source' => 'sport_order', 'source_id' => (string)$next, 'bet_at' => $time, 'bet_amount' => $stake, 'net_profit' => $profit, 'effective_amount' => '999999', 'currency' => 'CNY', 'settled' => 1, 'voided' => 0]); } private function invalid(callable $callback, string $message): void { try { $callback(); $this->fail('Expected validation failure: ' . $message); } catch (ValidationException $e) { $this->assertStringContainsString($message, $e->validator->errors()->first()); } } private function complete(int $activity): array { $this->rawDeposit(1, '3000', '2026-10-26 12:01:00'); $this->rawDeposit(2, '500', '2026-10-26 12:01:00'); $this->bet(1, '6000', '-5000', '2026-10-26 12:01:00'); $this->bet(2, '4000', '-3000', '2026-10-26 12:01:00'); DB::table('user_login')->insert(['user_id' => 'm1', 'status' => 1, 'platform' => 'H5', 'created_at' => '2026-10-26 12:01:00']); for ($i = 0; $i <= 60; ++$i) DB::table('reporting_activity')->insert(['user_id' => 1, 'session_hash' => 'a', 'observed_at' => Carbon::parse('2026-10-26 12:01:00')->addMinutes($i)->format('Y-m-d H:i:s')]); $this->travel('2026-10-26 14:00:00'); return $this->service->progress($activity, 1, 'H5'); } public function test_images_are_only_configurable_for_first_deposit_not_fixed_loss_or_wheel_campaigns(): void { $admin = new PromotionAdminService(); foreach ([7, 8, 9] as $type) { $id = $this->activity($type); DB::table('promotion_activities')->where('id', $id)->update(['image' => 'previous.png']); $input = $admin->detail($id); $input['image'] = $type === 8 ? 'first-deposit.png' : ['ignored-legacy-upload']; $saved = $admin->save($input); foreach ([$saved, $admin->detail($id), $admin->listing(['type' => $type])['data'][0], $this->service->listing(1, 'H5', 'zh', $type)['data'][0]] as $row) { $this->assertSame($type === 8, array_key_exists('image', $row)); } $this->assertSame($type === 8 ? 'first-deposit.png' : 'previous.png', DB::table('promotion_activities')->where('id', $id)->value('image')); } } public function test_three_languages_edit_one_campaign_and_keep_rules_history_and_other_tabs(): void { $admin = new PromotionAdminService(); foreach ([7, 8, 9] as $type) { $id = $this->activity($type); $before = $admin->detail($id); $count = DB::table('promotion_activities')->count(); $input = $before; $input['translations']['en']['sub_title'] = 'Updated English subtitle'; $saved = $admin->save($input); $this->assertSame($id, $saved['id']); $this->assertSame($count, DB::table('promotion_activities')->count()); $this->assertSame($before['translations']['zh'], $saved['translations']['zh']); $this->assertSame($before['translations']['vi'], $saved['translations']['vi']); $this->assertSame('Updated English subtitle', $admin->detail($id)['translations']['en']['sub_title']); foreach (['params', 'rewarded_count', 'budget_used', 'first_published_at'] as $field) $this->assertSame($before[$field], $saved[$field]); foreach (['zh', 'en', 'vi'] as $lang) { $rows = $this->service->listing(1, 'H5', $lang, $type)['data']; $this->assertCount(1, $rows); $this->assertSame($id, $rows[0]['activity_id']); $this->assertSame($saved['translations'][$lang]['sub_title'], $rows[0]['sub_title']); $state = $this->service->detail($id, 1, 'H5', $lang); $this->assertSame($lang, $state['lang']); $this->assertSame($saved['translations'][$lang]['title'], $state['title']); $this->assertSame($saved['translations'][$lang]['content'], $state['content']); } } $this->assertSame(['zh', 'en', 'vi'], array_column($admin->options()['languages'], 'value')); } public function test_all_nine_text_fields_are_required_and_invalid_tabs_cannot_overwrite_saved_content(): void { $id = $this->activity(9); $admin = new PromotionAdminService(); $input = $admin->detail($id); $before = DB::table('promotion_activities')->where('id', $id)->first(); foreach (['zh', 'en', 'vi'] as $lang) { foreach (['title', 'sub_title', 'content'] as $field) { $bad = $input; unset($bad['translations'][$lang][$field]); try { $admin->save($bad); $this->fail('Missing language text was accepted'); } catch (ValidationException $e) { $this->assertTrue($e->validator->errors()->has('translations.' . $lang . '.' . $field)); } $bad = $input; $bad['translations'][$lang][$field] = '
Existing content
']); $rows = DB::table('promotion_activities')->orderBy('id')->get(); $published = DB::table('promotion_campaigns')->orderBy('activity_id')->get(); $migration->up(); foreach ($rows as $row) { $saved = DB::table('promotion_activities')->where('id', $row->id)->first(); foreach ((array) $row as $field => $value) $this->assertSame($value, $saved->$field); } $texts = (new PromotionAdminService())->detail((int) $id)['translations']; $this->assertSame(['title' => 'Existing title', 'sub_title' => 'Existing subtitle', 'content' => 'Existing content
'], $texts['en']); $this->assertSame('', $texts['zh']['title']); $this->assertEquals($published, DB::table('promotion_campaigns')->orderBy('activity_id')->get()); $this->assertSame(0, DB::table('menus')->count()); $this->assertSame(0, $this->service->listing(1, 'H5', 'vi', 7)['total']); } public function test_probability_default_zero_boundaries_and_incomplete_gate(): void { $this->assertSame('20.00', PromotionRules::normalize(9, [])['completion_probability']); $this->assertSame('0.00', PromotionRules::normalize(9, ['completion_probability' => 0])['completion_probability']); $this->assertTrue(PromotionRules::draw(66599, true, '20.00', fn ($a, $b) => $a === 1 ? 2000 : 0)['reached']); $this->assertFalse(PromotionRules::draw(66599, true, '20.00', fn ($a, $b) => $a === 1 ? 2001 : 0)['reached']); $this->assertFalse(PromotionRules::draw(66599, false, '100.00', fn ($a, $b) => $a)['reached']); $this->invalid(fn () => PromotionRules::normalize(9, ['completion_probability' => 100.01]), 'between'); } public function test_day_amounts_do_not_add_together_but_completed_flags_survive_midnight(): void { $id = $this->activity(); $this->service->progress($id, 1); $this->rawDeposit(1, '2000', '2026-10-26 13:00:00'); $this->travel('2026-10-27 14:00:00'); $this->rawDeposit(1, '1000', '2026-10-27 12:00:00'); $state = $this->service->progress($id, 1); $this->assertFalse($state['completed_tasks']['deposit']); $this->assertSame(0, (int)$state['spins']); $this->rawDeposit(1, '2000', '2026-10-27 13:00:00'); $state = $this->service->progress($id, 1); $this->assertTrue($state['completed_tasks']['deposit']); $this->assertSame(1, (int)$state['spins']); $this->travel('2026-10-28 14:00:00'); $state = $this->service->progress($id, 1); $this->assertTrue($state['completed_tasks']['deposit']); $this->assertSame(1, (int)$state['spins']); } public function test_repeated_rounds_keep_unused_spins_and_daily_flags_but_require_new_other_events(): void { $id = $this->activity(9, ['completion_probability' => 100]); $state = $this->complete($id); $this->assertTrue($state['conditions_complete']); $this->assertSame(7, (int)$state['spins']); $spin = $this->service->spin($id, 1, 'request_spin_00001'); $this->assertTrue($spin['spin']['reached']); $this->assertSame('666.00', $spin['spin']['coins']); $this->assertSame($spin['spin'], $this->service->spin($id, 1, 'request_spin_00001')['spin']); $claim = $this->service->claim($id, 1, 1); $this->assertSame(2, $claim['round_no']); $this->assertSame(6, (int)$claim['spins']); $this->assertTrue($this->service->claim($id, 1, 1)['replayed']); $state = $this->service->progress($id, 1); $this->assertSame('0.00', $state['coins']); $this->assertSame(6, (int)$state['spins']); $this->assertTrue($state['completed_tasks']['online']); $this->assertTrue($state['completed_tasks']['login']); foreach (['invite', 'deposit', 'team_deposit', 'bet', 'team_bet'] as $task) $this->assertFalse($state['completed_tasks'][$task]); $this->rawDeposit(1, '3000', '2026-10-26 14:00:00'); $this->rawDeposit(2, '500', '2026-10-26 14:00:00'); $this->bet(1, '6000', '-5000', '2026-10-26 14:00:00'); $this->bet(2, '4000', '-3000', '2026-10-26 14:00:00'); $this->member(3, 1, '2026-10-26 14:00:00'); $this->rawDeposit(3, '200', '2026-10-26 14:00:00'); $this->bet(3, '300', '-300', '2026-10-26 14:00:00'); $state = $this->service->progress($id, 1); $this->assertTrue($state['conditions_complete']); $this->assertSame(11, (int)$state['spins']); $this->assertTrue($this->service->spin($id, 1, 'request_spin_00002')['spin']['reached']); $this->service->claim($id, 1, 2); $this->assertSame('1332.0000000000', (string)DB::table('wallets')->where('member_id', 'm1')->value('available_balance')); $this->assertSame(2, DB::table('promotion_rewards')->count()); $this->assertSame(1, (int)DB::table('promotion_activities')->where('id', $id)->value('rewarded_count')); } public function test_probability_changes_affect_new_spins_and_reserved_reward_can_be_claimed_after_down(): void { $id = $this->activity(9, ['completion_probability' => 0]); $this->complete($id); $first = $this->service->spin($id, 1, 'request_spin_00001'); $this->assertFalse($first['spin']['reached']); $row = ActivityReward::find($id); $rules = json_decode($row->params, true); $rules['completion_probability'] = '100.00'; $row->params = json_encode($rules); $row->save(); $next = $this->service->spin($id, 1, 'request_spin_00002'); $this->assertTrue($next['spin']['reached']); $this->assertSame('0', (string)DB::table('promotion_spins')->orderBy('id')->value('probability')); DB::table('promotion_activities')->where('id', $id)->update(['status' => 0]); $this->service->claim($id, 1, 1); $this->invalid(fn () => $this->service->progress($id, 1), '未上架'); $this->assertTrue($this->service->claim($id, 1, 1)['replayed']); } public function test_first_deposit_is_historical_first_and_does_not_refill_on_relisting(): void { $id = $this->activity(8, ['min_deposit' => '100', 'reward_amount' => '25']); $first = $this->deposit(1, '99'); $this->service->onDeposit($first); $this->service->onDeposit($first); $second = $this->deposit(1, '1000'); $this->service->onDeposit($second); $this->assertSame(0, DB::table('promotion_rewards')->count()); $other = $this->deposit(2, '100'); $this->service->onDeposit($other); $this->service->onDeposit($other); $this->assertSame(1, DB::table('promotion_rewards')->count()); $this->assertSame('25.0000000000', (string)DB::table('wallets')->where('member_id', 'm2')->value('available_balance')); $published = DB::table('promotion_campaigns')->where('activity_id', $id)->value('first_published_at'); $this->travel('2026-10-27 12:00:00'); $row = ActivityReward::find($id); $row->status = 0; $row->save(); PromotionService::publish($row); $row->status = 1; $row->save(); PromotionService::publish($row); $this->assertSame($published, DB::table('promotion_campaigns')->where('activity_id', $id)->value('first_published_at')); $this->service->onDeposit($this->deposit(2, '500')); $this->assertSame(1, DB::table('promotion_rewards')->count()); } public function test_loss_uses_post_publication_net_profit_then_only_new_direct_invitees_with_both_thresholds(): void { $this->bet(1, '900', '-900', '2026-10-25 12:00:00'); $id = $this->activity(7); $this->invalid(fn () => $this->service->apply($id, 1), '净亏'); $this->bet(1, '600', '-600', '2026-10-26 12:00:00'); $this->bet(1, '200', '100', '2026-10-26 12:00:00'); // A referral created in the same second, but before applying, is still an existing user. DB::table('users')->where('id', 2)->update(['created_at' => '2026-10-26 12:00:00']); DB::table('share_members')->where('user_id', 2)->update(['bound_at' => '2026-10-26 12:00:00']); $this->service->apply($id, 1); $this->service->apply($id, 1); $this->rawDeposit(2, '500', '2026-10-26 12:00:00'); $this->bet(2, '300', '-300', '2026-10-26 12:00:00'); foreach ([3, 4, 5] as $child) { $this->member($child, 1, '2026-10-26 12:00:00'); $this->rawDeposit($child, '200', '2026-10-26 12:00:00'); if ($child !== 5) $this->bet($child, '300', '-300', '2026-10-26 12:00:00'); } $this->invalid(fn () => $this->service->claim($id, 1, 1), '人数不足'); $this->bet(5, '1000', '-300', '2026-10-26 12:00:00'); $this->service->claim($id, 1, 1); $this->service->claim($id, 1, 1); $this->assertSame(1, DB::table('promotion_rewards')->count()); $this->assertSame('500.0000000000', (string)DB::table('wallets')->where('member_id', 'm1')->value('available_balance')); } public function test_successful_online_time_deduplicates_devices_and_does_not_count_closed_session_as_online(): void { $id = $this->activity(); foreach (['a', 'b'] as $session) for ($i = 0; $i <= 30; ++$i) DB::table('reporting_activity')->insert(['user_id' => 1, 'session_hash' => $session, 'observed_at' => Carbon::parse('2026-10-26 12:00:00')->addMinutes($i)->format('Y-m-d H:i:s')]); $this->travel('2026-10-26 16:00:00'); $state = $this->service->progress($id, 1); $this->assertArrayNotHasKey('online', $state['completed_tasks']); $this->assertSame(1800, (new PromotionFacts())->online(1, '2026-10-26 12:00:00')['2026-10-26']); } public function test_effective_minimum_void_pending_and_next_day_policy_are_respected(): void { DB::table('share_policies')->insert(['id' => 2, 'effective_date' => '2026-10-27', 'valid_member_min_deposit' => '9999', 'valid_member_min_effective_bet' => '9999']); $facts = new PromotionFacts(); $this->assertSame(1, $facts->policy()['id']); $this->bet(1, '1000', '50', '2026-10-26 12:00:00'); $this->bet(1, '500', '-500', '2026-10-26 12:00:00', ['voided' => 1]); $this->bet(1, '500', '-500', '2026-10-26 12:00:00', ['settled' => 0]); $this->assertSame('50.0000000000', $facts->total($facts->betting([1], '2026-10-26 12:00:00')['effective'])); $this->travel('2026-10-27 00:00:00'); $this->assertSame(2, $facts->policy()['id']); } public function test_turnover_rewards_only_unlock_from_new_bets_and_only_once(): void { $id = $this->activity(8, ['min_deposit' => 100, 'reward_amount' => 25, 'turnover_ratio' => 2]); $this->bet(1, '100', '-100', '2026-10-26 12:00:00'); $this->service->onDeposit($this->deposit(1, '100')); $this->assertSame(0, $this->service->unlock(1)); $this->bet(1, '1000', '-49', '2026-10-26 12:00:00'); $this->assertSame(0, $this->service->unlock(1)); $this->bet(1, '1', '-1', '2026-10-26 12:00:00'); $this->assertSame(1, $this->service->unlock(1)); $this->assertSame(0, $this->service->unlock(1)); $this->assertSame('25.0000000000', (string)DB::table('wallets')->where('member_id', 'm1')->value('available_balance')); $this->assertSame('0.0000000000', (string)DB::table('wallets')->where('member_id', 'm1')->value('frozen_balance')); } public function test_quota_is_checked_before_consuming_a_spin_and_a_win_reserves_its_slot(): void { $id = $this->activity(9, ['completion_probability' => 100]); $this->complete($id); DB::table('promotion_activities')->where('id', $id)->update(['reward_quota' => 0]); $this->invalid(fn () => $this->service->spin($id, 1, 'request_spin_00001'), '份数'); $this->assertSame(7, (int)DB::table('promotion_accounts')->value('spins')); DB::table('promotion_activities')->where('id', $id)->update(['reward_quota' => 1]); $this->service->spin($id, 1, 'request_spin_00001'); $this->assertSame('reserved', DB::table('promotion_rewards')->value('status')); $this->assertSame(1, (int)DB::table('promotion_activities')->where('id', $id)->value('rewarded_count')); } public function test_independent_campaigns_do_not_touch_old_catalog_and_member_routes_live_in_member_project(): void { $id = $this->activity(9); $this->activity(7); $this->activity(8); $this->assertSame(0, DB::table('activity_rewards')->count()); $this->assertArrayNotHasKey(9, \App\Support\ActivityCatalog::activityTypes()); $this->assertSame(3, $this->service->listing(1, 'H5')['total']); $this->assertSame($id, $this->service->listing(1, 'H5', 'zh', 9)['data'][0]['activity_id']); $this->assertSame(0, DB::table('menus')->count(), 'Activity setup must leave menus to the frontend'); $router = new \Illuminate\Routing\Router(new \Illuminate\Events\Dispatcher($this->app), $this->app); $this->app->instance('router', $router); $router->group(['prefix' => 'api'], function () { require dirname(__DIR__, 2) . '/routes/api.php'; }); $memberRoutes=array_filter($router->getRoutes()->getRoutes(),fn ($route)=>str_starts_with($route->uri(),'api/promotion')); $this->assertSame([],$memberRoutes,'Member promotion routes must be owned by melbet_sport-api'); $router->group(['prefix' => 'admin'], function () { require dirname(__DIR__, 2) . '/routes/admin.php'; }); $route = $router->getRoutes()->match(Request::create('/admin/promotion/submit', 'POST')); $this->assertContains('admin.jwt', $route->middleware()); $this->assertContains('check.button.uri', $route->middleware()); } public function test_promotion_migration_preserves_frontend_owned_menus_on_up_and_down(): void { DB::table('menus')->insert([ ['id' => 1, 'parent_id' => 0, 'title' => '前端活动菜单', 'uri' => 'promotion', 'permission_name' => 'promotion.manage', 'type' => 1], ['id' => 2, 'parent_id' => 1, 'title' => '前端活动记录', 'uri' => 'admin/promotion/records', 'permission_name' => 'admin.promotion.records', 'type' => 2], ['id' => 3, 'parent_id' => 0, 'title' => '其他菜单', 'uri' => 'other', 'permission_name' => 'other.manage', 'type' => 1], ]); $before = DB::table('menus')->orderBy('id')->get()->toJson(); $migration = require dirname(__DIR__, 2) . '/database/migrations/2026_10_26_160000_create_promotion_activities.php'; $migration->down(); $this->assertSame($before, DB::table('menus')->orderBy('id')->get()->toJson(), 'Rollback must preserve frontend menus and permissions'); $this->assertFalse(DB::connection()->getSchemaBuilder()->hasTable('promotion_activities')); $migration->up(); $this->assertSame($before, DB::table('menus')->orderBy('id')->get()->toJson(), 'Migration must not add or modify frontend menus and permissions'); $this->assertTrue(DB::connection()->getSchemaBuilder()->hasTable('promotion_activities')); } public function test_admin_http_amounts_are_compact_without_changing_saved_rules_or_counters(): void { $id = $this->activity(8, ['min_deposit' => '200', 'reward_amount' => '10.12']); $request = Request::create('/admin/promotion/detail', 'GET', ['id' => $id]); $this->app->instance('request', $request); $response = (new \App\Http\Controllers\admin\Promotion())->detail(new PromotionAdminService())->getData(true); $this->assertSame(0, $response['code']); $this->assertSame('200', $response['data']['params']['min_deposit']); $this->assertSame('10.12', $response['data']['params']['reward_amount']); $this->assertSame($id, $response['data']['id']); $saved = json_decode(DB::table('promotion_activities')->where('id', $id)->value('params'), true); $this->assertSame('200.00', $saved['min_deposit']); $this->assertSame('10.12', $saved['reward_amount']); } public function test_fixed_activities_are_initialized_disabled_without_publishing_or_creating_menus(): void { $this->assertSame(2, DB::table('promotion_activities')->count()); foreach ([7, 9] as $type) { $row = DB::table('promotion_activities')->where('type', $type)->first(); $this->assertNotNull($row); $this->assertSame(0, (int) $row->status); $this->assertNull(DB::table('promotion_campaigns')->where('activity_id', $row->id)->value('first_published_at')); } $options = (new PromotionAdminService())->options(); $this->assertSame([7, 8, 9], array_column($options['types'], 'value')); $this->assertSame([8], array_column($options['create_types'], 'value')); $this->assertSame(0, DB::table('menus')->count()); } public function test_fixed_types_cannot_be_created_even_when_no_configuration_exists(): void { $admin = new PromotionAdminService(); foreach ([7, 9] as $type) { $input = ['translations' => $this->texts(), 'type' => $type, 'status' => 0, 'start_time' => '2026-10-01 00:00:00', 'end_time' => '2026-12-31 23:59:59']; $this->invalid(fn () => $admin->save($input), '不支持新增'); $this->assertSame(1, DB::table('promotion_activities')->where('type', $type)->count()); $id = DB::table('promotion_activities')->where('type', $type)->value('id'); DB::table('promotion_campaigns')->where('activity_id', $id)->delete(); DB::table('promotion_activities')->where('id', $id)->delete(); $this->invalid(fn () => $admin->save($input), '不支持新增'); $this->assertSame(0, DB::table('promotion_activities')->where('type', $type)->count()); } } public function test_first_deposit_still_allows_multiple_new_configurations_and_type_change_is_rejected(): void { $first = $this->activity(8); $second = $this->activity(8); $this->assertNotSame($first, $second); $this->assertSame(2, DB::table('promotion_activities')->where('type', 8)->count()); $this->invalid(fn () => (new PromotionAdminService())->save(['id' => $first, 'translations' => $this->texts(), 'type' => 9, 'status' => 0, 'start_time' => '2026-10-01 00:00:00', 'end_time' => '2026-12-31 23:59:59']), '不能变更活动类型'); $this->assertSame(8, (int) DB::table('promotion_activities')->where('id', $first)->value('type')); } public function test_fixed_initialization_and_relisting_do_not_replace_existing_activity_or_reset_its_history(): void { $id = $this->activity(9, ['completion_probability' => 0]); DB::table('promotion_activities')->where('id', $id)->update(['rewarded_count' => 3, 'budget_used' => '1998']); $before = (array) DB::table('promotion_activities')->where('id', $id)->first(); $published = DB::table('promotion_campaigns')->where('activity_id', $id)->value('first_published_at'); $migration = require dirname(__DIR__, 2) . '/database/migrations/2026_10_27_130000_initialize_fixed_promotion_activities.php'; $this->travel('2026-10-27 12:00:00'); $migration->up(); $migration->down(); $this->assertSame($before, (array) DB::table('promotion_activities')->where('id', $id)->first()); $this->assertSame(2, DB::table('promotion_activities')->count()); $admin = new PromotionAdminService(); $admin->status($id, 0); $admin->status($id, 1); $this->assertSame($published, DB::table('promotion_campaigns')->where('activity_id', $id)->value('first_published_at')); $this->assertSame(3, (int) DB::table('promotion_activities')->where('id', $id)->value('rewarded_count')); } public function test_admin_partial_rule_edit_preserves_other_settings_and_zero_probability(): void { $id = $this->activity(9, ['turnover_ratio' => 3, 'deposit_amount' => 5000]); $saved = (new PromotionAdminService())->save(['id' => $id, 'translations' => $this->texts(), 'type' => 9, 'status' => 1, 'start_time' => '2026-10-01', 'end_time' => '2026-12-31', 'params' => ['completion_probability' => 0]]); $this->assertSame('0.00', $saved['params']['completion_probability']); $this->assertSame('3.00', $saved['params']['turnover_ratio']); $this->assertSame('5000.00', $saved['params']['deposit_amount']); $this->assertSame(0, DB::table('activity_rewards')->count()); } public function test_voided_task_progress_is_not_eligible_for_completion_probability(): void { $id = $this->activity(9, ['completion_probability' => 100]); $this->complete($id); DB::table('share_bet_facts')->where('user_id', 2)->update(['voided' => 1]); $state = $this->service->progress($id, 1); $this->assertFalse($state['conditions_complete']); $this->assertFalse($state['completed_tasks']['invite']); $this->assertFalse($state['completed_tasks']['team_bet']); $this->assertFalse($this->service->spin($id, 1, 'request_spin_voided')['spin']['reached']); } public function test_real_chain_credit_hook_awards_first_deposit_once_and_rolls_back_a_failed_award(): void { foreach ([ 'config' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,field TEXT,val TEXT', 'level' => 'id INTEGER PRIMARY KEY,level INTEGER,recharge TEXT', 'recharge_channel_group' => 'id INTEGER PRIMARY KEY,activity_type TEXT,deleted_at TEXT', 'backflow' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,date TEXT,member_id TEXT,recharge_amount TEXT DEFAULT "0",withdrawal_amount TEXT DEFAULT "0",backflow_ratio TEXT,amount TEXT,created_at TEXT,updated_at TEXT', 'recharges' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,member_id TEXT,status INTEGER,amount TEXT,created_at TEXT,updated_at TEXT', ] as $table => $columns) DB::statement('CREATE TABLE bot_' . $table . ' (' . $columns . ')'); foreach (['huishui_percentage' => '0', 'huishui_restriction' => '0', 'recharge_rate' => '0'] as $field => $val) DB::table('config')->insert(compact('field', 'val')); $id = $this->activity(8, ['min_deposit' => 100, 'reward_amount' => 25]); $source = DB::table('recharges')->insertGetId(['member_id' => 'm1', 'status' => 0, 'amount' => 100]); $recharge = \App\Models\Recharge::find($source); $this->assertTrue(\App\Services\RechargeService::creditConfirmedRecharge($recharge, ['status' => 1], '100.00')); $this->assertFalse(\App\Services\RechargeService::creditConfirmedRecharge($recharge, ['status' => 1], '100.00')); $this->assertSame('125.0000000000', (string)DB::table('wallets')->where('member_id', 'm1')->value('available_balance')); $this->assertSame(1, DB::table('promotion_rewards')->count()); $source = DB::table('recharges')->insertGetId(['member_id' => 'm2', 'status' => 0, 'amount' => 100]); DB::statement("CREATE TRIGGER reject_award BEFORE INSERT ON bot_promotion_rewards BEGIN SELECT RAISE(ABORT, 'award failure'); END"); try { \App\Services\RechargeService::creditConfirmedRecharge(\App\Models\Recharge::find($source), ['status' => 1], '100.00'); $this->fail('Expected failed award'); } catch (\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('award failure', $e->getMessage()); } $this->assertSame(0, (int)DB::table('recharges')->where('id', $source)->value('status')); $this->assertSame('0', (string)DB::table('wallets')->where('member_id', 'm2')->value('available_balance')); $this->assertSame(0, DB::table('balance_logs')->where('member_id', 'm2')->count()); } public function test_promotion_heartbeat_records_login_once_and_online_without_other_reporting_tables(): void { DB::statement('DROP TABLE bot_reporting_activity'); $id = $this->activity(); for ($i = 0; $i <= 60; ++$i) { $this->travel(Carbon::parse('2026-10-26 12:00:00')->addMinutes($i)->format('Y-m-d H:i:s')); $this->service->heartbeat(1, 'secret-token', 'H5'); $this->service->heartbeat(1, 'secret-token', 'H5'); } $state = $this->service->progress($id, 1); $this->assertTrue($state['completed_tasks']['online']); $this->assertTrue($state['completed_tasks']['login']); $this->assertSame(2, (int)$state['spins']); $this->assertSame(61, DB::table('promotion_presence')->count()); $this->assertStringNotContainsString('secret-token', DB::table('promotion_presence')->first()->session_hash); $this->assertCount(7, $state['tasks']); $this->assertSame(0, $state['tasks'][0]['remaining_seconds']); $this->travel('2026-10-27 00:01:00'); $this->service->heartbeat(1, 'secret-token', 'H5'); $this->assertSame(3, (int)$this->service->progress($id, 1)['spins']); } public function test_missing_old_first_deposit_hook_is_not_back_awarded_when_campaign_starts_later(): void { $deposit = $this->deposit(1, '1000'); $this->travel('2026-10-26 13:00:00'); $this->activity(8); $this->service->onDeposit($deposit); $this->assertSame(0, DB::table('promotion_rewards')->count()); } public function test_manual_payment_approval_keeps_first_deposit_bonus_instead_of_overwriting_it_with_principal(): void { foreach ([ 'config' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,field TEXT,val TEXT', 'level' => 'id INTEGER PRIMARY KEY,level INTEGER,recharge TEXT', 'recharge_channel_group' => 'id INTEGER PRIMARY KEY,activity_type TEXT,deleted_at TEXT', 'backflow' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,date TEXT,member_id TEXT,recharge_amount TEXT DEFAULT "0",withdrawal_amount TEXT DEFAULT "0",backflow_ratio TEXT,amount TEXT,created_at TEXT,updated_at TEXT', 'payment_orders' => 'id INTEGER PRIMARY KEY,member_id TEXT,status INTEGER,type INTEGER,amount TEXT,remark TEXT,state INTEGER,created_at TEXT,updated_at TEXT', ] as $table => $columns) DB::statement('CREATE TABLE bot_' . $table . ' (' . $columns . ')'); foreach (['huishui_percentage' => '0', 'huishui_restriction' => '0', 'recharge_rate' => '0'] as $field => $val) DB::table('config')->insert(compact('field', 'val')); $this->activity(8, ['min_deposit' => 100, 'reward_amount' => 25]); DB::table('payment_orders')->insert(['id' => 1, 'member_id' => 'm1', 'status' => 5, 'type' => 3, 'amount' => '100']); $this->app->instance('request', Request::create('/admin/paymentOrder/manualAudit', 'POST', ['id' => 1, 'status' => 2])); $body = (new \App\Http\Controllers\admin\PaymentOrder())->manualAudit()->getData(true); $this->assertSame(0, $body['code'], $body['msg']); $this->assertSame('125.0000000000', (string)DB::table('wallets')->where('member_id', 'm1')->value('available_balance')); $deposit = DB::table('balance_logs')->where('change_type', '充值')->first(); $this->assertSame('0', (string)$deposit->before_balance); $this->assertSame('100.0000000000', (string)$deposit->after_balance); $this->assertSame(1, DB::table('promotion_rewards')->count()); $this->assertNotSame(0, (new \App\Http\Controllers\admin\PaymentOrder())->manualAudit()->getData(true)['code']); $this->assertSame(1, DB::table('promotion_rewards')->count()); } }