PromotionActivitiesTest.php 51 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676
  1. <?php
  2. namespace Tests\Integration;
  3. use App\Http\Controllers\admin\ActivityReward as AdminActivity;
  4. use App\Models\PromotionActivity as ActivityReward;
  5. use App\Models\BalanceLog;
  6. use App\Services\PromotionAdminService;
  7. use App\Services\PromotionFacts;
  8. use App\Services\PromotionService;
  9. use App\Support\PromotionRules;
  10. use Carbon\Carbon;
  11. use Illuminate\Config\Repository;
  12. use Illuminate\Database\Capsule\Manager;
  13. use Illuminate\Database\Schema\Blueprint;
  14. use Illuminate\Foundation\Application;
  15. use Illuminate\Http\Request;
  16. use Illuminate\Support\Facades\DB;
  17. use Illuminate\Support\Facades\Facade;
  18. use Illuminate\Translation\ArrayLoader;
  19. use Illuminate\Translation\Translator;
  20. use Illuminate\Validation\Factory;
  21. use Illuminate\Validation\ValidationException;
  22. use PHPUnit\Framework\TestCase;
  23. /** Runs real migrations, qualification, wallet writes and state machines in SQLite, without .env/providers. */
  24. class PromotionActivitiesTest extends TestCase
  25. {
  26. private Application $app;
  27. private PromotionService $service;
  28. private string $originalTimezone;
  29. protected function setUp(): void
  30. {
  31. $this->originalTimezone = date_default_timezone_get(); date_default_timezone_set('Asia/Shanghai'); Carbon::setTestNow(Carbon::parse('2026-10-26 12:00:00', 'Asia/Shanghai'));
  32. Facade::clearResolvedInstances(); $this->app = new Application(dirname(__DIR__, 2));
  33. $this->app->instance('config', new Repository(['app' => ['locale' => 'zh', 'timezone' => 'Asia/Shanghai'], 'third_game' => ['currency' => 'CNY']])); Facade::setFacadeApplication($this->app);
  34. $db = new Manager($this->app); $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
  35. $db->setAsGlobal(); $db->bootEloquent(); $this->app->instance('db', $db->getDatabaseManager()); $this->app->bind('db.schema', fn () => $db->schema());
  36. $this->app->instance('translator', new Translator(new ArrayLoader(), 'zh'));
  37. $this->app->instance('validator', new Factory($this->app['translator'], $this->app));
  38. $this->app->instance('log', new \Psr\Log\NullLogger()); $this->app->instance('request', Request::create('/api/promotion/progress'));
  39. $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new \Tests\Support\JsonResponseFactory());
  40. Request::macro('validate', function ($rules) { return app('validator')->make($this->all(), $rules)->validate(); });
  41. foreach ([
  42. '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',
  43. 'users' => 'id INTEGER PRIMARY KEY,member_id TEXT UNIQUE,user_id TEXT,account TEXT,language TEXT,"from" INTEGER,level INTEGER DEFAULT 0,recharge_channel_group_id INTEGER DEFAULT 1,created_at TEXT,updated_at TEXT',
  44. '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',
  45. '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',
  46. 'share_members' => 'user_id INTEGER PRIMARY KEY,parent_user_id INTEGER,bound_at TEXT',
  47. 'share_policies' => 'id INTEGER PRIMARY KEY,effective_date TEXT,valid_member_min_deposit TEXT,valid_member_min_effective_bet TEXT',
  48. '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',
  49. 'user_login' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,user_id TEXT,status INTEGER,platform TEXT,created_at TEXT',
  50. 'reporting_activity' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,user_id INTEGER,session_hash TEXT,observed_at TEXT',
  51. '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',
  52. ] as $table => $columns) DB::statement('CREATE TABLE bot_' . $table . ' (' . $columns . ')');
  53. 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', '2026_11_03_120000_add_promotion_coupons.php'] as $migration) (require dirname(__DIR__, 2) . '/database/migrations/' . $migration)->up();
  54. $this->member(1); $this->member(2, 1);
  55. DB::table('share_policies')->insert(['id' => 1, 'effective_date' => '2026-10-01', 'valid_member_min_deposit' => '200', 'valid_member_min_effective_bet' => '300']);
  56. $this->service = new PromotionService();
  57. }
  58. protected function tearDown(): void { Carbon::setTestNow(); date_default_timezone_set($this->originalTimezone); DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown(); }
  59. private function travel(string $time): void { Carbon::setTestNow(Carbon::parse($time, 'Asia/Shanghai')); }
  60. private function member(int $id, ?int $parent = null, string $at = '2026-10-01 00:00:00'): void
  61. {
  62. DB::table('users')->insert(['id' => $id, 'member_id' => 'm' . $id, 'user_id' => 'u' . $id, 'language' => 'zh', 'from' => 1, 'created_at' => $at, 'updated_at' => $at]);
  63. DB::table('wallets')->insert(['member_id' => 'm' . $id, 'available_balance' => '0', 'frozen_balance' => '0']);
  64. if ($parent) DB::table('share_members')->insert(['user_id' => $id, 'parent_user_id' => $parent, 'bound_at' => $at]);
  65. }
  66. private function activity(int $type = 9, array $params = [], int $status = 1): int
  67. {
  68. $input = ['translations' => $this->texts(), 'type' => $type, 'status' => $status,
  69. 'start_time' => '2026-10-01 00:00:00', 'end_time' => '2026-12-31 23:59:59', 'lang' => 'zh', 'params' => $params];
  70. if (in_array($type, [7, 9], true)) $input['id'] = (int) DB::table('promotion_activities')->where('type', $type)->value('id');
  71. return (int) (new PromotionAdminService())->save($input)['id'];
  72. }
  73. private function texts(): array
  74. {
  75. return ['zh' => ['title' => '测试活动', 'content' => '<p>中文活动正文</p>'],
  76. 'en' => ['title' => 'Test promotion', 'content' => '<p>English content</p>'],
  77. 'vi' => ['title' => 'Khuyến mãi', 'content' => '<p>Nội dung</p>']];
  78. }
  79. private function deposit(int $id, string $amount, ?string $time = null, string $type = '充值'): BalanceLog
  80. {
  81. return BalanceLog::create(['member_id' => 'm' . $id, 'type' => 1, 'amount' => $amount, 'change_type' => $type, 'created_at' => $time ?? PromotionFacts::now()]);
  82. }
  83. private function rawDeposit(int $id, string $amount, string $time, string $type = '充值'): int
  84. {
  85. return DB::table('balance_logs')->insertGetId(['member_id' => 'm' . $id, 'type' => 1, 'amount' => $amount, 'change_type' => $type, 'created_at' => $time, 'updated_at' => $time]);
  86. }
  87. private function bet(int $id, string $stake, string $profit, string $time, array $extra = []): int
  88. {
  89. $next = (int)DB::table('share_bet_facts')->max('id') + 1;
  90. return DB::table('share_bet_facts')->insertGetId($extra + ['user_id' => $id, 'source' => 'sport_order', 'source_id' => (string)$next,
  91. 'bet_at' => $time, 'bet_amount' => $stake, 'net_profit' => $profit, 'effective_amount' => '999999', 'currency' => 'CNY', 'settled' => 1, 'voided' => 0]);
  92. }
  93. private function invalid(callable $callback, string $message): void
  94. {
  95. try { $callback(); $this->fail('Expected validation failure: ' . $message); }
  96. catch (ValidationException $e) { $this->assertStringContainsString($message, $e->validator->errors()->first()); }
  97. }
  98. private function complete(int $activity): array
  99. {
  100. $this->rawDeposit(1, '3000', '2026-10-26 12:01:00'); $this->rawDeposit(2, '500', '2026-10-26 12:01:00');
  101. $this->bet(1, '6000', '-5000', '2026-10-26 12:01:00'); $this->bet(2, '4000', '-3000', '2026-10-26 12:01:00');
  102. DB::table('user_login')->insert(['user_id' => 'm1', 'status' => 1, 'platform' => 'H5', 'created_at' => '2026-10-26 12:01:00']);
  103. 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')]);
  104. $this->travel('2026-10-26 14:00:00'); return $this->service->progress($activity, 1, 'H5');
  105. }
  106. public function test_background_sync_skips_page_metrics_and_duplicate_task_writes_but_observes_corrections(): void
  107. {
  108. $id = $this->activity(9); $before = $this->complete($id);
  109. $this->assertTrue($before['conditions_complete']);
  110. DB::enableQueryLog(); DB::flushQueryLog();
  111. try {
  112. $this->service->syncAccount($id, 1);
  113. $queries = DB::getQueryLog();
  114. $this->assertLessThanOrEqual(50, count($queries));
  115. $this->assertCount(0, array_filter($queries, fn ($q) => str_contains($q['query'], 'from "bot_promotion_rewards"')));
  116. $this->assertCount(0, array_filter($queries, fn ($q) => preg_match('/^(insert.*"bot_promotion_task_grants"|update "bot_promotion_rounds")/', $q['query'])));
  117. $this->assertLessThanOrEqual(4, count(array_filter($queries, fn ($q) => str_contains($q['query'], 'from "bot_share_bet_facts"'))));
  118. } finally { DB::disableQueryLog(); DB::flushQueryLog(); }
  119. $after = $this->service->detail($id, 1);
  120. $this->assertSame($before['completed_tasks'], $after['completed_tasks']);
  121. $this->assertSame($before['spins'], $after['spins']);
  122. DB::table('share_bet_facts')->where('user_id', 2)->update(['net_profit' => '0']);
  123. $this->service->syncAccount($id, 1);
  124. $corrected = $this->service->detail($id, 1);
  125. $this->assertFalse($corrected['completed_tasks']['invite']);
  126. $this->assertFalse($corrected['completed_tasks']['team_bet']);
  127. $this->assertSame(7, DB::table('promotion_task_grants')->count());
  128. }
  129. public function test_third_party_cursor_uses_one_query_and_original_order_ids(): void
  130. {
  131. DB::statement('CREATE TABLE bot_third_game_orders (id INTEGER PRIMARY KEY,order_key TEXT UNIQUE)');
  132. foreach (range(1, 20) as $id) {
  133. $key = str_pad(dechex($id), 64, '0', STR_PAD_LEFT);
  134. DB::table('third_game_orders')->insert(['id' => $id, 'order_key' => $key]);
  135. $this->bet(1, '10', '-10', '2026-10-26 11:00:00', ['source' => 'third_game_orders', 'source_id' => $key]);
  136. }
  137. $this->bet(1, '999', '-999', '2026-10-26 11:00:00', ['source' => 'third_game_orders', 'source_id' => str_repeat('f', 64)]);
  138. DB::enableQueryLog(); DB::flushQueryLog();
  139. try {
  140. $result = (new PromotionFacts())->betting([1], '2026-10-01 00:00:00', null, ['third_game_orders' => 10]);
  141. $this->assertLessThanOrEqual(6, count(DB::getQueryLog()));
  142. } finally { DB::disableQueryLog(); DB::flushQueryLog(); }
  143. $this->assertSame('100.0000000000', $result['effective']['2026-10-26']);
  144. $this->assertSame('-100.0000000000', $result['profit']);
  145. }
  146. public function test_images_are_only_configurable_for_first_deposit_not_fixed_loss_or_wheel_campaigns(): void
  147. {
  148. $admin = new PromotionAdminService();
  149. foreach ([7, 8, 9] as $type) {
  150. $id = $this->activity($type);
  151. DB::table('promotion_activities')->where('id', $id)->update(['image' => 'previous.png']);
  152. $input = $admin->detail($id); $input['image'] = $type === 8 ? 'first-deposit.png' : ['ignored-legacy-upload'];
  153. $saved = $admin->save($input);
  154. foreach ([$saved, $admin->detail($id), $admin->listing(['type' => $type])['data'][0], $this->service->listing(1, 'H5', 'zh', $type)['data'][0]] as $row) {
  155. $this->assertSame($type === 8, array_key_exists('image', $row));
  156. }
  157. $this->assertSame($type === 8 ? 'first-deposit.png' : 'previous.png', DB::table('promotion_activities')->where('id', $id)->value('image'));
  158. }
  159. }
  160. public function test_subtitles_are_not_required_saved_or_returned_for_any_independent_activity(): void
  161. {
  162. $admin = new PromotionAdminService();
  163. foreach ([7, 8, 9] as $type) {
  164. $id = $this->activity($type); $legacy = $this->texts();
  165. foreach ($legacy as &$text) $text['sub_title'] = 'Historical subtitle'; unset($text);
  166. DB::table('promotion_activities')->where('id', $id)->update(['sub_title' => 'Historical subtitle', 'translations' => json_encode($legacy)]);
  167. $detail = $admin->detail($id);
  168. $this->assertArrayNotHasKey('sub_title', $detail);
  169. foreach ($detail['translations'] as $text) $this->assertSame(['title', 'content'], array_keys($text));
  170. $input = $detail; $input['sub_title'] = 'Ignored legacy input';
  171. foreach ($input['translations'] as &$text) $text['sub_title'] = ['ignored legacy input']; unset($text);
  172. $saved = $admin->save($input);
  173. $this->assertSame($this->texts(), $saved['translations']);
  174. $this->assertSame('Historical subtitle', DB::table('promotion_activities')->where('id', $id)->value('sub_title'));
  175. foreach ([$saved, $admin->listing(['type' => $type])['data'][0], $admin->status($id, 1),
  176. $this->service->listing(1, 'H5', 'zh', $type)['data'][0], $this->service->detail($id, 1, 'H5', 'zh')] as $row) {
  177. $this->assertArrayNotHasKey('sub_title', $row);
  178. }
  179. }
  180. }
  181. public function test_three_languages_edit_one_campaign_and_keep_rules_history_and_other_tabs(): void
  182. {
  183. $admin = new PromotionAdminService();
  184. foreach ([7, 8, 9] as $type) {
  185. $id = $this->activity($type); $before = $admin->detail($id);
  186. $count = DB::table('promotion_activities')->count();
  187. $input = $before; $input['translations']['en']['title'] = 'Updated English title';
  188. $saved = $admin->save($input);
  189. $this->assertSame($id, $saved['id']);
  190. $this->assertSame($count, DB::table('promotion_activities')->count());
  191. $this->assertSame($before['translations']['zh'], $saved['translations']['zh']);
  192. $this->assertSame($before['translations']['vi'], $saved['translations']['vi']);
  193. $this->assertSame('Updated English title', $admin->detail($id)['translations']['en']['title']);
  194. foreach (['params', 'rewarded_count', 'budget_used', 'first_published_at'] as $field) $this->assertSame($before[$field], $saved[$field]);
  195. foreach (['zh', 'en', 'vi'] as $lang) {
  196. $rows = $this->service->listing(1, 'H5', $lang, $type)['data'];
  197. $this->assertCount(1, $rows); $this->assertSame($id, $rows[0]['activity_id']);
  198. $this->assertSame($saved['translations'][$lang]['title'], $rows[0]['title']);
  199. $state = $this->service->detail($id, 1, 'H5', $lang);
  200. $this->assertSame($lang, $state['lang']);
  201. $this->assertSame($saved['translations'][$lang]['title'], $state['title']);
  202. $this->assertSame($saved['translations'][$lang]['content'], $state['content']);
  203. }
  204. }
  205. $this->assertSame(['zh', 'en', 'vi'], array_column($admin->options()['languages'], 'value'));
  206. }
  207. public function test_all_six_text_fields_are_required_and_invalid_tabs_cannot_overwrite_saved_content(): void
  208. {
  209. $id = $this->activity(9); $admin = new PromotionAdminService(); $input = $admin->detail($id);
  210. $before = DB::table('promotion_activities')->where('id', $id)->first();
  211. foreach (['zh', 'en', 'vi'] as $lang) {
  212. foreach (['title', 'content'] as $field) {
  213. $bad = $input; unset($bad['translations'][$lang][$field]);
  214. try { $admin->save($bad); $this->fail('Missing language text was accepted'); }
  215. catch (ValidationException $e) { $this->assertTrue($e->validator->errors()->has('translations.' . $lang . '.' . $field)); }
  216. $bad = $input; $bad['translations'][$lang][$field] = '<p>&nbsp;<br></p>';
  217. $this->invalid(fn () => $admin->save($bad), '不能为空');
  218. }
  219. $bad = $input; unset($bad['translations'][$lang]);
  220. $this->invalid(fn () => $admin->save($bad), '完整填写');
  221. }
  222. $bad = $input; unset($bad['translations']); $bad['title'] = '旧单语言表单';
  223. $this->invalid(fn () => $admin->save($bad), '完整填写');
  224. $bad = $input; $bad['translations']['fr'] = $input['translations']['en'];
  225. try { $admin->save($bad); $this->fail('Unknown language was accepted'); }
  226. catch (ValidationException $e) { $this->assertTrue($e->validator->errors()->has('translations')); }
  227. $bad = $input; $bad['translations']['en']['title'] = str_repeat('a', 141);
  228. try { $admin->save($bad); $this->fail('Oversized title was accepted'); }
  229. catch (ValidationException $e) { $this->assertTrue($e->validator->errors()->has('translations.en.title')); }
  230. $this->assertEquals($before, DB::table('promotion_activities')->where('id', $id)->first());
  231. }
  232. public function test_admin_http_save_returns_all_tabs_and_rejects_partial_language_payloads(): void
  233. {
  234. $controller = new \App\Http\Controllers\admin\Promotion(); $admin = new PromotionAdminService();
  235. $input = ['type' => 8, 'status' => 0, 'start_time' => '2026-10-26', 'end_time' => '2026-12-31', 'translations' => $this->texts()];
  236. $this->app->instance('request', Request::create('/admin/promotion/submit', 'POST', $input));
  237. $saved = $controller->submit($admin)->getData(true);
  238. $this->assertSame(0, $saved['code']); $this->assertSame($this->texts(), $saved['data']['translations']);
  239. $input['id'] = $saved['data']['id']; unset($input['translations']['en']);
  240. $this->app->instance('request', Request::create('/admin/promotion/submit', 'POST', $input));
  241. $failed = $controller->submit($admin)->getData(true);
  242. $this->assertSame(-3, $failed['code']); $this->assertStringContainsString('英文', $failed['msg']);
  243. $this->assertSame($this->texts(), $admin->detail($input['id'])['translations']);
  244. }
  245. public function test_incomplete_legacy_activity_can_be_hidden_but_cannot_be_published_without_all_tabs(): void
  246. {
  247. $id = (int) DB::table('promotion_activities')->where('type', 7)->value('id');
  248. $admin = new PromotionAdminService();
  249. $this->assertSame(0, (int) $admin->status($id, 0)['status']);
  250. $this->invalid(fn () => $admin->status($id, 1), '不能为空');
  251. $this->assertSame(0, (int) DB::table('promotion_activities')->where('id', $id)->value('status'));
  252. $this->assertNull(DB::table('promotion_campaigns')->where('activity_id', $id)->value('first_published_at'));
  253. }
  254. public function test_first_deposit_translation_does_not_split_or_duplicate_awards(): void
  255. {
  256. $id = $this->activity(8, ['min_deposit' => 100, 'reward_amount' => 25]);
  257. DB::table('users')->where('id', 1)->update(['language' => 'vi']);
  258. $deposit = $this->deposit(1, '100'); $this->service->onDeposit($deposit);
  259. $this->assertSame(1, DB::table('promotion_rewards')->where('activity_id', $id)->count());
  260. DB::table('users')->where('id', 1)->update(['language' => 'en']);
  261. $this->service->onDeposit($deposit);
  262. $this->assertSame(1, DB::table('promotion_rewards')->where('activity_id', $id)->count());
  263. $this->assertSame('25.0000000000', (string) DB::table('wallets')->where('member_id', 'm1')->value('available_balance'));
  264. $this->assertSame(1, DB::table('promotion_accounts')->where('activity_id', $id)->count());
  265. }
  266. public function test_translation_migration_preserves_legacy_texts_and_campaign_identity(): void
  267. {
  268. $migration = require dirname(__DIR__, 2) . '/database/migrations/2026_10_27_140000_add_promotion_activity_translations.php';
  269. $migration->down();
  270. $id = DB::table('promotion_activities')->where('type', 7)->value('id');
  271. DB::table('promotion_activities')->where('id', $id)->update(['lang' => 'en', 'title' => 'Existing title', 'sub_title' => 'Existing subtitle', 'content' => '<p>Existing content</p>']);
  272. $rows = DB::table('promotion_activities')->orderBy('id')->get();
  273. $published = DB::table('promotion_campaigns')->orderBy('activity_id')->get();
  274. $migration->up();
  275. foreach ($rows as $row) {
  276. $saved = DB::table('promotion_activities')->where('id', $row->id)->first();
  277. foreach ((array) $row as $field => $value) $this->assertSame($value, $saved->$field);
  278. }
  279. $texts = (new PromotionAdminService())->detail((int) $id)['translations'];
  280. $this->assertSame(['title' => 'Existing title', 'content' => '<p>Existing content</p>'], $texts['en']);
  281. $this->assertSame('', $texts['zh']['title']);
  282. $this->assertEquals($published, DB::table('promotion_campaigns')->orderBy('activity_id')->get());
  283. $this->assertSame(0, DB::table('menus')->count());
  284. $this->assertSame(0, $this->service->listing(1, 'H5', 'vi', 7)['total']);
  285. }
  286. public function test_probability_default_zero_boundaries_and_incomplete_gate(): void
  287. {
  288. $this->assertSame('20.00', PromotionRules::normalize(9, [])['completion_probability']);
  289. $this->assertSame('0.00', PromotionRules::normalize(9, ['completion_probability' => 0])['completion_probability']);
  290. $this->assertTrue(PromotionRules::draw(66599, true, '20.00', fn ($a, $b) => $a === 1 ? 2000 : 0)['reached']);
  291. $this->assertFalse(PromotionRules::draw(66599, true, '20.00', fn ($a, $b) => $a === 1 ? 2001 : 0)['reached']);
  292. $this->assertFalse(PromotionRules::draw(66599, false, '100.00', fn ($a, $b) => $a)['reached']);
  293. $this->invalid(fn () => PromotionRules::normalize(9, ['completion_probability' => 100.01]), 'between');
  294. }
  295. public function test_day_amounts_do_not_add_together_but_completed_flags_survive_midnight(): void
  296. {
  297. $id = $this->activity(); $this->service->progress($id, 1);
  298. $this->rawDeposit(1, '2000', '2026-10-26 13:00:00');
  299. $this->travel('2026-10-27 14:00:00'); $this->rawDeposit(1, '1000', '2026-10-27 12:00:00');
  300. $state = $this->service->progress($id, 1);
  301. $this->assertFalse($state['completed_tasks']['deposit']); $this->assertSame(0, (int)$state['spins']);
  302. $this->rawDeposit(1, '2000', '2026-10-27 13:00:00'); $state = $this->service->progress($id, 1);
  303. $this->assertTrue($state['completed_tasks']['deposit']); $this->assertSame(1, (int)$state['spins']);
  304. $this->travel('2026-10-28 14:00:00'); $state = $this->service->progress($id, 1);
  305. $this->assertTrue($state['completed_tasks']['deposit']); $this->assertSame(1, (int)$state['spins']);
  306. }
  307. public function test_repeated_rounds_keep_unused_spins_and_daily_flags_but_require_new_other_events(): void
  308. {
  309. $id = $this->activity(9, ['completion_probability' => 100]); $state = $this->complete($id);
  310. $this->assertTrue($state['conditions_complete']); $this->assertSame(7, (int)$state['spins']);
  311. $spin = $this->service->spin($id, 1, 'request_spin_00001');
  312. $this->assertTrue($spin['spin']['reached']); $this->assertSame('666.00', $spin['spin']['coins']);
  313. $this->assertSame($spin['spin'], $this->service->spin($id, 1, 'request_spin_00001')['spin']);
  314. $claim = $this->service->claim($id, 1, 1); $this->assertSame(2, $claim['round_no']); $this->assertSame(6, (int)$claim['spins']);
  315. $this->assertTrue($this->service->claim($id, 1, 1)['replayed']);
  316. $state = $this->service->progress($id, 1); $this->assertSame('0.00', $state['coins']);
  317. $this->assertSame(6, (int)$state['spins']); $this->assertTrue($state['completed_tasks']['online']); $this->assertTrue($state['completed_tasks']['login']);
  318. foreach (['invite', 'deposit', 'team_deposit', 'bet', 'team_bet'] as $task) $this->assertFalse($state['completed_tasks'][$task]);
  319. $this->rawDeposit(1, '3000', '2026-10-26 14:00:00'); $this->rawDeposit(2, '500', '2026-10-26 14:00:00');
  320. $this->bet(1, '6000', '-5000', '2026-10-26 14:00:00'); $this->bet(2, '4000', '-3000', '2026-10-26 14:00:00');
  321. $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');
  322. $state = $this->service->progress($id, 1); $this->assertTrue($state['conditions_complete']); $this->assertSame(11, (int)$state['spins']);
  323. $this->assertTrue($this->service->spin($id, 1, 'request_spin_00002')['spin']['reached']); $this->service->claim($id, 1, 2);
  324. $this->assertSame('1332.0000000000', (string)DB::table('wallets')->where('member_id', 'm1')->value('available_balance'));
  325. $this->assertSame(2, DB::table('promotion_rewards')->count()); $this->assertSame(1, (int)DB::table('promotion_activities')->where('id', $id)->value('rewarded_count'));
  326. }
  327. public function test_probability_changes_affect_new_spins_and_reserved_reward_can_be_claimed_after_down(): void
  328. {
  329. $id = $this->activity(9, ['completion_probability' => 0]); $this->complete($id);
  330. $first = $this->service->spin($id, 1, 'request_spin_00001'); $this->assertFalse($first['spin']['reached']);
  331. $row = ActivityReward::find($id); $rules = json_decode($row->params, true); $rules['completion_probability'] = '100.00'; $row->params = json_encode($rules); $row->save();
  332. $next = $this->service->spin($id, 1, 'request_spin_00002'); $this->assertTrue($next['spin']['reached']);
  333. $this->assertSame('0', (string)DB::table('promotion_spins')->orderBy('id')->value('probability'));
  334. DB::table('promotion_activities')->where('id', $id)->update(['status' => 0]);
  335. $this->service->claim($id, 1, 1); $this->invalid(fn () => $this->service->progress($id, 1), '未上架');
  336. $this->assertTrue($this->service->claim($id, 1, 1)['replayed']);
  337. }
  338. public function test_first_deposit_is_historical_first_and_does_not_refill_on_relisting(): void
  339. {
  340. $id = $this->activity(8, ['min_deposit' => '100', 'reward_amount' => '25']);
  341. $first = $this->deposit(1, '99'); $this->service->onDeposit($first); $this->service->onDeposit($first);
  342. $second = $this->deposit(1, '1000'); $this->service->onDeposit($second);
  343. $this->assertSame(0, DB::table('promotion_rewards')->count());
  344. $other = $this->deposit(2, '100'); $this->service->onDeposit($other); $this->service->onDeposit($other);
  345. $this->assertSame(1, DB::table('promotion_rewards')->count());
  346. $this->assertSame('25.0000000000', (string)DB::table('wallets')->where('member_id', 'm2')->value('available_balance'));
  347. $published = DB::table('promotion_campaigns')->where('activity_id', $id)->value('first_published_at');
  348. $this->travel('2026-10-27 12:00:00'); $row = ActivityReward::find($id); $row->status = 0; $row->save(); PromotionService::publish($row);
  349. $row->status = 1; $row->save(); PromotionService::publish($row);
  350. $this->assertSame($published, DB::table('promotion_campaigns')->where('activity_id', $id)->value('first_published_at'));
  351. $this->service->onDeposit($this->deposit(2, '500')); $this->assertSame(1, DB::table('promotion_rewards')->count());
  352. }
  353. public function test_loss_uses_post_publication_net_profit_then_only_new_direct_invitees_with_both_thresholds(): void
  354. {
  355. $this->bet(1, '900', '-900', '2026-10-25 12:00:00'); $id = $this->activity(7);
  356. $this->invalid(fn () => $this->service->apply($id, 1), '净亏');
  357. $this->bet(1, '600', '-600', '2026-10-26 12:00:00'); $this->bet(1, '200', '100', '2026-10-26 12:00:00');
  358. // A referral created in the same second, but before applying, is still an existing user.
  359. DB::table('users')->where('id', 2)->update(['created_at' => '2026-10-26 12:00:00']);
  360. DB::table('share_members')->where('user_id', 2)->update(['bound_at' => '2026-10-26 12:00:00']);
  361. $this->service->apply($id, 1); $this->service->apply($id, 1);
  362. $this->rawDeposit(2, '500', '2026-10-26 12:00:00'); $this->bet(2, '300', '-300', '2026-10-26 12:00:00');
  363. foreach ([3, 4, 5] as $child) {
  364. $this->member($child, 1, '2026-10-26 12:00:00'); $this->rawDeposit($child, '200', '2026-10-26 12:00:00');
  365. if ($child !== 5) $this->bet($child, '300', '-300', '2026-10-26 12:00:00');
  366. }
  367. $this->invalid(fn () => $this->service->claim($id, 1, 1), '人数不足');
  368. $this->bet(5, '1000', '-300', '2026-10-26 12:00:00'); $this->service->claim($id, 1, 1); $this->service->claim($id, 1, 1);
  369. $this->assertSame(1, DB::table('promotion_rewards')->count()); $this->assertSame('500.0000000000', (string)DB::table('wallets')->where('member_id', 'm1')->value('available_balance'));
  370. }
  371. public function test_coupon_mode_issues_inviter_and_counted_invitee_coupons_once_without_cash(): void
  372. {
  373. $coupons = ['reward_mode' => 'coupon', 'reward_amount' => '300', 'invite_count' => 2,
  374. 'coupon_valid_from' => '2026-10-26 00:00:00', 'coupon_valid_until' => '2026-11-30 23:59:59', 'coupon_categories' => ['self:lottery', 'self:sport'],
  375. 'inviter_coupons' => [['min_amount' => '100', 'discount' => '50', 'quantity' => 2], ['min_amount' => '200', 'discount' => '100', 'quantity' => 1]],
  376. 'invitee_coupons' => [['min_amount' => '20', 'discount' => '10', 'quantity' => 1]]];
  377. $this->invalid(fn () => $this->activity(7, array_replace($coupons, ['reward_amount' => '150'])), '优惠券抵扣总额不能超过奖励金额');
  378. $this->invalid(fn () => $this->activity(7, array_replace($coupons, ['coupon_categories' => []])), '可用游戏类型');
  379. $this->invalid(fn () => $this->activity(7, array_replace($coupons, ['inviter_coupons' => [['min_amount' => '40', 'discount' => '50', 'quantity' => 1]]])), '使用门槛');
  380. $id = $this->activity(7, $coupons);
  381. $this->assertSame(['self:sport', 'self:lottery'], (new PromotionAdminService())->detail(null, 7)['params']['coupon_categories']);
  382. $this->bet(1, '600', '-600', '2026-10-26 12:00:00'); $this->service->apply($id, 1);
  383. $this->travel('2026-10-26 13:00:00');
  384. foreach ([3, 4, 5] as $child) {
  385. $this->member($child, 1, '2026-10-26 13:00:00'); $this->rawDeposit($child, '200', '2026-10-26 13:00:00'); $this->bet($child, '300', '-300', '2026-10-26 13:00:00');
  386. }
  387. $this->service->claim($id, 1, 1); $this->service->claim($id, 1, 1);
  388. $reward = DB::table('promotion_rewards')->first();
  389. $this->assertSame('paid', $reward->status); $this->assertEquals(220, $reward->amount); $this->assertEquals(0, $reward->required_flow);
  390. $this->assertSame('0', (string)DB::table('wallets')->where('member_id', 'm1')->value('available_balance'));
  391. $this->assertSame(0, DB::table('balance_logs')->where('change_type', '优惠活动')->count());
  392. $this->assertSame(3, DB::table('promotion_coupons')->where('user_id', 1)->where('role', 'inviter')->count());
  393. // Only the first invite_count qualified invitees are counted and receive coupons.
  394. $this->assertSame([3, 4], DB::table('promotion_coupons')->where('role', 'invitee')->orderBy('user_id')->pluck('user_id')->map(fn ($v) => (int)$v)->all());
  395. $state = $this->service->detail($id, 1, 'H5');
  396. $this->assertSame('claimed', $state['status']); $this->assertSame('coupon', $state['rules']['reward_mode']);
  397. $this->assertSame([2, 1], array_column($state['coupons'], 'quantity'));
  398. $this->assertEquals(220, DB::table('promotion_activities')->where('id', $id)->value('budget_used'));
  399. // Switching back to cash keeps the coupon settings for the next switch.
  400. $this->activity(7, ['reward_mode' => 'cash']);
  401. $this->assertCount(2, json_decode(DB::table('promotion_activities')->where('id', $id)->value('params'), true)['inviter_coupons']);
  402. $records = (new PromotionAdminService())->records(['activity_id' => $id, 'kind' => 'coupons', 'user_id' => 1]);
  403. $this->assertSame(5, $records['total']);
  404. }
  405. public function test_successful_online_time_deduplicates_devices_and_does_not_count_closed_session_as_online(): void
  406. {
  407. $id = $this->activity();
  408. foreach (['a', 'b'] as $session) for ($i = 0; $i <= 30; ++$i) DB::table('reporting_activity')->insert(['user_id' => 1, 'session_hash' => $session,
  409. 'observed_at' => Carbon::parse('2026-10-26 12:00:00')->addMinutes($i)->format('Y-m-d H:i:s')]);
  410. $this->travel('2026-10-26 16:00:00'); $state = $this->service->progress($id, 1);
  411. $this->assertArrayNotHasKey('online', $state['completed_tasks']);
  412. $this->assertSame(1800, (new PromotionFacts())->online(1, '2026-10-26 12:00:00')['2026-10-26']);
  413. }
  414. public function test_effective_minimum_void_pending_and_next_day_policy_are_respected(): void
  415. {
  416. DB::table('share_policies')->insert(['id' => 2, 'effective_date' => '2026-10-27', 'valid_member_min_deposit' => '9999', 'valid_member_min_effective_bet' => '9999']);
  417. $facts = new PromotionFacts(); $this->assertSame(1, $facts->policy()['id']);
  418. $this->bet(1, '1000', '50', '2026-10-26 12:00:00'); $this->bet(1, '500', '-500', '2026-10-26 12:00:00', ['voided' => 1]);
  419. $this->bet(1, '500', '-500', '2026-10-26 12:00:00', ['settled' => 0]);
  420. $this->assertSame('50.0000000000', $facts->total($facts->betting([1], '2026-10-26 12:00:00')['effective']));
  421. $this->travel('2026-10-27 00:00:00'); $this->assertSame(2, $facts->policy()['id']);
  422. }
  423. public function test_turnover_rewards_only_unlock_from_new_bets_and_only_once(): void
  424. {
  425. $id = $this->activity(8, ['min_deposit' => 100, 'reward_amount' => 25, 'turnover_ratio' => 2]);
  426. $this->bet(1, '100', '-100', '2026-10-26 12:00:00');
  427. $this->service->onDeposit($this->deposit(1, '100')); $this->assertSame(0, $this->service->unlock(1));
  428. $this->bet(1, '1000', '-49', '2026-10-26 12:00:00'); $this->assertSame(0, $this->service->unlock(1));
  429. $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));
  430. $this->assertSame('25.0000000000', (string)DB::table('wallets')->where('member_id', 'm1')->value('available_balance'));
  431. $this->assertSame('0.0000000000', (string)DB::table('wallets')->where('member_id', 'm1')->value('frozen_balance'));
  432. }
  433. public function test_quota_is_checked_before_consuming_a_spin_and_a_win_reserves_its_slot(): void
  434. {
  435. $id = $this->activity(9, ['completion_probability' => 100]); $this->complete($id);
  436. DB::table('promotion_activities')->where('id', $id)->update(['reward_quota' => 0]);
  437. $this->invalid(fn () => $this->service->spin($id, 1, 'request_spin_00001'), '份数');
  438. $this->assertSame(7, (int)DB::table('promotion_accounts')->value('spins'));
  439. DB::table('promotion_activities')->where('id', $id)->update(['reward_quota' => 1]);
  440. $this->service->spin($id, 1, 'request_spin_00001');
  441. $this->assertSame('reserved', DB::table('promotion_rewards')->value('status')); $this->assertSame(1, (int)DB::table('promotion_activities')->where('id', $id)->value('rewarded_count'));
  442. }
  443. public function test_independent_campaigns_do_not_touch_old_catalog_and_member_routes_live_in_member_project(): void
  444. {
  445. $id = $this->activity(9); $this->activity(7); $this->activity(8);
  446. $this->assertSame(0, DB::table('activity_rewards')->count());
  447. $this->assertArrayNotHasKey(9, \App\Support\ActivityCatalog::activityTypes());
  448. $this->assertSame(3, $this->service->listing(1, 'H5')['total']);
  449. $this->assertSame($id, $this->service->listing(1, 'H5', 'zh', 9)['data'][0]['activity_id']);
  450. $this->assertSame(0, DB::table('menus')->count(), 'Activity setup must leave menus to the frontend');
  451. $router = new \Illuminate\Routing\Router(new \Illuminate\Events\Dispatcher($this->app), $this->app); $this->app->instance('router', $router);
  452. $router->group(['prefix' => 'api'], function () { require dirname(__DIR__, 2) . '/routes/api.php'; });
  453. $memberRoutes=array_filter($router->getRoutes()->getRoutes(),fn ($route)=>str_starts_with($route->uri(),'api/promotion'));
  454. $this->assertSame([],$memberRoutes,'Member promotion routes must be owned by melbet_sport-api');
  455. $router->group(['prefix' => 'admin'], function () { require dirname(__DIR__, 2) . '/routes/admin.php'; });
  456. $route = $router->getRoutes()->match(Request::create('/admin/promotion/submit', 'POST'));
  457. $this->assertContains('admin.jwt', $route->middleware()); $this->assertContains('check.button.uri', $route->middleware());
  458. }
  459. public function test_promotion_migration_preserves_frontend_owned_menus_on_up_and_down(): void
  460. {
  461. DB::table('menus')->insert([
  462. ['id' => 1, 'parent_id' => 0, 'title' => '前端活动菜单', 'uri' => 'promotion', 'permission_name' => 'promotion.manage', 'type' => 1],
  463. ['id' => 2, 'parent_id' => 1, 'title' => '前端活动记录', 'uri' => 'admin/promotion/records', 'permission_name' => 'admin.promotion.records', 'type' => 2],
  464. ['id' => 3, 'parent_id' => 0, 'title' => '其他菜单', 'uri' => 'other', 'permission_name' => 'other.manage', 'type' => 1],
  465. ]);
  466. $before = DB::table('menus')->orderBy('id')->get()->toJson();
  467. $migration = require dirname(__DIR__, 2) . '/database/migrations/2026_10_26_160000_create_promotion_activities.php';
  468. $migration->down();
  469. $this->assertSame($before, DB::table('menus')->orderBy('id')->get()->toJson(), 'Rollback must preserve frontend menus and permissions');
  470. $this->assertFalse(DB::connection()->getSchemaBuilder()->hasTable('promotion_activities'));
  471. $migration->up();
  472. $this->assertSame($before, DB::table('menus')->orderBy('id')->get()->toJson(), 'Migration must not add or modify frontend menus and permissions');
  473. $this->assertTrue(DB::connection()->getSchemaBuilder()->hasTable('promotion_activities'));
  474. }
  475. public function test_admin_http_amounts_are_compact_without_changing_saved_rules_or_counters(): void
  476. {
  477. $id = $this->activity(8, ['min_deposit' => '200', 'reward_amount' => '10.12']);
  478. $request = Request::create('/admin/promotion/detail', 'GET', ['id' => $id]); $this->app->instance('request', $request);
  479. $response = (new \App\Http\Controllers\admin\Promotion())->detail(new PromotionAdminService())->getData(true);
  480. $this->assertSame(0, $response['code']);
  481. $this->assertSame('200', $response['data']['params']['min_deposit']);
  482. $this->assertSame('10.12', $response['data']['params']['reward_amount']);
  483. $this->assertSame($id, $response['data']['id']);
  484. $saved = json_decode(DB::table('promotion_activities')->where('id', $id)->value('params'), true);
  485. $this->assertSame('200.00', $saved['min_deposit']);
  486. $this->assertSame('10.12', $saved['reward_amount']);
  487. }
  488. public function test_ended_activities_are_taken_off_the_shelf_and_cannot_be_relisted(): void
  489. {
  490. $ended = $this->activity(8); $running = $this->activity(9);
  491. DB::table('promotion_activities')->where('id', $ended)->update(['end_time' => Carbon::parse('2026-10-26 11:59:59')->timestamp]);
  492. $this->assertSame(1, PromotionAdminService::expire());
  493. $this->assertSame(0, (int) DB::table('promotion_activities')->where('id', $ended)->value('status'));
  494. $this->assertSame(1, (int) DB::table('promotion_activities')->where('id', $running)->value('status'));
  495. $this->assertSame(0, PromotionAdminService::expire());
  496. $admin = new PromotionAdminService();
  497. $this->invalid(fn () => $admin->status($ended, 1), '活动已结束');
  498. $input = ['id' => $ended, 'translations' => $this->texts(), 'type' => 8, 'status' => 1, 'start_time' => '2026-10-01 00:00:00', 'end_time' => '2026-10-26 11:00:00'];
  499. $this->invalid(fn () => $admin->save($input), '活动已结束');
  500. $this->assertSame(0, (int) $admin->save(['status' => 0] + $input)['status']);
  501. }
  502. public function test_fixed_activities_are_initialized_disabled_without_publishing_or_creating_menus(): void
  503. {
  504. $this->assertSame(2, DB::table('promotion_activities')->count());
  505. foreach ([7, 9] as $type) {
  506. $row = DB::table('promotion_activities')->where('type', $type)->first();
  507. $this->assertNotNull($row); $this->assertSame(0, (int) $row->status);
  508. $this->assertNull(DB::table('promotion_campaigns')->where('activity_id', $row->id)->value('first_published_at'));
  509. }
  510. $options = (new PromotionAdminService())->options();
  511. $this->assertSame([7, 8, 9], array_column($options['types'], 'value'));
  512. $this->assertSame([8], array_column($options['create_types'], 'value'));
  513. $this->assertSame(0, DB::table('menus')->count());
  514. }
  515. public function test_fixed_types_cannot_be_created_even_when_no_configuration_exists(): void
  516. {
  517. $admin = new PromotionAdminService();
  518. foreach ([7, 9] as $type) {
  519. $input = ['translations' => $this->texts(), 'type' => $type, 'status' => 0,
  520. 'start_time' => '2026-10-01 00:00:00', 'end_time' => '2026-12-31 23:59:59'];
  521. $this->invalid(fn () => $admin->save($input), '不支持新增');
  522. $this->assertSame(1, DB::table('promotion_activities')->where('type', $type)->count());
  523. $id = DB::table('promotion_activities')->where('type', $type)->value('id');
  524. DB::table('promotion_campaigns')->where('activity_id', $id)->delete();
  525. DB::table('promotion_activities')->where('id', $id)->delete();
  526. $this->invalid(fn () => $admin->save($input), '不支持新增');
  527. $this->assertSame(0, DB::table('promotion_activities')->where('type', $type)->count());
  528. }
  529. }
  530. public function test_first_deposit_still_allows_multiple_new_configurations_and_type_change_is_rejected(): void
  531. {
  532. $first = $this->activity(8); $second = $this->activity(8);
  533. $this->assertNotSame($first, $second);
  534. $this->assertSame(2, DB::table('promotion_activities')->where('type', 8)->count());
  535. $this->invalid(fn () => (new PromotionAdminService())->save(['id' => $first, 'translations' => $this->texts(), 'type' => 9, 'status' => 0,
  536. 'start_time' => '2026-10-01 00:00:00', 'end_time' => '2026-12-31 23:59:59']), '不能变更活动类型');
  537. $this->assertSame(8, (int) DB::table('promotion_activities')->where('id', $first)->value('type'));
  538. }
  539. public function test_fixed_initialization_and_relisting_do_not_replace_existing_activity_or_reset_its_history(): void
  540. {
  541. $id = $this->activity(9, ['completion_probability' => 0]);
  542. DB::table('promotion_activities')->where('id', $id)->update(['rewarded_count' => 3, 'budget_used' => '1998']);
  543. $before = (array) DB::table('promotion_activities')->where('id', $id)->first();
  544. $published = DB::table('promotion_campaigns')->where('activity_id', $id)->value('first_published_at');
  545. $migration = require dirname(__DIR__, 2) . '/database/migrations/2026_10_27_130000_initialize_fixed_promotion_activities.php';
  546. $this->travel('2026-10-27 12:00:00'); $migration->up(); $migration->down();
  547. $this->assertSame($before, (array) DB::table('promotion_activities')->where('id', $id)->first());
  548. $this->assertSame(2, DB::table('promotion_activities')->count());
  549. $admin = new PromotionAdminService(); $admin->status($id, 0); $admin->status($id, 1);
  550. $this->assertSame($published, DB::table('promotion_campaigns')->where('activity_id', $id)->value('first_published_at'));
  551. $this->assertSame(3, (int) DB::table('promotion_activities')->where('id', $id)->value('rewarded_count'));
  552. }
  553. public function test_admin_partial_rule_edit_preserves_other_settings_and_zero_probability(): void
  554. {
  555. $id = $this->activity(9, ['turnover_ratio' => 3, 'deposit_amount' => 5000]);
  556. $saved = (new PromotionAdminService())->save(['id' => $id, 'translations' => $this->texts(), 'type' => 9, 'status' => 1,
  557. 'start_time' => '2026-10-01', 'end_time' => '2026-12-31', 'params' => ['completion_probability' => 0]]);
  558. $this->assertSame('0.00', $saved['params']['completion_probability']); $this->assertSame('3.00', $saved['params']['turnover_ratio']);
  559. $this->assertSame('5000.00', $saved['params']['deposit_amount']); $this->assertSame(0, DB::table('activity_rewards')->count());
  560. }
  561. public function test_voided_task_progress_is_not_eligible_for_completion_probability(): void
  562. {
  563. $id = $this->activity(9, ['completion_probability' => 100]); $this->complete($id);
  564. DB::table('share_bet_facts')->where('user_id', 2)->update(['voided' => 1]);
  565. $state = $this->service->progress($id, 1); $this->assertFalse($state['conditions_complete']);
  566. $this->assertFalse($state['completed_tasks']['invite']); $this->assertFalse($state['completed_tasks']['team_bet']);
  567. $this->assertFalse($this->service->spin($id, 1, 'request_spin_voided')['spin']['reached']);
  568. }
  569. public function test_real_chain_credit_hook_awards_first_deposit_once_and_rolls_back_a_failed_award(): void
  570. {
  571. foreach ([
  572. 'config' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,field TEXT,val TEXT',
  573. 'level' => 'id INTEGER PRIMARY KEY,level INTEGER,recharge TEXT',
  574. 'recharge_channel_group' => 'id INTEGER PRIMARY KEY,activity_type TEXT,deleted_at TEXT',
  575. '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',
  576. 'recharges' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,member_id TEXT,status INTEGER,amount TEXT,created_at TEXT,updated_at TEXT',
  577. ] as $table => $columns) DB::statement('CREATE TABLE bot_' . $table . ' (' . $columns . ')');
  578. foreach (['huishui_percentage' => '0', 'huishui_restriction' => '0', 'recharge_rate' => '0'] as $field => $val) DB::table('config')->insert(compact('field', 'val'));
  579. $id = $this->activity(8, ['min_deposit' => 100, 'reward_amount' => 25]);
  580. $source = DB::table('recharges')->insertGetId(['member_id' => 'm1', 'status' => 0, 'amount' => 100]);
  581. $recharge = \App\Models\Recharge::find($source);
  582. $this->assertTrue(\App\Services\RechargeService::creditConfirmedRecharge($recharge, ['status' => 1], '100.00'));
  583. $this->assertFalse(\App\Services\RechargeService::creditConfirmedRecharge($recharge, ['status' => 1], '100.00'));
  584. $this->assertSame('125.0000000000', (string)DB::table('wallets')->where('member_id', 'm1')->value('available_balance'));
  585. $this->assertSame(1, DB::table('promotion_rewards')->count());
  586. $source = DB::table('recharges')->insertGetId(['member_id' => 'm2', 'status' => 0, 'amount' => 100]);
  587. DB::statement("CREATE TRIGGER reject_award BEFORE INSERT ON bot_promotion_rewards BEGIN SELECT RAISE(ABORT, 'award failure'); END");
  588. try { \App\Services\RechargeService::creditConfirmedRecharge(\App\Models\Recharge::find($source), ['status' => 1], '100.00'); $this->fail('Expected failed award'); }
  589. catch (\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('award failure', $e->getMessage()); }
  590. $this->assertSame(0, (int)DB::table('recharges')->where('id', $source)->value('status'));
  591. $this->assertSame('0', (string)DB::table('wallets')->where('member_id', 'm2')->value('available_balance'));
  592. $this->assertSame(0, DB::table('balance_logs')->where('member_id', 'm2')->count());
  593. }
  594. public function test_promotion_heartbeat_records_login_once_and_online_without_other_reporting_tables(): void
  595. {
  596. DB::statement('DROP TABLE bot_reporting_activity'); $id = $this->activity();
  597. for ($i = 0; $i <= 60; ++$i) {
  598. $this->travel(Carbon::parse('2026-10-26 12:00:00')->addMinutes($i)->format('Y-m-d H:i:s'));
  599. $this->service->heartbeat(1, 'secret-token', 'H5'); $this->service->heartbeat(1, 'secret-token', 'H5');
  600. }
  601. $state = $this->service->progress($id, 1);
  602. $this->assertTrue($state['completed_tasks']['online']); $this->assertTrue($state['completed_tasks']['login']); $this->assertSame(2, (int)$state['spins']);
  603. $this->assertSame(61, DB::table('promotion_presence')->count());
  604. $this->assertStringNotContainsString('secret-token', DB::table('promotion_presence')->first()->session_hash);
  605. $this->assertCount(7, $state['tasks']); $this->assertSame(0, $state['tasks'][0]['remaining_seconds']);
  606. $this->travel('2026-10-27 00:01:00'); $this->service->heartbeat(1, 'secret-token', 'H5');
  607. $this->assertSame(3, (int)$this->service->progress($id, 1)['spins']);
  608. }
  609. public function test_missing_old_first_deposit_hook_is_not_back_awarded_when_campaign_starts_later(): void
  610. {
  611. $deposit = $this->deposit(1, '1000'); $this->travel('2026-10-26 13:00:00'); $this->activity(8);
  612. $this->service->onDeposit($deposit);
  613. $this->assertSame(0, DB::table('promotion_rewards')->count());
  614. }
  615. public function test_manual_payment_approval_keeps_first_deposit_bonus_instead_of_overwriting_it_with_principal(): void
  616. {
  617. foreach ([
  618. 'config' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,field TEXT,val TEXT',
  619. 'level' => 'id INTEGER PRIMARY KEY,level INTEGER,recharge TEXT',
  620. 'recharge_channel_group' => 'id INTEGER PRIMARY KEY,activity_type TEXT,deleted_at TEXT',
  621. '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',
  622. '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',
  623. ] as $table => $columns) DB::statement('CREATE TABLE bot_' . $table . ' (' . $columns . ')');
  624. foreach (['huishui_percentage' => '0', 'huishui_restriction' => '0', 'recharge_rate' => '0'] as $field => $val) DB::table('config')->insert(compact('field', 'val'));
  625. $this->activity(8, ['min_deposit' => 100, 'reward_amount' => 25]);
  626. DB::table('payment_orders')->insert(['id' => 1, 'member_id' => 'm1', 'status' => 5, 'type' => 3, 'amount' => '100']);
  627. $this->app->instance('request', Request::create('/admin/paymentOrder/manualAudit', 'POST', ['id' => 1, 'status' => 2]));
  628. $body = (new \App\Http\Controllers\admin\PaymentOrder())->manualAudit()->getData(true);
  629. $this->assertSame(0, $body['code'], $body['msg']);
  630. $this->assertSame('125.0000000000', (string)DB::table('wallets')->where('member_id', 'm1')->value('available_balance'));
  631. $deposit = DB::table('balance_logs')->where('change_type', '充值')->first();
  632. $this->assertSame('0', (string)$deposit->before_balance); $this->assertSame('100.0000000000', (string)$deposit->after_balance);
  633. $this->assertSame(1, DB::table('promotion_rewards')->count());
  634. $this->assertNotSame(0, (new \App\Http\Controllers\admin\PaymentOrder())->manualAudit()->getData(true)['code']);
  635. $this->assertSame(1, DB::table('promotion_rewards')->count());
  636. }
  637. }