Files
RelaisSMS/tests/Feature/SmsCampaignReconciliationTest.php
T

181 lines
9.5 KiB
PHP

<?php
namespace Tests\Feature;
use App\Contracts\Sms\SmsProviderInterface;
use App\Models\SmsCampaign;
use App\Models\User;
use App\Services\Sms\OvhSmsProvider;
use App\Services\Sms\SmsCampaignReconciler;
use App\Services\Sms\SmsCampaignSender;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Response;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class SmsCampaignReconciliationTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
config([
'relais_sms.ovh.application_key' => 'test',
'relais_sms.ovh.application_secret' => 'test',
'relais_sms.ovh.consumer_key' => 'test',
'relais_sms.ovh.sms_service_name' => 'sms-test',
'relais_sms.ovh.sms_sender' => 'AFPMA',
'relais_sms.ovh.sms_short_code' => false,
'relais_sms.ovh.send_timeout' => 60,
]);
}
public function test_timeout_for_107_recipients_is_uncertain_and_is_never_retried(): void
{
$history = [];
$stack = HandlerStack::create(new MockHandler([
new Response(200, [], (string) time()),
new ConnectException('cURL error 28: Operation timed out', new Request('POST', 'https://eu.api.ovh.com/1.0/sms/sms-test/jobs')),
]));
$stack->push(Middleware::history($history));
$this->app->instance(SmsProviderInterface::class, new OvhSmsProvider(new Client(['handler' => $stack])));
$recipients = array_map(fn ($id) => ['number' => '+336'.str_pad((string) $id, 8, '0', STR_PAD_LEFT), 'contact_name' => 'Contact '.$id], range(1, 107));
$campaign = $this->app->make(SmsCampaignSender::class)->send(User::factory()->create(), 'Reunion demain', $recipients);
$this->assertSame('uncertain', $campaign->status);
$this->assertSame(0, $campaign->failed_count);
$this->assertSame(0, $campaign->sent_count);
$this->assertSame(107, $campaign->deliveries()->where('status', 'uncertain')->count());
$this->assertSame(107, count($campaign->request_payload['receivers']));
$this->assertSame('sms-test', $campaign->provider_service_name);
$this->assertSame(60.0, $history[1]['options']['timeout']);
$this->assertCount(2, $history);
$this->assertNull($this->app->make(SmsCampaignSender::class)->sendScheduled($campaign));
$this->actingAs($campaign->user)->get(route('sms-campaigns.show', $campaign))->assertOk()->assertSee('A verifier')->assertSee('107 destinataire(s) a verifier')->assertDontSee('cURL error');
$this->actingAs($campaign->user)->get(route('sms-campaigns.index', ['status' => 'uncertain']))->assertOk()->assertSee('A verifier');
}
public function test_explicit_rejection_remains_a_failure_but_server_errors_are_uncertain(): void
{
foreach ([400 => 'failed', 503 => 'uncertain'] as $code => $status) {
$provider = $this->provider([new Response(200, [], (string) time()), new Response($code, [], '{"message":"OVH unavailable"}')]);
$sender = new SmsCampaignSender($provider);
$campaign = $sender->send(User::factory()->create(), 'Reunion demain', [['number' => '+33611111111', 'contact_name' => 'Contact']]);
$this->assertSame($status, $campaign->status);
$this->assertSame($status === 'failed' ? 1 : 0, $campaign->failed_count);
}
}
public function test_incomplete_success_response_does_not_claim_messages_were_sent(): void
{
foreach (['{}', '{"ids":[]}'] as $body) {
$campaign = (new SmsCampaignSender($this->provider([new Response(200, [], (string) time()), new Response(200, [], $body)])))
->send(User::factory()->create(), 'Reunion demain', [['number' => '+33611111111', 'contact_name' => 'Contact']]);
$this->assertSame('uncertain', $campaign->status);
$this->assertSame(0, $campaign->sent_count);
}
}
public function test_reconciliation_confirms_exact_matches_only_and_keeps_missing_recipients_uncertain(): void
{
$campaign = $this->uncertainCampaign();
$reconciler = new SmsCampaignReconciler($this->provider([]));
$campaign = $reconciler->applyOutgoing($campaign, [
$this->outgoing($campaign, '+33611111111', 1),
array_merge($this->outgoing($campaign, '+33622222222', 2), ['tag' => 'other-campaign']),
array_merge($this->outgoing($campaign, '+33622222222', 3), ['message' => 'Different message']),
$this->outgoing($campaign, '+33699999999', 4),
]);
$this->assertSame('uncertain', $campaign->status);
$this->assertSame(1, $campaign->sent_count);
$this->assertSame(0, $campaign->failed_count);
$this->assertDatabaseHas('sms_deliveries', ['number' => '+33611111111', 'status' => 'sent', 'provider_message_id' => '1']);
$this->assertDatabaseHas('sms_deliveries', ['number' => '+33622222222', 'status' => 'uncertain']);
$campaign = $reconciler->applyOutgoing($campaign, [$this->outgoing($campaign, '+33622222222', 2)]);
$this->assertSame('sent', $campaign->status);
$this->assertSame(2, $campaign->sent_count);
$this->assertNull($campaign->error_message);
$this->assertSame(2, $reconciler->applyOutgoing($campaign, [])->sent_count);
}
public function test_empty_history_and_api_denial_never_turn_uncertainty_into_failure(): void
{
foreach ([new Response(200, [], '[]'), new Response(403, [], '{"message":"This call has not been granted"}')] as $response) {
$campaign = $this->uncertainCampaign();
$campaign = (new SmsCampaignReconciler($this->provider([new Response(200, [], (string) time()), $response])))->reconcile($campaign);
$this->assertSame('uncertain', $campaign->status);
$this->assertSame(0, $campaign->failed_count);
$this->assertSame(0, $campaign->sent_count);
}
}
public function test_scheduler_verifies_using_get_requests_only(): void
{
$campaign = $this->uncertainCampaign();
$history = [];
$stack = HandlerStack::create(new MockHandler([
new Response(200, [], (string) time()),
new Response(200, [], '[1,2]'),
new Response(200, [], json_encode($this->outgoing($campaign, '+33622222222', 2))),
new Response(200, [], json_encode($this->outgoing($campaign, '+33611111111', 1))),
]));
$stack->push(Middleware::history($history));
$this->app->instance(OvhSmsProvider::class, new OvhSmsProvider(new Client(['handler' => $stack])));
$this->mock(SmsProviderInterface::class)->shouldNotReceive('send');
$this->artisan('sms-campaigns:reconcile')->assertSuccessful();
$this->assertSame('sent', $campaign->fresh()->status);
$this->assertSame(['GET', 'GET', 'GET', 'GET'], array_map(fn ($entry) => $entry['request']->getMethod(), $history));
$this->assertStringContainsString('tag='.$campaign->provider_tag, (string) $history[1]['request']->getUri());
$this->assertDatabaseHas('sms_deliveries', ['number' => '+33611111111', 'provider_message_id' => '1']);
}
public function test_stale_sending_campaign_is_marked_uncertain_even_when_ovh_is_unavailable(): void
{
$campaign = $this->uncertainCampaign();
$campaign->update(['status' => 'sending']);
SmsCampaign::query()->whereKey($campaign->id)->update(['updated_at' => now()->subMinutes(11)]);
$campaign->deliveries()->update(['status' => 'queued']);
$this->app->instance(OvhSmsProvider::class, $this->provider([new Response(200, [], (string) time()), new Response(403, [], '{}')]));
$this->artisan('sms-campaigns:reconcile')->assertSuccessful();
$this->assertSame('uncertain', $campaign->fresh()->status);
$this->assertSame(2, $campaign->deliveries()->where('status', 'uncertain')->count());
}
public function test_campaign_tags_are_unique_for_sends_created_in_the_same_second(): void
{
$sender = $this->app->make(SmsCampaignSender::class);
$user = User::factory()->create();
$first = $sender->schedule($user, 'Reunion', [], [], [], now()->addHour());
$second = $sender->schedule($user, 'Reunion', [], [], [], now()->addHour());
$this->assertNotSame($first->provider_tag, $second->provider_tag);
}
private function uncertainCampaign(): SmsCampaign
{
$campaign = SmsCampaign::query()->create(['user_id' => User::factory()->create()->id, 'provider' => 'ovh', 'status' => 'uncertain', 'message' => 'Reunion demain', 'recipients_count' => 2]);
$campaign->update(['provider_tag' => 'relais-sms-campaign-'.$campaign->id]);
foreach (['+33611111111', '+33622222222'] as $number) {
$campaign->deliveries()->create(['number' => $number, 'contact_name' => 'Contact', 'status' => 'uncertain']);
}
return $campaign->fresh();
}
private function outgoing(SmsCampaign $campaign, string $number, int $id): array
{
return ['id' => $id, 'tag' => $campaign->provider_tag, 'receiver' => $number, 'message' => $campaign->message, 'creationDatetime' => '2026-10-06T19:56:24Z', 'ptt' => 1];
}
private function provider(array $responses): OvhSmsProvider
{
return new OvhSmsProvider(new Client(['handler' => HandlerStack::create(new MockHandler($responses))]));
}
}