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