Publication initiale de RelaisSMS : documentation et sources nettoyees

This commit is contained in:
2026-10-21 09:29:39 +02:00
commit ac186b03b1
158 changed files with 24818 additions and 0 deletions
@@ -0,0 +1,291 @@
<?php
namespace Tests\Feature\Calendar;
use App\Contracts\Calendar\CalendarClientInterface;
use App\Contracts\Sms\SmsProviderInterface;
use App\Models\CalendarEvent;
use App\Models\CalendarReminder;
use App\Models\Contact;
use App\Models\SmsCampaign;
use App\Models\User;
use App\Services\Calendar\CalendarReminderSender;
use App\Services\Calendar\CalendarRemoteEvent;
use App\Services\Sms\SmsMessageLength;
use App\Services\Sms\SmsProviderException;
use App\Services\Sms\SmsSendResult;
use Carbon\CarbonInterface;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Tests\TestCase;
class CalendarReminderProcessingTest extends TestCase
{
use RefreshDatabase;
public function test_uncertain_reminder_is_not_retried_after_calendar_sync(): void
{
Carbon::setTestNow(Carbon::parse('2026-10-06 13:05:00', 'UTC'));
User::factory()->create();
$contact = Contact::query()->create(['source' => 'local', 'first_name' => 'Contact', 'last_name' => 'Test']);
$contact->phoneNumbers()->create([
'raw_number' => '06 11 11 11 11', 'normalized_number' => '+33611111111',
'country' => 'FR', 'is_valid' => true, 'is_mobile' => true, 'is_primary' => true,
]);
config(['relais_sms.caldav.default_contact_ids' => [$contact->id]]);
$this->mock(SmsProviderInterface::class)->shouldReceive('send')->once()
->andThrow(new SmsProviderException('Timeout', outcomeUnknown: true));
$ical = "BEGIN:VCALENDAR\nVERSION:2.0\nBEGIN:VEVENT\nUID:uncertain-reminder\nSUMMARY:Reunion\nDTSTART:20261006T140000Z\nBEGIN:VALARM\nACTION:DISPLAY\nTRIGGER:-PT1H\nEND:VALARM\nEND:VEVENT\nEND:VCALENDAR";
$this->mock(CalendarClientInterface::class)->shouldReceive('fetchEvents')->twice()
->andReturn([new CalendarRemoteEvent(href: '/calendar/uncertain.ics', etag: 'etag', ical: $ical)]);
$this->artisan('calendar:process-reminders --force-sync')->assertSuccessful();
$this->assertDatabaseHas('calendar_reminders', ['status' => 'uncertain', 'sent_at' => null]);
$this->artisan('calendar:process-reminders --force-sync')->assertSuccessful();
$this->assertSame(1, SmsCampaign::query()->count());
$this->assertDatabaseHas('calendar_reminders', ['status' => 'uncertain']);
Carbon::setTestNow();
}
public function test_expired_pre_event_reminder_is_skipped_without_calling_the_sms_provider(): void
{
$this->mock(SmsProviderInterface::class)->shouldNotReceive('send');
$event = CalendarEvent::query()->create([
'source' => 'caldav',
'external_id' => 'expired-reminder',
'summary' => 'Reunion passee',
'starts_at' => '2026-09-30 08:00:00',
]);
$reminder = $event->reminders()->create([
'reminder_key' => 'expired',
'action' => 'DISPLAY',
'remind_at' => '2026-09-30 07:00:00',
'status' => 'pending',
]);
$sent = $this->app->make(CalendarReminderSender::class)->sendDue(Carbon::parse('2026-09-30 10:00:00', 'Europe/Paris'));
$this->assertSame(0, $sent);
$this->assertSame('skipped', $reminder->fresh()->status);
$this->assertSame(0, SmsCampaign::query()->count());
}
public function test_reminder_message_prioritizes_title_date_location_then_truncated_description_in_one_sms(): void
{
config([
'relais_sms.caldav.reminder_message_header' => '',
'relais_sms.caldav.reminder_message_footer' => '',
'relais_sms.caldav.reminder_description_max_length' => 500,
'relais_sms.caldav.display_timezone' => 'Europe/Paris',
]);
$event = CalendarEvent::query()->create([
'source' => 'caldav',
'external_id' => 'long-message',
'summary' => 'Reunion importante',
'description' => str_repeat('description longue ', 30),
'location' => 'Salle A',
'starts_at' => '2026-06-12 14:00:00',
]);
$reminder = new CalendarReminder;
$reminder->setRelation('event', $event);
$method = new \ReflectionMethod(CalendarReminderSender::class, 'message');
/** @var string $message */
$message = $method->invoke($this->app->make(CalendarReminderSender::class), $reminder);
$this->assertStringStartsWith("Rappel : Reunion importante\n12/06/2026 16:00\nSalle A\n", $message);
$this->assertStringEndsWith('[...]', $message);
$this->assertTrue($this->app->make(SmsMessageLength::class)->fitsInOneSms($message));
}
public function test_due_display_alarm_sends_one_sms_to_attendee_and_organizer(): void
{
Carbon::setTestNow(Carbon::parse('2026-06-12 15:05:00', 'Europe/Paris'));
config([
'relais_sms.caldav.reminder_message_header' => 'AFPMA',
'relais_sms.caldav.reminder_message_footer' => 'A bientot',
'relais_sms.caldav.reminder_description_max_length' => 20,
'relais_sms.caldav.sync_interval_minutes' => 60,
]);
$calendarClient = new class implements CalendarClientInterface
{
public int $calls = 0;
public function fetchEvents(CarbonInterface $from, CarbonInterface $to): array
{
$this->calls++;
return [
new CalendarRemoteEvent(
href: '/remote.php/dav/calendars/david/personal/event-test.ics',
etag: 'etag-1',
ical: <<<'ICS'
BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
UID:event-test
SUMMARY:test
DESCRIPTION:Description calendrier tres longue pour le SMS.
LOCATION:Salle A
DTSTART;TZID=Europe/Paris:20260612T160000
DTEND;TZID=Europe/Paris:20260612T170000
ORGANIZER;CN=David Mercereau:mailto:david@example.test
ATTENDEE;CN=Marie Durand:mailto:marie@example.test
BEGIN:VALARM
ACTION:DISPLAY
TRIGGER:-PT1H
DESCRIPTION:Rappel
END:VALARM
BEGIN:VALARM
ACTION:EMAIL
TRIGGER:-PT2H
DESCRIPTION:Rappel email
END:VALARM
END:VEVENT
END:VCALENDAR
ICS,
),
];
}
};
$this->app->bind(CalendarClientInterface::class, fn (): CalendarClientInterface => $calendarClient);
$this->app->bind(SmsProviderInterface::class, fn (): SmsProviderInterface => new class implements SmsProviderInterface
{
public function send(SmsCampaign $campaign, array $recipients): SmsSendResult
{
return new SmsSendResult(
serviceName: 'sms-test',
providerJobId: 'job-calendar',
requestPayload: ['receivers' => array_column($recipients, 'number')],
responsePayload: ['ok' => true],
providerMessageIds: ['message-calendar-1', 'message-calendar-2'],
);
}
});
User::factory()->create();
$contact = Contact::query()->create([
'source' => 'local',
'first_name' => 'Marie',
'last_name' => 'Durand',
'email' => 'marie@example.test',
]);
$contact->phoneNumbers()->create([
'raw_number' => '06 11 11 11 11',
'normalized_number' => '+33611111111',
'country' => 'FR',
'is_valid' => true,
'is_mobile' => true,
'is_primary' => true,
]);
$organizer = Contact::query()->create([
'source' => 'local',
'first_name' => 'David',
'last_name' => 'Mercereau',
'email' => 'david@example.test',
]);
$organizer->phoneNumbers()->create([
'raw_number' => '06 22 22 22 22',
'normalized_number' => '+33622222222',
'country' => 'FR',
'is_valid' => true,
'is_mobile' => true,
'is_primary' => true,
]);
$this->artisan('calendar:process-reminders')->assertExitCode(0);
$this->artisan('calendar:process-reminders')->assertExitCode(0);
$this->assertSame(1, $calendarClient->calls, 'Le client CalDAV ne doit etre appele qu une fois dans l intervalle configure.');
$this->assertDatabaseHas('calendar_events', [
'source' => 'caldav',
'external_id' => 'event-test',
'summary' => 'test',
]);
$this->assertDatabaseHas('calendar_reminders', [
'status' => 'sent',
'action' => 'DISPLAY',
'trigger_minutes' => -60,
]);
$this->assertSame(1, CalendarReminder::query()->count(), 'Un seul rappel DISPLAY doit etre programme.');
$this->assertSame(1, SmsCampaign::query()->count(), 'Un rappel deja envoye ne doit pas recreer de campagne.');
$this->assertDatabaseHas('sms_campaigns', [
'message' => "AFPMA\nRappel : test\n12/06/2026 16:00\nSalle A\nDescription calendri[...]\nA bientot",
'status' => 'sent',
'recipients_count' => 2,
]);
$this->assertDatabaseHas('sms_deliveries', [
'number' => '+33611111111',
'status' => 'sent',
'provider_message_id' => 'message-calendar-1',
]);
$this->assertDatabaseHas('sms_deliveries', [
'number' => '+33622222222',
'status' => 'sent',
'provider_message_id' => 'message-calendar-2',
]);
}
public function test_skipped_reminder_is_not_reactivated_after_organizer_becomes_resolvable(): void
{
Carbon::setTestNow(Carbon::parse('2026-06-12 15:05:00', 'Europe/Paris'));
config(['relais_sms.caldav.sync_interval_minutes' => 60]);
$calendarClient = new class implements CalendarClientInterface
{
public function fetchEvents(CarbonInterface $from, CarbonInterface $to): array
{
return [
new CalendarRemoteEvent(
href: '/calendar/organizer-only.ics',
etag: 'etag-organizer',
ical: <<<'ICS'
BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
UID:organizer-only
SUMMARY:organizer only
DTSTART;TZID=Europe/Paris:20260612T160000
DTEND;TZID=Europe/Paris:20260612T170000
ORGANIZER;CN=David Mercereau:mailto:david@example.test
BEGIN:VALARM
ACTION:DISPLAY
TRIGGER:-PT1H
END:VALARM
END:VEVENT
END:VCALENDAR
ICS,
),
];
}
};
$this->app->bind(CalendarClientInterface::class, fn (): CalendarClientInterface => $calendarClient);
User::factory()->create();
$this->artisan('calendar:process-reminders')->assertExitCode(0);
$this->assertDatabaseHas('calendar_reminders', ['status' => 'skipped']);
$organizer = Contact::query()->create([
'source' => 'local',
'first_name' => 'David',
'last_name' => 'Mercereau',
'email' => 'david@example.test',
]);
$organizer->phoneNumbers()->create([
'raw_number' => '06 22 22 22 22',
'normalized_number' => '+33622222222',
'country' => 'FR',
'is_valid' => true,
'is_mobile' => true,
'is_primary' => true,
]);
Carbon::setTestNow(Carbon::now()->addHour());
$this->artisan('calendar:process-reminders --force-sync')->assertExitCode(0);
$this->assertDatabaseHas('calendar_reminders', ['status' => 'skipped']);
$this->assertSame(0, SmsCampaign::query()->count());
}
}
@@ -0,0 +1,151 @@
<?php
namespace Tests\Feature\Calendar;
use App\Contracts\Calendar\CalendarClientInterface;
use App\Contracts\Sms\SmsProviderInterface;
use App\Models\CalendarEvent;
use App\Models\CalendarReminder;
use App\Models\Contact;
use App\Models\SmsCampaign;
use App\Models\User;
use App\Services\Calendar\CalendarEventSyncService;
use App\Services\Calendar\CalendarReminderSender;
use App\Services\Calendar\CalendarRemoteEvent;
use App\Services\Calendar\ICalendarParser;
use App\Services\Sms\SmsSendResult;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Tests\TestCase;
class RecurringCalendarTest extends TestCase
{
use RefreshDatabase;
protected function tearDown(): void
{
Carbon::setTestNow();
parent::tearDown();
}
private function calendar(string $properties = '', string $exceptions = ''): string
{
return "BEGIN:VCALENDAR\nVERSION:2.0\nBEGIN:VEVENT\nUID:staff\nSUMMARY:Point Salaries\nDTSTART;TZID=Europe/Paris:20261005T110000\nDTEND;TZID=Europe/Paris:20261005T120000\nRRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=MO;COUNT=4\n{$properties}\nBEGIN:VALARM\nACTION:DISPLAY\nTRIGGER:-PT2H\nEND:VALARM\nEND:VEVENT\n{$exceptions}\nEND:VCALENDAR";
}
private function sync(string $ical): void
{
$client = $this->mock(CalendarClientInterface::class);
$client->shouldReceive('fetchEvents')->once()->andReturn([
new CalendarRemoteEvent('/calendar/staff.ics', 'etag', $ical),
]);
$this->app->make(CalendarEventSyncService::class)->sync();
}
public function test_each_occurrence_sends_once_and_keeps_local_time_across_dst(): void
{
Carbon::setTestNow(Carbon::parse('2026-10-19 09:14:00', 'Europe/Paris'));
User::factory()->create();
$contact = Contact::query()->create(['source' => 'local', 'first_name' => 'Test', 'last_name' => 'Contact']);
$contact->phoneNumbers()->create([
'raw_number' => '0611111111', 'normalized_number' => '+33611111111',
'country' => 'FR', 'is_valid' => true, 'is_mobile' => true, 'is_primary' => true,
]);
config(['relais_sms.caldav.default_contact_ids' => [$contact->id], 'relais_sms.caldav.display_timezone' => 'Europe/Paris']);
$this->mock(SmsProviderInterface::class)->shouldReceive('send')->twice()->andReturn(
new SmsSendResult('test', 'job', [], [], ['message']),
);
$ical = $this->calendar('EXDATE;TZID=Europe/Paris:20261005T110000');
$this->sync($ical);
$sender = $this->app->make(CalendarReminderSender::class);
$this->assertSame(1, $sender->sendDue());
$this->sync($ical);
$this->assertSame(0, $sender->sendDue());
$this->assertDatabaseHas('calendar_events', ['starts_at' => '2026-11-02 10:00:00']);
$this->assertDatabaseHas('calendar_reminders', ['remind_at' => '2026-11-02 08:00:00', 'status' => 'pending']);
Carbon::setTestNow(Carbon::parse('2026-11-02 09:14:00', 'Europe/Paris'));
$this->sync($ical);
$this->assertSame(1, $sender->sendDue());
$this->assertSame(2, SmsCampaign::query()->count());
$this->assertStringContainsString('02/11/2026 11:00', SmsCampaign::query()->latest('id')->first()->message);
}
public function test_new_exdate_and_cancelled_exception_cancel_existing_pending_reminders(): void
{
Carbon::setTestNow(Carbon::parse('2026-10-18 12:00:00', 'UTC'));
$this->sync($this->calendar());
$october = CalendarEvent::query()->where('starts_at', '2026-10-19 09:00:00')->firstOrFail();
$november = CalendarEvent::query()->where('starts_at', '2026-11-02 10:00:00')->firstOrFail();
$this->sync($this->calendar('EXDATE;TZID=Europe/Paris:20261019T110000', "BEGIN:VEVENT\nUID:staff\nRECURRENCE-ID;TZID=Europe/Paris:20261102T110000\nSTATUS:CANCELLED\nEND:VEVENT"));
foreach ([$october, $november] as $event) {
$this->assertNotNull($event->fresh()->deleted_at);
$this->assertSame('cancelled', $event->reminders()->first()->status);
}
$this->mock(SmsProviderInterface::class)->shouldNotReceive('send');
Carbon::setTestNow(Carbon::parse('2026-11-02 08:14:00', 'UTC'));
$this->assertSame(0, $this->app->make(CalendarReminderSender::class)->sendDue());
}
public function test_moved_occurrence_updates_one_reminder_and_keeps_identity_after_send(): void
{
Carbon::setTestNow(Carbon::parse('2026-10-18 12:00:00', 'UTC'));
$this->sync($this->calendar());
$event = CalendarEvent::query()->where('starts_at', '2026-10-19 09:00:00')->firstOrFail();
$reminder = $event->reminders()->firstOrFail();
$exception = "BEGIN:VEVENT\nUID:staff\nRECURRENCE-ID;TZID=Europe/Paris:20261019T110000\nDTSTART;TZID=Europe/Paris:20261020T140000\nDTEND;TZID=Europe/Paris:20261020T150000\nSUMMARY:Point deplace\nBEGIN:VALARM\nACTION:DISPLAY\nTRIGGER:-PT2H\nEND:VALARM\nEND:VEVENT";
$this->sync($this->calendar('', $exception));
$this->assertSame('2026-10-20 12:00:00', $event->fresh()->getRawOriginal('starts_at'));
$this->assertSame('2026-10-20 10:00:00', $reminder->fresh()->getRawOriginal('remind_at'));
$this->assertSame(1, $event->reminders()->count());
$reminder->update(['status' => 'sent']);
$this->sync($this->calendar('', str_replace('20261020T140000', '20261020T160000', $exception)));
$this->assertSame('sent', $reminder->fresh()->status);
$this->assertSame(1, $event->reminders()->count());
}
public function test_existing_sent_first_occurrence_is_preserved_when_importing_a_series(): void
{
Carbon::setTestNow(Carbon::parse('2026-10-05 07:15:00', 'UTC'));
$event = CalendarEvent::query()->create(['source' => 'caldav', 'external_id' => 'staff', 'starts_at' => '2026-10-05 09:00:00']);
$event->reminders()->create(['reminder_key' => sha1('DISPLAY|-PT2H|0'), 'status' => 'sent', 'sent_at' => Carbon::now(), 'remind_at' => '2026-10-05 07:00:00']);
$this->sync($this->calendar());
$this->assertSame(4, CalendarEvent::query()->count());
$this->assertSame('sent', $event->reminders()->first()->status);
$this->assertStringStartsWith('recurrence:', $event->fresh()->external_id);
$this->assertSame(1, CalendarReminder::query()->where('status', 'sent')->count());
}
public function test_cancelled_series_can_be_restored_and_expired_reminders_are_not_sent(): void
{
Carbon::setTestNow(Carbon::parse('2026-10-19 09:36:00', 'UTC'));
$this->mock(SmsProviderInterface::class)->shouldNotReceive('send');
$this->sync($this->calendar());
$event = CalendarEvent::query()->where('starts_at', '2026-10-19 09:00:00')->firstOrFail();
$this->assertSame(0, $this->app->make(CalendarReminderSender::class)->sendDue());
$this->assertSame('skipped', $event->reminders()->first()->status);
$future = CalendarEvent::query()->where('starts_at', '2026-11-02 10:00:00')->firstOrFail();
$this->sync($this->calendar('STATUS:CANCELLED'));
$this->assertSame('cancelled', $future->reminders()->first()->status);
$this->sync($this->calendar());
$this->assertNull($future->fresh()->deleted_at);
$this->assertSame('pending', $future->reminders()->first()->status);
$this->assertSame('skipped', $event->reminders()->first()->status);
}
public function test_monthly_count_until_and_additional_dates_are_bounded(): void
{
$parser = new ICalendarParser;
$from = Carbon::parse('2026-10-01', 'UTC');
$to = Carbon::parse('2027-02-01', 'UTC');
$ical = str_replace('FREQ=WEEKLY;INTERVAL=2;BYDAY=MO;COUNT=4', 'FREQ=MONTHLY;BYDAY=1MO;COUNT=3', $this->calendar());
$events = $parser->parse($ical, $from, $to);
$this->assertSame(['2026-10-05 09:00:00', '2026-11-02 10:00:00', '2026-12-07 10:00:00'], array_map(fn ($e) => $e->startsAt->format('Y-m-d H:i:s'), $events));
$ical = str_replace('COUNT=3', 'UNTIL=20261102T100000Z', $ical);
$this->assertCount(2, $parser->parse($ical, $from, $to));
$combined = str_replace('BEGIN:VALARM', "RDATE;TZID=Europe/Paris:20261019T110000,20261102T110000\nBEGIN:VALARM", $ical);
$this->assertCount(3, $parser->parse($combined, $from, $to));
$ical = str_replace('RRULE:FREQ=MONTHLY;BYDAY=1MO;UNTIL=20261102T100000Z', 'RDATE;TZID=Europe/Paris:20261019T110000,20261102T110000', $ical);
$this->assertCount(3, $parser->parse($ical, $from, $to));
}
}
@@ -0,0 +1,109 @@
<?php
namespace Tests\Feature\CardDav;
use App\Contracts\CardDav\CardDavClientInterface;
use App\Models\Contact;
use App\Models\ContactGroup;
use App\Models\SourceSyncRun;
use App\Services\CardDav\CardDavContactSyncService;
use App\Services\CardDav\CardDavRemoteCard;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class CardDavContactSyncServiceTest extends TestCase
{
use RefreshDatabase;
public function test_it_syncs_carddav_contacts_categories_and_deletes_missing_contacts(): void
{
$localGroup = ContactGroup::query()->create([
'source' => 'local',
'name' => 'Equipe locale',
'slug' => 'equipe-locale',
]);
$existingContact = Contact::query()->create([
'source' => 'carddav',
'external_id' => 'uid-marie',
'first_name' => 'Ancien',
'last_name' => 'Nom',
'sms_opt_out' => true,
'is_favorite' => true,
]);
$existingContact->groups()->attach($localGroup);
$existingContact->phoneNumbers()->create([
'raw_number' => '04 00 00 00 00',
'is_primary' => true,
]);
$missingContact = Contact::query()->create([
'source' => 'carddav',
'external_id' => 'uid-missing',
'last_name' => 'Disparu',
]);
$this->app->bind(CardDavClientInterface::class, fn (): CardDavClientInterface => new class implements CardDavClientInterface
{
public function fetchCards(): array
{
return [
new CardDavRemoteCard(
href: '/remote.php/dav/addressbooks/users/david/contacts/marie.vcf',
etag: 'etag-1',
vcard: <<<'VCF'
BEGIN:VCARD
VERSION:3.0
UID:uid-marie
FN:Marie Durand
N:Durand;Marie;;;
EMAIL:marie@example.test
TEL;TYPE=CELL:064-201-4207
CATEGORIES:Benevoles,Conseil
END:VCARD
VCF,
),
];
}
});
$run = $this->app->make(CardDavContactSyncService::class)->sync();
$this->assertSame('success', $run->status);
$this->assertSame(1, $run->fetched_contacts);
$this->assertSame(1, $run->synced_contacts);
$this->assertSame(1, $run->deleted_contacts);
$this->assertDatabaseHas('contacts', [
'id' => $existingContact->id,
'source' => 'carddav',
'external_id' => 'uid-marie',
'remote_etag' => 'etag-1',
'first_name' => 'Marie',
'last_name' => 'Durand',
'email' => 'marie@example.test',
'sms_opt_out' => true,
'is_favorite' => true,
]);
$this->assertSoftDeleted('contacts', [
'id' => $missingContact->id,
]);
$this->assertDatabaseHas('contact_phone_numbers', [
'contact_id' => $existingContact->id,
'raw_number' => '064-201-4207',
'normalized_number' => '+33642014207',
'is_valid' => true,
'is_mobile' => true,
]);
$this->assertDatabaseHas('contact_groups', [
'source' => 'carddav',
'external_id' => 'benevoles',
'name' => 'Benevoles',
]);
$this->assertDatabaseHas('contact_contact_group', [
'contact_id' => $existingContact->id,
'contact_group_id' => $localGroup->id,
]);
$this->assertSame(1, SourceSyncRun::query()->where('source', 'carddav')->where('status', 'success')->count());
}
}
+440
View File
@@ -0,0 +1,440 @@
<?php
namespace Tests\Feature;
use App\Models\Contact;
use App\Models\ContactGroup;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ContactManagementTest extends TestCase
{
use RefreshDatabase;
public function test_authenticated_user_can_create_group_and_local_contact(): void
{
$user = User::factory()->create();
$this->actingAs($user)
->post('/contact-groups', [
'name' => 'Benevoles',
'description' => 'Equipe locale',
'is_favorite' => '1',
])
->assertRedirect('/contacts');
$group = ContactGroup::query()->where('name', 'Benevoles')->firstOrFail();
$response = $this->actingAs($user)
->post('/contacts', [
'first_name' => 'Marie',
'last_name' => 'Durand',
'organization' => 'AFPMA',
'email' => 'marie@example.test',
'raw_phone_number' => '06 11 11 00 03',
'phone_label' => 'Mobile',
'group_ids' => [$group->id],
'sms_opt_out' => '1',
'is_favorite' => '1',
]);
$contact = Contact::query()->where('email', 'marie@example.test')->firstOrFail();
$response->assertRedirect(route('contacts.show', $contact));
$this->assertDatabaseHas('contacts', [
'id' => $contact->id,
'source' => 'local',
'sms_opt_out' => true,
'is_favorite' => true,
]);
$this->assertDatabaseHas('contact_contact_group', [
'contact_id' => $contact->id,
'contact_group_id' => $group->id,
]);
$this->assertDatabaseHas('contact_phone_numbers', [
'contact_id' => $contact->id,
'raw_number' => '06 11 11 00 03',
'normalized_number' => '+33611110003',
'country' => 'FR',
'is_valid' => true,
'is_mobile' => true,
]);
}
public function test_contacts_can_be_searched_by_name_and_phone_number(): void
{
$user = User::factory()->create();
$contact = Contact::query()->create([
'source' => 'local',
'first_name' => 'Jean',
'last_name' => 'Martin',
'email' => 'jean@example.test',
]);
$contact->phoneNumbers()->create([
'raw_number' => '06 11 11 00 01',
'normalized_number' => '+33611110001',
'country' => 'FR',
'is_valid' => true,
'is_mobile' => true,
'is_primary' => true,
]);
$this->actingAs($user)
->get('/contacts?q=Martin')
->assertOk()
->assertSee('Jean Martin')
->assertSee('+33611110001');
$this->actingAs($user)
->get('/contacts?q=11110001')
->assertOk()
->assertSee('Jean Martin');
}
public function test_contacts_default_mobile_filter_includes_stale_normalized_mobile_numbers(): void
{
$user = User::factory()->create();
$contact = Contact::query()->create([
'source' => 'carddav',
'external_id' => 'uid-yveline',
'first_name' => 'Yveline',
'last_name' => 'Mobile',
]);
$contact->phoneNumbers()->create([
'raw_number' => '064-201-4207',
'normalized_number' => '+33642014207',
'country' => 'FR',
'is_valid' => true,
'is_mobile' => false,
'is_primary' => true,
]);
$this->actingAs($user)
->get('/contacts')
->assertOk()
->assertSee('Yveline Mobile')
->assertSee('Mobile detecte');
}
public function test_authenticated_user_can_update_contact(): void
{
$user = User::factory()->create();
$oldGroup = ContactGroup::query()->create([
'source' => 'local',
'name' => 'Anciens',
'slug' => 'anciens',
]);
$newGroup = ContactGroup::query()->create([
'source' => 'local',
'name' => 'Membres',
'slug' => 'membres',
]);
$contact = Contact::query()->create([
'source' => 'local',
'first_name' => 'Jean',
'last_name' => 'Martin',
'email' => 'jean@example.test',
]);
$contact->groups()->attach($oldGroup);
$contact->phoneNumbers()->create([
'label' => 'Mobile',
'raw_number' => '06 11 11 00 01',
'normalized_number' => '+33611110001',
'country' => 'FR',
'is_valid' => true,
'is_mobile' => true,
'is_primary' => true,
]);
$this->actingAs($user)
->put(route('contacts.update', $contact), [
'first_name' => 'Jeanne',
'last_name' => 'Martin',
'email' => 'jeanne@example.test',
'raw_phone_number' => '07 55 55 00 04',
'phone_label' => 'Portable',
'group_ids' => [$newGroup->id],
'is_favorite' => '1',
])
->assertRedirect(route('contacts.show', $contact));
$this->assertDatabaseHas('contacts', [
'id' => $contact->id,
'first_name' => 'Jeanne',
'email' => 'jeanne@example.test',
'sms_opt_out' => false,
'is_favorite' => true,
]);
$this->assertDatabaseMissing('contact_contact_group', [
'contact_id' => $contact->id,
'contact_group_id' => $oldGroup->id,
]);
$this->assertDatabaseHas('contact_contact_group', [
'contact_id' => $contact->id,
'contact_group_id' => $newGroup->id,
]);
$this->assertDatabaseHas('contact_phone_numbers', [
'contact_id' => $contact->id,
'label' => 'Portable',
'raw_number' => '07 55 55 00 04',
'normalized_number' => '+33755550004',
'is_mobile' => true,
]);
}
public function test_imported_contacts_cannot_be_edited_or_deleted(): void
{
$user = User::factory()->create();
$contact = Contact::query()->create([
'source' => 'carddav',
'external_id' => 'remote-1',
'first_name' => 'Jean',
'last_name' => 'Importe',
]);
$this->actingAs($user)
->get(route('contacts.edit', $contact))
->assertForbidden();
$this->actingAs($user)
->put(route('contacts.update', $contact), [
'last_name' => 'Modifie',
])
->assertForbidden();
$this->actingAs($user)
->delete(route('contacts.destroy', $contact))
->assertForbidden();
$this->assertDatabaseHas('contacts', [
'id' => $contact->id,
'last_name' => 'Importe',
'deleted_at' => null,
]);
}
public function test_authenticated_user_can_toggle_favorite_on_imported_contact(): void
{
$user = User::factory()->create();
$contact = Contact::query()->create([
'source' => 'carddav',
'external_id' => 'remote-favorite',
'first_name' => 'Yveline',
'last_name' => 'Importee',
'is_favorite' => false,
]);
$this->actingAs($user)
->patch(route('contacts.favorite', $contact), [
'is_favorite' => '1',
])
->assertRedirect();
$this->assertDatabaseHas('contacts', [
'id' => $contact->id,
'source' => 'carddav',
'is_favorite' => true,
]);
$this->actingAs($user)
->patch(route('contacts.favorite', $contact), [
'is_favorite' => '0',
])
->assertRedirect();
$this->assertDatabaseHas('contacts', [
'id' => $contact->id,
'source' => 'carddav',
'is_favorite' => false,
]);
}
public function test_authenticated_user_can_toggle_sms_opt_out_on_imported_contact(): void
{
$user = User::factory()->create();
$contact = Contact::query()->create([
'source' => 'galette',
'external_id' => 'remote-opt-out',
'first_name' => 'Claire',
'last_name' => 'Importee',
'sms_opt_out' => false,
]);
$this->actingAs($user)
->patch(route('contacts.opt-out', $contact), [
'sms_opt_out' => '1',
])
->assertRedirect();
$this->assertDatabaseHas('contacts', [
'id' => $contact->id,
'source' => 'galette',
'sms_opt_out' => true,
]);
$this->actingAs($user)
->patch(route('contacts.opt-out', $contact), [
'sms_opt_out' => '0',
])
->assertRedirect();
$this->assertDatabaseHas('contacts', [
'id' => $contact->id,
'source' => 'galette',
'sms_opt_out' => false,
]);
}
public function test_authenticated_user_can_delete_contact(): void
{
$user = User::factory()->create();
$contact = Contact::query()->create([
'source' => 'local',
'first_name' => 'Jean',
'last_name' => 'Martin',
]);
$this->actingAs($user)
->delete(route('contacts.destroy', $contact))
->assertRedirect(route('contacts.index'));
$this->assertSoftDeleted('contacts', [
'id' => $contact->id,
]);
}
public function test_authenticated_user_can_update_and_delete_group(): void
{
$user = User::factory()->create();
$group = ContactGroup::query()->create([
'source' => 'local',
'name' => 'Benevoles',
'slug' => 'benevoles',
]);
$contact = Contact::query()->create([
'source' => 'local',
'first_name' => 'Marie',
'last_name' => 'Durand',
]);
$contact->groups()->attach($group);
$this->actingAs($user)
->put(route('contact-groups.update', $group), [
'name' => 'Equipe accueil',
'description' => 'Permanences',
'is_favorite' => '1',
])
->assertRedirect(route('contacts.index', ['group' => $group->id]));
$this->assertDatabaseHas('contact_groups', [
'id' => $group->id,
'name' => 'Equipe accueil',
'slug' => 'equipe-accueil',
'description' => 'Permanences',
'is_favorite' => true,
]);
$this->actingAs($user)
->delete(route('contact-groups.destroy', $group))
->assertRedirect(route('contacts.index'));
$this->assertSoftDeleted('contact_groups', [
'id' => $group->id,
]);
$this->assertDatabaseMissing('contact_contact_group', [
'contact_id' => $contact->id,
'contact_group_id' => $group->id,
]);
}
public function test_authenticated_user_can_update_local_group_members(): void
{
$user = User::factory()->create();
$group = ContactGroup::query()->create([
'source' => 'local',
'name' => 'Benevoles',
'slug' => 'benevoles',
]);
$kept = Contact::query()->create([
'source' => 'local',
'first_name' => 'Marie',
'last_name' => 'Durand',
]);
$removed = Contact::query()->create([
'source' => 'local',
'first_name' => 'Paul',
'last_name' => 'Ancien',
]);
$addedImported = Contact::query()->create([
'source' => 'carddav',
'external_id' => 'remote-added',
'first_name' => 'Claire',
'last_name' => 'Importee',
]);
$group->contacts()->attach([$kept->id, $removed->id]);
$this->actingAs($user)
->put(route('contact-groups.update', $group), [
'name' => 'Benevoles',
'members_submitted' => '1',
'contact_ids' => [$kept->id, $addedImported->id],
])
->assertRedirect(route('contacts.index', ['group' => $group->id]));
$this->assertDatabaseHas('contact_contact_group', [
'contact_id' => $kept->id,
'contact_group_id' => $group->id,
]);
$this->assertDatabaseHas('contact_contact_group', [
'contact_id' => $addedImported->id,
'contact_group_id' => $group->id,
]);
$this->assertDatabaseMissing('contact_contact_group', [
'contact_id' => $removed->id,
'contact_group_id' => $group->id,
]);
}
public function test_imported_groups_cannot_be_edited_or_deleted(): void
{
$user = User::factory()->create();
$group = ContactGroup::query()->create([
'source' => 'galette',
'external_id' => 'remote-group',
'name' => 'Groupe importe',
'slug' => 'galette-groupe-importe',
]);
$this->actingAs($user)
->get(route('contact-groups.edit', $group))
->assertForbidden();
$this->actingAs($user)
->put(route('contact-groups.update', $group), [
'name' => 'Modifie',
])
->assertForbidden();
$this->actingAs($user)
->delete(route('contact-groups.destroy', $group))
->assertForbidden();
$this->assertDatabaseHas('contact_groups', [
'id' => $group->id,
'name' => 'Groupe importe',
'deleted_at' => null,
]);
}
public function test_guest_cannot_access_contacts(): void
{
$this->get('/contacts')
->assertRedirect('/login');
}
}
+114
View File
@@ -0,0 +1,114 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Env;
use Illuminate\Support\Facades\Hash;
use RuntimeException;
use Tests\TestCase;
class ExampleTest extends TestCase
{
use RefreshDatabase;
public function test_home_redirects_to_recipient_selection(): void
{
$this->get('/')
->assertRedirect('/recipients');
}
public function test_guests_are_redirected_to_login(): void
{
$this->get('/recipients')
->assertRedirect('/login');
}
public function test_login_page_is_available(): void
{
$this->get('/login')
->assertOk()
->assertSee('Connexion');
}
public function test_authenticated_user_can_access_recipient_selection(): void
{
$user = User::factory()->create();
$this->actingAs($user)
->get('/recipients')
->assertOk()
->assertSee('Composer')
->assertSee('Pour');
}
public function test_user_can_log_in_and_log_out(): void
{
User::factory()->create([
'email' => 'admin@relais-sms.test',
'password' => 'relais-sms',
]);
$this->post('/login', [
'email' => 'admin@relais-sms.test',
'password' => 'relais-sms',
])->assertRedirect('/recipients');
$this->assertAuthenticated();
$this->post('/logout')
->assertRedirect('/login');
$this->assertGuest();
}
public function test_default_app_settings_are_seeded(): void
{
config(['relais_sms.default_settings.max_sms_per_campaign' => 100]);
$this->seed();
$this->assertDatabaseHas('app_settings', [
'key' => 'max_sms_per_campaign',
'value' => '100',
'type' => 'integer',
]);
}
public function test_production_seeding_rejects_default_admin_password(): void
{
$this->app['env'] = 'production';
$repository = Env::getRepository();
$originalPassword = $repository->get('RELAIS_SMS_ADMIN_PASSWORD');
$repository->set('RELAIS_SMS_ADMIN_PASSWORD', 'relais-sms');
try {
$this->expectException(RuntimeException::class);
(new DatabaseSeeder)->run();
} finally {
$originalPassword === null
? $repository->clear('RELAIS_SMS_ADMIN_PASSWORD')
: $repository->set('RELAIS_SMS_ADMIN_PASSWORD', $originalPassword);
}
}
public function test_production_seeding_accepts_a_personal_admin_password(): void
{
$this->app['env'] = 'production';
$repository = Env::getRepository();
$originalPassword = $repository->get('RELAIS_SMS_ADMIN_PASSWORD');
$repository->set('RELAIS_SMS_ADMIN_PASSWORD', 'test-only-personal-password');
try {
(new DatabaseSeeder)->run();
$user = User::query()->sole();
$this->assertTrue(Hash::check('test-only-personal-password', $user->password));
} finally {
$originalPassword === null
? $repository->clear('RELAIS_SMS_ADMIN_PASSWORD')
: $repository->set('RELAIS_SMS_ADMIN_PASSWORD', $originalPassword);
}
}
}
+87
View File
@@ -0,0 +1,87 @@
<?php
namespace Tests\Feature;
use App\Models\SmsCampaign;
use App\Services\Sms\OvhSmsProvider;
use App\Services\Sms\SmsProviderException;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use GuzzleHttp\Psr7\Response;
use Tests\TestCase;
class OvhSmsProviderTest extends TestCase
{
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' => '',
'relais_sms.ovh.sms_short_code' => true,
'relais_sms.ovh.endpoint' => 'ovh-eu',
]);
}
public function test_short_code_strips_links_from_the_actual_signed_request(): void
{
$history = [];
$stack = HandlerStack::create(new MockHandler([new Response(200, [], '123456'), new Response(200, [], '{"ids":["1"]}')]));
$stack->push(Middleware::history($history));
$provider = new OvhSmsProvider(new Client(['handler' => $stack]));
$result = $provider->send(new SmsCampaign(['message' => 'Reunion https://zoom.us/j/123 à 10h']), [['number' => '+33611111111']]);
$payload = json_decode((string) $history[1]['request']->getBody(), true);
$this->assertSame('Reunion à 10h', $payload['message']);
$this->assertTrue($payload['senderForResponse']);
$this->assertArrayNotHasKey('sender', $payload);
$this->assertSame($payload, $result->requestPayload);
}
public function test_named_sender_preserves_links_and_does_not_request_a_short_code(): void
{
config(['relais_sms.ovh.sms_short_code' => false, 'relais_sms.ovh.sms_sender' => 'AFPMA']);
$provider = new OvhSmsProvider(new Client(['handler' => HandlerStack::create(new MockHandler([new Response(200, [], '123456'), new Response(200, [], '{"ids":["1"]}')]))]));
$result = $provider->send(new SmsCampaign(['message' => 'Reunion https://zoom.us/j/123']), [['number' => '+33611111111']]);
$this->assertSame('Reunion https://zoom.us/j/123', $result->requestPayload['message']);
$this->assertSame('AFPMA', $result->requestPayload['sender']);
$this->assertArrayNotHasKey('senderForResponse', $result->requestPayload);
}
public function test_link_only_message_is_rejected_before_any_network_request(): void
{
$this->expectException(SmsProviderException::class);
$this->expectExceptionMessage('vide apres retrait');
(new OvhSmsProvider(new Client(['handler' => HandlerStack::create(new MockHandler([]))])))->send(new SmsCampaign(['message' => 'https://zoom.us/j/123']), []);
}
public function test_disabling_short_code_requires_an_explicit_sender(): void
{
config(['relais_sms.ovh.sms_short_code' => false]);
$this->expectException(SmsProviderException::class);
$this->expectExceptionMessage('Configurer OVH_SMS_SENDER');
(new OvhSmsProvider(new Client(['handler' => HandlerStack::create(new MockHandler([]))])))->send(new SmsCampaign(['message' => 'Rappel']), []);
}
public function test_conflicting_sender_configuration_is_rejected(): void
{
config(['relais_sms.ovh.sms_sender' => 'AFPMA']);
$this->expectException(SmsProviderException::class);
$this->expectExceptionMessage('incompatible');
(new OvhSmsProvider(new Client(['handler' => HandlerStack::create(new MockHandler([]))])))->send(new SmsCampaign(['message' => 'Rappel']), []);
}
public function test_unknown_endpoint_is_not_silently_routed_to_ovh_europe(): void
{
config(['relais_sms.ovh.endpoint' => 'another-provider']);
$this->expectException(SmsProviderException::class);
$this->expectExceptionMessage('Endpoint OVH non pris en charge');
(new OvhSmsProvider(new Client(['handler' => HandlerStack::create(new MockHandler([]))])))->send(new SmsCampaign(['message' => 'Rappel']), []);
}
}
+108
View File
@@ -0,0 +1,108 @@
<?php
namespace Tests\Feature;
use App\Models\Contact;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class OvhStopCallbackTest extends TestCase
{
use RefreshDatabase;
public function test_ovh_stop_callback_marks_matching_contacts_as_sms_opt_out(): void
{
config(['relais_sms.ovh.stop_callback_token' => 'secret-token']);
$cardDavContact = Contact::query()->create([
'source' => 'carddav',
'external_id' => 'remote-carddav',
'last_name' => 'CardDAV',
]);
$cardDavContact->phoneNumbers()->create([
'raw_number' => '06 11 11 11 11',
'normalized_number' => '+33611111111',
'country' => 'FR',
'is_valid' => true,
'is_mobile' => true,
'is_primary' => true,
]);
$galetteContact = Contact::query()->create([
'source' => 'galette',
'external_id' => 'remote-galette',
'last_name' => 'Galette',
]);
$galetteContact->phoneNumbers()->create([
'raw_number' => '06 11 11 11 11',
'normalized_number' => '+33611111111',
'country' => 'FR',
'is_valid' => true,
'is_mobile' => true,
'is_primary' => true,
]);
$this->postJson(route('ovh.sms.stop-callback', ['token' => 'secret-token']), [
'sender' => '06 11 11 11 11',
'message' => 'STOP',
])
->assertOk()
->assertJson([
'status' => 'ok',
'sender' => '+33611111111',
'updated_contacts' => 2,
'already_opted_out_contacts' => 0,
]);
$this->assertDatabaseHas('contacts', [
'id' => $cardDavContact->id,
'sms_opt_out' => true,
]);
$this->assertDatabaseHas('contacts', [
'id' => $galetteContact->id,
'sms_opt_out' => true,
]);
}
public function test_ovh_stop_callback_requires_configured_token(): void
{
config(['relais_sms.ovh.stop_callback_token' => 'secret-token']);
$this->postJson(route('ovh.sms.stop-callback', ['token' => 'bad-token']), [
'sender' => '06 11 11 11 11',
])->assertForbidden();
}
public function test_ovh_stop_callback_accepts_sender_in_query_string(): void
{
config(['relais_sms.ovh.stop_callback_token' => 'secret-token']);
$contact = Contact::query()->create([
'source' => 'local',
'last_name' => 'Local',
]);
$contact->phoneNumbers()->create([
'raw_number' => '07 55 55 00 04',
'normalized_number' => '+33755550004',
'country' => 'FR',
'is_valid' => true,
'is_mobile' => true,
'is_primary' => true,
]);
$this->getJson(route('ovh.sms.stop-callback', [
'token' => 'secret-token',
'sender' => '+33755550004',
]))
->assertOk()
->assertJson([
'status' => 'ok',
'updated_contacts' => 1,
]);
$this->assertDatabaseHas('contacts', [
'id' => $contact->id,
'sms_opt_out' => true,
]);
}
}
+216
View File
@@ -0,0 +1,216 @@
<?php
namespace Tests\Feature;
use App\Models\AppSetting;
use App\Models\Contact;
use App\Models\ContactGroup;
use App\Models\User;
use App\Services\Recipients\RecipientResolver;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class RecipientSelectionTest extends TestCase
{
use RefreshDatabase;
public function test_resolver_returns_unique_valid_mobile_numbers_for_selected_contacts_and_groups(): void
{
$group = ContactGroup::query()->create([
'source' => 'local',
'name' => 'Benevoles',
'slug' => 'benevoles',
]);
$first = $this->createContact('Alice', 'Alpha');
$duplicate = $this->createContact('Bob', 'Beta');
$optOut = $this->createContact('Claire', 'Refus', smsOptOut: true);
$fixed = $this->createContact('Denis', 'Fixe');
$invalid = $this->createContact('Eva', 'Invalide');
$missing = $this->createContact('Fatima', 'Sansphone');
$group->contacts()->attach([$first->id, $optOut->id, $fixed->id, $invalid->id, $missing->id]);
$first->phoneNumbers()->create([
'raw_number' => '06 11 11 11 11',
'normalized_number' => '+33611111111',
'country' => 'FR',
'is_valid' => true,
'is_mobile' => true,
'is_primary' => true,
]);
$duplicate->phoneNumbers()->create([
'raw_number' => '+33 6 11 11 11 11',
'normalized_number' => '+33611111111',
'country' => 'FR',
'is_valid' => true,
'is_mobile' => true,
'is_primary' => true,
]);
$optOut->phoneNumbers()->create([
'raw_number' => '06 22 22 22 22',
'normalized_number' => '+33622222222',
'country' => 'FR',
'is_valid' => true,
'is_mobile' => true,
'is_primary' => true,
]);
$fixed->phoneNumbers()->create([
'raw_number' => '01 44 44 44 44',
'normalized_number' => '+33144444444',
'country' => 'FR',
'is_valid' => true,
'is_mobile' => false,
'is_primary' => true,
]);
$invalid->phoneNumbers()->create([
'raw_number' => '123',
'normalized_number' => null,
'country' => 'FR',
'is_valid' => false,
'is_mobile' => false,
'is_primary' => true,
'validation_error' => 'Numero invalide.',
]);
$result = app(RecipientResolver::class)->resolve([$duplicate->id], [$group->id]);
$summary = $result->summary();
$this->assertSame(1, $summary['ready_numbers']);
$this->assertSame('+33611111111', $result->recipients[0]['number']);
$this->assertSame(1, $summary['duplicate']);
$this->assertSame(1, $summary['opt_out']);
$this->assertSame(1, $summary['non_mobile']);
$this->assertSame(1, $summary['invalid']);
$this->assertSame(1, $summary['missing_phone']);
}
public function test_resolver_trusts_normalized_mobile_flag_instead_of_raw_regex(): void
{
config(['relais_sms.default_settings.mobile_phone_regex' => '/^never-matches$/']);
$contact = $this->createContact('Alice', 'Mobile');
$contact->phoneNumbers()->create([
'raw_number' => '06 11 11 11 11',
'normalized_number' => '+33611111111',
'country' => 'FR',
'is_valid' => true,
'is_mobile' => true,
'is_primary' => true,
]);
$result = app(RecipientResolver::class)->resolve([$contact->id], []);
$this->assertSame(1, $result->summary()['ready_numbers']);
$this->assertSame([], $result->excluded);
}
public function test_resolver_accepts_stale_rows_when_normalized_number_is_a_french_mobile(): void
{
$contact = $this->createContact('Yveline', 'Mobile');
$contact->phoneNumbers()->create([
'raw_number' => '064-201-4207',
'normalized_number' => '+33642014207',
'country' => 'FR',
'is_valid' => true,
'is_mobile' => false,
'is_primary' => true,
]);
$result = app(RecipientResolver::class)->resolve([$contact->id], []);
$this->assertSame(1, $result->summary()['ready_numbers']);
$this->assertSame('+33642014207', $result->recipients[0]['number']);
$this->assertSame([], $result->excluded);
}
public function test_authenticated_user_can_preview_recipient_selection(): void
{
$user = User::factory()->create();
$group = ContactGroup::query()->create([
'source' => 'local',
'name' => 'Membres',
'slug' => 'membres',
]);
$contact = $this->createContact('Marie', 'Durand');
$contact->groups()->attach($group);
$contact->phoneNumbers()->create([
'raw_number' => '07 55 55 00 04',
'normalized_number' => '+33755550004',
'country' => 'FR',
'is_valid' => true,
'is_mobile' => true,
'is_primary' => true,
]);
$this->actingAs($user)
->get('/recipients')
->assertOk()
->assertSee('Composer')
->assertSee('Pour');
$this->actingAs($user)
->getJson('/recipients/search?q=Durand')
->assertOk()
->assertJsonFragment([
'type' => 'contact',
'label' => 'Marie Durand',
]);
$this->actingAs($user)
->getJson('/recipients/search?q=Membres')
->assertOk()
->assertJsonFragment([
'type' => 'group',
'label' => 'Membres',
]);
$this->actingAs($user)
->post('/recipients/preview', [
'group_ids' => [$group->id],
'contact_ids' => [$contact->id],
])
->assertOk()
->assertSee('Liste finale')
->assertSee('+33755550004')
->assertSee('Numeros prets');
}
public function test_composer_shows_configured_sms_header_footer_and_complete_message(): void
{
AppSetting::putString('sms_message_header', 'AFPMA');
AppSetting::putString('sms_message_footer', 'Envoyer STOP SMS pour ne plus etre contacte.');
$user = User::factory()->create();
$contact = $this->createContact('Marie', 'Durand');
$contact->phoneNumbers()->create([
'raw_number' => '07 55 55 00 04',
'normalized_number' => '+33755550004',
'country' => 'FR',
'is_valid' => true,
'is_mobile' => true,
'is_primary' => true,
]);
$this->actingAs($user)
->post('/recipients/preview', [
'contact_ids' => [$contact->id],
'message' => 'Bonjour',
'include_sms_header' => '1',
'include_sms_footer' => '1',
])
->assertOk()
->assertSee('SMS complet')
->assertSee("AFPMA\nBonjour\nEnvoyer STOP SMS pour ne plus etre contacte.");
}
private function createContact(string $firstName, string $lastName, bool $smsOptOut = false): Contact
{
return Contact::query()->create([
'source' => 'local',
'first_name' => $firstName,
'last_name' => $lastName,
'sms_opt_out' => $smsOptOut,
]);
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class SettingsTest extends TestCase
{
use RefreshDatabase;
public function test_authenticated_user_can_configure_sms_header_and_footer(): void
{
$user = User::factory()->create();
$this->actingAs($user)
->get(route('settings.edit'))
->assertOk()
->assertSee('Parametres')
->assertSee('Entete de message')
->assertSee('Pied de message');
$this->actingAs($user)
->put(route('settings.update'), [
'sms_message_header' => 'AFPMA',
'sms_message_footer' => 'Envoyer STOP SMS pour ne plus etre contacte.',
])
->assertRedirect(route('settings.edit'));
$this->assertDatabaseHas('app_settings', [
'key' => 'sms_message_header',
'value' => 'AFPMA',
'type' => 'string',
]);
$this->assertDatabaseHas('app_settings', [
'key' => 'sms_message_footer',
'value' => 'Envoyer STOP SMS pour ne plus etre contacte.',
'type' => 'string',
]);
}
}
+128
View File
@@ -0,0 +1,128 @@
<?php
namespace Tests\Feature;
use App\Models\ContactGroup;
use App\Models\SmsCampaign;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class SmsCampaignHistoryTest extends TestCase
{
use RefreshDatabase;
public function test_history_can_be_filtered_by_status_user_group_and_dates(): void
{
$user = User::factory()->create(['name' => 'Alice Admin']);
$otherUser = User::factory()->create(['name' => 'Bob Admin']);
$group = ContactGroup::query()->create([
'source' => 'local',
'name' => 'Benevoles',
'slug' => 'benevoles',
]);
$matchingCampaign = $this->createCampaign($user, [
'message' => 'Message filtre',
'status' => 'sent',
'selected_group_ids' => [$group->id],
'selected_group_names' => [$group->name],
]);
$this->createCampaign($otherUser, [
'message' => 'Message hors filtre',
'status' => 'failed',
])->forceFill([
'created_at' => now()->subDays(10),
])->save();
$this->actingAs($user)
->get(route('sms-campaigns.index', [
'from' => now()->subDay()->toDateString(),
'to' => now()->addDay()->toDateString(),
'status' => 'sent',
'user_id' => $user->id,
'group_id' => $group->id,
]))
->assertOk()
->assertSee($matchingCampaign->message)
->assertSee('Benevoles')
->assertDontSee('Message hors filtre');
}
public function test_history_export_uses_current_filters(): void
{
$user = User::factory()->create();
$group = ContactGroup::query()->create([
'source' => 'local',
'name' => 'Membres',
'slug' => 'membres',
]);
$this->createCampaign($user, [
'message' => 'Message exporte',
'status' => 'sent',
'selected_group_ids' => [$group->id],
'selected_group_names' => [$group->name],
]);
$this->createCampaign($user, [
'message' => 'Message ignore',
'status' => 'sent',
]);
$response = $this->actingAs($user)
->get(route('sms-campaigns.export', ['group_id' => $group->id]))
->assertOk();
$csv = $response->streamedContent();
$this->assertStringContainsString('Message exporte', $csv);
$this->assertStringContainsString('Membres', $csv);
$this->assertStringNotContainsString('Message ignore', $csv);
}
public function test_expired_sms_history_can_be_pruned(): void
{
config(['relais_sms.default_settings.history_retention_years' => 3]);
$user = User::factory()->create();
$expired = $this->createCampaign($user, ['message' => 'Ancienne campagne'])
->forceFill(['created_at' => now()->subYears(4)]);
$expired->save();
$recent = $this->createCampaign($user, ['message' => 'Campagne recente']);
$this->artisan('sms-campaigns:prune-history')
->assertExitCode(0);
$this->assertDatabaseMissing('sms_campaigns', ['id' => $expired->id]);
$this->assertDatabaseHas('sms_campaigns', ['id' => $recent->id]);
}
/**
* @param array<string, mixed> $attributes
*/
private function createCampaign(User $user, array $attributes = []): SmsCampaign
{
$campaign = SmsCampaign::query()->create(array_merge([
'user_id' => $user->id,
'provider' => 'ovh',
'status' => 'sent',
'message' => 'Message',
'recipients_count' => 1,
'sent_count' => 1,
'failed_count' => 0,
'sent_at' => now(),
], $attributes));
$campaign->deliveries()->create([
'contact_name' => 'Marie Durand',
'number' => '+33611111111',
'status' => 'sent',
'provider_message_id' => 'message-123',
'sent_at' => now(),
]);
return $campaign;
}
}
@@ -0,0 +1,180 @@
<?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))]));
}
}
+358
View File
@@ -0,0 +1,358 @@
<?php
namespace Tests\Feature;
use App\Contracts\Sms\SmsProviderInterface;
use App\Models\AppSetting;
use App\Models\Contact;
use App\Models\SmsCampaign;
use App\Models\User;
use App\Services\Sms\OvhSmsProvider;
use App\Services\Sms\SmsCampaignSender;
use App\Services\Sms\SmsSendResult;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Tests\TestCase;
class SmsCampaignSendTest extends TestCase
{
use RefreshDatabase;
protected function tearDown(): void
{
Carbon::setTestNow();
parent::tearDown();
}
public function test_other_provider_receives_links_unchanged(): void
{
config(['relais_sms.default_settings.active_sms_provider' => 'other']);
$message = 'Reunion https://zoom.us/j/123';
$provider = $this->mock(SmsProviderInterface::class);
$provider->shouldReceive('send')->once()->withArgs(fn (SmsCampaign $campaign, array $recipients): bool => $campaign->message === $message)
->andReturn(new SmsSendResult(serviceName: 'other', providerJobId: '1', requestPayload: ['message' => $message], responsePayload: []));
$campaign = $this->app->make(SmsCampaignSender::class)->send(User::factory()->create(), $message, []);
$this->assertSame($message, $campaign->message);
}
public function test_history_keeps_the_message_actually_transmitted_by_ovh(): void
{
config([
'relais_sms.ovh.sms_short_code' => true,
'relais_sms.ovh.sms_sender' => '',
'relais_sms.ovh.sms_service_name' => 'sms-test',
'relais_sms.ovh.application_key' => 'test',
'relais_sms.ovh.application_secret' => 'test',
'relais_sms.ovh.consumer_key' => 'test',
]);
$client = new Client(['handler' => HandlerStack::create(new MockHandler([
new Response(200, [], '123456'),
new Response(200, [], '{"ids":[]}'),
]))]);
$this->app->instance(SmsProviderInterface::class, new OvhSmsProvider($client));
$campaign = $this->app->make(SmsCampaignSender::class)->send(User::factory()->create(), 'Reunion https://zoom.us/j/123 demain', []);
$this->assertSame('Reunion demain', $campaign->message);
$this->assertSame('Reunion demain', $campaign->request_payload['message']);
}
public function test_authenticated_user_can_send_sms_campaign_and_history_is_recorded(): void
{
$this->app->bind(SmsProviderInterface::class, fn (): SmsProviderInterface => new class implements SmsProviderInterface
{
public function send(SmsCampaign $campaign, array $recipients): SmsSendResult
{
return new SmsSendResult(
serviceName: 'sms-test',
providerJobId: 'job-123',
requestPayload: ['receivers' => array_column($recipients, 'number')],
responsePayload: ['ids' => ['message-123']],
providerMessageIds: ['message-123'],
);
}
});
$user = User::factory()->create();
$contact = $this->createSmsContact();
$response = $this->actingAs($user)
->post(route('sms-campaigns.store'), [
'contact_ids' => [$contact->id],
'message' => 'Message de test',
]);
$response->assertRedirect();
$this->assertDatabaseHas('sms_campaigns', [
'message' => 'Message de test',
'status' => 'sent',
'recipients_count' => 1,
'sent_count' => 1,
'provider_service_name' => 'sms-test',
'provider_job_id' => 'job-123',
]);
$this->assertDatabaseHas('sms_deliveries', [
'contact_id' => $contact->id,
'number' => '+33611111111',
'status' => 'sent',
'provider_message_id' => 'message-123',
]);
$this->actingAs($user)
->get(route('sms-campaigns.index'))
->assertOk()
->assertSee('Message de test');
}
public function test_sms_campaign_message_uses_configured_header_and_footer_when_selected(): void
{
AppSetting::putString('sms_message_header', 'AFPMA');
AppSetting::putString('sms_message_footer', 'Envoyer STOP SMS pour ne plus etre contacte.');
$this->app->bind(SmsProviderInterface::class, fn (): SmsProviderInterface => new class implements SmsProviderInterface
{
public function send(SmsCampaign $campaign, array $recipients): SmsSendResult
{
return new SmsSendResult(
serviceName: 'sms-test',
providerJobId: 'job-headers',
requestPayload: ['message' => $campaign->message],
responsePayload: ['ids' => ['message-headers']],
providerMessageIds: ['message-headers'],
);
}
});
$user = User::factory()->create();
$contact = $this->createSmsContact();
$this->actingAs($user)
->post(route('sms-campaigns.store'), [
'contact_ids' => [$contact->id],
'message' => 'Message de test',
'include_sms_header' => '1',
'include_sms_footer' => '1',
])
->assertRedirect();
$this->assertDatabaseHas('sms_campaigns', [
'message' => "AFPMA\nMessage de test\nEnvoyer STOP SMS pour ne plus etre contacte.",
'provider_job_id' => 'job-headers',
]);
}
public function test_sms_campaign_message_can_remove_configured_footer_in_composition(): void
{
AppSetting::putString('sms_message_header', 'AFPMA');
AppSetting::putString('sms_message_footer', 'Envoyer STOP SMS pour ne plus etre contacte.');
$this->app->bind(SmsProviderInterface::class, fn (): SmsProviderInterface => new class implements SmsProviderInterface
{
public function send(SmsCampaign $campaign, array $recipients): SmsSendResult
{
return new SmsSendResult(
serviceName: 'sms-test',
providerJobId: 'job-no-footer',
requestPayload: ['message' => $campaign->message],
responsePayload: ['ids' => ['message-no-footer']],
providerMessageIds: ['message-no-footer'],
);
}
});
$user = User::factory()->create();
$contact = $this->createSmsContact();
$this->actingAs($user)
->post(route('sms-campaigns.store'), [
'contact_ids' => [$contact->id],
'message' => 'Message de test',
'include_sms_header' => '1',
'include_sms_footer' => '0',
])
->assertRedirect();
$this->assertDatabaseHas('sms_campaigns', [
'message' => "AFPMA\nMessage de test",
'provider_job_id' => 'job-no-footer',
]);
}
public function test_authenticated_user_can_schedule_sms_campaign_without_sending_immediately(): void
{
Carbon::setTestNow(Carbon::parse('2026-06-12 10:00:00', 'UTC'));
$provider = new class implements SmsProviderInterface
{
public int $calls = 0;
public function send(SmsCampaign $campaign, array $recipients): SmsSendResult
{
$this->calls++;
return new SmsSendResult(
serviceName: 'sms-test',
providerJobId: 'job-123',
requestPayload: ['receivers' => array_column($recipients, 'number')],
responsePayload: ['ids' => ['message-123']],
providerMessageIds: ['message-123'],
);
}
};
$this->app->instance(SmsProviderInterface::class, $provider);
$user = User::factory()->create();
$contact = $this->createSmsContact();
$response = $this->actingAs($user)
->post(route('sms-campaigns.store'), [
'contact_ids' => [$contact->id],
'message' => 'Message programme',
'send_mode' => 'scheduled',
'scheduled_at' => '2026-06-12T12:05',
]);
$response->assertRedirect();
$this->assertSame(0, $provider->calls);
$this->assertDatabaseHas('sms_campaigns', [
'message' => 'Message programme',
'status' => 'scheduled',
'recipients_count' => 1,
'sent_count' => 0,
]);
$this->assertDatabaseHas('sms_deliveries', [
'contact_id' => $contact->id,
'number' => '+33611111111',
'status' => 'queued',
]);
}
public function test_scheduled_campaign_can_be_canceled_before_processing(): void
{
Carbon::setTestNow(Carbon::parse('2026-06-12 10:00:00', 'UTC'));
$provider = new class implements SmsProviderInterface
{
public int $calls = 0;
public function send(SmsCampaign $campaign, array $recipients): SmsSendResult
{
$this->calls++;
return new SmsSendResult('sms-test', 'job-123', [], []);
}
};
$this->app->instance(SmsProviderInterface::class, $provider);
$user = User::factory()->create();
$contact = $this->createSmsContact();
$this->actingAs($user)
->post(route('sms-campaigns.store'), [
'contact_ids' => [$contact->id],
'message' => 'Message annulable',
'send_mode' => 'scheduled',
'scheduled_at' => '2026-06-12T12:05',
]);
$campaign = SmsCampaign::query()->firstOrFail();
$this->actingAs($user)
->patch(route('sms-campaigns.cancel', $campaign))
->assertRedirect(route('sms-campaigns.show', $campaign));
$this->assertSame(0, $provider->calls);
$this->assertDatabaseHas('sms_campaigns', [
'id' => $campaign->id,
'status' => 'canceled',
]);
$this->assertDatabaseHas('sms_deliveries', [
'sms_campaign_id' => $campaign->id,
'status' => 'canceled',
]);
}
public function test_due_scheduled_campaign_is_sent_by_scheduler_command(): void
{
Carbon::setTestNow(Carbon::parse('2026-06-12 10:00:00', 'UTC'));
Log::spy();
$provider = new class implements SmsProviderInterface
{
public int $calls = 0;
public function send(SmsCampaign $campaign, array $recipients): SmsSendResult
{
$this->calls++;
return new SmsSendResult(
serviceName: 'sms-test',
providerJobId: 'job-456',
requestPayload: ['receivers' => array_column($recipients, 'number')],
responsePayload: ['ids' => ['message-456']],
providerMessageIds: ['message-456'],
);
}
};
$this->app->instance(SmsProviderInterface::class, $provider);
$user = User::factory()->create();
$contact = $this->createSmsContact();
$this->actingAs($user)
->post(route('sms-campaigns.store'), [
'contact_ids' => [$contact->id],
'message' => 'Message automatique',
'send_mode' => 'scheduled',
'scheduled_at' => '2026-06-12T12:05',
]);
Carbon::setTestNow(Carbon::parse('2026-06-12 10:05:00', 'UTC'));
$this->artisan('sms-campaigns:send-due')
->expectsOutput('1 campagne(s) SMS programmee(s) prise(s) en charge.')
->assertExitCode(0);
$this->assertSame(1, $provider->calls);
$this->assertDatabaseHas('sms_campaigns', [
'message' => 'Message automatique',
'status' => 'sent',
'sent_count' => 1,
'provider_job_id' => 'job-456',
]);
$this->assertDatabaseHas('sms_deliveries', [
'number' => '+33611111111',
'status' => 'sent',
'provider_message_id' => 'message-456',
]);
Log::shouldHaveReceived('info')
->once()
->with('SMS campaign status changed from scheduled to sending.', \Mockery::type('array'));
}
private function createSmsContact(): Contact
{
$contact = Contact::query()->create([
'source' => 'local',
'first_name' => 'Marie',
'last_name' => 'Durand',
]);
$contact->phoneNumbers()->create([
'raw_number' => '06 11 11 11 11',
'normalized_number' => '+33611111111',
'country' => 'FR',
'is_valid' => true,
'is_mobile' => true,
'is_primary' => true,
]);
return $contact;
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
//
}
@@ -0,0 +1,75 @@
<?php
namespace Tests\Unit\Calendar;
use App\Services\Calendar\ICalendarParser;
use Illuminate\Support\Carbon;
use Tests\TestCase;
class ICalendarParserTest extends TestCase
{
public function test_it_parses_event_attendees_organizer_and_display_alarm(): void
{
Carbon::setTestNow(Carbon::parse('2026-06-12 12:00:00', 'Europe/Paris'));
$events = (new ICalendarParser)->parse(<<<'ICS'
BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
UID:event-test
SUMMARY:test
DTSTART;TZID=Europe/Paris:20260613T100000
DTEND;TZID=Europe/Paris:20260613T110000
ORGANIZER;CN=Alex Martin:mailto:alex@example.test
ATTENDEE;CN=Marie Durand:mailto:marie@example.test
BEGIN:VALARM
ACTION:DISPLAY
TRIGGER:-PT1H
DESCRIPTION:Rappel
END:VALARM
BEGIN:VALARM
ACTION:EMAIL
TRIGGER:-PT2H
DESCRIPTION:Rappel email
END:VALARM
END:VEVENT
END:VCALENDAR
ICS);
$this->assertCount(1, $events);
$this->assertSame('event-test', $events[0]->uid);
$this->assertSame('test', $events[0]->summary);
$this->assertSame([
['email' => 'alex@example.test', 'name' => 'Alex Martin'],
['email' => 'marie@example.test', 'name' => 'Marie Durand'],
], $events[0]->attendees);
$this->assertSame('DISPLAY', $events[0]->alarms[0]->action);
$this->assertSame(-60, $events[0]->alarms[0]->triggerMinutes);
$this->assertSame('EMAIL', $events[0]->alarms[1]->action);
}
public function test_it_parses_an_attendee_invited_through_a_circle(): void
{
$events = (new ICalendarParser)->parse(<<<'ICS'
BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
UID:circle-event
SUMMARY:Circle test
DTSTART;TZID=Europe/Paris:20260918T000000
DTEND;TZID=Europe/Paris:20260918T003000
ORGANIZER;CN=Camille Test:mailto:camille@example.test
ATTENDEE;CN=EquipeTest;CUTYPE=GROUP:mailto:circle+team@example.test
ATTENDEE;CN="Martin; Alex";CUTYPE=INDIVIDUAL;MEMBER="mailto:circle+team@example.test";PARTSTAT=NEEDS-ACTION;RSVP=TRUE;SCHEDULE-STATUS=1.1:mailto:alex@example.test
END:VEVENT
END:VCALENDAR
ICS);
$this->assertCount(1, $events);
$this->assertSame([
['email' => 'camille@example.test', 'name' => 'Camille Test'],
['email' => 'circle+team@example.test', 'name' => 'EquipeTest'],
['email' => 'alex@example.test', 'name' => 'Martin; Alex'],
], $events[0]->attendees);
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace Tests\Unit\CardDav;
use App\Services\CardDav\VCardParser;
use PHPUnit\Framework\TestCase;
class VCardParserTest extends TestCase
{
public function test_it_parses_contact_fields_phone_numbers_and_categories(): void
{
$vcard = <<<'VCF'
BEGIN:VCARD
VERSION:3.0
UID:contact-123
FN:Marie Durand
N:Durand;Marie;;;
ORG:AFPMA
EMAIL;TYPE=HOME:marie@example.test
TEL;TYPE=CELL,VOICE:06 11 11 00 03
TEL;TYPE=HOME:04 00 00 00 00
CATEGORIES:Benevoles,Conseil\, bureau
END:VCARD
VCF;
$contact = (new VCardParser)->parse($vcard);
$this->assertSame('contact-123', $contact->uid);
$this->assertSame('Marie Durand', $contact->fullName);
$this->assertSame('Marie', $contact->firstName);
$this->assertSame('Durand', $contact->lastName);
$this->assertSame('AFPMA', $contact->organization);
$this->assertSame('marie@example.test', $contact->email);
$this->assertCount(2, $contact->phoneNumbers);
$this->assertSame('Cell, Voice', $contact->phoneNumbers[0]->label);
$this->assertSame(['Benevoles', 'Conseil, bureau'], $contact->categories);
}
public function test_it_unfolds_multiline_values(): void
{
$vcard = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:folded\r\nFN:Association des amis\r\n du lac\r\nTEL:0611110001\r\nEND:VCARD\r\n";
$contact = (new VCardParser)->parse($vcard);
$this->assertSame('Association des amis du lac', $contact->fullName);
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
class ExampleTest extends TestCase
{
/**
* A basic test example.
*/
public function test_that_true_is_true(): void
{
$this->assertTrue(true);
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace Tests\Unit;
use App\Services\Sms\OvhShortCodeMessage;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
class OvhShortCodeMessageTest extends TestCase
{
#[DataProvider('messages')]
public function test_it_removes_links_and_preserves_useful_text(string $input, string $expected): void
{
$this->assertSame($expected, (new OvhShortCodeMessage)->prepare($input));
}
public static function messages(): array
{
return [
'production reminder' => ["Rappel : Réunion Communication\n30/09/2026 10:00\nParticiper à la réunion Zoom https://us06web.zoom.us/j/12345 ID de réunion: 123 456", "Rappel : Réunion Communication\n30/09/2026 10:00\nParticiper à la réunion Zoom ID de réunion: 123 456"],
'schemes and www' => ['Voir HTTPS://example.org/x?a=b et www.example.fr/test merci', 'Voir et merci'],
'bare domain' => ['Zoom zoom.us/j/123 puis exemple.fr', 'Zoom puis'],
'markdown' => ['Rejoindre [la réunion](https://zoom.us/j/123)', 'Rejoindre la réunion'],
'html' => ['Rejoindre <a href="https://zoom.us/j/123">la réunion</a>', 'Rejoindre la réunion'],
'email phones and date' => ['Contact prenom.nom@example.fr +33 6 12 34 56 78 le 30.09.2026 à 10:00', 'Contact prenom.nom@example.fr +33 6 12 34 56 78 le 30.09.2026 à 10:00'],
'url only' => ['https://example.org', ''],
'unicode domain' => ['Voir https://exemple.fr/été et réunion.fr/a', 'Voir et'],
];
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace Tests\Unit;
use App\Contracts\PhoneNumberNormalizerInterface;
use Tests\TestCase;
class PhoneNumberNormalizerTest extends TestCase
{
public function test_it_normalizes_french_mobile_numbers_to_e164(): void
{
$result = app(PhoneNumberNormalizerInterface::class)->normalize('(+33) 6 11 11 00 02', 'FR');
$this->assertTrue($result->isValid);
$this->assertTrue($result->isMobile);
$this->assertSame('+33611110002', $result->normalizedNumber);
$this->assertSame('FR', $result->country);
$this->assertNull($result->error);
}
public function test_it_keeps_fixed_line_numbers_valid_but_not_mobile(): void
{
$result = app(PhoneNumberNormalizerInterface::class)->normalize('01 23 45 67 89', 'FR');
$this->assertTrue($result->isValid);
$this->assertFalse($result->isMobile);
$this->assertSame('+33123456789', $result->normalizedNumber);
}
public function test_it_detects_mobile_numbers_with_libphonenumber_when_regex_does_not_match(): void
{
config(['relais_sms.default_settings.mobile_phone_regex' => '/^never-matches$/']);
$result = app(PhoneNumberNormalizerInterface::class)->normalize('tel:+33611110002', 'FR');
$this->assertTrue($result->isValid);
$this->assertTrue($result->isMobile);
$this->assertSame('+33611110002', $result->normalizedNumber);
}
public function test_it_detects_dash_separated_french_mobile_numbers(): void
{
$result = app(PhoneNumberNormalizerInterface::class)->normalize('064-201-4207', 'FR');
$this->assertTrue($result->isValid);
$this->assertTrue($result->isMobile);
$this->assertSame('+33642014207', $result->normalizedNumber);
}
public function test_it_reports_invalid_numbers(): void
{
$result = app(PhoneNumberNormalizerInterface::class)->normalize('not a phone', 'FR');
$this->assertFalse($result->isValid);
$this->assertFalse($result->isMobile);
$this->assertNull($result->normalizedNumber);
$this->assertNotNull($result->error);
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace Tests\Unit;
use App\Services\Sms\SmsMessageLength;
use PHPUnit\Framework\TestCase;
class SmsMessageLengthTest extends TestCase
{
public function test_gsm_message_is_limited_to_160_units(): void
{
$length = new SmsMessageLength;
$this->assertTrue($length->fitsInOneSms(str_repeat('a', 160)));
$this->assertFalse($length->fitsInOneSms(str_repeat('a', 161)));
$this->assertSame(160, $length->units(str_repeat('^', 80)));
}
public function test_unicode_message_is_limited_to_70_units(): void
{
$length = new SmsMessageLength;
$this->assertTrue($length->fitsInOneSms(str_repeat('ê', 70)));
$this->assertFalse($length->fitsInOneSms(str_repeat('ê', 71)));
}
public function test_value_is_truncated_with_marker_inside_one_sms(): void
{
$length = new SmsMessageLength;
$value = $length->truncateBetween('Rappel : ', str_repeat('a', 200), "\n17/09/2026 14:00");
$message = 'Rappel : '.$value."\n17/09/2026 14:00";
$this->assertStringEndsWith('[...]', $value);
$this->assertTrue($length->fitsInOneSms($message));
}
}