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

1 month ago
<?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;
1 month ago
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
1 month ago
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();
}
1 month ago
public function test_company_is_required_and_job_is_optional(): void
1 month ago
{
$competition = $this->publishedCompetition();
1 month ago
$session = $this->openSession($competition, [
'name_en' => 'Shanghai Preliminary',
]);
1 month ago
$token = $this->audienceToken('13800138002');
$this->withToken($token)
->postJson('/api/v1/audience/registrations', [
'competition_slug' => $competition->slug,
'audience_session_id' => $session->id,
'name' => '王清萍',
'phone' => '13800138002',
1 month ago
'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',
1 month ago
])
->assertCreated()
->assertJsonPath('session_name', '上海预赛路演')
1 month ago
->assertJsonPath('session_name_en', 'Shanghai Preliminary')
1 month ago
->assertJsonPath('checked_in_at', null);
$this->assertNotEmpty(
AudienceRegistration::query()->where('name', '王清萍')->value('ticket_code'),
);
$this->assertDatabaseHas('audience_registrations', [
'audience_session_id' => $session->id,
'name' => '王清萍',
1 month ago
'company' => '朗业科技',
1 month ago
'job' => null,
1 month ago
'purpose' => 'networking',
1 month ago
]);
}
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',
1 month ago
'company' => '朗业科技',
'purpose' => 'networking',
1 month ago
])
->assertCreated()
->assertJsonPath('phone', '13800138010');
$this->assertDatabaseHas('audience_registrations', [
'audience_session_id' => $session->id,
'name' => '锁号用户',
'phone' => '13800138010',
]);
}
1 month ago
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);
}
1 month ago
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',
1 month ago
'company' => '朗业科技',
1 month ago
'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',
1 month ago
'company' => '朗业科技',
1 month ago
'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',
1 month ago
'purpose' => 'networking',
1 month ago
'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',
1 month ago
'company' => '朗业科技',
'purpose' => 'networking',
1 month ago
])
->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',
1 month ago
'purpose' => 'investment',
1 month ago
'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();
}
1 month ago
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',
]);
}
1 month ago
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([
1 month ago
'scene' => SmsVerification::SCENE_AUDIENCE_LOGIN,
1 month ago
'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',
]);
}
}