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