MaintenanceTest.php 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368
  1. <?php
  2. namespace Tests\Integration;
  3. use App\Http\Controllers\admin\Maintenance as MaintenanceController;
  4. use App\Http\Middleware\MemberMaintenance;
  5. use App\Http\Middleware\OperationsAdminMiddleware;
  6. use App\Services\MaintenanceService;
  7. use App\Services\Sharing\ShareDefaults;
  8. use App\Support\MaintenancePolicy as Policy;
  9. use Carbon\Carbon;
  10. use Illuminate\Config\Repository;
  11. use Illuminate\Database\Capsule\Manager;
  12. use Illuminate\Database\Schema\Blueprint;
  13. use Illuminate\Events\Dispatcher;
  14. use Illuminate\Foundation\Application;
  15. use Illuminate\Http\JsonResponse;
  16. use Illuminate\Http\Request;
  17. use Illuminate\Routing\Router;
  18. use Illuminate\Support\Facades\DB;
  19. use Illuminate\Support\Facades\Facade;
  20. use Illuminate\Support\Facades\Schema;
  21. use Illuminate\Translation\ArrayLoader;
  22. use Illuminate\Translation\Translator;
  23. use Illuminate\Validation\Factory;
  24. use Illuminate\Validation\ValidationException;
  25. use PHPUnit\Framework\Attributes\DataProvider;
  26. use PHPUnit\Framework\TestCase;
  27. class MaintenanceTest extends TestCase
  28. {
  29. /** Production game_list: list `type` is a display id, `params.type` is the game's own type. */
  30. private const GAME_LIST = [['type' => 1, 'name' => '加拿大28', 'game' => 'canada28', 'status' => 1, 'sort' => 0],
  31. ['type' => 2, 'name' => '极速28', 'game' => 'jisu28', 'status' => 1, 'sort' => 0],
  32. ['type' => 3, 'name' => '澳门六合彩', 'game' => 'lhc', 'params' => ['type' => 3], 'status' => 1, 'sort' => 0],
  33. ['type' => 4, 'name' => '新澳门六合彩', 'game' => 'lhc', 'params' => ['type' => 1], 'status' => 1, 'sort' => 0],
  34. ['type' => 5, 'name' => '极速六合彩', 'game' => 'lhc', 'params' => ['type' => 4], 'status' => 1, 'sort' => 0],
  35. ['type' => 6, 'name' => '香港六合彩', 'game' => 'lhc', 'params' => ['type' => 2], 'status' => 1, 'sort' => 0],
  36. ['type' => 7, 'name' => '极速赛车', 'game' => 'jisu', 'params' => ['type' => 1], 'status' => 1, 'sort' => 0],
  37. ['type' => 8, 'name' => '极速时时彩', 'game' => 'jisu', 'params' => ['type' => 2], 'status' => 1, 'sort' => 0],
  38. ['type' => 9, 'name' => '极速飞艇', 'game' => 'jisu', 'params' => ['type' => 3], 'status' => 1, 'sort' => 0],
  39. ['type' => 13, 'name' => '极速快3', 'game' => 'jisu', 'params' => ['type' => 6], 'status' => 1, 'sort' => 0],
  40. ['type' => 10, 'name' => 'SG时时彩', 'game' => 'jisu', 'params' => ['type' => 4], 'status' => 1, 'sort' => 0],
  41. ['type' => 11, 'name' => 'SG飞艇', 'game' => 'jisu', 'params' => ['type' => 5], 'status' => 1, 'sort' => 0],
  42. ['type' => 12, 'name' => 'SG快3', 'game' => 'jisu', 'params' => ['type' => 7], 'status' => 0, 'sort' => 0]];
  43. private Application $app;
  44. private MaintenanceService $service;
  45. protected function setUp(): void
  46. {
  47. Carbon::setTestNow(Carbon::parse('2026-10-26 10:00:00', 'Asia/Shanghai'));
  48. Facade::clearResolvedInstances();
  49. $this->app = new Application(dirname(__DIR__, 2));
  50. $this->app->instance('config', new Repository(['app' => ['locale' => 'zh', 'timezone' => 'Asia/Shanghai'], 'operations' => ['admin_ids' => [1]]]));
  51. Facade::setFacadeApplication($this->app);
  52. $db = new Manager($this->app); $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_', 'foreign_key_constraints' => true]);
  53. $db->setAsGlobal(); $db->bootEloquent(); $this->app->instance('db', $db->getDatabaseManager());
  54. $this->app->bind('db.schema', fn () => $db->schema());
  55. $translator = new Translator(new ArrayLoader(), 'zh'); $this->app->instance('translator', $translator);
  56. $this->app->instance('validator', new Factory($translator, $this->app)); $this->app->instance('log', new \Psr\Log\NullLogger());
  57. $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new \Tests\Support\JsonResponseFactory());
  58. Request::macro('validate', function (array $rules) { return app('validator')->make($this->all(), $rules)->validate(); });
  59. Schema::create('operation_audits', function (Blueprint $t) {
  60. $t->id(); $t->string('resource_type'); $t->integer('resource_id'); $t->string('action'); $t->json('changes');
  61. $t->integer('operator_id'); $t->string('operator_name'); $t->timestamp('created_at')->useCurrent();
  62. });
  63. Schema::create('users', function (Blueprint $t) { $t->id(); $t->string('user_id'); $t->string('language'); $t->bigInteger('delete_time')->nullable(); $t->integer('is_banned')->default(0); });
  64. Schema::create('user_session', function (Blueprint $t) { $t->id(); $t->string('user_id'); $t->string('token'); $t->bigInteger('expire_time'); $t->bigInteger('create_time'); $t->bigInteger('update_time'); });
  65. Schema::create('config', function (Blueprint $t) { $t->id(); $t->string('field'); $t->text('val'); $t->string('remark')->nullable(); $t->integer('group_id')->default(1); $t->timestamps(); });
  66. DB::table('config')->insert([['field' => 'game_list', 'val' => json_encode(self::GAME_LIST, JSON_UNESCAPED_UNICODE), 'group_id' => 5],
  67. ['field' => 'pc28_switch', 'val' => '0', 'group_id' => 5]]);
  68. (require dirname(__DIR__, 2) . '/database/migrations/2026_11_10_130000_add_sport_list_config.php')->up();
  69. $this->migration()->up(); (require dirname(__DIR__, 2) . '/database/migrations/2026_10_27_150000_create_site_realtime_events.php')->up();
  70. (require dirname(__DIR__, 2) . '/database/migrations/2026_11_10_140000_add_embeddable_to_maintenance_settings.php')->up(); $this->service = new MaintenanceService(); $this->request();
  71. }
  72. protected function tearDown(): void { Carbon::setTestNow(); DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown(); }
  73. private function migration() { return require dirname(__DIR__, 2) . '/database/migrations/2026_10_26_120000_create_maintenance_settings.php'; }
  74. private function request(array $params = []): Request
  75. {
  76. $request = Request::create('/admin/maintenance/submit', 'POST', $params); $request->user = (object) ['id' => 1, 'username' => 'admin'];
  77. $this->app->instance('request', $request); return $request;
  78. }
  79. private function id(string $scope = 'site'): int { return (int) DB::table('maintenance_settings')->where('scope_code', $scope)->value('id'); }
  80. private function save(array $values = [], string $scope = 'site'): array
  81. {
  82. return $this->service->save($values + ['id' => $this->id($scope), 'enabled' => 1, 'starts_at' => '2026-10-26 09:00:00']);
  83. }
  84. private function decide(?string $platform = 'PC', ?string $category = null, ?string $subcategory = null): ?array
  85. {
  86. return Policy::select($this->service->runtimeRows('zh'), $platform, $category, now()->getTimestamp(), $subcategory);
  87. }
  88. public function test_websocket_events_use_local_delivery_times_for_utc_maintenance_and_cancel_disabled_boundaries(): void
  89. {
  90. $row = $this->save(['starts_at' => '2026-10-26 11:00:00', 'ends_at' => '2026-10-26 12:00:00']);
  91. $this->assertSame(['update', 'start', 'end'], DB::table('site_realtime_events')->orderBy('id')->pluck('action')->all());
  92. $this->assertSame(['2026-10-26 10:00:00', '2026-10-26 11:00:00', '2026-10-26 12:00:00'], DB::table('site_realtime_events')->orderBy('id')->pluck('available_at')->all());
  93. $this->service->status($row['id'], 0); $this->service->status($row['id'], 0);
  94. $this->assertSame(['update', 'update'], DB::table('site_realtime_events')->orderBy('id')->pluck('action')->all());
  95. DB::beginTransaction(); $this->save(); DB::rollBack();
  96. $this->assertSame(2, DB::table('site_realtime_events')->count());
  97. }
  98. public function test_migration_defaults_and_catalog_match_commission_types(): void
  99. {
  100. $this->assertSame(10, DB::table('maintenance_settings')->count());
  101. $this->assertSame(0, DB::table('maintenance_settings')->where('enabled', 1)->count());
  102. $this->assertSame(ShareDefaults::categories(), Policy::CATEGORIES);
  103. $all = $this->service->listing([]);
  104. $this->assertSame(10, $all['total'], 'only site and categories are paged');
  105. $this->assertSame(count(self::GAME_LIST) + count(Policy::SPORTS), array_sum(array_map(fn ($r) => count($r['children']), $all['data'])), 'subcategories are nested');
  106. $this->assertSame([], $all['data'][0]['children'], 'site has no children');
  107. $this->assertSame(0, DB::table('maintenance_settings')->where('enabled', 1)->count());
  108. $this->assertNull($this->decide());
  109. $this->assertContains('UTC', $this->service->options()['timezones']);
  110. $this->assertSame(['zh', 'en', 'vi'], array_column($this->service->options()['languages'], 'value'));
  111. $this->migration()->down(); $this->assertFalse(Schema::hasTable('maintenance_settings')); $this->assertTrue(Schema::hasTable('users'));
  112. }
  113. public function test_utc_conversion_and_exact_start_end_without_scheduler(): void
  114. {
  115. $row = $this->save(['starts_at' => '2026-10-26 10:00:00', 'ends_at' => '2026-10-26 11:00:00']);
  116. $this->assertSame('active', $row['state']); $this->assertSame('2026-10-26T02:00:00Z', $row['starts_at_utc']);
  117. $this->assertSame('2026-10-26 02:00:00', DB::table('maintenance_settings')->where('id', $row['id'])->value('starts_at'));
  118. Carbon::setTestNow(Carbon::parse('2026-10-26 09:59:59', 'Asia/Shanghai')); $this->assertNull($this->decide());
  119. Carbon::setTestNow(Carbon::parse('2026-10-26 10:00:00', 'Asia/Shanghai')); $this->assertSame('site', $this->decide()['scope_code']);
  120. Carbon::setTestNow(Carbon::parse('2026-10-26 11:00:00', 'Asia/Shanghai')); $this->assertNull($this->decide());
  121. $this->assertSame('expired', $this->service->detail($row['id'])['state']);
  122. $this->assertSame(1, DB::table('maintenance_settings')->where('id', $row['id'])->value('enabled'));
  123. $this->assertSame('expired', $this->service->status($row['id'], 1)['state']);
  124. $this->assertSame('active', $this->service->save(['id' => $row['id'], 'start_now' => true, 'ends_at' => null])['state']);
  125. }
  126. public function test_site_precedence_applies_only_to_effective_platform_and_time(): void
  127. {
  128. $this->save(['platforms' => ['H5']]); $this->save(['platforms' => ['PC', 'H5']], 'third:4');
  129. $this->assertSame('site', $this->decide('H5', 'third:4')['scope_code']);
  130. $this->assertSame('third:4', $this->decide('PC', 'third:4')['scope_code']);
  131. $this->assertNull($this->decide('APP', 'third:4')); $this->assertNull($this->decide('PC', 'self:sport'));
  132. $this->assertSame('site', $this->decide(null, 'third:4')['scope_code']);
  133. $this->save(['starts_at' => '2026-10-27 00:00:00']);
  134. $this->assertSame('third:4', $this->decide('H5', 'third:4')['scope_code']);
  135. $this->save(['ends_at' => '2026-10-26 10:00:00']);
  136. $this->assertSame('third:4', $this->decide('H5', 'third:4')['scope_code']);
  137. $this->assertSame('third:4', $this->decide(null, 'third:4')['scope_code']);
  138. }
  139. public function test_translations_blank_fallback_partial_update_and_audit_history(): void
  140. {
  141. $this->save(['translations' => ['zh' => ['content' => '<b>维护</b>'], 'en' => ['content' => 'Maintenance'], 'vi' => ['content' => ' ']]]);
  142. $this->assertSame(2, DB::table('maintenance_translations')->count());
  143. $this->assertSame('<b>维护</b>', $this->decide()['content']);
  144. $this->assertSame('Maintenance', Policy::failure($this->service->runtimeRows('en')[0], 'PC', 'en', now()->getTimestamp())['msg']);
  145. $fallback = Policy::failure($this->service->runtimeRows('vi')[0], 'PC', 'vi', now()->getTimestamp());
  146. $this->assertSame(11001, $fallback['code']); $this->assertStringContainsString('bảo trì', $fallback['msg']);
  147. $this->service->save(['id' => $this->id(), 'platforms' => ['PC']]); $this->assertSame(2, DB::table('maintenance_translations')->count());
  148. $before = DB::table('operation_audits')->count(); $this->service->status($this->id(), 1); $this->assertSame($before, DB::table('operation_audits')->count());
  149. $this->service->save(['id' => $this->id(), 'translations' => ['zh' => ['content' => '中文']]]);
  150. $logs = $this->service->logs(['language' => 'en', 'operator_name' => 'adm', 'resource_id' => $this->id()]);
  151. $this->assertSame(3, $logs['total']);
  152. $this->assertSame('整站', ((array) $logs['data'][0]['old_values'])['translations']['en']['title']);
  153. $audit = json_decode(DB::table('operation_audits')->orderBy('id')->value('changes'), true);
  154. $this->assertSame('site', $audit['after']['scope_code']);
  155. $this->assertSame(0, $this->service->listing(['language' => 'en'])['total']);
  156. }
  157. public function test_filters_are_applied_before_pagination_and_timezone_edit_preserves_instants(): void
  158. {
  159. $row = $this->save(['platforms' => ['APP'], 'translations' => ['en' => ['content' => 'Text']]]);
  160. $this->save(['platforms' => ['APP'], 'translations' => ['en' => ['content' => 'Game']]], 'third:2');
  161. $list = $this->service->listing(['enabled' => 1, 'state' => 'active', 'platform' => 'APP', 'language' => 'en', 'limit' => 1, 'page' => 2]);
  162. $this->assertSame(2, $list['total']); $this->assertSame('third:2', $list['data'][0]['scope_code']);
  163. $edited = $this->service->save(['id' => $row['id'], 'timezone' => 'America/New_York']);
  164. $this->assertSame($row['starts_at_utc'], $edited['starts_at_utc']); $this->assertSame('2026-10-25 21:00:00', $edited['starts_at']);
  165. $this->assertSame(1, $this->service->listing(['scope_code' => 'third:2'])['total']);
  166. $this->assertSame([], $this->service->listing(['scope_code' => 'third:2'])['data'][0]['children'], 'third:2 has no subcategories in this fixture');
  167. }
  168. public function test_enabled_child_shows_under_its_parent_even_when_parent_is_off(): void
  169. {
  170. $this->service->listing([]); // 子项的维护行在第一次读取列表时补齐
  171. $this->save([], 'self:lottery:pc28');
  172. $list = $this->service->listing(['enabled' => 1]);
  173. $this->assertSame(['self:lottery'], array_column($list['data'], 'scope_code'));
  174. $this->assertFalse($list['data'][0]['matched'], 'parent itself is off');
  175. $this->assertSame(['self:lottery:pc28'], array_column($list['data'][0]['children'], 'scope_code'));
  176. $this->assertSame(count(self::GAME_LIST), $list['data'][0]['children_count'], 'children_count counts all subcategories');
  177. $off = collect($this->service->listing(['enabled' => 0])['data'])->firstWhere('scope_code', 'self:lottery');
  178. $this->assertTrue($off['matched']);
  179. $this->assertNotContains('self:lottery:pc28', array_column($off['children'], 'scope_code'));
  180. }
  181. public function test_subcategory_can_be_maintained_without_closing_its_category(): void
  182. {
  183. Schema::create('egame_items', function (Blueprint $t) {
  184. $t->id(); $t->string('item_type'); $t->string('plat_type'); $t->integer('game_type'); $t->string('name')->default(''); $t->integer('status')->default(1);
  185. });
  186. DB::table('egame_items')->insert([
  187. ['item_type' => 'platform', 'plat_type' => 'PG', 'game_type' => 2, 'name' => 'PG电子', 'status' => 1],
  188. ['item_type' => 'platform', 'plat_type' => 'JDB', 'game_type' => 2, 'name' => '', 'status' => 1],
  189. ['item_type' => 'game', 'plat_type' => 'pg', 'game_type' => 2, 'name' => '某个游戏', 'status' => 1],
  190. ['item_type' => 'platform', 'plat_type' => 'bad code', 'game_type' => 2, 'name' => '忽略', 'status' => 1],
  191. ]);
  192. $list = $this->service->listing(['parent_scope' => 'third:2']);
  193. $this->assertSame(1, $list['total']);
  194. $this->assertSame(['third:2', 'category', 2], [$list['data'][0]['scope_code'], $list['data'][0]['scope_level'], $list['data'][0]['children_count']]);
  195. $kids = $list['data'][0]['children'];
  196. $this->assertSame(['third:2:pg', 'third:2:jdb'], array_column($kids, 'scope_code'));
  197. $this->assertSame('电子 / PG电子', $kids[0]['scope_name']);
  198. $this->assertSame('电子 / JDB', $kids[1]['scope_name']);
  199. $this->assertSame('subcategory', $kids[0]['scope_level']);
  200. $row = $this->save(['translations' => ['zh' => ['content' => 'PG维护']]], 'third:2:pg');
  201. $this->assertSame('third:2', $row['parent_scope']);
  202. $this->assertSame('third:2:pg', $row['subcategory']);
  203. $this->assertSame('PG维护', $this->decide('PC', 'third:2', 'third:2:pg')['content']);
  204. $this->assertNull($this->decide('PC', 'third:2', 'third:2:jdb'));
  205. $this->save([], 'third:2');
  206. $this->assertSame('third:2:pg', $this->decide('PC', 'third:2', 'third:2:pg')['scope_code']);
  207. $this->assertSame('third:2', $this->decide('PC', 'third:2', 'third:2:jdb')['scope_code']);
  208. $this->save(['platforms' => ['H5']]);
  209. $this->assertSame('site', $this->decide('H5', 'third:2', 'third:2:pg')['scope_code']);
  210. $this->assertSame('third:2:pg', $this->decide('PC', 'third:2', 'third:2:pg')['scope_code']);
  211. $parent = $this->service->listing(['scope_code' => 'self:lottery:pc28'])['data'][0];
  212. $this->assertSame(['self:lottery', false], [$parent['scope_code'], $parent['matched']], 'searching a child returns its parent as container');
  213. $lottery = $parent['children'][0];
  214. $this->assertSame('F1彩票 / 加拿大28', $lottery['scope_name']);
  215. $this->assertSame('self:lottery', $lottery['parent_scope']);
  216. $this->assertSame('site', $this->service->detail($this->id())['scope_level']);
  217. $this->assertSame('category', $this->service->detail($this->id('third:2'))['scope_level']);
  218. $this->assertContains(['value' => 'third:2:pg', 'label' => '电子 / PG电子', 'parent' => 'third:2'], $this->service->options()['subcategories']);
  219. $this->assertSame(['self:lottery:pc28', 'self:lottery:jisu28', 'self:lottery:lhc:3', 'self:lottery:lhc:1', 'self:lottery:lhc:4',
  220. 'self:lottery:lhc:2', 'self:lottery:jisu:1', 'self:lottery:jisu:2', 'self:lottery:jisu:3', 'self:lottery:jisu:6',
  221. 'self:lottery:jisu:4', 'self:lottery:jisu:5', 'self:lottery:jisu:7'], array_column($this->service->listing(['parent_scope' => 'self:lottery'])['data'][0]['children'], 'scope_code'));
  222. $this->assertSame('F1彩票 / 新澳门六合彩', $this->service->listing(['scope_code' => 'self:lottery:lhc:1'])['data'][0]['children'][0]['scope_name']);
  223. $sports = $this->service->listing(['parent_scope' => 'self:sport'])['data'][0]['children'];
  224. $this->assertSame(['足球', '篮球', '乒乓球', '排球', '板球', '网球'], array_map(fn ($r) => explode(' / ', $r['scope_name'])[1], $sports));
  225. $this->save([], 'self:sport:basketball');
  226. $this->assertSame('self:sport:basketball', $this->decide('PC', 'self:sport', 'self:sport:basketball')['scope_code']);
  227. $this->assertNull($this->decide('PC', 'self:sport', 'self:sport:football'));
  228. $this->assertNull($this->decide('PC', 'self:lottery', 'self:sport:basketball'));
  229. // Embeddable: subcategory-only, defaults to supported, independent of maintenance.
  230. $this->assertSame(1, $this->service->listing(['scope_code' => 'third:2:jdb'])['data'][0]['children'][0]['embeddable']);
  231. $this->assertNull($this->service->detail($this->id('third:2'))['embeddable']);
  232. $jdb = $this->service->save(['id' => $this->id('third:2:jdb'), 'embeddable' => 0]);
  233. $this->assertSame([0, 0], [$jdb['embeddable'], $jdb['enabled']]);
  234. $this->assertSame(0, $this->service->listing(['scope_code' => 'third:2:jdb'])['data'][0]['children'][0]['embeddable']);
  235. $this->assertSame(1, $this->service->options()['defaults']['embeddable']);
  236. try { $this->service->save(['id' => $this->id('third:2'), 'embeddable' => 0]); $this->fail('category cannot set embeddable'); }
  237. catch (ValidationException $e) { $this->assertArrayHasKey('embeddable', $e->errors()); }
  238. $this->assertSame(1, (int) DB::table('maintenance_settings')->where('scope_code', 'third:2')->value('embeddable'));
  239. $long = str_repeat('a', 32);
  240. $this->assertSame('third:2:' . $long, Policy::thirdSubcategory(2, $long));
  241. $this->assertNull(Policy::thirdSubcategory(2, str_repeat('a', 33)));
  242. }
  243. public function test_telegram_28_bets_follow_the_game_selected_by_pc28_switch(): void
  244. {
  245. $this->service->options();
  246. $this->assertNull($this->service->telegramPc28Failure('zh'));
  247. $this->save(['platforms' => ['APP'], 'translations' => ['en' => ['content' => 'Jisu28 down']]], 'self:lottery:jisu28');
  248. $this->assertNull($this->service->telegramPc28Failure('en'), 'switch 0 plays 加拿大28, so 极速28 maintenance is ignored');
  249. DB::table('config')->where('field', 'pc28_switch')->update(['val' => '1']);
  250. $failure = $this->service->telegramPc28Failure('en');
  251. $this->assertSame([11002, 'Jisu28 down', 'self:lottery:jisu28'], [$failure['code'], $failure['msg'], $failure['data']['subcategory']], 'Telegram matches any platform');
  252. $this->service->status($this->id('self:lottery:jisu28'), 0);
  253. $this->assertNull($this->service->telegramPc28Failure(null));
  254. $this->save([], 'self:lottery');
  255. $this->assertSame('self:lottery', $this->service->telegramPc28Failure('xx')['data']['category']);
  256. $this->service->status($this->id('self:lottery'), 0);
  257. $this->save(['platforms' => ['H5']]);
  258. $this->assertSame(11001, $this->service->telegramPc28Failure('zh')['code']);
  259. }
  260. public function test_subcategory_rollback_preserves_legacy_settings_and_translations(): void
  261. {
  262. $migration = require dirname(__DIR__, 2) . '/database/migrations/2026_11_09_120000_extend_maintenance_scope_code.php';
  263. $migration->up();
  264. $this->service->options();
  265. $legacy = $this->save(['translations' => ['zh' => ['content' => '大类维护']]], 'third:2');
  266. $child = $this->save(['translations' => ['zh' => ['content' => '彩种维护']]], 'self:lottery:pc28');
  267. DB::table('maintenance_settings')->insert([
  268. 'scope_code' => 'third:2:' . str_repeat('a', 32), 'platforms' => '["PC"]',
  269. 'starts_at' => '2026-10-26 01:00:00', 'timezone' => 'Asia/Shanghai',
  270. ]);
  271. $migration->down();
  272. $this->assertSame(array_keys(Policy::scopes()), DB::table('maintenance_settings')->orderBy('id')->pluck('scope_code')->all());
  273. $this->assertEquals($legacy, $this->service->detail($legacy['id']));
  274. $this->assertFalse(DB::table('maintenance_translations')->where('maintenance_id', $child['id'])->exists());
  275. $migration->up();
  276. $this->assertSame(0, $this->service->listing(['scope_code' => 'self:lottery:pc28'])['data'][0]['children'][0]['enabled']);
  277. }
  278. #[DataProvider('invalidInputs')]
  279. public function test_invalid_schedules_scopes_and_payloads_leave_no_changes(array $input): void
  280. {
  281. $before = (array) DB::table('maintenance_settings')->where('id', $this->id())->first();
  282. try { $this->save($input); $this->fail('Expected validation failure'); }
  283. catch (ValidationException $e) { $this->assertNotEmpty($e->errors()); }
  284. $this->assertSame($before, (array) DB::table('maintenance_settings')->where('id', $this->id())->first());
  285. $this->assertSame(0, DB::table('operation_audits')->count());
  286. }
  287. public static function invalidInputs(): array
  288. {
  289. return array_map(fn ($input) => [$input], [
  290. ['id' => 999], ['scope_code' => 'third:4'], ['platforms' => []], ['platforms' => ['IOS']], ['platforms' => ['PC', 'PC']],
  291. ['enabled' => 2], ['timezone' => '+08:00'], ['timezone' => 'Not/AZone'], ['starts_at' => '2026-02-30 00:00:00'],
  292. ['ends_at' => '2026-10-26 08:00:00'], ['translations' => ['th' => ['content' => 'bad']]], ['translations' => ['zh' => 'bad']],
  293. ['translations' => ['en' => ['content' => str_repeat('x', 5001)]]],
  294. ['timezone' => 'America/New_York', 'starts_at' => '2026-03-08 02:30:00'],
  295. ['timezone' => 'America/New_York', 'starts_at' => '2026-11-01 01:30:00'],
  296. ]);
  297. }
  298. public function test_audit_failure_rolls_back_rule_and_translations(): void
  299. {
  300. DB::statement("CREATE TRIGGER reject_maintenance_audit BEFORE INSERT ON bot_operation_audits BEGIN SELECT RAISE(ABORT, 'audit failed'); END");
  301. try { $this->save(['translations' => ['zh' => ['content' => '维护']]]); $this->fail('Expected audit failure'); }
  302. catch (\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('audit failed', $e->getMessage()); }
  303. $this->assertSame(0, DB::table('maintenance_settings')->where('enabled', 1)->count());
  304. $this->assertSame(0, DB::table('maintenance_translations')->count());
  305. }
  306. public function test_legacy_middleware_blocks_before_business_and_uses_saved_language_without_renewal(): void
  307. {
  308. $this->save(['translations' => ['en' => ['content' => 'Saved English']]]);
  309. DB::table('users')->insert(['user_id' => '101', 'language' => 'en']);
  310. DB::table('user_session')->insert(['user_id' => '101', 'token' => 'valid', 'create_time' => now()->getTimestamp() - 3600,
  311. 'update_time' => now()->getTimestamp() - 3600, 'expire_time' => now()->getTimestamp() + 3600]);
  312. $before = (array) DB::table('user_session')->first(); $called = false;
  313. $request = Request::create('/api/wallet/createPay', 'POST'); $request->headers->add(['Authorization' => 'valid', 'Lang' => 'vi', 'Platform' => 'PC']);
  314. $response = (new MemberMaintenance())->handle($request, function () use (&$called) { $called = true; return new JsonResponse(['code' => 0]); });
  315. $this->assertFalse($called); $this->assertSame(200, $response->getStatusCode());
  316. $this->assertSame(11001, $response->getData(true)['code']); $this->assertSame('Saved English', $response->getData(true)['msg']);
  317. $this->assertSame($before, (array) DB::table('user_session')->first());
  318. $this->assertStringContainsString('no-store', $response->headers->get('Cache-Control'));
  319. $this->service->status($this->id(), 0);
  320. $this->assertSame(0, (new MemberMaintenance())->handle($request, fn () => new JsonResponse(['code' => 0]))->getData(true)['code']);
  321. }
  322. public function test_routes_permissions_and_callbacks_settlement_and_admin_remain_outside_gate(): void
  323. {
  324. $router = new Router(new Dispatcher($this->app), $this->app); $this->app->instance('router', $router);
  325. if (!class_exists('Route')) class_alias(\Illuminate\Support\Facades\Route::class, 'Route');
  326. $router->group(['prefix' => 'admin'], function () { require dirname(__DIR__, 2) . '/routes/admin.php'; });
  327. $router->group(['prefix' => 'api'], function () { require dirname(__DIR__, 2) . '/routes/api.php'; });
  328. foreach (['' => 'GET', '/options' => 'GET', '/detail' => 'GET', '/logs' => 'GET', '/submit' => 'POST', '/status' => 'POST'] as $suffix => $method) {
  329. $route = $router->getRoutes()->match(Request::create('/admin/maintenance' . $suffix, $method));
  330. foreach (['admin.jwt', 'check.button.uri', 'operations.admin'] as $middleware) $this->assertContains($middleware, $route->middleware());
  331. $this->assertNotContains(MemberMaintenance::class, $route->middleware());
  332. }
  333. $this->assertSame('admin/maintenance/submit', $route->defaults['permission_uri']);
  334. foreach (['/api/wallet/createPay' => 'POST', '/api/ActivityReward' => 'GET', '/api/newPc' => 'GET', '/api/issue' => 'GET', '/api/pcissue' => 'GET'] as $uri => $method) {
  335. $this->assertContains(MemberMaintenance::class, $router->getRoutes()->match(Request::create($uri, $method))->middleware());
  336. }
  337. foreach (['/api/pay/notify', '/api/pay/harvest', '/api/onMessage'] as $uri) {
  338. $this->assertNotContains(MemberMaintenance::class, $router->getRoutes()->match(Request::create($uri, 'POST'))->middleware());
  339. }
  340. $request = $this->request(); $request->user->id = 99;
  341. $this->assertSame(-1, (new OperationsAdminMiddleware())->handle($request, fn () => new JsonResponse(['code' => 0]))->getData(true)['code']);
  342. $this->request(['id' => $this->id(), 'enabled' => 1]);
  343. $this->assertSame(0, (new MaintenanceController())->submit($this->service)->getData(true)['code']);
  344. }
  345. }