publishedCompetition(); $this->issueSmsCode('13800138000', '123456'); $this->postJson('/api/auth/sms/audience/login', [ 'mobile' => '13800138000', 'code' => '123456', 'competition_slug' => $competition->slug, ])->assertOk() ->assertJsonPath('token_type', 'Bearer'); $this->assertDatabaseHas('users', ['mobile' => '13800138000']); $this->assertSame(0, Application::query()->count()); } public function test_participant_token_cannot_access_audience_api(): void { $user = User::query()->create(['mobile' => '13800138001']); $token = $user->createToken('web')->plainTextToken; $this->withToken($token) ->getJson('/api/v1/audience/me') ->assertForbidden(); } public function test_can_register_without_company_and_job(): void { $competition = $this->publishedCompetition(); $session = $this->openSession($competition); $token = $this->audienceToken('13800138002'); $this->withToken($token) ->postJson('/api/v1/audience/registrations', [ 'competition_slug' => $competition->slug, 'audience_session_id' => $session->id, 'name' => '王清萍', 'phone' => '13800138002', 'purpose' => '项目交流', ]) ->assertCreated() ->assertJsonPath('session_name', '上海预赛路演') ->assertJsonPath('checked_in_at', null); $this->assertNotEmpty( AudienceRegistration::query()->where('name', '王清萍')->value('ticket_code'), ); $this->assertDatabaseHas('audience_registrations', [ 'audience_session_id' => $session->id, 'name' => '王清萍', 'company' => null, 'job' => null, 'purpose' => '项目交流', ]); } public function test_registration_phone_is_always_the_login_mobile(): void { $competition = $this->publishedCompetition(); $session = $this->openSession($competition); $token = $this->audienceToken('13800138010'); $this->withToken($token) ->postJson('/api/v1/audience/registrations', [ 'competition_slug' => $competition->slug, 'audience_session_id' => $session->id, 'name' => '锁号用户', 'phone' => '13900000000', 'purpose' => '项目交流', ]) ->assertCreated() ->assertJsonPath('phone', '13800138010'); $this->assertDatabaseHas('audience_registrations', [ 'audience_session_id' => $session->id, 'name' => '锁号用户', 'phone' => '13800138010', ]); } public function test_duplicate_registration_is_rejected(): void { $competition = $this->publishedCompetition(); $session = $this->openSession($competition); $token = $this->audienceToken('13800138003'); $payload = [ 'competition_slug' => $competition->slug, 'audience_session_id' => $session->id, 'name' => '重复用户', 'phone' => '13800138003', 'purpose' => '学习参访', ]; $this->withToken($token)->postJson('/api/v1/audience/registrations', $payload)->assertCreated(); $this->withToken($token) ->postJson('/api/v1/audience/registrations', $payload) ->assertUnprocessable() ->assertJsonValidationErrors('audience_session_id'); } public function test_registration_outside_window_is_rejected(): void { $competition = $this->publishedCompetition(); $session = $this->makeSession($competition, [ 'signup_start_at' => Carbon::parse('2026-09-01 00:00:00', BizDateTime::TIMEZONE), 'signup_end_at' => Carbon::parse('2026-09-10 23:59:59', BizDateTime::TIMEZONE), ]); $token = $this->audienceToken('13800138004'); $this->withToken($token) ->postJson('/api/v1/audience/registrations', [ 'competition_slug' => $competition->slug, 'audience_session_id' => $session->id, 'name' => '未开窗', 'phone' => '13800138004', 'purpose' => '其他', ]) ->assertUnprocessable(); } public function test_full_session_is_rejected(): void { $competition = $this->publishedCompetition(); $session = $this->openSession($competition, ['capacity' => 1]); $first = User::query()->create(['mobile' => '13800138005']); AudienceRegistration::query()->create([ 'competition_id' => $competition->id, 'audience_session_id' => $session->id, 'user_id' => $first->id, 'name' => '先到', 'phone' => '13800138005', 'purpose' => '项目交流', 'registered_at' => now(), ]); $token = $this->audienceToken('13800138006'); $this->withToken($token) ->postJson('/api/v1/audience/registrations', [ 'competition_slug' => $competition->slug, 'audience_session_id' => $session->id, 'name' => '满额', 'phone' => '13800138006', 'purpose' => '项目交流', ]) ->assertUnprocessable(); } public function test_sessions_list_marks_registered_and_remaining(): void { $competition = $this->publishedCompetition(); $session = $this->openSession($competition, ['capacity' => 10]); $token = $this->audienceToken('13800138007'); $this->withToken($token) ->postJson('/api/v1/audience/registrations', [ 'competition_slug' => $competition->slug, 'audience_session_id' => $session->id, 'name' => '列表用户', 'phone' => '13800138007', 'company' => '组委会', 'job' => '观察员', 'purpose' => '媒体报道', ]) ->assertCreated(); $this->withToken($token) ->getJson('/api/v1/audience/sessions?competition_slug='.$competition->slug) ->assertOk() ->assertJsonPath('data.0.is_registered', true) ->assertJsonPath('data.0.remaining', 9) ->assertJsonPath('data.0.status_label', '已报名'); } public function test_admin_can_create_session_and_list_registrations(): void { Sanctum::actingAs($this->admin()); $competition = $this->publishedCompetition(); $this->postJson("/api/v1/admin/competitions/{$competition->id}/audience-sessions", [ 'code' => 'demo-session', 'name' => '演示场次', 'event_date_text' => '2026年9月1日', 'address' => '上海', 'capacity' => 20, 'signup_start_at' => '2026-08-01T00:00:00+08:00', 'signup_end_at' => '2026-08-31T23:59:59+08:00', 'is_final' => false, 'sort' => 1, 'enabled' => true, ])->assertCreated() ->assertJsonPath('code', 'demo-session'); $session = AudienceSession::query()->where('code', 'demo-session')->firstOrFail(); $user = User::query()->create(['mobile' => '13800138008']); AudienceRegistration::query()->create([ 'competition_id' => $competition->id, 'audience_session_id' => $session->id, 'user_id' => $user->id, 'name' => '名单用户', 'phone' => '13800138008', 'purpose' => '投资对接', 'registered_at' => now(), ]); $this->getJson("/api/v1/admin/competitions/{$competition->id}/audience-registrations?purpose=".urlencode('投资对接')) ->assertOk() ->assertJsonPath('total', 1) ->assertJsonPath('data.0.name', '名单用户'); $this->get("/api/v1/admin/competitions/{$competition->id}/audience-registrations/export") ->assertOk(); } public function test_admin_cannot_delete_session_with_registrations(): void { Sanctum::actingAs($this->admin()); $competition = $this->publishedCompetition(); $session = $this->openSession($competition); $user = User::query()->create(['mobile' => '13800138009']); AudienceRegistration::query()->create([ 'competition_id' => $competition->id, 'audience_session_id' => $session->id, 'user_id' => $user->id, 'name' => '占位', 'phone' => '13800138009', 'purpose' => '其他', 'registered_at' => now(), ]); $this->deleteJson("/api/v1/admin/competitions/{$competition->id}/audience-sessions/{$session->id}") ->assertUnprocessable(); } private function publishedCompetition(): Competition { return Competition::query()->create([ 'slug' => 'husu-2026', 'name' => '沪苏协同新兴产业科创大赛', 'published' => true, 'status' => 'signup_open', ]); } /** * @param array $overrides */ private function openSession(Competition $competition, array $overrides = []): AudienceSession { return $this->makeSession($competition, array_merge([ 'signup_start_at' => Carbon::parse('2026-08-10 00:00:00', BizDateTime::TIMEZONE), 'signup_end_at' => Carbon::parse('2026-09-10 23:59:59', BizDateTime::TIMEZONE), ], $overrides)); } /** * @param array $overrides */ private function makeSession(Competition $competition, array $overrides = []): AudienceSession { return AudienceSession::query()->create(array_merge([ 'competition_id' => $competition->id, 'code' => 'shanghai-preliminary', 'name' => '上海预赛路演', 'event_date_text' => '2026年9月18日(星期五)', 'address' => '上海市浦东新区张江科学会堂', 'capacity' => 300, 'signup_start_at' => Carbon::parse('2026-08-10 00:00:00', BizDateTime::TIMEZONE), 'signup_end_at' => Carbon::parse('2026-09-10 23:59:59', BizDateTime::TIMEZONE), 'is_final' => false, 'sort' => 1, 'enabled' => true, ], $overrides)); } private function audienceToken(string $mobile): string { $user = User::query()->firstOrCreate(['mobile' => $mobile]); return $user->createToken('audience')->plainTextToken; } private function issueSmsCode(string $mobile, string $code): void { SmsVerification::query()->create([ 'scene' => SmsVerification::SCENE_PARTICIPANT_LOGIN, 'mobile' => $mobile, 'code' => $code, 'provider' => SmsVerification::PROVIDER_DISABLED, 'status' => SmsVerification::STATUS_SENT, 'expires_at' => now()->addMinutes(5), ]); } private function admin(): AdminUser { return AdminUser::query()->create([ 'username' => 'audience_admin', 'password_hash' => 'unused', 'name' => '管理员', 'status' => 'active', ]); } }