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());
}
}