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