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