publishedCompetition(); $session = $this->openSession($competition); $token = $this->audienceToken('13800138100'); $this->withToken($token) ->postJson('/api/v1/audience/registrations', [ 'competition_slug' => $competition->slug, 'audience_session_id' => $session->id, 'name' => '持票人', 'phone' => '13800138100', 'company' => '朗业科技', 'purpose' => '学习参访', ]) ->assertCreated() ->assertJsonStructure(['ticket_code', 'checked_in_at']); $code = AudienceRegistration::query()->where('name', '持票人')->value('ticket_code'); $this->assertIsString($code); $this->assertSame(16, strlen($code)); $this->assertMatchesRegularExpression('/^[0-9a-f]{16}$/', $code); } public function test_admin_can_checkin_by_ticket_once(): void { Sanctum::actingAs($this->admin()); [$competition, $registration] = $this->seedRegistration(); $first = $this->postJson("/api/v1/admin/competitions/{$competition->id}/audience-checkin", [ 'ticket_code' => $registration->ticket_code, ])->assertOk() ->assertJsonPath('already_checked_in', false) ->assertJsonPath('name', '到场观众'); $checkedAt = $first->json('checked_in_at'); $this->assertNotEmpty($checkedAt); Carbon::setTestNow(Carbon::parse('2026-08-18 12:05:00', BizDateTime::TIMEZONE)); $this->postJson("/api/v1/admin/competitions/{$competition->id}/audience-checkin", [ 'ticket_code' => $registration->ticket_code, ])->assertOk() ->assertJsonPath('already_checked_in', true) ->assertJsonPath('checked_in_at', $checkedAt); $this->assertSame(1, AudienceRegistration::query()->whereNotNull('checked_in_at')->count()); $this->assertEquals( $checkedAt, BizDateTime::toIso8601($registration->fresh()->checked_in_at), ); } public function test_checkin_accepts_ticket_url(): void { Sanctum::actingAs($this->admin()); [$competition, $registration] = $this->seedRegistration(); $this->postJson("/api/v1/admin/competitions/{$competition->id}/audience-checkin", [ 'ticket_code' => "https://example.com/c/{$competition->slug}/t/{$registration->ticket_code}", ])->assertOk() ->assertJsonPath('already_checked_in', false) ->assertJsonPath('name', '到场观众'); } public function test_invalid_and_foreign_ticket_cannot_checkin(): void { Sanctum::actingAs($this->admin()); [$competition] = $this->seedRegistration(); $other = $this->publishedCompetition('other-2026'); $otherSession = $this->openSession($other, ['code' => 'other-session']); $otherUser = User::query()->create(['mobile' => '13800138101']); $foreign = AudienceRegistration::query()->create([ 'competition_id' => $other->id, 'audience_session_id' => $otherSession->id, 'user_id' => $otherUser->id, 'name' => '他赛观众', 'phone' => '13800138101', 'purpose' => '其他', 'registered_at' => now(), ]); $this->postJson("/api/v1/admin/competitions/{$competition->id}/audience-checkin", [ 'ticket_code' => 'ffffffffffffffff', ])->assertUnprocessable() ->assertJsonValidationErrors('ticket_code'); $this->postJson("/api/v1/admin/competitions/{$competition->id}/audience-checkin", [ 'ticket_code' => $foreign->ticket_code, ])->assertUnprocessable() ->assertJsonValidationErrors('ticket_code'); } public function test_wrong_session_is_rejected(): void { Sanctum::actingAs($this->admin()); [$competition, $registration] = $this->seedRegistration(); $otherSession = $this->openSession($competition, ['code' => 'suzhou-preliminary', 'name' => '苏州预赛路演']); $this->postJson("/api/v1/admin/competitions/{$competition->id}/audience-checkin", [ 'ticket_code' => $registration->ticket_code, 'audience_session_id' => $otherSession->id, ])->assertUnprocessable() ->assertJsonValidationErrors('ticket_code'); $this->assertNull($registration->fresh()->checked_in_at); } public function test_manual_checkin_matches_scan(): void { Sanctum::actingAs($this->admin()); [$competition, $registration] = $this->seedRegistration(); $this->postJson("/api/v1/admin/competitions/{$competition->id}/audience-registrations/{$registration->id}/checkin") ->assertOk() ->assertJsonPath('already_checked_in', false) ->assertJsonPath('name', '到场观众'); $this->assertNotNull($registration->fresh()->checked_in_at); } public function test_audience_token_cannot_checkin(): void { [$competition, $registration] = $this->seedRegistration(); $token = $this->audienceToken('13800138100'); $this->withToken($token) ->postJson("/api/v1/admin/competitions/{$competition->id}/audience-checkin", [ 'ticket_code' => $registration->ticket_code, ]) ->assertForbidden(); } public function test_disabled_session_cannot_checkin(): void { Sanctum::actingAs($this->admin()); [$competition, $registration, $session] = $this->seedRegistration(); $session->update(['enabled' => false]); $this->postJson("/api/v1/admin/competitions/{$competition->id}/audience-checkin", [ 'ticket_code' => $registration->ticket_code, ])->assertUnprocessable(); } public function test_stats_and_export_include_checkin(): void { Sanctum::actingAs($this->admin()); [$competition, $registration] = $this->seedRegistration(); $this->postJson("/api/v1/admin/competitions/{$competition->id}/audience-checkin", [ 'ticket_code' => $registration->ticket_code, ])->assertOk(); $this->getJson("/api/v1/admin/competitions/{$competition->id}/audience-checkin-stats") ->assertOk() ->assertJsonPath('registered_count', 1) ->assertJsonPath('checked_in_count', 1) ->assertJsonPath('not_checked_in_count', 0) ->assertJsonPath('today_checked_in_count', 1); $this->getJson("/api/v1/admin/competitions/{$competition->id}/audience-registrations?checkin_status=checked_in") ->assertOk() ->assertJsonPath('total', 1) ->assertJsonPath('data.0.checked_in', true) ->assertJsonPath('data.0.ticket_code', $registration->ticket_code); $this->getJson("/api/v1/admin/competitions/{$competition->id}/audience-registrations?checkin_status=not_checked_in") ->assertOk() ->assertJsonPath('total', 0); $this->get("/api/v1/admin/competitions/{$competition->id}/audience-registrations/export") ->assertOk(); } /** * @return array{0: Competition, 1: AudienceRegistration, 2: AudienceSession} */ private function seedRegistration(): array { $competition = $this->publishedCompetition(); $session = $this->openSession($competition); $user = User::query()->firstOrCreate(['mobile' => '13800138100']); $registration = AudienceRegistration::query()->create([ 'competition_id' => $competition->id, 'audience_session_id' => $session->id, 'user_id' => $user->id, 'name' => '到场观众', 'phone' => '13800138100', 'purpose' => '项目交流', 'registered_at' => now(), ]); return [$competition, $registration, $session]; } private function publishedCompetition(string $slug = 'husu-2026'): Competition { return Competition::query()->create([ 'slug' => $slug, 'name' => '沪苏协同新兴产业科创大赛', 'published' => true, 'status' => 'signup_open', ]); } /** * @param array $overrides */ private function openSession(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 admin(): AdminUser { return AdminUser::query()->create([ 'username' => 'checkin_admin', 'password_hash' => 'unused', 'name' => '核销管理员', 'status' => 'active', ]); } }