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_company_is_required_and_job_is_optional(): void { $competition = $this->publishedCompetition(); $session = $this->openSession($competition, [ 'name_en' => 'Shanghai Preliminary', ]); $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' => 'networking', ]) ->assertUnprocessable() ->assertJsonValidationErrors('company'); $this->withToken($token) ->postJson('/api/v1/audience/registrations', [ 'competition_slug' => $competition->slug, 'audience_session_id' => $session->id, 'name' => '王清萍', 'phone' => '13800138002', 'company' => '朗业科技', 'purpose' => 'networking', ]) ->assertCreated() ->assertJsonPath('session_name', '上海预赛路演') ->assertJsonPath('session_name_en', 'Shanghai Preliminary') ->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' => '朗业科技', 'job' => null, 'purpose' => 'networking', ]); } 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', 'company' => '朗业科技', 'purpose' => 'networking', ]) ->assertCreated() ->assertJsonPath('phone', '13800138010'); $this->assertDatabaseHas('audience_registrations', [ 'audience_session_id' => $session->id, 'name' => '锁号用户', 'phone' => '13800138010', ]); } public function test_successful_registration_sends_submail_audience_template(): void { $this->enableSubmailConfig(); Http::fake([ 'https://api-v4.mysubmail.com/sms/xsend.json' => Http::response([ 'status' => 'success', 'send_id' => 'audience-sms-1', ], 200), ]); $competition = $this->publishedCompetition(); $session = $this->openSession($competition); $token = $this->audienceToken('13800138201'); $this->withToken($token) ->postJson('/api/v1/audience/registrations', [ 'competition_slug' => $competition->slug, 'audience_session_id' => $session->id, 'name' => '短信用户', 'phone' => '13800138201', 'company' => '朗业科技', 'purpose' => 'networking', ]) ->assertCreated(); Http::assertSent(function (Request $request): bool { $data = $request->data(); $vars = json_decode((string) ($data['vars'] ?? ''), true); return parse_url($request->url(), PHP_URL_HOST) === 'api-v4.mysubmail.com' && parse_url($request->url(), PHP_URL_PATH) === '/sms/xsend.json' && ($data['appid'] ?? null) === '74663' && ($data['to'] ?? null) === '13800138201' && ($data['project'] ?? null) === 'qBiVf1' && ($data['sms_signature'] ?? null) === '苏州恒泰控股' && is_array($vars) && $vars['session'] === '上海预赛路演' && $vars['time'] === '2026年9月18日(星期五)' && $vars['address'] === '上海市浦东新区张江科学会堂'; }); } public function test_audience_sms_failure_does_not_block_registration(): void { $this->enableSubmailConfig(); Http::fake([ 'https://api-v4.mysubmail.com/sms/xsend.json' => Http::response([ 'status' => 'error', 'code' => 101, 'msg' => 'permission denied', ], 200), ]); $competition = $this->publishedCompetition(); $session = $this->openSession($competition); $token = $this->audienceToken('13800138203'); $this->withToken($token) ->postJson('/api/v1/audience/registrations', [ 'competition_slug' => $competition->slug, 'audience_session_id' => $session->id, 'name' => '短信失败用户', 'phone' => '13800138203', 'company' => '朗业科技', 'purpose' => 'networking', ]) ->assertCreated() ->assertJsonPath('name', '短信失败用户'); $this->assertDatabaseHas('audience_registrations', [ 'audience_session_id' => $session->id, 'name' => '短信失败用户', 'phone' => '13800138203', ]); Http::assertSentCount(1); } 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', 'company' => '朗业科技', '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', 'company' => '朗业科技', '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' => 'networking', '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', 'company' => '朗业科技', 'purpose' => 'networking', ]) ->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' => 'investment', '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 enableSubmailConfig(): void { config([ 'sms.driver' => 'submail', 'sms.enabled' => true, 'sms.submail.endpoint' => 'https://api-v4.mysubmail.com/sms/xsend.json', 'sms.submail.app_id' => '74663', 'sms.submail.app_key' => 'test-submail-key', 'sms.submail.sign_name' => '苏州恒泰控股', 'sms.submail.login_template_id' => 'ECp5J3', 'sms.submail.audience_template_id' => 'qBiVf1', ]); } 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_AUDIENCE_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', ]); } }