PromotionActivitiesTest.php 80 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063
  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\Services\TelegramFirstDeposit;
  10. use App\Models\User;
  11. use App\Support\FirstDepositChoice;
  12. use App\Support\PromotionRules;
  13. use Carbon\Carbon;
  14. use Illuminate\Config\Repository;
  15. use Illuminate\Database\Capsule\Manager;
  16. use Illuminate\Database\Schema\Blueprint;
  17. use Illuminate\Foundation\Application;
  18. use Illuminate\Http\Request;
  19. use Illuminate\Support\Facades\DB;
  20. use Illuminate\Support\Facades\Facade;
  21. use Illuminate\Translation\ArrayLoader;
  22. use Illuminate\Translation\Translator;
  23. use Illuminate\Validation\Factory;
  24. use Illuminate\Validation\ValidationException;
  25. use PHPUnit\Framework\TestCase;
  26. /** Runs real migrations, qualification, wallet writes and state machines in SQLite, without .env/providers. */
  27. class PromotionActivitiesTest extends TestCase
  28. {
  29. private Application $app;
  30. private PromotionService $service;
  31. private string $originalTimezone;
  32. protected function setUp(): void
  33. {
  34. $this->originalTimezone = date_default_timezone_get(); date_default_timezone_set('Asia/Shanghai'); Carbon::setTestNow(Carbon::parse('2026-10-26 12:00:00', 'Asia/Shanghai'));
  35. Facade::clearResolvedInstances(); $this->app = new Application(dirname(__DIR__, 2));
  36. $this->app->instance('config', new Repository(['app' => ['locale' => 'zh', 'timezone' => 'Asia/Shanghai'], 'third_game' => ['currency' => 'CNY']])); Facade::setFacadeApplication($this->app);
  37. $db = new Manager($this->app); $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
  38. $db->setAsGlobal(); $db->bootEloquent(); $this->app->instance('db', $db->getDatabaseManager()); $this->app->bind('db.schema', fn () => $db->schema());
  39. $this->app->instance('translator', new Translator(new ArrayLoader(), 'zh'));
  40. $this->app->instance('validator', new Factory($this->app['translator'], $this->app));
  41. $this->app->instance('log', new \Psr\Log\NullLogger()); $this->app->instance('request', Request::create('/api/promotion/progress'));
  42. $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new \Tests\Support\JsonResponseFactory());
  43. Request::macro('validate', function ($rules) { return app('validator')->make($this->all(), $rules)->validate(); });
  44. foreach ([
  45. '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',
  46. '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',
  47. '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',
  48. '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',
  49. 'share_members' => 'user_id INTEGER PRIMARY KEY,parent_user_id INTEGER,bound_at TEXT',
  50. 'share_policies' => 'id INTEGER PRIMARY KEY,effective_date TEXT,valid_member_min_deposit TEXT,valid_member_min_effective_bet TEXT',
  51. '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',
  52. 'user_login' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,user_id TEXT,status INTEGER,platform TEXT,created_at TEXT',
  53. 'reporting_activity' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,user_id INTEGER,session_hash TEXT,observed_at TEXT',
  54. 'share_levels' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,level INTEGER UNIQUE,name TEXT',
  55. 'config' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,field TEXT,val TEXT',
  56. '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',
  57. ] as $table => $columns) DB::statement('CREATE TABLE bot_' . $table . ' (' . $columns . ')');
  58. 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', '2026_11_03_170000_repeat_loss_invite_rounds.php', '2026_11_04_120000_create_upgrade_invite_promotion.php'] as $migration) (require dirname(__DIR__, 2) . '/database/migrations/' . $migration)->up();
  59. (require dirname(__DIR__, 2) . '/database/migrations/2026_11_07_180000_allow_permanent_activities.php')->up();
  60. (require dirname(__DIR__, 2) . '/database/migrations/2026_11_12_120000_add_first_deposit_join_choice.php')->up();
  61. (require dirname(__DIR__, 2) . '/database/migrations/2026_11_12_130000_bind_first_deposit_choices.php')->up();
  62. $this->member(1); $this->member(2, 1);
  63. DB::table('share_policies')->insert(['id' => 1, 'effective_date' => '2026-10-01', 'valid_member_min_deposit' => '200', 'valid_member_min_effective_bet' => '300']);
  64. $this->service = new PromotionService();
  65. }
  66. protected function tearDown(): void { Carbon::setTestNow(); date_default_timezone_set($this->originalTimezone); DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown(); }
  67. private function travel(string $time): void { Carbon::setTestNow(Carbon::parse($time, 'Asia/Shanghai')); }
  68. private function member(int $id, ?int $parent = null, string $at = '2026-10-01 00:00:00'): void
  69. {
  70. DB::table('users')->insert(['id' => $id, 'member_id' => 'm' . $id, 'user_id' => 'u' . $id, 'language' => 'zh', 'from' => 1, 'created_at' => $at, 'updated_at' => $at]);
  71. DB::table('wallets')->insert(['member_id' => 'm' . $id, 'available_balance' => '0', 'frozen_balance' => '0']);
  72. if ($parent) DB::table('share_members')->insert(['user_id' => $id, 'parent_user_id' => $parent, 'bound_at' => $at]);
  73. }
  74. private function activity(int $type = 9, array $params = [], int $status = 1): int
  75. {
  76. $input = ['translations' => $this->texts(), 'type' => $type, 'status' => $status,
  77. 'start_time' => '2026-10-01 00:00:00', 'end_time' => '2026-12-31 23:59:59', 'lang' => 'zh', 'params' => $params];
  78. if (in_array($type, PromotionRules::FIXED, true)) $input['id'] = (int) DB::table('promotion_activities')->where('type', $type)->value('id');
  79. return (int) (new PromotionAdminService())->save($input)['id'];
  80. }
  81. private function texts(): array
  82. {
  83. return ['zh' => ['title' => '测试活动', 'content' => '<p>中文活动正文</p>'],
  84. 'en' => ['title' => 'Test promotion', 'content' => '<p>English content</p>'],
  85. 'vi' => ['title' => 'Khuyến mãi', 'content' => '<p>Nội dung</p>']];
  86. }
  87. /** 会员在首笔充值前勾选参加首充活动。 */
  88. private function join(int $id, $value = 1): bool
  89. {
  90. $joined = FirstDepositChoice::parse($value);
  91. $offer = null;
  92. if ($joined) {
  93. $activity = $this->service->listing($id, 'H5', 'zh', 8)['data'][0];
  94. $offer = FirstDepositChoice::offer($activity['activity_id'], 'H5', $activity['rules'], $activity['start_time'], $activity['end_time']);
  95. }
  96. return FirstDepositChoice::record(User::find($id), $joined, 'test', $offer);
  97. }
  98. /** 全部首充活动共用的禁止投注大类。 */
  99. private function restrict($categories): array
  100. {
  101. return (new PromotionAdminService())->saveFirstDepositRestriction(['categories' => $categories]);
  102. }
  103. private function deposit(int $id, string $amount, ?string $time = null, string $type = '充值'): BalanceLog
  104. {
  105. return BalanceLog::create(['member_id' => 'm' . $id, 'type' => 1, 'amount' => $amount, 'change_type' => $type, 'created_at' => $time ?? PromotionFacts::now()]);
  106. }
  107. private function rawDeposit(int $id, string $amount, string $time, string $type = '充值'): int
  108. {
  109. return DB::table('balance_logs')->insertGetId(['member_id' => 'm' . $id, 'type' => 1, 'amount' => $amount, 'change_type' => $type, 'created_at' => $time, 'updated_at' => $time]);
  110. }
  111. private function bet(int $id, string $stake, string $profit, string $time, array $extra = []): int
  112. {
  113. $next = (int)DB::table('share_bet_facts')->max('id') + 1;
  114. return DB::table('share_bet_facts')->insertGetId($extra + ['user_id' => $id, 'source' => 'sport_order', 'source_id' => (string)$next,
  115. 'bet_at' => $time, 'bet_amount' => $stake, 'net_profit' => $profit, 'effective_amount' => '999999', 'currency' => 'CNY', 'settled' => 1, 'voided' => 0]);
  116. }
  117. private function invalid(callable $callback, string $message): void
  118. {
  119. try { $callback(); $this->fail('Expected validation failure: ' . $message); }
  120. catch (ValidationException $e) { $this->assertStringContainsString($message, $e->validator->errors()->first()); }
  121. }
  122. private function complete(int $activity): array
  123. {
  124. $this->rawDeposit(1, '3000', '2026-10-26 12:01:00'); $this->rawDeposit(2, '500', '2026-10-26 12:01:00');
  125. $this->bet(1, '6000', '-5000', '2026-10-26 12:01:00'); $this->bet(2, '4000', '-3000', '2026-10-26 12:01:00');
  126. DB::table('user_login')->insert(['user_id' => 'm1', 'status' => 1, 'platform' => 'H5', 'created_at' => '2026-10-26 12:01:00']);
  127. 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')]);
  128. $this->travel('2026-10-26 14:00:00'); return $this->service->progress($activity, 1, 'H5');
  129. }
  130. public function test_all_independent_activities_support_permanent_dates_and_reject_half_dates(): void
  131. {
  132. $admin = new PromotionAdminService();
  133. DB::table('share_levels')->insert(['level' => 2, 'name' => '二级']);
  134. foreach ([7, 8, 9, 10] as $type) {
  135. $id = $this->activity($type, [], 0);
  136. $input = ['id' => $id, 'type' => $type, 'status' => 0, 'translations' => $this->texts(), 'start_time' => null, 'end_time' => ''];
  137. if ($type === 10) $input['params'] = ['tiers' => [['level' => 2, 'amount' => '10']]];
  138. $saved = $admin->save($input);
  139. $this->assertNull($saved['start_time']); $this->assertNull($saved['end_time']);
  140. $this->assertNull($admin->detail($id)['end_time']);
  141. $before = DB::table('promotion_activities')->where('id', $id)->first();
  142. foreach ([['start_time' => '2026-10-01', 'end_time' => null], ['start_time' => null, 'end_time' => '2026-12-31']] as $half) {
  143. $this->invalid(fn () => $admin->save(array_replace($input, $half)), '同时填写或同时留空');
  144. $this->assertEquals($before, DB::table('promotion_activities')->where('id', $id)->first());
  145. }
  146. // Publishing a permanent activity must not mistake null end_time for 1970.
  147. $admin->status($id, 1);
  148. $this->assertSame($id, (int)$this->service->listing(1, 'PC', 'zh', $type)['data'][0]['activity_id']);
  149. $detail = $this->service->detail($id, 1, 'PC', 'zh');
  150. $this->assertNull($detail['start_time']); $this->assertNull($detail['end_time']);
  151. }
  152. $this->assertSame(0, PromotionAdminService::expire());
  153. $id = (int)DB::table('promotion_activities')->where('type', 8)->value('id');
  154. $this->join(1); $this->service->onDeposit($this->deposit(1, '100'));
  155. $this->assertSame(1, DB::table('promotion_rewards')->where('activity_id', $id)->count());
  156. }
  157. public function test_new_independent_activities_omit_dates_for_permanence_and_keep_dates_on_partial_edit(): void
  158. {
  159. $admin = new PromotionAdminService();
  160. $input = ['type' => 8, 'status' => 0, 'translations' => $this->texts()];
  161. $saved = $admin->save($input);
  162. $this->assertNull($saved['start_time']); $this->assertNull($saved['end_time']);
  163. $saved = $admin->save($input + ['id' => $saved['id'], 'start_time' => '2026-10-01', 'end_time' => '2026-12-31']);
  164. $edited = $admin->save($input + ['id' => $saved['id']]);
  165. $this->assertSame($saved['start_time'], $edited['start_time']);
  166. $this->assertSame($saved['end_time'], $edited['end_time']);
  167. foreach (['start_time', 'end_time'] as $field) {
  168. $this->invalid(fn () => $admin->save($input + [$field => '2026-12-01']), '同时填写或同时留空');
  169. }
  170. }
  171. public function test_background_sync_skips_page_metrics_and_duplicate_task_writes_but_observes_corrections(): void
  172. {
  173. $id = $this->activity(9); $before = $this->complete($id);
  174. $this->assertTrue($before['conditions_complete']);
  175. DB::enableQueryLog(); DB::flushQueryLog();
  176. try {
  177. $this->service->syncAccount($id, 1);
  178. $queries = DB::getQueryLog();
  179. $this->assertLessThanOrEqual(50, count($queries));
  180. $this->assertCount(0, array_filter($queries, fn ($q) => str_contains($q['query'], 'from "bot_promotion_rewards"')));
  181. $this->assertCount(0, array_filter($queries, fn ($q) => preg_match('/^(insert.*"bot_promotion_task_grants"|update "bot_promotion_rounds")/', $q['query'])));
  182. $this->assertLessThanOrEqual(4, count(array_filter($queries, fn ($q) => str_contains($q['query'], 'from "bot_share_bet_facts"'))));
  183. } finally { DB::disableQueryLog(); DB::flushQueryLog(); }
  184. $after = $this->service->detail($id, 1);
  185. $this->assertSame($before['completed_tasks'], $after['completed_tasks']);
  186. $this->assertSame($before['spins'], $after['spins']);
  187. DB::table('share_bet_facts')->where('user_id', 2)->update(['net_profit' => '0']);
  188. $this->service->syncAccount($id, 1);
  189. $corrected = $this->service->detail($id, 1);
  190. $this->assertFalse($corrected['completed_tasks']['invite']);
  191. $this->assertFalse($corrected['completed_tasks']['team_bet']);
  192. $this->assertSame(7, DB::table('promotion_task_grants')->count());
  193. }
  194. public function test_third_party_cursor_uses_one_query_and_original_order_ids(): void
  195. {
  196. DB::statement('CREATE TABLE bot_third_game_orders (id INTEGER PRIMARY KEY,order_key TEXT UNIQUE)');
  197. foreach (range(1, 20) as $id) {
  198. $key = str_pad(dechex($id), 64, '0', STR_PAD_LEFT);
  199. DB::table('third_game_orders')->insert(['id' => $id, 'order_key' => $key]);
  200. $this->bet(1, '10', '-10', '2026-10-26 11:00:00', ['source' => 'third_game_orders', 'source_id' => $key]);
  201. }
  202. $this->bet(1, '999', '-999', '2026-10-26 11:00:00', ['source' => 'third_game_orders', 'source_id' => str_repeat('f', 64)]);
  203. DB::enableQueryLog(); DB::flushQueryLog();
  204. try {
  205. $result = (new PromotionFacts())->betting([1], '2026-10-01 00:00:00', null, ['third_game_orders' => 10]);
  206. $this->assertLessThanOrEqual(6, count(DB::getQueryLog()));
  207. } finally { DB::disableQueryLog(); DB::flushQueryLog(); }
  208. $this->assertSame('100.0000000000', $result['effective']['2026-10-26']);
  209. $this->assertSame('-100.0000000000', $result['profit']);
  210. }
  211. public function test_images_are_only_configurable_for_first_deposit_not_fixed_loss_or_wheel_campaigns(): void
  212. {
  213. $admin = new PromotionAdminService();
  214. foreach ([7, 8, 9] as $type) {
  215. $id = $this->activity($type);
  216. DB::table('promotion_activities')->where('id', $id)->update(['image' => 'previous.png']);
  217. $input = $admin->detail($id); $input['image'] = $type === 8 ? 'first-deposit.png' : ['ignored-legacy-upload'];
  218. $saved = $admin->save($input);
  219. foreach ([$saved, $admin->detail($id), $admin->listing(['type' => $type])['data'][0], $this->service->listing(1, 'H5', 'zh', $type)['data'][0]] as $row) {
  220. $this->assertSame($type === 8, array_key_exists('image', $row));
  221. }
  222. $this->assertSame($type === 8 ? 'first-deposit.png' : 'previous.png', DB::table('promotion_activities')->where('id', $id)->value('image'));
  223. }
  224. }
  225. public function test_subtitles_are_not_required_saved_or_returned_for_any_independent_activity(): void
  226. {
  227. $admin = new PromotionAdminService();
  228. foreach ([7, 8, 9] as $type) {
  229. $id = $this->activity($type); $legacy = $this->texts();
  230. foreach ($legacy as &$text) $text['sub_title'] = 'Historical subtitle'; unset($text);
  231. DB::table('promotion_activities')->where('id', $id)->update(['sub_title' => 'Historical subtitle', 'translations' => json_encode($legacy)]);
  232. $detail = $admin->detail($id);
  233. $this->assertArrayNotHasKey('sub_title', $detail);
  234. foreach ($detail['translations'] as $text) $this->assertSame(['title', 'content'], array_keys($text));
  235. $input = $detail; $input['sub_title'] = 'Ignored legacy input';
  236. foreach ($input['translations'] as &$text) $text['sub_title'] = ['ignored legacy input']; unset($text);
  237. $saved = $admin->save($input);
  238. $this->assertSame($this->texts(), $saved['translations']);
  239. $this->assertSame('Historical subtitle', DB::table('promotion_activities')->where('id', $id)->value('sub_title'));
  240. foreach ([$saved, $admin->listing(['type' => $type])['data'][0], $admin->status($id, 1),
  241. $this->service->listing(1, 'H5', 'zh', $type)['data'][0], $this->service->detail($id, 1, 'H5', 'zh')] as $row) {
  242. $this->assertArrayNotHasKey('sub_title', $row);
  243. }
  244. }
  245. }
  246. public function test_three_languages_edit_one_campaign_and_keep_rules_history_and_other_tabs(): void
  247. {
  248. $admin = new PromotionAdminService();
  249. foreach ([7, 8, 9] as $type) {
  250. $id = $this->activity($type); $before = $admin->detail($id);
  251. $count = DB::table('promotion_activities')->count();
  252. $input = $before; $input['translations']['en']['title'] = 'Updated English title';
  253. $saved = $admin->save($input);
  254. $this->assertSame($id, $saved['id']);
  255. $this->assertSame($count, DB::table('promotion_activities')->count());
  256. $this->assertSame($before['translations']['zh'], $saved['translations']['zh']);
  257. $this->assertSame($before['translations']['vi'], $saved['translations']['vi']);
  258. $this->assertSame('Updated English title', $admin->detail($id)['translations']['en']['title']);
  259. foreach (['params', 'rewarded_count', 'budget_used', 'first_published_at'] as $field) $this->assertSame($before[$field], $saved[$field]);
  260. foreach (['zh', 'en', 'vi'] as $lang) {
  261. $rows = $this->service->listing(1, 'H5', $lang, $type)['data'];
  262. $this->assertCount(1, $rows); $this->assertSame($id, $rows[0]['activity_id']);
  263. $this->assertSame($saved['translations'][$lang]['title'], $rows[0]['title']);
  264. $state = $this->service->detail($id, 1, 'H5', $lang);
  265. $this->assertSame($lang, $state['lang']);
  266. $this->assertSame($saved['translations'][$lang]['title'], $state['title']);
  267. $this->assertSame($saved['translations'][$lang]['content'], $state['content']);
  268. }
  269. }
  270. $this->assertSame(['zh', 'en', 'vi'], array_column($admin->options()['languages'], 'value'));
  271. }
  272. public function test_all_six_text_fields_are_required_and_invalid_tabs_cannot_overwrite_saved_content(): void
  273. {
  274. $id = $this->activity(9); $admin = new PromotionAdminService(); $input = $admin->detail($id);
  275. $before = DB::table('promotion_activities')->where('id', $id)->first();
  276. foreach (['zh', 'en', 'vi'] as $lang) {
  277. foreach (['title', 'content'] as $field) {
  278. $bad = $input; unset($bad['translations'][$lang][$field]);
  279. try { $admin->save($bad); $this->fail('Missing language text was accepted'); }
  280. catch (ValidationException $e) { $this->assertTrue($e->validator->errors()->has('translations.' . $lang . '.' . $field)); }
  281. $bad = $input; $bad['translations'][$lang][$field] = '<p>&nbsp;<br></p>';
  282. $this->invalid(fn () => $admin->save($bad), '不能为空');
  283. }
  284. $bad = $input; unset($bad['translations'][$lang]);
  285. $this->invalid(fn () => $admin->save($bad), '完整填写');
  286. }
  287. $bad = $input; unset($bad['translations']); $bad['title'] = '旧单语言表单';
  288. $this->invalid(fn () => $admin->save($bad), '完整填写');
  289. $bad = $input; $bad['translations']['fr'] = $input['translations']['en'];
  290. try { $admin->save($bad); $this->fail('Unknown language was accepted'); }
  291. catch (ValidationException $e) { $this->assertTrue($e->validator->errors()->has('translations')); }
  292. $bad = $input; $bad['translations']['en']['title'] = str_repeat('a', 141);
  293. try { $admin->save($bad); $this->fail('Oversized title was accepted'); }
  294. catch (ValidationException $e) { $this->assertTrue($e->validator->errors()->has('translations.en.title')); }
  295. $this->assertEquals($before, DB::table('promotion_activities')->where('id', $id)->first());
  296. }
  297. public function test_admin_http_save_returns_all_tabs_and_rejects_partial_language_payloads(): void
  298. {
  299. $controller = new \App\Http\Controllers\admin\Promotion(); $admin = new PromotionAdminService();
  300. $input = ['type' => 8, 'status' => 0, 'start_time' => '2026-10-26', 'end_time' => '2026-12-31', 'translations' => $this->texts()];
  301. $this->app->instance('request', Request::create('/admin/promotion/submit', 'POST', $input));
  302. $saved = $controller->submit($admin)->getData(true);
  303. $this->assertSame(0, $saved['code']); $this->assertSame($this->texts(), $saved['data']['translations']);
  304. $input['id'] = $saved['data']['id']; unset($input['translations']['en']);
  305. $this->app->instance('request', Request::create('/admin/promotion/submit', 'POST', $input));
  306. $failed = $controller->submit($admin)->getData(true);
  307. $this->assertSame(-3, $failed['code']); $this->assertStringContainsString('英文', $failed['msg']);
  308. $this->assertSame($this->texts(), $admin->detail($input['id'])['translations']);
  309. }
  310. public function test_incomplete_legacy_activity_can_be_hidden_but_cannot_be_published_without_all_tabs(): void
  311. {
  312. $id = (int) DB::table('promotion_activities')->where('type', 7)->value('id');
  313. $admin = new PromotionAdminService();
  314. $this->assertSame(0, (int) $admin->status($id, 0)['status']);
  315. $this->invalid(fn () => $admin->status($id, 1), '不能为空');
  316. $this->assertSame(0, (int) DB::table('promotion_activities')->where('id', $id)->value('status'));
  317. $this->assertNull(DB::table('promotion_campaigns')->where('activity_id', $id)->value('first_published_at'));
  318. }
  319. public function test_first_deposit_translation_does_not_split_or_duplicate_awards(): void
  320. {
  321. $id = $this->activity(8, ['min_deposit' => 100, 'reward_amount' => 25]);
  322. DB::table('users')->where('id', 1)->update(['language' => 'vi']); $this->join(1);
  323. $deposit = $this->deposit(1, '100'); $this->service->onDeposit($deposit);
  324. $this->assertSame(1, DB::table('promotion_rewards')->where('activity_id', $id)->count());
  325. DB::table('users')->where('id', 1)->update(['language' => 'en']);
  326. $this->service->onDeposit($deposit);
  327. $this->assertSame(1, DB::table('promotion_rewards')->where('activity_id', $id)->count());
  328. $this->assertSame('25.0000000000', (string) DB::table('wallets')->where('member_id', 'm1')->value('available_balance'));
  329. $this->assertSame(1, DB::table('promotion_accounts')->where('activity_id', $id)->count());
  330. }
  331. public function test_translation_migration_preserves_legacy_texts_and_campaign_identity(): void
  332. {
  333. $migration = require dirname(__DIR__, 2) . '/database/migrations/2026_10_27_140000_add_promotion_activity_translations.php';
  334. $migration->down();
  335. $id = DB::table('promotion_activities')->where('type', 7)->value('id');
  336. DB::table('promotion_activities')->where('id', $id)->update(['lang' => 'en', 'title' => 'Existing title', 'sub_title' => 'Existing subtitle', 'content' => '<p>Existing content</p>']);
  337. $rows = DB::table('promotion_activities')->orderBy('id')->get();
  338. $published = DB::table('promotion_campaigns')->orderBy('activity_id')->get();
  339. $migration->up();
  340. foreach ($rows as $row) {
  341. $saved = DB::table('promotion_activities')->where('id', $row->id)->first();
  342. foreach ((array) $row as $field => $value) $this->assertSame($value, $saved->$field);
  343. }
  344. $texts = (new PromotionAdminService())->detail((int) $id)['translations'];
  345. $this->assertSame(['title' => 'Existing title', 'content' => '<p>Existing content</p>'], $texts['en']);
  346. $this->assertSame('', $texts['zh']['title']);
  347. $this->assertEquals($published, DB::table('promotion_campaigns')->orderBy('activity_id')->get());
  348. $this->assertSame(0, DB::table('menus')->count());
  349. $this->assertSame(0, $this->service->listing(1, 'H5', 'vi', 7)['total']);
  350. }
  351. public function test_probability_default_zero_boundaries_and_incomplete_gate(): void
  352. {
  353. $this->assertSame('20.00', PromotionRules::normalize(9, [])['completion_probability']);
  354. $this->assertSame('0.00', PromotionRules::normalize(9, ['completion_probability' => 0])['completion_probability']);
  355. $this->assertTrue(PromotionRules::draw(66599, true, '20.00', fn ($a, $b) => $a === 1 ? 2000 : 0)['reached']);
  356. $this->assertFalse(PromotionRules::draw(66599, true, '20.00', fn ($a, $b) => $a === 1 ? 2001 : 0)['reached']);
  357. $this->assertFalse(PromotionRules::draw(66599, false, '100.00', fn ($a, $b) => $a)['reached']);
  358. $this->invalid(fn () => PromotionRules::normalize(9, ['completion_probability' => 100.01]), 'between');
  359. }
  360. public function test_wheel_limits_early_prizes_and_leaves_room_for_ten_misses_at_twenty_percent(): void
  361. {
  362. // Choose the floating sector, force a miss, then take the maximum allowed amount.
  363. $maxFloat = fn ($min, $max) => $min === 0 ? 0 : $max;
  364. $this->assertSame(16625, PromotionRules::draw(0, false, '20.00', $maxFloat)['amount_cents']);
  365. // 未完成任务时600后不再归零,按剩余空间的1/20继续发放,并始终给完成阶段留1元。
  366. $this->assertSame(325, PromotionRules::draw(60000, false, '100.00', $maxFloat)['amount_cents']);
  367. $this->assertSame(1, PromotionRules::draw(66499, false, '100.00', $maxFloat)['amount_cents']);
  368. $this->assertSame(0, PromotionRules::draw(66500, false, '100.00', $maxFloat)['amount_cents']);
  369. $fixed = PromotionRules::draw(0, false, '20.00', fn ($min, $max) => $max);
  370. $this->assertSame(6, $fixed['prize_index']);
  371. $this->assertSame(15000, $fixed['amount_cents']);
  372. $coins = 60000;
  373. for ($i = 0; $i < 10; ++$i) {
  374. $draw = PromotionRules::draw($coins, true, '20.00', $maxFloat);
  375. $this->assertFalse($draw['reached']);
  376. $this->assertGreaterThan(0, $draw['amount_cents']);
  377. $coins += $draw['amount_cents'];
  378. }
  379. $this->assertLessThan(66500, $coins);
  380. $this->assertSame(99, PromotionRules::draw(65600, true, '20.00', $maxFloat)['amount_cents']);
  381. $this->assertSame(600, PromotionRules::draw(65600, true, '50.00', $maxFloat)['amount_cents']);
  382. $this->assertSame(1, PromotionRules::draw(66598, true, '0.00', $maxFloat)['amount_cents']);
  383. $this->assertSame(0, PromotionRules::draw(66599, true, '0.00', $maxFloat)['amount_cents']);
  384. }
  385. public function test_day_amounts_do_not_add_together_but_completed_flags_survive_midnight(): void
  386. {
  387. $id = $this->activity(); $this->service->progress($id, 1);
  388. $this->rawDeposit(1, '2000', '2026-10-26 13:00:00');
  389. $this->travel('2026-10-27 14:00:00'); $this->rawDeposit(1, '1000', '2026-10-27 12:00:00');
  390. $state = $this->service->progress($id, 1);
  391. $this->assertFalse($state['completed_tasks']['deposit']); $this->assertSame(0, (int)$state['spins']);
  392. $this->rawDeposit(1, '2000', '2026-10-27 13:00:00'); $state = $this->service->progress($id, 1);
  393. $this->assertTrue($state['completed_tasks']['deposit']); $this->assertSame(1, (int)$state['spins']);
  394. $this->travel('2026-10-28 14:00:00'); $state = $this->service->progress($id, 1);
  395. $this->assertTrue($state['completed_tasks']['deposit']); $this->assertSame(1, (int)$state['spins']);
  396. }
  397. public function test_repeated_rounds_keep_unused_spins_and_daily_flags_but_require_new_other_events(): void
  398. {
  399. $id = $this->activity(9, ['completion_probability' => 100]); $state = $this->complete($id);
  400. $this->assertTrue($state['conditions_complete']); $this->assertSame(7, (int)$state['spins']);
  401. $spin = $this->service->spin($id, 1, 'request_spin_00001');
  402. $this->assertTrue($spin['spin']['reached']); $this->assertSame('666.00', $spin['spin']['coins']);
  403. $this->assertSame($spin['spin'], $this->service->spin($id, 1, 'request_spin_00001')['spin']);
  404. $claim = $this->service->claim($id, 1, 1); $this->assertSame(2, $claim['round_no']); $this->assertSame(6, (int)$claim['spins']);
  405. $this->assertTrue($this->service->claim($id, 1, 1)['replayed']);
  406. $state = $this->service->progress($id, 1); $this->assertSame('0.00', $state['coins']);
  407. $this->assertSame(6, (int)$state['spins']); $this->assertTrue($state['completed_tasks']['online']); $this->assertTrue($state['completed_tasks']['login']);
  408. foreach (['invite', 'deposit', 'team_deposit', 'bet', 'team_bet'] as $task) $this->assertFalse($state['completed_tasks'][$task]);
  409. $this->rawDeposit(1, '3000', '2026-10-26 14:00:00'); $this->rawDeposit(2, '500', '2026-10-26 14:00:00');
  410. $this->bet(1, '6000', '-5000', '2026-10-26 14:00:00'); $this->bet(2, '4000', '-3000', '2026-10-26 14:00:00');
  411. $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');
  412. $state = $this->service->progress($id, 1); $this->assertTrue($state['conditions_complete']); $this->assertSame(11, (int)$state['spins']);
  413. $this->assertTrue($this->service->spin($id, 1, 'request_spin_00002')['spin']['reached']); $this->service->claim($id, 1, 2);
  414. $this->assertSame('1332.0000000000', (string)DB::table('wallets')->where('member_id', 'm1')->value('available_balance'));
  415. $this->assertSame(2, DB::table('promotion_rewards')->count()); $this->assertSame(1, (int)DB::table('promotion_activities')->where('id', $id)->value('rewarded_count'));
  416. }
  417. public function test_probability_changes_affect_new_spins_and_reserved_reward_can_be_claimed_after_down(): void
  418. {
  419. $id = $this->activity(9, ['completion_probability' => 0]); $this->complete($id);
  420. $first = $this->service->spin($id, 1, 'request_spin_00001'); $this->assertFalse($first['spin']['reached']);
  421. $row = ActivityReward::find($id); $rules = json_decode($row->params, true); $rules['completion_probability'] = '100.00'; $row->params = json_encode($rules); $row->save();
  422. $next = $this->service->spin($id, 1, 'request_spin_00002'); $this->assertTrue($next['spin']['reached']);
  423. $this->assertSame('0', (string)DB::table('promotion_spins')->orderBy('id')->value('probability'));
  424. DB::table('promotion_activities')->where('id', $id)->update(['status' => 0]);
  425. $this->service->claim($id, 1, 1); $this->invalid(fn () => $this->service->progress($id, 1), '未上架');
  426. $this->assertTrue($this->service->claim($id, 1, 1)['replayed']);
  427. }
  428. public function test_first_deposit_is_historical_first_and_does_not_refill_on_relisting(): void
  429. {
  430. $id = $this->activity(8, ['min_deposit' => '100', 'reward_amount' => '25']); $this->join(1); $this->join(2);
  431. $first = $this->deposit(1, '99'); $this->service->onDeposit($first); $this->service->onDeposit($first);
  432. $second = $this->deposit(1, '1000'); $this->service->onDeposit($second);
  433. $this->assertSame(0, DB::table('promotion_rewards')->count());
  434. $other = $this->deposit(2, '100'); $this->service->onDeposit($other); $this->service->onDeposit($other);
  435. $this->assertSame(1, DB::table('promotion_rewards')->count());
  436. $this->assertSame('25.0000000000', (string)DB::table('wallets')->where('member_id', 'm2')->value('available_balance'));
  437. $published = DB::table('promotion_campaigns')->where('activity_id', $id)->value('first_published_at');
  438. $this->travel('2026-10-27 12:00:00'); $row = ActivityReward::find($id); $row->status = 0; $row->save(); PromotionService::publish($row);
  439. $row->status = 1; $row->save(); PromotionService::publish($row);
  440. $this->assertSame($published, DB::table('promotion_campaigns')->where('activity_id', $id)->value('first_published_at'));
  441. $this->service->onDeposit($this->deposit(2, '500')); $this->assertSame(1, DB::table('promotion_rewards')->count());
  442. }
  443. public function test_loss_uses_post_publication_net_profit_then_only_new_direct_invitees_with_both_thresholds(): void
  444. {
  445. $this->bet(1, '900', '-900', '2026-10-25 12:00:00'); $id = $this->activity(7);
  446. $this->invalid(fn () => $this->service->apply($id, 1), '净亏');
  447. $this->bet(1, '600', '-600', '2026-10-26 12:00:00'); $this->bet(1, '200', '100', '2026-10-26 12:00:00');
  448. // A referral created in the same second, but before applying, is still an existing user.
  449. DB::table('users')->where('id', 2)->update(['created_at' => '2026-10-26 12:00:00']);
  450. DB::table('share_members')->where('user_id', 2)->update(['bound_at' => '2026-10-26 12:00:00']);
  451. $this->service->apply($id, 1); $this->service->apply($id, 1);
  452. $this->rawDeposit(2, '500', '2026-10-26 12:00:00'); $this->bet(2, '300', '-300', '2026-10-26 12:00:00');
  453. foreach ([3, 4, 5] as $child) {
  454. $this->member($child, 1, '2026-10-26 12:00:00'); $this->rawDeposit($child, '200', '2026-10-26 12:00:00');
  455. if ($child !== 5) $this->bet($child, '300', '-300', '2026-10-26 12:00:00');
  456. }
  457. $this->invalid(fn () => $this->service->claim($id, 1, 1), '人数不足');
  458. $this->bet(5, '1000', '-300', '2026-10-26 12:00:00'); $this->service->claim($id, 1, 1); $this->service->claim($id, 1, 1);
  459. $this->assertSame(1, DB::table('promotion_rewards')->count()); $this->assertSame('500.0000000000', (string)DB::table('wallets')->where('member_id', 'm1')->value('available_balance'));
  460. }
  461. public function test_coupon_mode_issues_inviter_and_counted_invitee_coupons_once_without_cash(): void
  462. {
  463. $coupons = ['reward_mode' => 'coupon', 'reward_amount' => '300', 'invite_count' => 2,
  464. 'coupon_valid_from' => '2026-10-26 00:00:00', 'coupon_valid_until' => '2026-11-30 23:59:59', 'coupon_categories' => ['self:lottery', 'self:sport'],
  465. 'inviter_coupons' => [['min_amount' => '100', 'discount' => '50', 'quantity' => 2], ['min_amount' => '200', 'discount' => '100', 'quantity' => 1]],
  466. 'invitee_coupons' => [['min_amount' => '20', 'discount' => '10', 'quantity' => 1]]];
  467. $this->invalid(fn () => $this->activity(7, array_replace($coupons, ['reward_amount' => '150'])), '优惠券抵扣总额不能超过奖励金额');
  468. $this->invalid(fn () => $this->activity(7, array_replace($coupons, ['coupon_categories' => []])), '可用游戏类型');
  469. $this->invalid(fn () => $this->activity(7, array_replace($coupons, ['inviter_coupons' => [['min_amount' => '40', 'discount' => '50', 'quantity' => 1]]])), '使用门槛');
  470. $id = $this->activity(7, $coupons);
  471. $this->assertSame(['self:sport', 'self:lottery'], (new PromotionAdminService())->detail(null, 7)['params']['coupon_categories']);
  472. $this->bet(1, '600', '-600', '2026-10-26 12:00:00'); $this->service->apply($id, 1);
  473. $this->travel('2026-10-26 13:00:00');
  474. foreach ([3, 4, 5] as $child) {
  475. $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');
  476. }
  477. $this->service->claim($id, 1, 1); $this->service->claim($id, 1, 1);
  478. $reward = DB::table('promotion_rewards')->first();
  479. // The amount is the inviter's own coupons; the invitee coupons only count toward the budget.
  480. $this->assertSame('paid', $reward->status); $this->assertEquals(200, $reward->amount); $this->assertEquals(0, $reward->required_flow);
  481. $this->assertSame('220.00', json_decode($reward->rule_snapshot, true)['coupon_face_value']);
  482. $this->assertSame('0', (string)DB::table('wallets')->where('member_id', 'm1')->value('available_balance'));
  483. $this->assertSame(0, DB::table('balance_logs')->where('change_type', '优惠活动')->count());
  484. $this->assertSame(3, DB::table('promotion_coupons')->where('user_id', 1)->where('role', 'inviter')->count());
  485. // Only the first invite_count qualified invitees are counted and receive coupons.
  486. $this->assertSame([3, 4], DB::table('promotion_coupons')->where('role', 'invitee')->orderBy('user_id')->pluck('user_id')->map(fn ($v) => (int)$v)->all());
  487. $state = $this->service->detail($id, 1, 'H5');
  488. $this->assertSame('not_applied', $state['status']); $this->assertSame(2, (int)$state['round_no']); $this->assertSame('coupon', $state['rules']['reward_mode']);
  489. $this->assertSame([2, 1], array_column($state['coupons'], 'quantity'));
  490. $this->assertEquals(220, DB::table('promotion_activities')->where('id', $id)->value('budget_used'));
  491. // Switching back to cash keeps the coupon settings for the next switch.
  492. $this->activity(7, ['reward_mode' => 'cash']);
  493. $this->assertCount(2, json_decode(DB::table('promotion_activities')->where('id', $id)->value('params'), true)['inviter_coupons']);
  494. $records = (new PromotionAdminService())->records(['activity_id' => $id, 'kind' => 'coupons', 'user_id' => 1]);
  495. $this->assertSame(5, $records['total']);
  496. }
  497. public function test_loss_invite_repeats_with_net_loss_from_previous_claim_and_new_invitees(): void
  498. {
  499. $id = $this->activity(7, ['invite_count' => 1]);
  500. $this->bet(1, '600', '-600', '2026-10-26 12:00:00'); $this->service->apply($id, 1);
  501. $this->travel('2026-10-26 13:00:00');
  502. $this->member(3, 1, '2026-10-26 13:00:00'); $this->rawDeposit(3, '200', '2026-10-26 13:00:00'); $this->bet(3, '300', '-300', '2026-10-26 13:00:00');
  503. $this->assertSame(2, $this->service->claim($id, 1, 1)['round_no']);
  504. $state = $this->service->detail($id, 1, 'H5');
  505. $this->assertSame('not_applied', $state['status']); $this->assertSame(2, (int)$state['round_no']);
  506. $this->assertSame('0.00', $state['loss']); $this->assertSame('2026-10-26 13:00:00', $state['loss_from']);
  507. // Loss before the claim is not counted again; the server picks the round, so a retry before reapplying replays.
  508. $this->invalid(fn () => $this->service->apply($id, 1), '净亏');
  509. $this->assertTrue($this->service->claim($id, 1, 1)['replayed']);
  510. $replay = $this->service->claim($id, 1);
  511. $this->assertTrue($replay['replayed']); $this->assertSame(1, (int)$replay['reward']['round_no']);
  512. $this->travel('2026-10-27 12:00:00');
  513. $this->bet(1, '500', '-500', '2026-10-27 12:00:00'); $this->service->apply($id, 1);
  514. // The invitee counted in round 1 registered before this application.
  515. $this->invalid(fn () => $this->service->claim($id, 1), '人数不足');
  516. $this->travel('2026-10-27 13:00:00');
  517. $this->member(4, 1, '2026-10-27 13:00:00'); $this->rawDeposit(4, '200', '2026-10-27 13:00:00'); $this->bet(4, '300', '-300', '2026-10-27 13:00:00');
  518. // A client still sending round_no=1 claims the current round.
  519. $claim = $this->service->claim($id, 1, 1);
  520. $this->assertFalse($claim['replayed']); $this->assertSame(3, $claim['round_no']); $this->assertSame(2, (int)$claim['reward']['round_no']);
  521. $rewards = DB::table('promotion_rewards')->orderBy('round_no')->get();
  522. $this->assertSame([1, 2], $rewards->pluck('round_no')->map(fn ($v) => (int)$v)->all());
  523. $this->assertSame('1000.0000000000', (string)DB::table('wallets')->where('member_id', 'm1')->value('available_balance'));
  524. $this->assertEquals(600, json_decode($rewards[0]->rule_snapshot, true)['application']['loss']);
  525. $this->assertSame([4], json_decode($rewards[1]->rule_snapshot, true)['qualified_invitees']);
  526. $this->assertEquals(1, DB::table('promotion_activities')->where('id', $id)->value('rewarded_count'));
  527. $row = (new PromotionAdminService())->records(['activity_id' => $id, 'kind' => 'accounts'])['data'][0];
  528. $this->assertSame(2, $row->claimed_count); $this->assertSame(2, $row->reward_round_no); $this->assertNull($row->applied_at);
  529. $this->assertSame([4], array_column($row->invitees, 'id'));
  530. }
  531. public function test_repeat_migration_moves_claimed_members_to_next_round_and_keeps_only_inviter_coupon_amount(): void
  532. {
  533. $id = $this->activity(7);
  534. $snapshot = ['reward_mode' => 'coupon', 'inviter_coupons' => [['min_amount' => '100', 'discount' => '50', 'quantity' => 10]], 'invitee_coupons' => [['min_amount' => '100', 'discount' => '40', 'quantity' => 1]]];
  535. $account = DB::table('promotion_accounts')->insertGetId(['activity_id' => $id, 'user_id' => 1, 'round_no' => 1, 'spins' => 0,
  536. 'applied_at' => '2026-10-26 12:00:00', 'application_snapshot' => json_encode(['loss' => '550', 'rules' => $snapshot])]);
  537. DB::table('promotion_rewards')->insert(['activity_id' => $id, 'user_id' => 1, 'account_id' => $account, 'round_no' => 1, 'amount' => '620',
  538. 'status' => 'paid', 'rule_snapshot' => json_encode($snapshot), 'created_at' => '2026-10-26 13:00:00']);
  539. $migration = require dirname(__DIR__, 2) . '/database/migrations/2026_11_03_170000_repeat_loss_invite_rounds.php';
  540. $migration->up(); $migration->up();
  541. $reward = DB::table('promotion_rewards')->first(); $rules = json_decode($reward->rule_snapshot, true);
  542. $this->assertEquals(500, $reward->amount); $this->assertSame('620.00', $rules['coupon_face_value']); $this->assertSame('550', $rules['application']['loss']);
  543. $row = DB::table('promotion_accounts')->where('id', $account)->first();
  544. $this->assertSame(2, (int)$row->round_no); $this->assertNull($row->applied_at); $this->assertNull($row->application_snapshot);
  545. }
  546. public function test_successful_online_time_deduplicates_devices_and_does_not_count_closed_session_as_online(): void
  547. {
  548. $id = $this->activity();
  549. foreach (['a', 'b'] as $session) for ($i = 0; $i <= 30; ++$i) DB::table('reporting_activity')->insert(['user_id' => 1, 'session_hash' => $session,
  550. 'observed_at' => Carbon::parse('2026-10-26 12:00:00')->addMinutes($i)->format('Y-m-d H:i:s')]);
  551. $this->travel('2026-10-26 16:00:00'); $state = $this->service->progress($id, 1);
  552. $this->assertArrayNotHasKey('online', $state['completed_tasks']);
  553. $this->assertSame(1800, (new PromotionFacts())->online(1, '2026-10-26 12:00:00')['2026-10-26']);
  554. }
  555. public function test_effective_minimum_void_pending_and_next_day_policy_are_respected(): void
  556. {
  557. DB::table('share_policies')->insert(['id' => 2, 'effective_date' => '2026-10-27', 'valid_member_min_deposit' => '9999', 'valid_member_min_effective_bet' => '9999']);
  558. $facts = new PromotionFacts(); $this->assertSame(1, $facts->policy()['id']);
  559. $this->bet(1, '1000', '50', '2026-10-26 12:00:00'); $this->bet(1, '500', '-500', '2026-10-26 12:00:00', ['voided' => 1]);
  560. $this->bet(1, '500', '-500', '2026-10-26 12:00:00', ['settled' => 0]);
  561. $this->assertSame('50.0000000000', $facts->total($facts->betting([1], '2026-10-26 12:00:00')['effective']));
  562. $this->travel('2026-10-27 00:00:00'); $this->assertSame(2, $facts->policy()['id']);
  563. }
  564. public function test_turnover_rewards_are_paid_to_available_balance_and_never_locked(): void
  565. {
  566. // 活动奖金不再冻结:直接进入可用余额,流水要求计入统一提现打码量(required_flow 仍保留在奖励记录上)
  567. $id = $this->activity(8, ['min_deposit' => 100, 'reward_amount' => 25, 'turnover_ratio' => 2]);
  568. $this->join(1); $this->service->onDeposit($this->deposit(1, '100'));
  569. $reward = DB::table('promotion_rewards')->where('activity_id', $id)->first();
  570. $this->assertSame('paid', $reward->status);
  571. $this->assertSame(0, bccomp('50', (string)$reward->required_flow, 2));
  572. $this->assertSame('25.0000000000', (string)DB::table('wallets')->where('member_id', 'm1')->value('available_balance'));
  573. $this->assertSame('0', (string)DB::table('wallets')->where('member_id', 'm1')->value('frozen_balance'));
  574. $this->assertSame(1, (int)DB::table('balance_logs')->where('change_type', '优惠活动')->where('type', 1)->count());
  575. $this->bet(1, '1000', '-100', '2026-10-26 12:00:00'); $this->assertSame(0, $this->service->unlock(1));
  576. }
  577. public function test_quota_is_checked_before_consuming_a_spin_and_a_win_reserves_its_slot(): void
  578. {
  579. $id = $this->activity(9, ['completion_probability' => 100]); $this->complete($id);
  580. DB::table('promotion_activities')->where('id', $id)->update(['reward_quota' => 0]);
  581. $this->invalid(fn () => $this->service->spin($id, 1, 'request_spin_00001'), '份数');
  582. $this->assertSame(7, (int)DB::table('promotion_accounts')->value('spins'));
  583. DB::table('promotion_activities')->where('id', $id)->update(['reward_quota' => 1]);
  584. $this->service->spin($id, 1, 'request_spin_00001');
  585. $this->assertSame('reserved', DB::table('promotion_rewards')->value('status')); $this->assertSame(1, (int)DB::table('promotion_activities')->where('id', $id)->value('rewarded_count'));
  586. }
  587. public function test_independent_campaigns_do_not_touch_old_catalog_and_member_routes_live_in_member_project(): void
  588. {
  589. $id = $this->activity(9); $this->activity(7); $this->activity(8);
  590. $this->assertSame(0, DB::table('activity_rewards')->count());
  591. $this->assertArrayNotHasKey(9, \App\Support\ActivityCatalog::activityTypes());
  592. $this->assertSame(3, $this->service->listing(1, 'H5')['total']);
  593. $this->assertSame($id, $this->service->listing(1, 'H5', 'zh', 9)['data'][0]['activity_id']);
  594. $this->assertSame(0, DB::table('menus')->count(), 'Activity setup must leave menus to the frontend');
  595. $router = new \Illuminate\Routing\Router(new \Illuminate\Events\Dispatcher($this->app), $this->app); $this->app->instance('router', $router);
  596. $router->group(['prefix' => 'api'], function () { require dirname(__DIR__, 2) . '/routes/api.php'; });
  597. $memberRoutes=array_filter($router->getRoutes()->getRoutes(),fn ($route)=>str_starts_with($route->uri(),'api/promotion'));
  598. $this->assertSame([],$memberRoutes,'Member promotion routes must be owned by melbet_sport-api');
  599. $router->group(['prefix' => 'admin'], function () { require dirname(__DIR__, 2) . '/routes/admin.php'; });
  600. $route = $router->getRoutes()->match(Request::create('/admin/promotion/submit', 'POST'));
  601. $this->assertContains('admin.jwt', $route->middleware()); $this->assertContains('check.button.uri', $route->middleware());
  602. }
  603. public function test_promotion_migration_preserves_frontend_owned_menus_on_up_and_down(): void
  604. {
  605. DB::table('menus')->insert([
  606. ['id' => 1, 'parent_id' => 0, 'title' => '前端活动菜单', 'uri' => 'promotion', 'permission_name' => 'promotion.manage', 'type' => 1],
  607. ['id' => 2, 'parent_id' => 1, 'title' => '前端活动记录', 'uri' => 'admin/promotion/records', 'permission_name' => 'admin.promotion.records', 'type' => 2],
  608. ['id' => 3, 'parent_id' => 0, 'title' => '其他菜单', 'uri' => 'other', 'permission_name' => 'other.manage', 'type' => 1],
  609. ]);
  610. $before = DB::table('menus')->orderBy('id')->get()->toJson();
  611. $migration = require dirname(__DIR__, 2) . '/database/migrations/2026_10_26_160000_create_promotion_activities.php';
  612. $migration->down();
  613. $this->assertSame($before, DB::table('menus')->orderBy('id')->get()->toJson(), 'Rollback must preserve frontend menus and permissions');
  614. $this->assertFalse(DB::connection()->getSchemaBuilder()->hasTable('promotion_activities'));
  615. $migration->up();
  616. $this->assertSame($before, DB::table('menus')->orderBy('id')->get()->toJson(), 'Migration must not add or modify frontend menus and permissions');
  617. $this->assertTrue(DB::connection()->getSchemaBuilder()->hasTable('promotion_activities'));
  618. }
  619. public function test_admin_http_amounts_are_compact_without_changing_saved_rules_or_counters(): void
  620. {
  621. $id = $this->activity(8, ['min_deposit' => '200', 'reward_amount' => '10.12']);
  622. $request = Request::create('/admin/promotion/detail', 'GET', ['id' => $id]); $this->app->instance('request', $request);
  623. $response = (new \App\Http\Controllers\admin\Promotion())->detail(new PromotionAdminService())->getData(true);
  624. $this->assertSame(0, $response['code']);
  625. $this->assertSame('200', $response['data']['params']['min_deposit']);
  626. $this->assertSame('10.12', $response['data']['params']['reward_amount']);
  627. $this->assertSame($id, $response['data']['id']);
  628. $saved = json_decode(DB::table('promotion_activities')->where('id', $id)->value('params'), true);
  629. $this->assertSame('200.00', $saved['min_deposit']);
  630. $this->assertSame('10.12', $saved['reward_amount']);
  631. }
  632. public function test_ended_activities_are_taken_off_the_shelf_and_cannot_be_relisted(): void
  633. {
  634. $ended = $this->activity(8); $running = $this->activity(9);
  635. DB::table('promotion_activities')->where('id', $ended)->update(['end_time' => Carbon::parse('2026-10-26 11:59:59')->timestamp]);
  636. $this->assertSame(1, PromotionAdminService::expire());
  637. $this->assertSame(0, (int) DB::table('promotion_activities')->where('id', $ended)->value('status'));
  638. $this->assertSame(1, (int) DB::table('promotion_activities')->where('id', $running)->value('status'));
  639. $this->assertSame(0, PromotionAdminService::expire());
  640. $admin = new PromotionAdminService();
  641. $this->invalid(fn () => $admin->status($ended, 1), '活动已结束');
  642. $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'];
  643. $this->invalid(fn () => $admin->save($input), '活动已结束');
  644. $this->assertSame(0, (int) $admin->save(['status' => 0] + $input)['status']);
  645. }
  646. public function test_sync_command_expires_both_activity_catalogs_without_repeating_changes(): void
  647. {
  648. $promotion = $this->activity(8);
  649. DB::table('promotion_activities')->where('id', $promotion)->update(['end_time'=>now()->timestamp - 1]);
  650. $legacy = DB::table('activity_rewards')->insertGetId(['type'=>3, 'status'=>1, 'end_time'=>now()->timestamp - 1]);
  651. $command = new \App\Console\Commands\SyncPromotions();
  652. $command->setLaravel($this->app);
  653. $tester = new \Symfony\Component\Console\Tester\CommandTester($command);
  654. $this->assertSame(0, $tester->execute([]));
  655. $this->assertSame(0, (int)DB::table('activity_rewards')->where('id', $legacy)->value('status'));
  656. $this->assertSame(0, (int)DB::table('promotion_activities')->where('id', $promotion)->value('status'));
  657. $this->assertStringContainsString('到期下架:2', $tester->getDisplay());
  658. $this->assertSame(0, $tester->execute([]));
  659. $this->assertStringContainsString('到期下架:0', $tester->getDisplay());
  660. }
  661. public function test_catalog_expiry_failure_does_not_skip_independent_promotion_expiry(): void
  662. {
  663. $promotion = $this->activity(8);
  664. DB::table('promotion_activities')->where('id', $promotion)->update(['end_time'=>now()->timestamp - 1]);
  665. $legacy = DB::table('activity_rewards')->insertGetId(['type'=>3, 'status'=>1, 'end_time'=>now()->timestamp - 1]);
  666. DB::statement("CREATE TRIGGER reject_catalog_expiry BEFORE UPDATE ON bot_activity_rewards BEGIN SELECT RAISE(ABORT, 'simulated update failure'); END");
  667. $command = new \App\Console\Commands\SyncPromotions();
  668. $command->setLaravel($this->app);
  669. $tester = new \Symfony\Component\Console\Tester\CommandTester($command);
  670. $this->assertSame(1, $tester->execute([]));
  671. $this->assertSame(1, (int)DB::table('activity_rewards')->where('id', $legacy)->value('status'));
  672. $this->assertSame(0, (int)DB::table('promotion_activities')->where('id', $promotion)->value('status'));
  673. $this->assertStringContainsString('到期下架:1', $tester->getDisplay());
  674. $this->assertStringContainsString('失败数:1', $tester->getDisplay());
  675. }
  676. public function test_fixed_activities_are_initialized_disabled_without_publishing_or_creating_menus(): void
  677. {
  678. $this->assertSame(3, DB::table('promotion_activities')->count());
  679. foreach ([7, 9, 10] as $type) {
  680. $row = DB::table('promotion_activities')->where('type', $type)->first();
  681. $this->assertNotNull($row); $this->assertSame(0, (int) $row->status);
  682. $this->assertNull(DB::table('promotion_campaigns')->where('activity_id', $row->id)->value('first_published_at'));
  683. }
  684. $options = (new PromotionAdminService())->options();
  685. $this->assertSame([7, 8, 9, 10], array_column($options['types'], 'value'));
  686. $this->assertSame([8], array_column($options['create_types'], 'value'));
  687. $this->assertSame(0, DB::table('menus')->count());
  688. }
  689. public function test_fixed_types_cannot_be_created_even_when_no_configuration_exists(): void
  690. {
  691. $admin = new PromotionAdminService();
  692. foreach ([7, 9, 10] as $type) {
  693. $input = ['translations' => $this->texts(), 'type' => $type, 'status' => 0,
  694. 'start_time' => '2026-10-01 00:00:00', 'end_time' => '2026-12-31 23:59:59'];
  695. $this->invalid(fn () => $admin->save($input), '不支持新增');
  696. $this->assertSame(1, DB::table('promotion_activities')->where('type', $type)->count());
  697. $id = DB::table('promotion_activities')->where('type', $type)->value('id');
  698. DB::table('promotion_campaigns')->where('activity_id', $id)->delete();
  699. DB::table('promotion_activities')->where('id', $id)->delete();
  700. $this->invalid(fn () => $admin->save($input), '不支持新增');
  701. $this->assertSame(0, DB::table('promotion_activities')->where('type', $type)->count());
  702. }
  703. }
  704. public function test_first_deposit_still_allows_multiple_new_configurations_and_type_change_is_rejected(): void
  705. {
  706. $first = $this->activity(8); $second = $this->activity(8);
  707. $this->assertNotSame($first, $second);
  708. $this->assertSame(2, DB::table('promotion_activities')->where('type', 8)->count());
  709. $this->invalid(fn () => (new PromotionAdminService())->save(['id' => $first, 'translations' => $this->texts(), 'type' => 9, 'status' => 0,
  710. 'start_time' => '2026-10-01 00:00:00', 'end_time' => '2026-12-31 23:59:59']), '不能变更活动类型');
  711. $this->assertSame(8, (int) DB::table('promotion_activities')->where('id', $first)->value('type'));
  712. }
  713. public function test_fixed_initialization_and_relisting_do_not_replace_existing_activity_or_reset_its_history(): void
  714. {
  715. $id = $this->activity(9, ['completion_probability' => 0]);
  716. DB::table('promotion_activities')->where('id', $id)->update(['rewarded_count' => 3, 'budget_used' => '1998']);
  717. $before = (array) DB::table('promotion_activities')->where('id', $id)->first();
  718. $published = DB::table('promotion_campaigns')->where('activity_id', $id)->value('first_published_at');
  719. $migration = require dirname(__DIR__, 2) . '/database/migrations/2026_10_27_130000_initialize_fixed_promotion_activities.php';
  720. $this->travel('2026-10-27 12:00:00'); $migration->up(); $migration->down();
  721. $this->assertSame($before, (array) DB::table('promotion_activities')->where('id', $id)->first());
  722. $this->assertSame(3, DB::table('promotion_activities')->count());
  723. $admin = new PromotionAdminService(); $admin->status($id, 0); $admin->status($id, 1);
  724. $this->assertSame($published, DB::table('promotion_campaigns')->where('activity_id', $id)->value('first_published_at'));
  725. $this->assertSame(3, (int) DB::table('promotion_activities')->where('id', $id)->value('rewarded_count'));
  726. }
  727. public function test_admin_partial_rule_edit_preserves_other_settings_and_zero_probability(): void
  728. {
  729. $id = $this->activity(9, ['turnover_ratio' => 3, 'deposit_amount' => 5000]);
  730. $saved = (new PromotionAdminService())->save(['id' => $id, 'translations' => $this->texts(), 'type' => 9, 'status' => 1,
  731. 'start_time' => '2026-10-01', 'end_time' => '2026-12-31', 'params' => ['completion_probability' => 0]]);
  732. $this->assertSame('0.00', $saved['params']['completion_probability']); $this->assertSame('3.00', $saved['params']['turnover_ratio']);
  733. $this->assertSame('5000.00', $saved['params']['deposit_amount']); $this->assertSame(0, DB::table('activity_rewards')->count());
  734. }
  735. public function test_voided_task_progress_is_not_eligible_for_completion_probability(): void
  736. {
  737. $id = $this->activity(9, ['completion_probability' => 100]); $this->complete($id);
  738. DB::table('share_bet_facts')->where('user_id', 2)->update(['voided' => 1]);
  739. $state = $this->service->progress($id, 1); $this->assertFalse($state['conditions_complete']);
  740. $this->assertFalse($state['completed_tasks']['invite']); $this->assertFalse($state['completed_tasks']['team_bet']);
  741. $this->assertFalse($this->service->spin($id, 1, 'request_spin_voided')['spin']['reached']);
  742. }
  743. public function test_real_chain_credit_hook_awards_first_deposit_once_and_rolls_back_a_failed_award(): void
  744. {
  745. foreach ([
  746. 'level' => 'id INTEGER PRIMARY KEY,level INTEGER,recharge TEXT',
  747. 'recharge_channel_group' => 'id INTEGER PRIMARY KEY,activity_type TEXT,deleted_at TEXT',
  748. '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',
  749. 'recharges' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,member_id TEXT,status INTEGER,amount TEXT,created_at TEXT,updated_at TEXT',
  750. ] as $table => $columns) DB::statement('CREATE TABLE bot_' . $table . ' (' . $columns . ')');
  751. foreach (['huishui_percentage' => '0', 'huishui_restriction' => '0', 'recharge_rate' => '0'] as $field => $val) DB::table('config')->insert(compact('field', 'val'));
  752. $id = $this->activity(8, ['min_deposit' => 100, 'reward_amount' => 25]); $this->join(1); $this->join(2);
  753. $source = DB::table('recharges')->insertGetId(['member_id' => 'm1', 'status' => 0, 'amount' => 100]);
  754. $recharge = \App\Models\Recharge::find($source);
  755. $this->assertTrue(\App\Services\RechargeService::creditConfirmedRecharge($recharge, ['status' => 1], '100.00'));
  756. $this->assertFalse(\App\Services\RechargeService::creditConfirmedRecharge($recharge, ['status' => 1], '100.00'));
  757. $this->assertSame('125.0000000000', (string)DB::table('wallets')->where('member_id', 'm1')->value('available_balance'));
  758. $this->assertSame(1, DB::table('promotion_rewards')->count());
  759. $source = DB::table('recharges')->insertGetId(['member_id' => 'm2', 'status' => 0, 'amount' => 100]);
  760. DB::statement("CREATE TRIGGER reject_award BEFORE INSERT ON bot_promotion_rewards BEGIN SELECT RAISE(ABORT, 'award failure'); END");
  761. try { \App\Services\RechargeService::creditConfirmedRecharge(\App\Models\Recharge::find($source), ['status' => 1], '100.00'); $this->fail('Expected failed award'); }
  762. catch (\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('award failure', $e->getMessage()); }
  763. $this->assertSame(0, (int)DB::table('recharges')->where('id', $source)->value('status'));
  764. $this->assertSame('0', (string)DB::table('wallets')->where('member_id', 'm2')->value('available_balance'));
  765. $this->assertSame(0, DB::table('balance_logs')->where('member_id', 'm2')->count());
  766. }
  767. public function test_promotion_heartbeat_records_login_once_and_online_without_other_reporting_tables(): void
  768. {
  769. DB::statement('DROP TABLE bot_reporting_activity'); $id = $this->activity();
  770. for ($i = 0; $i <= 60; ++$i) {
  771. $this->travel(Carbon::parse('2026-10-26 12:00:00')->addMinutes($i)->format('Y-m-d H:i:s'));
  772. $this->service->heartbeat(1, 'secret-token', 'H5'); $this->service->heartbeat(1, 'secret-token', 'H5');
  773. }
  774. $state = $this->service->progress($id, 1);
  775. $this->assertTrue($state['completed_tasks']['online']); $this->assertTrue($state['completed_tasks']['login']); $this->assertSame(2, (int)$state['spins']);
  776. $this->assertSame(61, DB::table('promotion_presence')->count());
  777. $this->assertStringNotContainsString('secret-token', DB::table('promotion_presence')->first()->session_hash);
  778. $this->assertCount(7, $state['tasks']); $this->assertSame(0, $state['tasks'][0]['remaining_seconds']);
  779. $this->travel('2026-10-27 00:01:00'); $this->service->heartbeat(1, 'secret-token', 'H5');
  780. $this->assertSame(3, (int)$this->service->progress($id, 1)['spins']);
  781. }
  782. public function test_missing_old_first_deposit_hook_is_not_back_awarded_when_campaign_starts_later(): void
  783. {
  784. $deposit = $this->deposit(1, '1000'); $this->travel('2026-10-26 13:00:00'); $this->activity(8);
  785. $this->service->onDeposit($deposit);
  786. $this->assertSame(0, DB::table('promotion_rewards')->count());
  787. }
  788. public function test_manual_payment_approval_keeps_first_deposit_bonus_instead_of_overwriting_it_with_principal(): void
  789. {
  790. foreach ([
  791. 'level' => 'id INTEGER PRIMARY KEY,level INTEGER,recharge TEXT',
  792. 'recharge_channel_group' => 'id INTEGER PRIMARY KEY,activity_type TEXT,deleted_at TEXT',
  793. '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',
  794. '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',
  795. ] as $table => $columns) DB::statement('CREATE TABLE bot_' . $table . ' (' . $columns . ')');
  796. foreach (['huishui_percentage' => '0', 'huishui_restriction' => '0', 'recharge_rate' => '0'] as $field => $val) DB::table('config')->insert(compact('field', 'val'));
  797. $this->activity(8, ['min_deposit' => 100, 'reward_amount' => 25]); $this->join(1);
  798. DB::table('payment_orders')->insert(['id' => 1, 'member_id' => 'm1', 'status' => 5, 'type' => 3, 'amount' => '100']);
  799. $this->app->instance('request', Request::create('/admin/paymentOrder/manualAudit', 'POST', ['id' => 1, 'status' => 2]));
  800. $body = (new \App\Http\Controllers\admin\PaymentOrder())->manualAudit()->getData(true);
  801. $this->assertSame(0, $body['code'], $body['msg']);
  802. $this->assertSame('125.0000000000', (string)DB::table('wallets')->where('member_id', 'm1')->value('available_balance'));
  803. $deposit = DB::table('balance_logs')->where('change_type', '充值')->first();
  804. $this->assertSame('0', (string)$deposit->before_balance); $this->assertSame('100.0000000000', (string)$deposit->after_balance);
  805. $this->assertSame(1, DB::table('promotion_rewards')->count());
  806. $this->assertNotSame(0, (new \App\Http\Controllers\admin\PaymentOrder())->manualAudit()->getData(true)['code']);
  807. $this->assertSame(1, DB::table('promotion_rewards')->count());
  808. }
  809. public function test_upgrade_invite_tiers_are_validated_sorted_and_must_exist_before_publishing(): void
  810. {
  811. foreach ([1 => '等级1', 2 => 'VIP2', 3 => 'VIP3', 4 => 'VIP4'] as $level => $name) DB::table('share_levels')->insert(['level' => $level, 'name' => $name]);
  812. $admin = new PromotionAdminService();
  813. $this->assertSame(['tiers' => []], $admin->options()['defaults'][10]);
  814. $this->assertSame([2, 3, 4], array_slice(array_column($admin->options()['share_levels'], 'level'), 1));
  815. $id = (int) DB::table('promotion_activities')->where('type', 10)->value('id');
  816. $this->assertSame($id, $admin->detail(null, 10)['id']);
  817. $this->assertArrayNotHasKey('image', $admin->detail(null, 10));
  818. $this->invalid(fn () => $this->activity(10, ['tiers' => []]), '至少配置一档');
  819. $this->activity(10, ['tiers' => []], 0);
  820. $this->invalid(fn () => $admin->status($id, 1), '至少配置一档');
  821. foreach ([
  822. [[['level' => 1, 'amount' => '8']], '2及以上'],
  823. [[['level' => 2, 'amount' => '8'], ['level' => 2, 'amount' => '9']], '重复'],
  824. [[['level' => 2, 'amount' => '0']], '大于0'],
  825. [[['level' => 2, 'amount' => '8.001']], '两位小数'],
  826. [[['level' => 2]], 'level和amount'],
  827. [[['level' => 9, 'amount' => '8']], '分享等级9不存在'],
  828. [['level' => 2, 'amount' => '8'], 'tiers参数错误'],
  829. ] as [$tiers, $message]) $this->invalid(fn () => $this->activity(10, ['tiers' => $tiers]), $message);
  830. $this->activity(10, ['tiers' => [['level' => '4', 'amount' => '18'], ['level' => 2, 'amount' => 8]]]);
  831. $saved = $admin->detail(null, 10);
  832. $this->assertSame([['level' => 2, 'amount' => '8.00'], ['level' => 4, 'amount' => '18.00']], $saved['params']['tiers']);
  833. $this->assertSame(1, (int) $saved['status']);
  834. $this->assertNotNull(DB::table('promotion_campaigns')->where('activity_id', $id)->value('first_published_at'));
  835. $this->assertSame(1, DB::table('promotion_activities')->where('type', 10)->count());
  836. }
  837. public function test_upgrade_invite_has_body_text_only_without_titles(): void
  838. {
  839. DB::table('share_levels')->insert(['level' => 2, 'name' => 'VIP2']);
  840. $admin = new PromotionAdminService();
  841. $id = (int) DB::table('promotion_activities')->where('type', 10)->value('id');
  842. $base = ['id' => $id, 'type' => 10, 'status' => 1, 'start_time' => '2026-10-01 00:00:00', 'end_time' => '2026-12-31 23:59:59',
  843. 'params' => ['tiers' => [['level' => 2, 'amount' => '8']]]];
  844. $texts = ['zh' => ['content' => '<p>中文正文</p>'], 'en' => ['content' => '<p>English</p>'], 'vi' => ['content' => '<p>Nội dung</p>']];
  845. $this->invalid(fn () => $admin->save($base + ['translations' => array_replace($texts, ['en' => ['content' => '<p> </p>']])]), '英文正文不能为空');
  846. $saved = $admin->save($base + ['translations' => array_replace_recursive($texts, ['zh' => ['title' => '会被忽略']])]);
  847. $this->assertSame(['content' => '<p>中文正文</p>'], $saved['translations']['zh']);
  848. $this->assertSame('邀请升级奖励', $saved['title']);
  849. $row = DB::table('promotion_activities')->where('id', $id)->first();
  850. $this->assertSame(['lang' => 'en', 'content' => '<p>English</p>'], \App\Support\PromotionTranslations::select((array) $row, 'en'));
  851. $admin->status($id, 0); $this->assertSame(1, (int) $admin->status($id, 1)['status']);
  852. $this->invalid(fn () => $admin->save(['id' => (int) DB::table('promotion_activities')->where('type', 7)->value('id'), 'type' => 7, 'status' => 0,
  853. 'start_time' => '2026-10-01 00:00:00', 'end_time' => '2026-12-31 23:59:59', 'translations' => $texts]), '标题');
  854. }
  855. public function test_upgrade_invite_records_list_each_paid_tier_with_both_accounts(): void
  856. {
  857. foreach ([2 => 'VIP2', 4 => 'VIP4'] as $level => $name) DB::table('share_levels')->insert(['level' => $level, 'name' => $name]);
  858. $id = (int) DB::table('promotion_activities')->where('type', 10)->value('id');
  859. DB::table('users')->whereIn('id', [1, 2])->update(['account' => DB::raw("'acc' || id")]);
  860. $this->member(3, 1);
  861. foreach ([[2, 2], [2, 4], [3, 2]] as $i => [$invitee, $level]) DB::table('promotion_upgrade_grants')->insert(['activity_id' => $id, 'reward_id' => 1,
  862. 'inviter_user_id' => 1, 'invitee_user_id' => $invitee, 'level' => $level, 'amount' => $level === 2 ? '8.00' : '18.00', 'invitee_level' => 4]);
  863. $admin = new PromotionAdminService();
  864. $all = $admin->records(['activity_id' => $id, 'kind' => 'upgrades']);
  865. $this->assertSame(3, $all['total']);
  866. $first = $all['data'][2];
  867. $this->assertSame(['acc1', 'acc2', 'VIP2', 'VIP4'], [$first->inviter_account, $first->invitee_account, $first->level_name, $first->invitee_level_name]);
  868. $this->assertSame(2, $admin->records(['activity_id' => $id, 'kind' => 'upgrades', 'user_id' => 2])['total']);
  869. $this->assertSame(3, $admin->records(['activity_id' => $id, 'kind' => 'upgrades', 'user_id' => 1])['total']);
  870. $this->assertNull($admin->records(['activity_id' => $id, 'kind' => 'upgrades', 'user_id' => 3])['data'][0]->invitee_account);
  871. $this->assertSame(3, $admin->records(['activity_id' => $id, 'type' => 10, 'kind' => 'upgrades'])['total']);
  872. $loss = (int) DB::table('promotion_activities')->where('type', 7)->value('id');
  873. $this->invalid(fn () => $admin->records(['activity_id' => $loss, 'type' => 10, 'kind' => 'rewards']), 'activity_id与type不匹配');
  874. $this->invalid(fn () => $admin->records(['activity_id' => $loss, 'kind' => 'upgrades']), 'upgrades仅用于邀请升级奖励');
  875. $this->invalid(fn () => $admin->records(['activity_id' => 99999, 'kind' => 'rewards']), '独立活动不存在');
  876. $this->assertSame(0, $admin->records(['activity_id' => $loss, 'type' => 7, 'kind' => 'rewards'])['total']);
  877. }
  878. public function test_first_deposit_is_automatic_without_opt_in(): void
  879. {
  880. $id = $this->activity(8, ['min_deposit' => 100, 'reward_amount' => 25]);
  881. // 不需要勾选:没有任何选择记录的会员首笔到账也发奖,第二笔起不再算首充。
  882. $this->service->onDeposit($this->deposit(1, '100'));
  883. $this->service->onDeposit($this->deposit(1, '1000'));
  884. $this->assertSame(1, DB::table('promotion_rewards')->where('activity_id', $id)->where('user_id', 1)->count());
  885. $this->assertSame(1, DB::table('promotion_first_deposits')->where('user_id', 1)->count());
  886. // 旧前端保存过「不参加」也照样发奖。
  887. $this->assertTrue($this->join(2, '0'));
  888. $this->service->onDeposit($this->deposit(2, '100'));
  889. $this->assertSame(1, DB::table('promotion_rewards')->where('activity_id', $id)->where('user_id', 2)->count());
  890. // 首笔没达到门槛就不发,后面的充值也不再补。
  891. $this->member(3);
  892. $this->service->onDeposit($this->deposit(3, '50')); $this->service->onDeposit($this->deposit(3, '500'));
  893. $this->assertSame(0, DB::table('promotion_rewards')->where('user_id', 3)->count());
  894. }
  895. public function test_first_deposit_restriction_is_one_setting_shared_by_every_campaign(): void
  896. {
  897. $admin = new PromotionAdminService();
  898. $options = $admin->options();
  899. $this->assertArrayNotHasKey('forbidden_categories', $options);
  900. $this->assertArrayNotHasKey('forbidden_categories', $options['defaults'][8]);
  901. $setting = $admin->firstDepositRestriction();
  902. $this->assertSame([], $setting['categories']);
  903. $this->assertSame(array_keys(\App\Services\Sharing\ShareDefaults::commissionCategories()), array_column($setting['options'], 'value'));
  904. $this->assertNotContains('third:3', array_column($setting['options'], 'value'));
  905. $this->assertSame(['third:1', 'third:2', 'self:sport'], $this->restrict(['self:sport', 'third:2', 'third:1'])['categories']);
  906. $this->assertSame(['third:1', 'third:2', 'self:sport'], $admin->firstDepositRestriction()['categories']);
  907. foreach ([['third:3'], ['third:1', 'third:1'], 'third:1', ['x' => 'third:1'], [4]] as $bad) $this->invalid(fn () => $this->restrict($bad), 'categories参数错误');
  908. $this->invalid(fn () => $this->restrict(array_keys(\App\Services\Sharing\ShareDefaults::commissionCategories())), '至少保留一个可投注的游戏大类');
  909. $this->invalid(fn () => $admin->saveFirstDepositRestriction([]), '请传categories');
  910. $this->assertSame(['third:1', 'third:2', 'self:sport'], $admin->firstDepositRestriction()['categories']);
  911. // 活动里不再保存、返回禁止大类;旧前端仍传该参数时忽略。
  912. $first = $this->activity(8, ['min_deposit' => 100, 'reward_amount' => 25, 'forbidden_categories' => ['third:5']]);
  913. $second = $this->activity(8, ['min_deposit' => 50, 'reward_amount' => 10]);
  914. $this->assertArrayNotHasKey('forbidden_categories', json_decode(DB::table('promotion_activities')->where('id', $first)->value('params'), true));
  915. DB::table('promotion_activities')->where('id', $second)->update(['params' => json_encode(['min_deposit' => '50.00', 'reward_amount' => '10.00', 'turnover_ratio' => '0.00', 'forbidden_categories' => ['third:5']])]);
  916. foreach ([$admin->detail($first), $admin->detail($second), $admin->listing(['type' => 8])['data'][0]] as $row) {
  917. $this->assertArrayNotHasKey('forbidden_categories', $row);
  918. $this->assertArrayNotHasKey('forbidden_categories', $row['params']);
  919. }
  920. $saved = $admin->save(['id' => $second, 'type' => 8, 'status' => 1, 'translations' => $this->texts(), 'params' => ['reward_amount' => 12]]);
  921. $this->assertArrayNotHasKey('forbidden_categories', json_decode(DB::table('promotion_activities')->where('id', $second)->value('params'), true));
  922. $this->assertSame('12.00', $saved['params']['reward_amount']);
  923. // 每个首充活动都使用同一设置;发奖快照记录到账时的设置。
  924. foreach ($this->service->listing(1, 'H5', 'zh', 8)['data'] as $item) $this->assertSame(['third:1', 'third:2', 'self:sport'], $item['rules']['forbidden_categories']);
  925. $admin->save(['id' => $first, 'type' => 8, 'status' => 1, 'translations' => $this->texts(), 'sort' => 9, 'params' => ['turnover_ratio' => 2]]);
  926. $this->restrict(['third:5']);
  927. $this->service->onDeposit($this->deposit(1, '100'));
  928. $snapshot = json_decode(DB::table('promotion_rewards')->where('user_id', 1)->value('rule_snapshot'), true);
  929. $this->assertSame(['third:5'], $snapshot['forbidden_categories']);
  930. foreach ($this->service->listing(2, 'H5', 'zh', 8)['data'] as $item) $this->assertSame(['third:5'], $item['rules']['forbidden_categories']);
  931. }
  932. public function test_first_deposit_pays_the_highest_tier_the_deposit_reaches(): void
  933. {
  934. // 100 送 10、1000 送 50:充 1000 发 50,充 500 发 10,充 50 不发;高档份数用完发下一档。
  935. $low = $this->activity(8, ['min_deposit' => 100, 'reward_amount' => 10]);
  936. $high = $this->activity(8, ['min_deposit' => 1000, 'reward_amount' => 50, 'turnover_ratio' => 2]);
  937. $this->restrict(['self:lottery']);
  938. $this->service->onDeposit($this->deposit(1, '1000'));
  939. $reward = DB::table('promotion_rewards')->where('user_id', 1)->first();
  940. $this->assertSame([$high, '50.00', '100.00'], [(int) $reward->activity_id, number_format((float) $reward->amount, 2, '.', ''), number_format((float) $reward->required_flow, 2, '.', '')]);
  941. $this->assertSame(['self:lottery'], json_decode($reward->rule_snapshot, true)['forbidden_categories'], 'restriction set at credit time');
  942. $this->service->onDeposit($this->deposit(2, '500'));
  943. $this->assertSame($low, (int) DB::table('promotion_rewards')->where('user_id', 2)->value('activity_id'));
  944. $this->member(3);
  945. $this->service->onDeposit($this->deposit(3, '50'));
  946. $this->assertSame(0, DB::table('promotion_rewards')->where('user_id', 3)->count());
  947. DB::table('promotion_activities')->where('id', $high)->update(['reward_quota' => 1, 'rewarded_count' => 1]);
  948. $this->member(4);
  949. $this->service->onDeposit($this->deposit(4, '2000'));
  950. $this->assertSame($low, (int) DB::table('promotion_rewards')->where('user_id', 4)->value('activity_id'));
  951. }
  952. public function test_legacy_campaign_params_cannot_forbid_every_category_any_more(): void
  953. {
  954. $all = array_keys(\App\Services\Sharing\ShareDefaults::commissionCategories());
  955. $draft = $this->activity(8, [], 0);
  956. DB::table('promotion_activities')->where('id', $draft)->update(['params' => json_encode(['turnover_ratio' => 2, 'forbidden_categories' => $all])]);
  957. (new PromotionAdminService())->status($draft, 1);
  958. $this->assertSame([], $this->service->listing(1, 'H5', 'zh', 8)['data'][0]['rules']['forbidden_categories']);
  959. }
  960. public function test_telegram_choice_is_bound_to_the_member_and_its_restriction_lifts_after_turnover(): void
  961. {
  962. \Illuminate\Support\Facades\Cache::swap(new \Illuminate\Cache\Repository(new \Illuminate\Cache\ArrayStore()));
  963. $activity = $this->activity(8, ['reward_amount' => 25, 'turnover_ratio' => 2]);
  964. $this->restrict(['self:sport']);
  965. $member = User::find(1);
  966. $reply = TelegramFirstDeposit::menu($member);
  967. $button = json_decode($reply['reply_markup'], true)['inline_keyboard'][0][0]['callback_data'];
  968. TelegramFirstDeposit::callback(User::find(2), $button);
  969. $this->assertNull(FirstDepositChoice::selection(2));
  970. TelegramFirstDeposit::callback($member, $button);
  971. $this->assertSame($activity, (int) FirstDepositChoice::selection(1)->activity_id);
  972. $this->service->onDeposit($this->deposit(1, '100'));
  973. $this->assertFalse($this->service->lotteryRestricted('m1'));
  974. // 禁投 F1彩票 时 Telegram 没有可参加的首充活动,网页端仍可参加。
  975. $this->restrict(['self:lottery']);
  976. $this->member(3);
  977. $this->assertSame(1, count(json_decode(TelegramFirstDeposit::menu(User::find(3))['reply_markup'], true)['inline_keyboard']));
  978. $this->assertTrue($this->join(2));
  979. $this->assertSame($activity, (int) FirstDepositChoice::selection(2)->activity_id);
  980. $this->service->onDeposit($this->deposit(2, '100'));
  981. $this->assertTrue($this->service->lotteryRestricted('m2'));
  982. $this->bet(2, '50', '-50', PromotionFacts::now());
  983. $this->assertFalse($this->service->lotteryRestricted('m2'));
  984. }
  985. public function test_admin_http_reads_and_saves_the_shared_first_deposit_restriction(): void
  986. {
  987. $controller = new \App\Http\Controllers\admin\Promotion(); $admin = new PromotionAdminService();
  988. $this->app->instance('request', Request::create('/admin/promotion/first-deposit-restriction', 'POST', ['categories' => ['self:lottery', 'third:2']]));
  989. $saved = $controller->firstDepositRestriction($admin)->getData(true);
  990. $this->assertSame(0, $saved['code']); $this->assertSame(['third:2', 'self:lottery'], $saved['data']['categories']);
  991. $this->app->instance('request', Request::create('/admin/promotion/first-deposit-restriction', 'POST', ['categories' => ['third:3']]));
  992. $failed = $controller->firstDepositRestriction($admin)->getData(true);
  993. $this->assertSame(-3, $failed['code']); $this->assertStringContainsString('categories参数错误', $failed['msg']);
  994. $this->app->instance('request', Request::create('/admin/promotion/first-deposit-restriction', 'GET'));
  995. $read = $controller->firstDepositRestriction($admin)->getData(true);
  996. $this->assertSame(['third:2', 'self:lottery'], $read['data']['categories']);
  997. $this->assertCount(8, $read['data']['options']);
  998. }
  999. }