seedPendingLog('send-ok', '13800138001'); $this->seedPendingLog('send-fail', '13800138002', $competition); config(['sms.submail.subhook_key' => 'hook-secret']); $token = '0123456789abcdef0123456789abcdef'; $signature = md5($token.'hook-secret'); $this->post('/api/v1/public/sms/subhook', [ 'events' => 'delivered', 'send_id' => 'send-ok', 'address' => '13800138001', 'timestamp' => now()->timestamp, 'token' => $token, 'signature' => $signature, ])->assertOk()->assertJsonPath('status', 'success'); $this->post('/api/v1/public/sms/subhook', [ 'events' => 'dropped', 'send_id' => 'send-fail', 'address' => '13800138002', 'report' => 'UNDELIV', 'report_desc' => '空号/停机', 'timestamp' => now()->timestamp, 'token' => $token, 'signature' => $signature, ])->assertOk(); $this->assertSame(AudienceSmsLog::STATUS_DELIVERED, AudienceSmsLog::query()->where('send_id', 'send-ok')->value('status')); $failed = AudienceSmsLog::query()->where('send_id', 'send-fail')->firstOrFail(); $this->assertSame(AudienceSmsLog::STATUS_DROPPED, $failed->status); $this->assertSame('空号/停机', $failed->dropped_reason); $this->assertTrue($failed->canResend()); } public function test_subhook_rejects_invalid_signature(): void { $this->seedPendingLog('send-sig', '13800138003'); config(['sms.submail.subhook_key' => 'hook-secret']); $this->post('/api/v1/public/sms/subhook', [ 'events' => 'delivered', 'send_id' => 'send-sig', 'token' => '0123456789abcdef0123456789abcdef', 'signature' => 'deadbeef', ])->assertForbidden(); $this->assertSame(AudienceSmsLog::STATUS_PENDING, AudienceSmsLog::query()->where('send_id', 'send-sig')->value('status')); } public function test_admin_list_syncs_from_log_api(): void { [$competition] = $this->seedPendingLog('log-send-1', '13800138004'); $this->enableSubmailConfig(); Http::fake([ 'https://log.mysubmail.com/sms/log.json' => Http::response([ 'status' => 'success', 'data' => [[ 'sendID' => 'log-send-1', 'status' => 'delivered', 'fee' => 1, 'report_state' => 'DELIVRD', 'report_at' => now()->timestamp, 'sms_content' => '【苏州恒泰控股】预报名成功', ]], ], 200), ]); Sanctum::actingAs($this->admin()); $this->getJson("/api/v1/admin/competitions/{$competition->id}/audience-sms-logs") ->assertOk() ->assertJsonPath('data.0.send_id', 'log-send-1') ->assertJsonPath('data.0.status', 'delivered') ->assertJsonPath('data.0.status_label', '发送成功') ->assertJsonPath('data.0.fee', 1) ->assertJsonPath('data.0.can_resend', false); } public function test_admin_can_resend_failed_sms(): void { [$competition, $registration, $log] = $this->seedPendingLog('old-fail', '13800138005'); $log->update([ 'status' => AudienceSmsLog::STATUS_FAILED, 'dropped_reason' => 'permission denied', ]); $this->enableSubmailConfig(); Http::fake([ 'https://api-v4.mysubmail.com/sms/xsend.json' => Http::response([ 'status' => 'success', 'send_id' => 'resend-ok', 'fee' => 1, ], 200), 'https://log.mysubmail.com/sms/log.json' => Http::response([ 'status' => 'success', 'data' => [], ], 200), ]); Sanctum::actingAs($this->admin()); $this->postJson("/api/v1/admin/competitions/{$competition->id}/audience-sms-logs/{$log->id}/resend") ->assertOk() ->assertJsonPath('send_id', 'resend-ok') ->assertJsonPath('status', 'pending') ->assertJsonPath('audience_registration_id', $registration->id); Http::assertSent(function (Request $request) use ($registration): bool { $data = $request->data(); $vars = json_decode((string) ($data['vars'] ?? ''), true); return parse_url($request->url(), PHP_URL_PATH) === '/sms/xsend.json' && ($data['project'] ?? null) === 'preReg1' && is_array($vars) && ($vars['session'] ?? null) === '上海预赛路演' && ($data['to'] ?? null) === $registration->phone; }); } public function test_admin_cannot_resend_successful_sms(): void { [$competition, , $log] = $this->seedPendingLog('ok-send', '13800138006'); $log->update(['status' => AudienceSmsLog::STATUS_DELIVERED]); Sanctum::actingAs($this->admin()); $this->postJson("/api/v1/admin/competitions/{$competition->id}/audience-sms-logs/{$log->id}/resend") ->assertUnprocessable() ->assertJsonValidationErrors('id'); } public function test_audience_login_code_is_recorded_in_sms_list(): void { $this->enableSubmailConfig(); Http::fake([ 'https://api-v4.mysubmail.com/sms/xsend.json' => Http::response([ 'status' => 'success', 'send_id' => 'login-sms-1', 'fee' => 1, ], 200), ]); $competition = $this->publishedCompetition(); $this->postJson('/api/auth/sms/send', [ 'mobile' => '13800138007', 'portal' => 'audience', 'competition_slug' => $competition->slug, ])->assertOk(); $this->assertDatabaseHas('audience_sms_logs', [ 'competition_id' => $competition->id, 'mobile' => '13800138007', 'scene' => AudienceSmsLog::SCENE_AUDIENCE_LOGIN, 'template_id' => 'ECp5J3', 'send_id' => 'login-sms-1', 'status' => AudienceSmsLog::STATUS_PENDING, ]); } /** * @return array{0: Competition, 1: AudienceRegistration, 2: AudienceSmsLog} */ private function seedPendingLog(string $sendId, string $mobile, ?Competition $competition = null): array { $competition ??= $this->publishedCompetition(); $session = AudienceSession::query()->firstOrCreate( ['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, ] ); $user = User::query()->firstOrCreate(['mobile' => $mobile]); $registration = AudienceRegistration::query()->create([ 'competition_id' => $competition->id, 'audience_session_id' => $session->id, 'user_id' => $user->id, 'name' => '短信观众', 'phone' => $mobile, 'purpose' => 'networking', 'review_status' => AudienceRegistration::STATUS_PENDING, 'registered_at' => now(), ]); $log = AudienceSmsLog::query()->create([ 'competition_id' => $competition->id, 'audience_registration_id' => $registration->id, 'scene' => AudienceSmsLog::SCENE_PRE_REGISTER, 'mobile' => $mobile, 'template_id' => 'preReg1', 'content' => '【苏州恒泰控股】预报名成功', 'vars_json' => ['session' => '上海预赛路演'], 'send_id' => $sendId, 'status' => AudienceSmsLog::STATUS_PENDING, 'sent_at' => now(), ]); return [$competition, $registration, $log]; } private function enableSubmailConfig(): void { config([ 'sms.driver' => 'submail', 'sms.enabled' => true, 'sms.submail.endpoint' => 'https://api-v4.mysubmail.com/sms/xsend.json', 'sms.submail.log_endpoint' => 'https://log.mysubmail.com/sms/log.json', 'sms.submail.app_id' => '115671', 'sms.submail.app_key' => 'test-submail-key', 'sms.submail.sign_name' => '苏州恒泰控股', 'sms.submail.audience_pre_register_template_id' => 'preReg1', 'sms.submail.audience_approved_template_id' => 'approved1', 'sms.submail.login_template_id' => 'ECp5J3', 'sms.submail.login_content' => '【苏州恒泰控股】您的验证码是{code},请在{ttl}分钟内输入。', ]); } private function publishedCompetition(): Competition { $n = Competition::query()->count(); return Competition::query()->create([ 'slug' => 'husu-sms-'.$n, 'name' => '沪苏协同新兴产业科创大赛', 'published' => true, 'status' => 'signup_open', ]); } private function admin(): AdminUser { return AdminUser::query()->firstOrCreate( ['username' => 'sms_admin'], [ 'password_hash' => 'unused', 'name' => '管理员', 'status' => 'active', ] ); } }