app = new Application(dirname(__DIR__, 2)); $this->app->instance('config', new Repository(['app' => ['locale' => 'zh', 'timezone' => 'Asia/Shanghai'], 'operations' => ['admin_ids' => [1]]])); Facade::setFacadeApplication($this->app); $db = new Manager($this->app); $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']); $db->setAsGlobal(); $db->bootEloquent(); $this->app->instance('db', $db->getDatabaseManager()); $this->app->bind('db.schema', fn () => $db->schema()); $translator = new Translator(new ArrayLoader(), 'zh'); $this->app->instance('translator', $translator); $this->app->instance('validator', new Factory($translator, $this->app)); $this->app->instance('log', new \Psr\Log\NullLogger()); $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new class { public function json($data, $status = 200, $headers = [], $options = 0) { return new JsonResponse($data, $status, $headers, $options); } }); Request::macro('validate', function (array $rules) { return app('validator')->make($this->all(), $rules)->validate(); }); Schema::create('operation_audits', function (Blueprint $t) { $t->id(); $t->string('resource_type'); $t->integer('resource_id'); $t->string('action'); $t->json('changes'); $t->integer('operator_id'); $t->string('operator_name'); $t->timestamp('created_at')->useCurrent(); }); 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); }); 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'); }); $this->migration()->up(); (require dirname(__DIR__, 2) . '/database/migrations/2026_10_27_150000_create_site_realtime_events.php')->up(); $this->service = new MaintenanceService(); $this->request(); } protected function tearDown(): void { Carbon::setTestNow(); DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown(); } private function migration() { return require dirname(__DIR__, 2) . '/database/migrations/2026_10_26_120000_create_maintenance_settings.php'; } private function request(array $params = []): Request { $request = Request::create('/admin/maintenance/submit', 'POST', $params); $request->user = (object) ['id' => 1, 'username' => 'admin']; $this->app->instance('request', $request); return $request; } private function id(string $scope = 'site'): int { return (int) DB::table('maintenance_settings')->where('scope_code', $scope)->value('id'); } private function save(array $values = [], string $scope = 'site'): array { return $this->service->save($values + ['id' => $this->id($scope), 'enabled' => 1, 'starts_at' => '2026-10-26 09:00:00']); } private function decide(?string $platform = 'PC', ?string $category = null): ?array { return Policy::select($this->service->runtimeRows('zh'), $platform, $category, now()->getTimestamp()); } public function test_websocket_events_use_local_delivery_times_for_utc_maintenance_and_cancel_disabled_boundaries(): void { $row = $this->save(['starts_at' => '2026-10-26 11:00:00', 'ends_at' => '2026-10-26 12:00:00']); $this->assertSame(['update', 'start', 'end'], DB::table('site_realtime_events')->orderBy('id')->pluck('action')->all()); $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()); $this->service->status($row['id'], 0); $this->service->status($row['id'], 0); $this->assertSame(['update', 'update'], DB::table('site_realtime_events')->orderBy('id')->pluck('action')->all()); DB::beginTransaction(); $this->save(); DB::rollBack(); $this->assertSame(2, DB::table('site_realtime_events')->count()); } public function test_migration_defaults_and_catalog_match_commission_types(): void { $this->assertSame(10, DB::table('maintenance_settings')->count()); $this->assertSame(0, DB::table('maintenance_settings')->where('enabled', 1)->count()); $this->assertSame(ShareDefaults::categories(), Policy::CATEGORIES); $this->assertSame(10, $this->service->listing([])['total']); $this->assertNull($this->decide()); $this->assertContains('UTC', $this->service->options()['timezones']); $this->assertSame(['zh', 'en', 'vi'], array_column($this->service->options()['languages'], 'value')); $this->migration()->down(); $this->assertFalse(Schema::hasTable('maintenance_settings')); $this->assertTrue(Schema::hasTable('users')); } public function test_utc_conversion_and_exact_start_end_without_scheduler(): void { $row = $this->save(['starts_at' => '2026-10-26 10:00:00', 'ends_at' => '2026-10-26 11:00:00']); $this->assertSame('active', $row['state']); $this->assertSame('2026-10-26T02:00:00Z', $row['starts_at_utc']); $this->assertSame('2026-10-26 02:00:00', DB::table('maintenance_settings')->where('id', $row['id'])->value('starts_at')); Carbon::setTestNow(Carbon::parse('2026-10-26 09:59:59', 'Asia/Shanghai')); $this->assertNull($this->decide()); Carbon::setTestNow(Carbon::parse('2026-10-26 10:00:00', 'Asia/Shanghai')); $this->assertSame('site', $this->decide()['scope_code']); Carbon::setTestNow(Carbon::parse('2026-10-26 11:00:00', 'Asia/Shanghai')); $this->assertNull($this->decide()); $this->assertSame('expired', $this->service->detail($row['id'])['state']); $this->assertSame(1, DB::table('maintenance_settings')->where('id', $row['id'])->value('enabled')); $this->assertSame('expired', $this->service->status($row['id'], 1)['state']); $this->assertSame('active', $this->service->save(['id' => $row['id'], 'start_now' => true, 'ends_at' => null])['state']); } public function test_site_precedence_applies_only_to_effective_platform_and_time(): void { $this->save(['platforms' => ['H5']]); $this->save(['platforms' => ['PC', 'H5']], 'third:4'); $this->assertSame('site', $this->decide('H5', 'third:4')['scope_code']); $this->assertSame('third:4', $this->decide('PC', 'third:4')['scope_code']); $this->assertNull($this->decide('APP', 'third:4')); $this->assertNull($this->decide('PC', 'self:sport')); $this->assertSame('site', $this->decide(null, 'third:4')['scope_code']); $this->save(['starts_at' => '2026-10-27 00:00:00']); $this->assertSame('third:4', $this->decide('H5', 'third:4')['scope_code']); $this->save(['ends_at' => '2026-10-26 10:00:00']); $this->assertSame('third:4', $this->decide('H5', 'third:4')['scope_code']); $this->assertSame('third:4', $this->decide(null, 'third:4')['scope_code']); } public function test_translations_blank_fallback_partial_update_and_audit_history(): void { $this->save(['translations' => ['zh' => ['content' => '维护'], 'en' => ['content' => 'Maintenance'], 'vi' => ['content' => ' ']]]); $this->assertSame(2, DB::table('maintenance_translations')->count()); $this->assertSame('维护', $this->decide()['content']); $this->assertSame('Maintenance', Policy::failure($this->service->runtimeRows('en')[0], 'PC', 'en', now()->getTimestamp())['msg']); $fallback = Policy::failure($this->service->runtimeRows('vi')[0], 'PC', 'vi', now()->getTimestamp()); $this->assertSame(11001, $fallback['code']); $this->assertStringContainsString('bảo trì', $fallback['msg']); $this->service->save(['id' => $this->id(), 'platforms' => ['PC']]); $this->assertSame(2, DB::table('maintenance_translations')->count()); $before = DB::table('operation_audits')->count(); $this->service->status($this->id(), 1); $this->assertSame($before, DB::table('operation_audits')->count()); $this->service->save(['id' => $this->id(), 'translations' => ['zh' => ['content' => '中文']]]); $logs = $this->service->logs(['language' => 'en', 'operator_name' => 'adm', 'resource_id' => $this->id()]); $this->assertSame(3, $logs['total']); $this->assertSame('整站', ((array) $logs['data'][0]['old_values'])['translations']['en']['title']); $audit = json_decode(DB::table('operation_audits')->orderBy('id')->value('changes'), true); $this->assertSame('site', $audit['after']['scope_code']); $this->assertSame(0, $this->service->listing(['language' => 'en'])['total']); } public function test_filters_are_applied_before_pagination_and_timezone_edit_preserves_instants(): void { $row = $this->save(['platforms' => ['APP'], 'translations' => ['en' => ['content' => 'Text']]]); $this->save(['platforms' => ['APP'], 'translations' => ['en' => ['content' => 'Game']]], 'third:2'); $list = $this->service->listing(['enabled' => 1, 'state' => 'active', 'platform' => 'APP', 'language' => 'en', 'limit' => 1, 'page' => 2]); $this->assertSame(2, $list['total']); $this->assertSame('third:2', $list['data'][0]['scope_code']); $edited = $this->service->save(['id' => $row['id'], 'timezone' => 'America/New_York']); $this->assertSame($row['starts_at_utc'], $edited['starts_at_utc']); $this->assertSame('2026-10-25 21:00:00', $edited['starts_at']); $this->assertSame(1, $this->service->listing(['scope_code' => 'third:2'])['total']); } /** @dataProvider invalidInputs */ public function test_invalid_schedules_scopes_and_payloads_leave_no_changes(array $input): void { $before = (array) DB::table('maintenance_settings')->where('id', $this->id())->first(); try { $this->save($input); $this->fail('Expected validation failure'); } catch (ValidationException $e) { $this->assertNotEmpty($e->errors()); } $this->assertSame($before, (array) DB::table('maintenance_settings')->where('id', $this->id())->first()); $this->assertSame(0, DB::table('operation_audits')->count()); } public static function invalidInputs(): array { return array_map(fn ($input) => [$input], [ ['id' => 999], ['scope_code' => 'third:4'], ['platforms' => []], ['platforms' => ['IOS']], ['platforms' => ['PC', 'PC']], ['enabled' => 2], ['timezone' => '+08:00'], ['timezone' => 'Not/AZone'], ['starts_at' => '2026-02-30 00:00:00'], ['ends_at' => '2026-10-26 08:00:00'], ['translations' => ['th' => ['content' => 'bad']]], ['translations' => ['zh' => 'bad']], ['translations' => ['en' => ['content' => str_repeat('x', 5001)]]], ['timezone' => 'America/New_York', 'starts_at' => '2026-03-08 02:30:00'], ['timezone' => 'America/New_York', 'starts_at' => '2026-11-01 01:30:00'], ]); } public function test_audit_failure_rolls_back_rule_and_translations(): void { DB::statement("CREATE TRIGGER reject_maintenance_audit BEFORE INSERT ON bot_operation_audits BEGIN SELECT RAISE(ABORT, 'audit failed'); END"); try { $this->save(['translations' => ['zh' => ['content' => '维护']]]); $this->fail('Expected audit failure'); } catch (\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('audit failed', $e->getMessage()); } $this->assertSame(0, DB::table('maintenance_settings')->where('enabled', 1)->count()); $this->assertSame(0, DB::table('maintenance_translations')->count()); } public function test_legacy_middleware_blocks_before_business_and_uses_saved_language_without_renewal(): void { $this->save(['translations' => ['en' => ['content' => 'Saved English']]]); DB::table('users')->insert(['user_id' => '101', 'language' => 'en']); DB::table('user_session')->insert(['user_id' => '101', 'token' => 'valid', 'create_time' => now()->getTimestamp() - 3600, 'update_time' => now()->getTimestamp() - 3600, 'expire_time' => now()->getTimestamp() + 3600]); $before = (array) DB::table('user_session')->first(); $called = false; $request = Request::create('/api/wallet/createPay', 'POST'); $request->headers->add(['Authorization' => 'valid', 'Lang' => 'vi', 'Platform' => 'PC']); $response = (new MemberMaintenance())->handle($request, function () use (&$called) { $called = true; return new JsonResponse(['code' => 0]); }); $this->assertFalse($called); $this->assertSame(200, $response->getStatusCode()); $this->assertSame(11001, $response->getData(true)['code']); $this->assertSame('Saved English', $response->getData(true)['msg']); $this->assertSame($before, (array) DB::table('user_session')->first()); $this->assertStringContainsString('no-store', $response->headers->get('Cache-Control')); $this->service->status($this->id(), 0); $this->assertSame(0, (new MemberMaintenance())->handle($request, fn () => new JsonResponse(['code' => 0]))->getData(true)['code']); } public function test_routes_permissions_and_callbacks_settlement_and_admin_remain_outside_gate(): void { $router = new Router(new Dispatcher($this->app), $this->app); $this->app->instance('router', $router); if (!class_exists('Route')) class_alias(\Illuminate\Support\Facades\Route::class, 'Route'); $router->group(['prefix' => 'admin'], function () { require dirname(__DIR__, 2) . '/routes/admin.php'; }); $router->group(['prefix' => 'api'], function () { require dirname(__DIR__, 2) . '/routes/api.php'; require dirname(__DIR__, 2) . '/routes/api_private.php'; }); foreach (['' => 'GET', '/options' => 'GET', '/detail' => 'GET', '/logs' => 'GET', '/submit' => 'POST', '/status' => 'POST'] as $suffix => $method) { $route = $router->getRoutes()->match(Request::create('/admin/maintenance' . $suffix, $method)); foreach (['admin.jwt', 'check.button.uri', 'operations.admin'] as $middleware) $this->assertContains($middleware, $route->middleware()); $this->assertNotContains(MemberMaintenance::class, $route->middleware()); } $this->assertSame('admin/maintenance/submit', $route->defaults['permission_uri']); foreach (['/api/wallet/createPay' => 'POST', '/api/ActivityReward' => 'GET', '/api/newPc' => 'GET', '/api/issue' => 'GET', '/api/pcissue' => 'GET'] as $uri => $method) { $this->assertContains(MemberMaintenance::class, $router->getRoutes()->match(Request::create($uri, $method))->middleware()); } foreach (['/api/pay/notify', '/api/pay/harvest', '/api/onMessage', '/api/pcissue/verify'] as $uri) { $this->assertNotContains(MemberMaintenance::class, $router->getRoutes()->match(Request::create($uri, 'POST'))->middleware()); } $request = $this->request(); $request->user->id = 99; $this->assertSame(-1, (new OperationsAdminMiddleware())->handle($request, fn () => new JsonResponse(['code' => 0]))->getData(true)['code']); $this->request(['id' => $this->id(), 'enabled' => 1]); $this->assertSame(0, (new MaintenanceController())->submit($this->service)->getData(true)['code']); } }