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.

451 lines
16 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\Application;
use App\Models\AudienceRegistration;
use App\Models\AudienceSession;
use App\Models\Competition;
use App\Models\SmsVerification;
use App\Models\User;
use App\Support\BizDateTime;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class AudienceRegistrationTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Carbon::setTestNow(Carbon::parse('2026-08-17 12:00:00', BizDateTime::TIMEZONE));
}
protected function tearDown(): void
{
Carbon::setTestNow();
parent::tearDown();
}
public function test_audience_login_does_not_create_application(): void
{
$competition = $this->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<string, mixed> $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<string, mixed> $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',
]);
}
}