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.

280 lines
10 KiB

4 weeks ago
<?php
namespace Tests\Feature;
use App\Models\AdminUser;
use App\Models\AudienceRegistration;
use App\Models\AudienceSession;
use App\Models\AudienceSmsLog;
use App\Models\Competition;
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 AudienceSmsLogTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Carbon::setTestNow(Carbon::parse('2026-08-29 16:00:00', BizDateTime::TIMEZONE));
}
protected function tearDown(): void
{
Carbon::setTestNow();
parent::tearDown();
}
public function test_subhook_marks_delivered_and_dropped(): void
{
[$competition] = $this->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',
]
);
}
}