|
|
|
|
|
<?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',
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|