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