You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

274 lines
10 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<?php
namespace Tests\Feature;
use App\Models\AdminUser;
use App\Models\AudienceRegistration;
use App\Models\AudienceSession;
use App\Models\Competition;
use App\Models\User;
use App\Support\BizDateTime;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class AudienceCheckinTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Carbon::setTestNow(Carbon::parse('2026-08-18 12:00:00', BizDateTime::TIMEZONE));
}
protected function tearDown(): void
{
Carbon::setTestNow();
parent::tearDown();
}
public function test_registration_generates_unique_ticket_code(): void
{
$competition = $this->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<string, mixed> $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',
]);
}
}