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

1 month ago
<?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',
1 month ago
'company' => '朗业科技',
1 month ago
'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)
4 weeks ago
->assertJsonPath('not_checked_in_count', 0)
->assertJsonPath('today_checked_in_count', 1);
1 month ago
$this->getJson("/api/v1/admin/competitions/{$competition->id}/audience-registrations?checkin_status=checked_in")
->assertOk()
->assertJsonPath('total', 1)
4 weeks ago
->assertJsonPath('data.0.checked_in', true)
->assertJsonPath('data.0.ticket_code', $registration->ticket_code);
1 month ago
$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',
]);
}
}