app = new Application(dirname(__DIR__, 2)); $this->app->instance('config', new Repository([ 'app' => ['locale' => 'zh', 'url' => 'https://banner.example.test', 'timezone' => 'Asia/Shanghai'], ])); 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(\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(); }); $db->schema()->create('banner', function (Blueprint $t) { $t->id(); $t->string('title'); $t->string('image'); $t->string('link')->nullable(); $t->string('type'); $t->integer('sort')->default(0); $t->string('lang'); $t->timestamps(); }); $this->migration()->up(); $db->schema()->create('activity_rewards', function (Blueprint $t) { $t->id(); $t->integer('status')->default(1); $t->integer('start_time'); $t->integer('end_time'); }); $this->request('index'); } protected function tearDown(): void { DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown(); } private function migration() { return require dirname(__DIR__, 2) . '/database/migrations/2026_10_24_140000_add_status_to_banner.php'; } private function request(string $action, array $params = []): void { $this->app->instance('request', Request::create('/admin/banner/' . $action, $action === 'index' ? 'GET' : 'POST', $params)); } private function form(array $overrides = []): array { return $overrides + [ 'title' => '轮播图', 'image' => '/banner.png', 'type' => 'pc', 'lang' => 'zh', ]; } private function save(array $params = []): array { $this->request('update', $this->form($params)); return (new AdminBanner())->update()->getData(true); } private function status(array $params): array { $this->request('status', $params); return (new AdminBanner())->status()->getData(true); } private function listing(array $params = []): array { $this->request('index', $params); $result = (new AdminBanner())->index()->getData(true); $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']); return $result['data']; } private function banner(array $params = []): int { return DB::table('banner')->insertGetId($params + $this->form() + ['link' => null, 'sort' => 0, 'status' => 1]); } private function activity(int $id, int $start, int $end, int $status = 1): void { DB::table('activity_rewards')->insert([ 'id' => $id, 'status' => $status, 'start_time' => $start, 'end_time' => $end, ]); } private function decorated(int $now): array { return collect(BannerVisibilityService::decorate(Banner::query()->orderBy('id')->get(), $now))->keyBy('id')->all(); } private function assertState(array $row, ?int $manual, int $effective, ?string $reason, ?string $activityId): void { $this->assertSame($manual, $row['manual_status']); $this->assertSame($effective, $row['status']); $this->assertSame($effective, $row['effective_status']); $this->assertSame($reason, $row['offline_reason']); $this->assertSame($activityId, $row['activity_id']); $this->assertSame($activityId === null, $row['can_manual_toggle']); } public function test_migration_publishes_existing_banners_by_default_and_rolls_back_only_its_column(): void { $this->migration()->down(); $this->assertFalse(Schema::hasColumn('banner', 'status')); $id = DB::table('banner')->insertGetId($this->form(['title' => '历史轮播图', 'sort' => 23]) + ['link' => 'https://example.test']); $this->migration()->up(); $row = DB::table('banner')->find($id); $this->assertSame(1, (int) $row->status); $this->assertSame('历史轮播图', $row->title); $this->assertSame(23, (int) $row->sort); $this->assertSame('https://example.test', $row->link); $newId = DB::table('banner')->insertGetId($this->form()); $this->assertSame(1, (int) DB::table('banner')->where('id', $newId)->value('status')); $this->migration()->down(); $this->assertFalse(Schema::hasColumn('banner', 'status')); $this->assertSame(2, DB::table('banner')->count()); $this->assertSame('历史轮播图', DB::table('banner')->where('id', $id)->value('title')); } public function test_create_defaults_to_manual_on_and_legacy_edit_preserves_omitted_status_link_and_sort(): void { $result = $this->save(['sort' => 27, 'link' => 'https://example.test/promotion', 'type' => 'app', 'lang' => 'en']); $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']); $banner = Banner::query()->firstOrFail(); $this->assertSame(1, $banner->status); $this->assertSame('app', $banner->type); $this->assertSame('en', $banner->lang); $this->assertSame(27, (int) $banner->sort); $this->assertSame(HttpStatus::OK, $this->status(['id' => $banner->id, 'status' => 0])['code']); $result = $this->save(['id' => $banner->id, 'title' => '编辑标题', 'type' => 'app', 'lang' => 'en']); $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']); $banner->refresh(); $this->assertSame('编辑标题', $banner->title); $this->assertSame(0, $banner->status); $this->assertSame('https://example.test/promotion', $banner->link); $this->assertSame(27, (int) $banner->sort); $this->assertSame('app', $banner->type); $this->assertSame('en', $banner->lang); $this->assertSame('/banner.png', $banner->getRawOriginal('image')); $this->assertSame('https://banner.example.test/banner.png', $banner->image); } public function test_status_endpoint_accepts_both_aliases_and_repeated_values_without_changing_banner_content(): void { $id = $this->banner(['sort' => 19, 'lang' => 'en', 'link' => 'https://example.test']); foreach (['status', 'manual_status'] as $field) { foreach ([0, 0, 1, 1, '0', '1'] as $status) { $result = $this->status(['id' => $id, $field => $status]); $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']); $row = DB::table('banner')->find($id); $this->assertSame((int) $status, (int) $row->status); $this->assertSame('https://example.test', $row->link); $this->assertSame(19, (int) $row->sort); $this->assertSame('en', $row->lang); } } } public function test_status_takes_precedence_with_manual_status_as_fallback_in_forms_and_status_requests(): void { foreach ([ [['manual_status' => 0], 0], [['status' => 1], 1], [['manual_status' => null], 1], [['status' => 1, 'manual_status' => null], 1], [['status' => 0, 'manual_status' => 1], 0], [['status' => 1, 'manual_status' => 0], 1], ] as [$fields, $expected]) { $result = $this->save($fields); $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']); $id = DB::table('banner')->latest('id')->value('id'); $this->assertSame($expected, (int) DB::table('banner')->where('id', $id)->value('status')); } foreach ([[0, 1], [1, 0]] as [$canonical, $manual]) { $result = $this->status(['id' => $id, 'status' => $canonical, 'manual_status' => $manual]); $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']); $this->assertSame($canonical, (int) DB::table('banner')->where('id', $id)->value('status')); $result = $this->save(['id' => $id, 'title' => '兼容完整编辑', 'status' => $canonical, 'manual_status' => $manual]); $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']); $this->assertSame($canonical, (int) DB::table('banner')->where('id', $id)->value('status')); } } public function test_unbound_list_row_edits_and_buttons_follow_changed_status_over_stale_manual_alias(): void { $ids = []; foreach ([null, '', '0', 'https://example.test/promotion'] as $link) { $ids[] = $this->banner(['link' => $link, 'status' => 1]); } $orderedIds = array_reverse($ids); $assertPublication = function (int $id, int $expected) use ($orderedIds): array { $rows = collect($this->listing()['data'])->keyBy('id')->all(); $this->assertState($rows[$id], $expected, $expected, $expected ? null : 'manual', null); $this->assertSame($expected, (int) DB::table('banner')->where('id', $id)->value('status')); $downIds = $expected ? [] : [$id]; $upIds = array_values(array_diff($orderedIds, $downIds)); foreach ([['0', $downIds], ['1', $upIds]] as [$status, $expectedIds]) { $page = $this->listing(['status' => $status]); $this->assertSame(count($expectedIds), $page['total']); $this->assertSame($expectedIds, array_column($page['data'], 'id')); } return $rows[$id]; }; foreach ($ids as $id) { $row = $assertPublication($id, 1); $originalLink = $row['link']; $row['status'] = 0; $row['title'] = '列表编辑下架'; $this->assertSame(1, $row['manual_status']); $result = $this->save($row); $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']); $row = $assertPublication($id, 0); $result = $this->status(['id' => $id, 'status' => 1, 'manual_status' => $row['manual_status']]); $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']); $row = $assertPublication($id, 1); $result = $this->status(['id' => $id, 'status' => 0, 'manual_status' => $row['manual_status']]); $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']); $row = $assertPublication($id, 0); $row['status'] = 1; $row['title'] = '列表编辑上架'; $this->assertSame(0, $row['manual_status']); $result = $this->save($row); $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']); $row = $assertPublication($id, 1); $this->assertSame('列表编辑上架', $row['title']); $this->assertSame($originalLink, $row['link']); } } public function test_create_accepts_manual_off_and_invalid_publication_values_leave_rows_unchanged(): void { $result = $this->save(['status' => 0]); $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']); $id = DB::table('banner')->value('id'); $this->assertSame(0, (int) DB::table('banner')->where('id', $id)->value('status')); foreach (['status', 'manual_status'] as $field) { foreach ($field === 'status' ? [null, -1, 2, 'invalid'] : [-1, 2, 'invalid'] as $status) { $result = $this->save(['id' => $id, 'title' => '不应保存', $field => $status]); $this->assertSame(HttpStatus::VALIDATION_FAILED, $result['code']); $row = DB::table('banner')->find($id); $this->assertSame(0, (int) $row->status); $this->assertSame('轮播图', $row->title); } } foreach ([['status' => 1, 'manual_status' => 'invalid'], ['status' => 'invalid', 'manual_status' => 1]] as $fields) { $result = $this->save($fields + ['id' => $id, 'title' => '不应保存']); $this->assertSame(HttpStatus::VALIDATION_FAILED, $result['code']); $result = $this->status($fields + ['id' => $id]); $this->assertSame(HttpStatus::VALIDATION_FAILED, $result['code']); $this->assertSame(0, (int) DB::table('banner')->where('id', $id)->value('status')); $this->assertSame('轮播图', DB::table('banner')->where('id', $id)->value('title')); } $result = $this->save(['id' => $id, 'manual_status' => null, 'title' => '空值保持手动设置']); $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']); $this->assertSame(0, (int) DB::table('banner')->where('id', $id)->value('status')); $this->assertSame(1, DB::table('banner')->count()); } public function test_status_endpoint_rejects_missing_invalid_status_and_missing_banner(): void { $id = $this->banner(); foreach ([[], ['id' => $id], ['status' => 0], ['id' => $id, 'status' => -1], ['id' => $id, 'status' => 2], ['id' => $id, 'status' => 'invalid'], ['id' => $id, 'status' => null], ['id' => 0, 'status' => 0], ['id' => 'invalid', 'status' => 0], ['manual_status' => 0], ['id' => $id, 'manual_status' => null], ['id' => $id, 'manual_status' => -1], ['id' => $id, 'manual_status' => 2], ['id' => $id, 'manual_status' => 'invalid']] as $params) { $this->assertSame(HttpStatus::VALIDATION_FAILED, $this->status($params)['code']); $this->assertSame(1, (int) DB::table('banner')->where('id', $id)->value('status')); } $result = $this->status(['id' => 999999, 'status' => 0]); $this->assertSame(HttpStatus::CUSTOM_ERROR, $result['code']); $this->assertStringContainsString('数据不存在', $result['msg']); $this->assertSame(1, DB::table('banner')->count()); } public function test_bound_visibility_uses_raw_seconds_and_includes_start_but_excludes_end(): void { $now = strtotime('2026-10-24 12:30:00 UTC'); $expected = []; foreach ([ 1 => [$now - 1, $now + 1, 1, null], 2 => [$now + 1, $now + 120, 1, 'activity_not_started'], 3 => [$now - 120, $now - 1, 1, 'activity_ended'], 4 => [$now, $now + 1, 1, null], 5 => [$now - 1, $now, 1, 'activity_ended'], 6 => [$now - 1, $now + 1, 0, 'activity_offline'], ] as $activityId => [$start, $end, $status, $reason]) { $this->activity($activityId, $start, $end, $status); $id = $this->banner(['link' => (string) $activityId]); $expected[$id] = [$reason, (string) $activityId]; } $id = $this->banner(['link' => '999']); $expected[$id] = ['activity_missing', '999']; $rows = $this->decorated($now); foreach ($expected as $id => [$reason, $activityId]) { $this->assertState($rows[$id], null, $reason === null ? 1 : 0, $reason, $activityId); $this->assertSame(1, (int) DB::table('banner')->where('id', $id)->value('status')); } } public function test_null_empty_zero_and_url_links_follow_only_the_manual_setting(): void { $expected = []; foreach ([null, '', '0', '000', 'https://example.test/activity/7'] as $link) { foreach ([0, 1] as $manual) { $id = $this->banner(['link' => $link, 'status' => $manual]); $expected[$id] = $manual; } } DB::connection()->enableQueryLog(); $rows = $this->decorated(1000); $queries = DB::connection()->getQueryLog(); DB::connection()->disableQueryLog(); foreach ($expected as $id => $manual) { $this->assertState($rows[$id], $manual, $manual, $manual ? null : 'manual', null); } $this->assertCount(1, $queries, 'Unbound banners should not query activities.'); } public function test_bound_banners_ignore_archived_manual_status_across_activity_states(): void { $now = time(); $this->activity(7, $now - 100, $now - 10); $on = $this->banner(['link' => '7', 'status' => 1]); $off = $this->banner(['link' => '7', 'status' => 0]); $rows = $this->decorated($now); $this->assertState($rows[$on], null, 0, 'activity_ended', '7'); $this->assertState($rows[$off], null, 0, 'activity_ended', '7'); DB::table('activity_rewards')->where('id', 7)->update(['end_time' => $now + 100]); $rows = $this->decorated($now); $this->assertState($rows[$on], null, 1, null, '7'); $this->assertState($rows[$off], null, 1, null, '7'); DB::table('activity_rewards')->where('id', 7)->update(['status' => 0]); $this->assertState($this->decorated($now)[$on], null, 0, 'activity_offline', '7'); DB::table('activity_rewards')->where('id', 7)->update(['status' => 1]); $this->assertState($this->decorated($now)[$on], null, 1, null, '7'); $this->assertSame(1, (int) DB::table('banner')->where('id', $on)->value('status')); $this->assertSame(0, (int) DB::table('banner')->where('id', $off)->value('status')); } public function test_get_expired_banner_reports_actual_off_and_full_row_edit_preserves_archive_for_reopening(): void { $now = time(); $this->activity(7, $now - 120, $now - 60); $on = $this->banner(['link' => '7', 'status' => 1, 'sort' => 17, 'lang' => 'en']); $off = $this->banner(['link' => '7', 'status' => 0]); $manualOff = $this->banner(['link' => 'https://example.test', 'status' => 0]); $page = $this->listing(); $rows = collect($page['data'])->keyBy('id')->all(); $this->assertSame(3, $page['total']); $this->assertState($rows[$on], null, 0, 'activity_ended', '7'); $this->assertState($rows[$off], null, 0, 'activity_ended', '7'); $this->assertState($rows[$manualOff], 0, 0, 'manual', null); $this->assertSame(1, (int) DB::table('banner')->where('id', $on)->value('status')); // Resubmitted activity-derived status and null manual_status do not alter the archived database flag. $edited = $rows[$on]; $edited['title'] = '过期活动轮播图编辑'; $result = $this->save($edited); $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']); $stored = DB::table('banner')->find($on); $this->assertSame(1, (int) $stored->status); $this->assertSame('过期活动轮播图编辑', $stored->title); $this->assertSame('7', $stored->link); $this->assertSame(17, (int) $stored->sort); $this->assertSame('en', $stored->lang); $this->assertSame('/banner.png', $stored->image); $rows = collect($this->listing()['data'])->keyBy('id')->all(); $this->assertState($rows[$on], null, 0, 'activity_ended', '7'); DB::table('activity_rewards')->where('id', 7)->update(['end_time' => time() + 600]); $rows = collect($this->listing()['data'])->keyBy('id')->all(); $this->assertState($rows[$on], null, 1, null, '7'); $this->assertState($rows[$off], null, 1, null, '7'); $this->assertState($rows[$manualOff], 0, 0, 'manual', null); $this->assertSame(1, (int) DB::table('banner')->where('id', $on)->value('status')); $this->assertSame(0, (int) DB::table('banner')->where('id', $off)->value('status')); } public function test_bound_banners_reject_both_manual_toggle_aliases_without_database_mutation(): void { $now = time(); $this->activity(7, $now - 120, $now + 600); $this->activity(8, $now - 120, $now - 60); foreach ([['7', 0], ['8', 1]] as [$link, $archivedStatus]) { $id = $this->banner(['link' => $link, 'status' => $archivedStatus]); $before = (array) DB::table('banner')->find($id); foreach (['status', 'manual_status'] as $field) { foreach ([0, 1] as $value) { $result = $this->status(['id' => $id, $field => $value]); $this->assertSame(HttpStatus::CUSTOM_ERROR, $result['code']); $this->assertStringContainsString('关联活动', $result['msg']); $this->assertStringContainsString('不能手动上下架', $result['msg']); $this->assertSame($before, (array) DB::table('banner')->find($id)); } } foreach ([['status' => null], ['manual_status' => null], ['status' => 'invalid'], ['manual_status' => []]] as $fields) { $result = $this->status(['id' => $id] + $fields); $this->assertSame(HttpStatus::VALIDATION_FAILED, $result['code']); $this->assertSame($before, (array) DB::table('banner')->find($id)); } } } public function test_bound_full_forms_ignore_status_inputs_and_new_bound_rows_keep_database_default_on(): void { $now = time(); $this->activity(7, $now - 120, $now + 600); $id = $this->banner(['link' => '7', 'status' => 0]); foreach ([['status' => 1], ['manual_status' => 0], ['status' => 0, 'manual_status' => 1], ['status' => 0, 'manual_status' => null]] as $fields) { $result = $this->save($fields + ['id' => $id, 'title' => '活动控制上下架']); $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']); $this->assertSame(0, (int) DB::table('banner')->where('id', $id)->value('status')); $this->assertState($this->decorated($now)[$id], null, 1, null, '7'); } foreach ([['status' => 'invalid'], ['manual_status' => []], ['status' => null]] as $fields) { $result = $this->save($fields + ['id' => $id, 'title' => '不应保存']); $this->assertSame(HttpStatus::VALIDATION_FAILED, $result['code']); $this->assertSame(0, (int) DB::table('banner')->where('id', $id)->value('status')); $this->assertSame('活动控制上下架', DB::table('banner')->where('id', $id)->value('title')); } $result = $this->save(['link' => '7', 'status' => 0, 'manual_status' => 0]); $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']); $newId = DB::table('banner')->latest('id')->value('id'); $this->assertSame(1, (int) DB::table('banner')->where('id', $newId)->value('status')); $this->assertState($this->decorated($now)[$newId], null, 1, null, '7'); } public function test_clearing_activity_link_restores_manual_control_and_rebinding_ignores_manual_off_input(): void { $now = time(); $this->activity(7, $now - 120, $now + 600); $id = $this->banner(['link' => '7', 'status' => 0]); $this->assertState($this->decorated($now)[$id], null, 1, null, '7'); $result = $this->save(['id' => $id, 'link' => null]); $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']); $this->assertState($this->decorated($now)[$id], 0, 0, 'manual', null); $result = $this->status(['id' => $id, 'manual_status' => 1]); $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']); $this->assertState($this->decorated($now)[$id], 1, 1, null, null); $result = $this->save(['id' => $id, 'link' => '7', 'status' => 0, 'manual_status' => 0]); $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']); $this->assertSame(1, (int) DB::table('banner')->where('id', $id)->value('status')); $this->assertState($this->decorated($now)[$id], null, 1, null, '7'); } public function test_integer_and_leading_zero_activity_links_are_validated_and_canonicalized(): void { $this->activity(7, 1, 4102444799); foreach ([7, '7', '0007'] as $link) { $result = $this->save(['link' => $link]); $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']); $this->assertSame('7', DB::table('banner')->latest('id')->value('link')); } foreach ([999, '000999'] as $link) { $result = $this->save(['link' => $link]); $this->assertSame(HttpStatus::VALIDATION_FAILED, $result['code']); $this->assertStringContainsString('绑定的活动不存在', $result['msg']); } $this->assertSame(3, DB::table('banner')->count()); $id = DB::table('banner')->value('id'); $result = $this->save(['id' => $id, 'link' => '999']); $this->assertSame(HttpStatus::VALIDATION_FAILED, $result['code']); $this->assertSame('7', DB::table('banner')->where('id', $id)->value('link')); $result = $this->save(['link' => ['id' => 7]]); $this->assertSame(HttpStatus::VALIDATION_FAILED, $result['code']); $this->assertSame(3, DB::table('banner')->count()); } public function test_stale_missing_activity_link_can_be_edited_but_cannot_be_manually_toggled(): void { $id = $this->banner(['link' => '500', 'sort' => 31]); $result = $this->save(['id' => $id, 'title' => '已删除活动的轮播图']); $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']); $this->assertSame('500', DB::table('banner')->where('id', $id)->value('link')); $this->assertSame(31, (int) DB::table('banner')->where('id', $id)->value('sort')); $result = $this->save(['id' => $id, 'link' => '000500', 'status' => 0]); $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']); $this->assertState($this->decorated(time())[$id], null, 0, 'activity_missing', '500'); foreach (['status', 'manual_status'] as $field) { foreach ([0, 1] as $value) { $result = $this->status(['id' => $id, $field => $value]); $this->assertSame(HttpStatus::CUSTOM_ERROR, $result['code']); $this->assertStringContainsString('关联活动', $result['msg']); $this->assertStringContainsString('不能手动上下架', $result['msg']); $this->assertSame(1, (int) DB::table('banner')->where('id', $id)->value('status')); } } } public function test_admin_pagination_counts_hidden_rows_and_batches_activity_queries(): void { $now = time(); $this->activity(7, $now - 100, $now + 100); $this->activity(8, $now - 100, $now - 10); for ($i = 0; $i < 24; $i++) { $this->banner(['title' => '轮播图 ' . $i, 'link' => $i % 2 ? '7' : '8', 'status' => $i % 3 ? 1 : 0, 'sort' => $i]); } DB::connection()->enableQueryLog(); $page = $this->listing(['limit' => 20, 'page' => 1]); $queries = DB::connection()->getQueryLog(); DB::connection()->disableQueryLog(); $this->assertSame(24, $page['total']); $this->assertCount(20, $page['data']); $this->assertCount(3, $queries, 'Admin list should use one count, one page, and one batched activity query.'); $this->assertSame(range(23, 4), array_column($page['data'], 'sort')); $this->assertNotEmpty(array_filter($page['data'], fn ($row) => $row['effective_status'] === 0)); $this->assertNotEmpty(array_filter($page['data'], fn ($row) => $row['effective_status'] === 1)); $last = $this->listing(['limit' => 20, 'page' => 2]); $this->assertSame(24, $last['total']); $this->assertCount(4, $last['data']); $this->assertSame(range(3, 0), array_column($last['data'], 'sort')); } public function test_admin_type_filter_and_sort_order_preserve_language_and_hidden_rows(): void { $first = $this->banner(['type' => 'app', 'lang' => 'en', 'sort' => 9, 'status' => 0]); $second = $this->banner(['type' => 'app', 'lang' => 'zh', 'sort' => 9]); $third = $this->banner(['type' => 'app', 'lang' => 'zh', 'sort' => 2]); $this->banner(['type' => 'pc', 'lang' => 'en', 'sort' => 100]); $page = $this->listing(['type' => 'app', 'limit' => 20]); $this->assertSame(3, $page['total']); $this->assertSame([$second, $first, $third], array_column($page['data'], 'id')); $this->assertSame(['zh', 'en', 'zh'], array_column($page['data'], 'lang')); $this->assertSame(['app', 'app', 'app'], array_column($page['data'], 'type')); $this->assertSame([1, 0, 1], array_column($page['data'], 'effective_status')); } public function test_platform_language_and_actual_status_filters_combine_without_mutating_data(): void { $now = time(); $this->activity(7, $now - 100, $now + 600); $this->activity(8, $now - 100, $now - 60); $this->activity(9, $now + 600, $now + 1200); $this->activity(10, $now - 100, $now + 600, 0); foreach ([ ['pc_zh_active', 'pc', 'zh', '7', 0, 110], ['pc_zh_ended', 'pc', 'zh', '8', 1, 100], ['pc_en_on', 'pc', 'en', null, 1, 90], ['pc_en_off', 'pc', 'en', null, 0, 80], ['app_zh_active', 'app', 'zh', '7', 0, 70], ['app_zh_ended', 'app', 'zh', '8', 1, 60], ['app_en_on', 'app', 'en', null, 1, 50], ['app_en_off', 'app', 'en', null, 0, 40], ['pc_zh_future', 'pc', 'zh', '9', 1, 30], ['pc_zh_offline', 'pc', 'zh', '10', 1, 20], ['pc_zh_missing', 'pc', 'zh', '999', 1, 10], ] as [$name, $type, $lang, $link, $manual, $sort]) { $ids[$name] = $this->banner(['type' => $type, 'lang' => $lang, 'link' => $link, 'status' => $manual, 'sort' => $sort]); } $bannerBefore = DB::table('banner')->orderBy('id')->get()->toJson(); $activityBefore = DB::table('activity_rewards')->orderBy('id')->get()->toJson(); $cases = [ [[], array_keys($ids)], [['platform' => 'pc'], ['pc_zh_active', 'pc_zh_ended', 'pc_en_on', 'pc_en_off', 'pc_zh_future', 'pc_zh_offline', 'pc_zh_missing']], [['platform' => 'h5'], ['app_zh_active', 'app_zh_ended', 'app_en_on', 'app_en_off']], [['type' => 'app'], ['app_zh_active', 'app_zh_ended', 'app_en_on', 'app_en_off']], [['platform' => 'h5', 'type' => 'app'], ['app_zh_active', 'app_zh_ended', 'app_en_on', 'app_en_off']], [['platform' => 'pc', 'type' => 'pc', 'lang' => 'en'], ['pc_en_on', 'pc_en_off']], [['lang' => 'zh'], ['pc_zh_active', 'pc_zh_ended', 'app_zh_active', 'app_zh_ended', 'pc_zh_future', 'pc_zh_offline', 'pc_zh_missing']], [['lang' => 'en'], ['pc_en_on', 'pc_en_off', 'app_en_on', 'app_en_off']], [['status' => 1], ['pc_zh_active', 'pc_en_on', 'app_zh_active', 'app_en_on']], [['status' => '0'], ['pc_zh_ended', 'pc_en_off', 'app_zh_ended', 'app_en_off', 'pc_zh_future', 'pc_zh_offline', 'pc_zh_missing']], [['platform' => 'pc', 'lang' => 'zh', 'status' => 0], ['pc_zh_ended', 'pc_zh_future', 'pc_zh_offline', 'pc_zh_missing']], [['platform' => 'h5', 'lang' => 'en', 'status' => '1'], ['app_en_on']], [['platform' => 'h5', 'lang' => 'zh', 'status' => '0'], ['app_zh_ended']], [['platform' => 'h5', 'status' => 0], ['app_zh_ended', 'app_en_off']], [['platform' => 'pc', 'lang' => 'fr', 'status' => 1], []], ]; foreach ($cases as [$filters, $names]) { $page = $this->listing($filters); $expectedIds = array_map(fn ($name) => $ids[$name], $names); $this->assertSame(count($expectedIds), $page['total']); $this->assertSame($expectedIds, array_column($page['data'], 'id')); if (isset($filters['platform'])) { foreach ($page['data'] as $row) $this->assertSame($filters['platform'] === 'h5' ? 'app' : 'pc', $row['type']); } if (array_key_exists('status', $filters)) { foreach ($page['data'] as $row) $this->assertSame((int) $filters['status'], $row['status']); } } $this->assertSame($bannerBefore, DB::table('banner')->orderBy('id')->get()->toJson()); $this->assertSame($activityBefore, DB::table('activity_rewards')->orderBy('id')->get()->toJson()); } public function test_actual_status_is_filtered_before_pagination_and_activity_lookup_stays_batched(): void { $now = time(); $this->activity(7, $now - 100, $now + 600); $this->activity(8, $now - 100, $now - 60); $up = $down = []; for ($i = 0; $i < 12; $i++) { $id = $this->banner(['type' => 'app', 'lang' => 'zh', 'link' => $i % 2 ? '8' : '7', 'status' => $i % 2, 'sort' => $i]); if ($i % 2) $down[] = $id; else $up[] = $id; } $this->banner(['type' => 'app', 'lang' => 'en', 'link' => '7', 'sort' => 100]); $this->banner(['type' => 'pc', 'lang' => 'zh', 'link' => '7', 'sort' => 101]); $up = array_reverse($up); $down = array_reverse($down); $snapshot = DB::table('banner')->orderBy('id')->get()->toJson(); $filters = ['platform' => 'h5', 'lang' => 'zh', 'status' => 1, 'limit' => 2]; DB::connection()->enableQueryLog(); $first = $this->listing($filters); $queries = DB::connection()->getQueryLog(); DB::connection()->disableQueryLog(); $this->assertCount(2, $queries, 'Status filtering should fetch matching candidates and all referenced activities in two queries.'); $this->assertSame(6, $first['total']); $this->assertSame(array_slice($up, 0, 2), array_column($first['data'], 'id')); $seen = array_column($first['data'], 'id'); foreach ([2, 3, 4] as $number) { $page = $this->listing($filters + ['page' => $number]); $this->assertSame(6, $page['total']); $this->assertSame(array_slice($up, ($number - 1) * 2, 2), array_column($page['data'], 'id')); $seen = array_merge($seen, array_column($page['data'], 'id')); } $this->assertSame($up, $seen); foreach ([1, 2] as $number) { $page = $this->listing(['platform' => 'h5', 'type' => 'app', 'lang' => 'zh', 'status' => '0', 'page' => $number, 'limit' => 4]); $this->assertSame(6, $page['total']); $this->assertSame(array_slice($down, ($number - 1) * 4, 4), array_column($page['data'], 'id')); } $this->assertSame($snapshot, DB::table('banner')->orderBy('id')->get()->toJson()); } public function test_empty_optional_filters_keep_all_languages_and_default_pagination(): void { for ($i = 0; $i < 16; $i++) { $ids[] = $this->banner(['type' => $i % 2 ? 'pc' : 'app', 'lang' => $i % 2 ? 'zh' : 'en', 'status' => $i % 2]); } $ids = array_reverse($ids); $snapshot = DB::table('banner')->orderBy('id')->get()->toJson(); foreach ([[], ['platform' => null, 'type' => null, 'lang' => null, 'status' => null], ['platform' => '', 'type' => '', 'lang' => '', 'status' => '']] as $filters) { $page = $this->listing($filters); $this->assertSame(16, $page['total']); $this->assertCount(15, $page['data']); $this->assertSame(array_slice($ids, 0, 15), array_column($page['data'], 'id')); $this->assertSame(['zh', 'en'], array_values(array_unique(array_column($page['data'], 'lang')))); $last = $this->listing($filters + ['page' => 2]); $this->assertSame(16, $last['total']); $this->assertSame(array_slice($ids, 15), array_column($last['data'], 'id')); } $largePage = $this->listing(['limit' => 200]); $this->assertSame(16, $largePage['total']); $this->assertSame($ids, array_column($largePage['data'], 'id')); $this->assertSame($snapshot, DB::table('banner')->orderBy('id')->get()->toJson()); } public function test_invalid_filters_and_conflicting_platform_aliases_are_rejected_without_mutation(): void { $this->banner(['type' => 'app', 'lang' => 'en']); $snapshot = DB::table('banner')->get()->toJson(); foreach ([ ['platform' => 'app'], ['platform' => 'android'], ['platform' => ['pc']], ['type' => 'h5'], ['type' => ['app']], ['platform' => 'pc', 'type' => 'app'], ['platform' => 'h5', 'type' => 'pc'], ['lang' => ['zh']], ['lang' => 123], ['lang' => 'abcdefghijk'], ['status' => -1], ['status' => 2], ['status' => 'up'], ['status' => '1.5'], ['status' => ['0']], ['page' => 0], ['limit' => 0], ['limit' => 201], ] as $filters) { $this->request('index', $filters); $result = (new AdminBanner())->index()->getData(true); $this->assertSame(HttpStatus::VALIDATION_FAILED, $result['code']); } $this->assertSame($snapshot, DB::table('banner')->get()->toJson()); } public function test_status_route_requires_existing_banner_edit_permission_and_preserves_superadmin_access(): void { Schema::create('menus', function (Blueprint $t) { $t->id(); $t->string('uri'); $t->integer('type'); $t->integer('status'); }); Schema::create('roles', function (Blueprint $t) { $t->id(); $t->string('name'); }); Schema::create('role_user', function (Blueprint $t) { $t->id(); $t->integer('user_id'); $t->integer('role_id'); }); Schema::create('role_menu', function (Blueprint $t) { $t->id(); $t->integer('role_id'); $t->integer('menu_id'); }); $editMenu = DB::table('menus')->insertGetId([ 'uri' => 'admin/banner/update', 'type' => 2, 'status' => 1, ]); $otherMenu = DB::table('menus')->insertGetId([ 'uri' => 'admin/banner/delete', 'type' => 2, 'status' => 1, ]); $router = new Router(new Dispatcher($this->app), $this->app); $this->app->instance('router', $router); $router->group(['prefix' => 'admin'], function () { require dirname(__DIR__, 2) . '/routes/admin.php'; }); $route = $router->getRoutes()->match(Request::create('/admin/banner/status', 'POST')); $this->assertSame('admin/banner/update', $route->defaults['permission_uri']); $this->assertContains('admin.jwt', $route->middleware()); $this->assertContains('check.button.uri', $route->middleware()); $this->assertSame(AdminBanner::class . '@status', $route->getAction('uses')); $id = $this->banner(); $changeStatus = function (int $userId, int $status) use ($router, $id): array { $this->request('status', ['id' => $id, 'manual_status' => $status, 'user' => (object) ['id' => $userId]]); $request = $this->app['request']; $route = $router->getRoutes()->match($request); $request->setRouteResolver(fn () => $route); return (new CheckButtonPermission())->handle($request, fn () => (new AdminBanner())->status())->getData(true); }; // The status URI has no separate menu: it must inherit the existing edit button gate. $this->assertSame(0, DB::table('menus')->where('uri', 'admin/banner/status')->count()); $result = $changeStatus(20, 0); $this->assertSame(-1, $result['code']); $this->assertSame('无权限访问', $result['msg']); $this->assertSame(1, (int) DB::table('banner')->where('id', $id)->value('status')); DB::table('roles')->insert(['id' => 5, 'name' => 'editor']); DB::table('role_user')->insert(['user_id' => 20, 'role_id' => 5]); DB::table('role_menu')->insert(['role_id' => 5, 'menu_id' => $otherMenu]); $this->assertSame(-1, $changeStatus(20, 0)['code']); $this->assertSame(1, (int) DB::table('banner')->where('id', $id)->value('status')); DB::table('role_menu')->insert(['role_id' => 5, 'menu_id' => $editMenu]); $result = $changeStatus(20, 0); $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']); $this->assertSame(0, (int) DB::table('banner')->where('id', $id)->value('status')); $result = $changeStatus(1, 1); $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']); $this->assertSame(1, (int) DB::table('banner')->where('id', $id)->value('status')); $this->assertSame(0, DB::table('role_user')->where('user_id', 1)->count()); } }