Publication initiale de RelaisSMS : documentation et sources nettoyees
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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']), []);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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))]));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user