From 4860e97263fd137d408756b4d23bcb27fb3036a1 Mon Sep 17 00:00:00 2001 From: tchapi Date: Sat, 7 Mar 2026 18:00:04 +0100 Subject: [PATCH 1/5] chore --- src/Controller/DAVController.php | 2 +- src/Entity/CalendarChange.php | 2 +- src/Plugins/BirthdayCalendarPlugin.php | 52 +++++++++++++---------- src/Services/BirthdayService.php | 57 ++++++++++---------------- 4 files changed, 54 insertions(+), 59 deletions(-) diff --git a/src/Controller/DAVController.php b/src/Controller/DAVController.php index 1b078252..abb1e5d0 100644 --- a/src/Controller/DAVController.php +++ b/src/Controller/DAVController.php @@ -282,7 +282,7 @@ private function initServer(string $authMethod, string $authRealm = User::DEFAUL } if ($this->cardDAVEnabled && $this->calDAVEnabled) { - $this->server->addPlugin(new BirthdayCalendarPlugin($this->birthdayService)); + $this->server->addPlugin(new BirthdayCalendarPlugin($this->birthdayService, $calendarBackend)); } // WebDAV plugins diff --git a/src/Entity/CalendarChange.php b/src/Entity/CalendarChange.php index cefba990..3036afe1 100644 --- a/src/Entity/CalendarChange.php +++ b/src/Entity/CalendarChange.php @@ -24,7 +24,7 @@ class CalendarChange private $calendar; #[ORM\Column(type: 'smallint')] - private $operation; + private $operation; // 1 = create, 2 = update, 3 = delete public function getId(): ?int { diff --git a/src/Plugins/BirthdayCalendarPlugin.php b/src/Plugins/BirthdayCalendarPlugin.php index 11b292cd..11aefcde 100644 --- a/src/Plugins/BirthdayCalendarPlugin.php +++ b/src/Plugins/BirthdayCalendarPlugin.php @@ -39,40 +39,30 @@ public function initialize(DAV\Server $server) $server->on('beforeUnbind', [$this, 'beforeCardDelete']); } - private function resyncCurrentPrincipal() + public function afterCardCreate(string $path, DAV\ICollection $parentNode): void { - $authPlugin = $this->server->getPlugin('auth'); - - if (!$authPlugin) { - return null; - } - - $principal = $authPlugin->getCurrentPrincipal(); - - if ($principal) { - $this->birthdayService->syncPrincipal($principal); + if (!$parentNode instanceof CardDAV\AddressBook) { + return; } + $this->handleCardChange($path, $parentNode); } - public function afterCardCreate($path, DAV\ICollection $parentNode) + public function afterCardUpdate(string $path, DAV\IFile $node): void { - if (!$parentNode instanceof CardDAV\IAddressBook) { + if (!$node instanceof CardDAV\ICard) { return; } + $parentPath = dirname($path); + $parentNode = $this->server->tree->getNodeForPath($parentPath); - $principal = $this->resyncCurrentPrincipal(); - } - - public function afterCardUpdate($path, DAV\IFile $node) - { - if (!$node instanceof CardDAV\ICard) { + if (!$parentNode instanceof CardDAV\AddressBook) { return; } - $principal = $this->resyncCurrentPrincipal(); + $this->handleCardChange($path, $parentNode); } - public function beforeCardDelete($path) + public function beforeCardDelete(string $path): void { $node = $this->server->tree->getNodeForPath($path); @@ -80,7 +70,25 @@ public function beforeCardDelete($path) return; } - $principal = $this->resyncCurrentPrincipal(); + $parentPath = dirname($path); + $parentNode = $this->server->tree->getNodeForPath($parentPath); + + if (!$parentNode instanceof CardDAV\AddressBook) { + return; + } + + $addressBookId = $parentNode->getProperties(['id'])['id']; + + $this->birthdayService->onCardDeleted($addressBookId, basename($path)); + } + + private function handleCardChange(string $path, CardDAV\AddressBook $parentNode): void + { + $cardUri = basename($path); + $addressBookId = $parentNode->getProperties(['id'])['id']; + $cardNode = $this->server->tree->getNodeForPath($path); + + $this->birthdayService->onCardChanged($addressBookId, $cardUri, $cardNode->get()); } public function getPluginName(): string diff --git a/src/Services/BirthdayService.php b/src/Services/BirthdayService.php index 9d84a7ab..63d169c7 100644 --- a/src/Services/BirthdayService.php +++ b/src/Services/BirthdayService.php @@ -20,6 +20,7 @@ use App\Entity\Principal; use Doctrine\Persistence\ManagerRegistry; use Sabre\DAV\Sharing\Plugin as SharingPlugin; +use Sabre\CalDAV\Backend\PDO as CalendarBackend; use Sabre\VObject\Component\VCalendar; use Sabre\VObject\Component\VCard; use Sabre\VObject\DateTimeParser; @@ -33,6 +34,7 @@ class BirthdayService public function __construct( private ManagerRegistry $doctrine, private string $birthdayReminderOffset, + private CalendarBackend $calendarBackend, ) { } @@ -62,11 +64,11 @@ public function onCardDeleted(int $addressBookId, string $cardUri): void $calendar = $this->ensureBirthdayCalendarExists($principalUri); $objectUri = $book->getUri().'-'.$cardUri.'.ics'; - $calendarObject = $this->doctrine->getRepository(CalendarObject::class)->findOneBy(['calendar' => $calendar, 'uri' => $objectUri]); - $em = $this->doctrine->getManager(); - $em->remove($calendarObject); - $em->flush(); + $this->calendarBackend->deleteCalendarObject( + $calendar->getCalendar()->getId(), + $objectUri + ); } public function shouldBirthdayCalendarExist(string $principalUri): bool @@ -290,7 +292,7 @@ public function syncPrincipal(string $principal): void } } - public function birthdayEvenChanged(string $existingCalendarData, VCalendar $newCalendarData): bool + public function birthdayEventChanged(string $existingCalendarData, VCalendar $newCalendarData): bool { try { $existingBirthday = Reader::read($existingCalendarData); @@ -315,44 +317,29 @@ private function updateCalendar(string $cardUri, string $cardData, AddressBook $ $existing = $this->doctrine->getRepository(CalendarObject::class)->findOneBy(['calendar' => $calendar, 'uri' => $objectUri]); - $em = $this->doctrine->getManager(); - if (null === $calendarData) { if (null !== $existing) { - $em->remove($existing); + $this->calendarBackend->deleteCalendarObject( + $calendar->getId(), + $objectUri + ); } } else { - $serializedCalendarData = $calendarData->serialize(); - $vEvent = $calendarData->getComponents()[0]; - $maxDate = new \DateTime(Constants::MAX_DATE); - if (null === $existing) { - $calendarObject = (new CalendarObject()) - ->setCalendar($calendar) - ->setUri($objectUri) - ->setComponentType('VEVENT') - ->setUid($objectUid) - ->setLastModified((new \DateTime())->getTimestamp()) - ->setFirstOccurence($vEvent->DTSTART->getDateTime()->getTimeStamp()) - ->setLastOccurence($maxDate->getTimestamp()) - ->setEtag(md5($serializedCalendarData)) - ->setSize(strlen($serializedCalendarData)) - ->setCalendarData($serializedCalendarData); - - $em->persist($calendarObject); + $this->calendarBackend->createCalendarObject( + $calendar->getId(), + $objectUri, + $calendarData + ); } else { - if ($this->birthdayEvenChanged($existing->getCalendarData(), $calendarData)) { - $existing - ->setLastModified((new \DateTime())->getTimestamp()) - ->setFirstOccurence($vEvent->DTSTART->getDateTime()->getTimeStamp()) - ->setLastOccurence($maxDate->getTimestamp()) - ->setEtag(md5($serializedCalendarData)) - ->setSize(strlen($serializedCalendarData)) - ->setCalendarData($serializedCalendarData); + if ($this->birthdayEventChanged($existing->getCalendarData(), $calendarData)) { + $this->calendarBackend->updateCalendarObject( + $calendar->getId(), + $objectUri, + $calendarData + ); } } } - - $em->flush(); } } From 66f929d5292e11cd8e22ba711188b6eae2ca9acf Mon Sep 17 00:00:00 2001 From: tchapi Date: Sat, 7 Mar 2026 18:16:17 +0100 Subject: [PATCH 2/5] chore --- config/services.yaml | 1 + src/Services/BirthdayService.php | 9 +++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/config/services.yaml b/config/services.yaml index 25662b12..58d22b4d 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -77,6 +77,7 @@ services: App\Services\BirthdayService: arguments: $birthdayReminderOffset: "%birthday_reminder_offset%" + $calendarBackend: null # will be overridden when instantiated manually App\Security\ApiKeyAuthenticator: arguments: diff --git a/src/Services/BirthdayService.php b/src/Services/BirthdayService.php index 63d169c7..c5099f48 100644 --- a/src/Services/BirthdayService.php +++ b/src/Services/BirthdayService.php @@ -19,8 +19,8 @@ use App\Entity\Card; use App\Entity\Principal; use Doctrine\Persistence\ManagerRegistry; -use Sabre\DAV\Sharing\Plugin as SharingPlugin; use Sabre\CalDAV\Backend\PDO as CalendarBackend; +use Sabre\DAV\Sharing\Plugin as SharingPlugin; use Sabre\VObject\Component\VCalendar; use Sabre\VObject\Component\VCard; use Sabre\VObject\DateTimeParser; @@ -34,8 +34,13 @@ class BirthdayService public function __construct( private ManagerRegistry $doctrine, private string $birthdayReminderOffset, - private CalendarBackend $calendarBackend, + private ?CalendarBackend $calendarBackend = null, ) { + if (!$calendarBackend) { + $em = $this->doctrine->getManager(); + $pdo = $em->getConnection()->getNativeConnection(); + $this->calendarBackend = new CalendarBackend($pdo); + } } public function onCardChanged(int $addressBookId, string $cardUri, string $cardData): void From 7ad08261e22a3c5cc7ff47b2ce0e0f8e6803b93a Mon Sep 17 00:00:00 2001 From: tchapi Date: Sat, 7 Mar 2026 18:26:56 +0100 Subject: [PATCH 3/5] chore --- config/services.yaml | 1 - src/Command/SyncBirthdayCalendars.php | 5 +++++ src/Plugins/BirthdayCalendarPlugin.php | 4 +++- src/Services/BirthdayService.php | 16 ++++++++++------ 4 files changed, 18 insertions(+), 8 deletions(-) diff --git a/config/services.yaml b/config/services.yaml index 58d22b4d..25662b12 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -77,7 +77,6 @@ services: App\Services\BirthdayService: arguments: $birthdayReminderOffset: "%birthday_reminder_offset%" - $calendarBackend: null # will be overridden when instantiated manually App\Security\ApiKeyAuthenticator: arguments: diff --git a/src/Command/SyncBirthdayCalendars.php b/src/Command/SyncBirthdayCalendars.php index ed074906..6a1ab775 100644 --- a/src/Command/SyncBirthdayCalendars.php +++ b/src/Command/SyncBirthdayCalendars.php @@ -5,6 +5,7 @@ use App\Entity\User; use App\Services\BirthdayService; use Doctrine\Persistence\ManagerRegistry; +use Sabre\CalDAV\Backend\PDO as CalendarBackend; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Helper\ProgressBar; use Symfony\Component\Console\Input\InputArgument; @@ -18,6 +19,10 @@ public function __construct( private BirthdayService $birthdayService, ) { parent::__construct(); + + $em = $doctrine->getManager(); + $pdo = $em->getConnection()->getNativeConnection(); + $this->birthdayService->setBackend(new CalendarBackend($pdo)); } protected function configure(): void diff --git a/src/Plugins/BirthdayCalendarPlugin.php b/src/Plugins/BirthdayCalendarPlugin.php index 11aefcde..879b5a15 100644 --- a/src/Plugins/BirthdayCalendarPlugin.php +++ b/src/Plugins/BirthdayCalendarPlugin.php @@ -3,6 +3,7 @@ namespace App\Plugins; use App\Services\BirthdayService; +use Sabre\CalDAV\Backend\PDO as CalendarBackend; use Sabre\CardDAV; use Sabre\DAV; @@ -18,9 +19,10 @@ class BirthdayCalendarPlugin extends DAV\ServerPlugin */ protected $server; - public function __construct(BirthdayService $birthdayService) + public function __construct(BirthdayService $birthdayService, CalendarBackend $calendarBackend) { $this->birthdayService = $birthdayService; + $this->birthdayService->setBackend($calendarBackend); } public function initialize(DAV\Server $server) diff --git a/src/Services/BirthdayService.php b/src/Services/BirthdayService.php index c5099f48..36551487 100644 --- a/src/Services/BirthdayService.php +++ b/src/Services/BirthdayService.php @@ -31,16 +31,20 @@ class BirthdayService { + /** + * @var CalendarBackend + */ + private $calendarBackend; + public function __construct( private ManagerRegistry $doctrine, private string $birthdayReminderOffset, - private ?CalendarBackend $calendarBackend = null, ) { - if (!$calendarBackend) { - $em = $this->doctrine->getManager(); - $pdo = $em->getConnection()->getNativeConnection(); - $this->calendarBackend = new CalendarBackend($pdo); - } + } + + public function setBackend(CalendarBackend $calendarBackend) + { + $this->calendarBackend = $calendarBackend; } public function onCardChanged(int $addressBookId, string $cardUri, string $cardData): void From 3d992370677e18ffe23d0a586748a28e0b85b1e1 Mon Sep 17 00:00:00 2001 From: tchapi Date: Sat, 7 Mar 2026 20:15:21 +0100 Subject: [PATCH 4/5] chore --- src/Services/BirthdayService.php | 28 ++- tests/Unit/BirthdayServiceTest.php | 335 +++++++++++++++++++++++++++++ 2 files changed, 353 insertions(+), 10 deletions(-) create mode 100644 tests/Unit/BirthdayServiceTest.php diff --git a/src/Services/BirthdayService.php b/src/Services/BirthdayService.php index 36551487..a221447b 100644 --- a/src/Services/BirthdayService.php +++ b/src/Services/BirthdayService.php @@ -56,9 +56,9 @@ public function onCardChanged(int $addressBookId, string $cardUri, string $cardD } $principalUri = $book->getPrincipalUri(); - $calendar = $this->ensureBirthdayCalendarExists($principalUri); + $calendarInstance = $this->ensureBirthdayCalendarExists($principalUri); - $this->updateCalendar($cardUri, $cardData, $book, $calendar->getCalendar()); + $this->updateCalendar($cardUri, $cardData, $book, $calendarInstance); } public function onCardDeleted(int $addressBookId, string $cardUri): void @@ -70,12 +70,16 @@ public function onCardDeleted(int $addressBookId, string $cardUri): void } $principalUri = $book->getPrincipalUri(); - $calendar = $this->ensureBirthdayCalendarExists($principalUri); + $calendarInstance = $this->ensureBirthdayCalendarExists($principalUri); $objectUri = $book->getUri().'-'.$cardUri.'.ics'; + $calendar = $calendarInstance->getCalendar(); + // This is the structure that needs to be passed to the backend methods + $calendarId = [$calendar->getId(), $calendarInstance->getId()]; + $this->calendarBackend->deleteCalendarObject( - $calendar->getCalendar()->getId(), + $calendarId, $objectUri ); } @@ -318,34 +322,38 @@ public function birthdayEventChanged(string $existingCalendarData, VCalendar $ne /** * @throws InvalidDataException */ - private function updateCalendar(string $cardUri, string $cardData, AddressBook $book, Calendar $calendar): void + private function updateCalendar(string $cardUri, string $cardData, AddressBook $book, CalendarInstance $calendarInstance): void { $objectUid = $book->getUri().'-'.$cardUri; $objectUri = $objectUid.'.ics'; $calendarData = $this->buildDataFromContact($cardData); + $calendar = $calendarInstance->getCalendar(); + // This is the structure that needs to be passed to the backend methods + $calendarId = [$calendar->getId(), $calendarInstance->getId()]; + $existing = $this->doctrine->getRepository(CalendarObject::class)->findOneBy(['calendar' => $calendar, 'uri' => $objectUri]); if (null === $calendarData) { if (null !== $existing) { $this->calendarBackend->deleteCalendarObject( - $calendar->getId(), + [$calendar->getId(), $calendarInstance->getId()], $objectUri ); } } else { if (null === $existing) { $this->calendarBackend->createCalendarObject( - $calendar->getId(), + [$calendar->getId(), $calendarInstance->getId()], $objectUri, - $calendarData + $calendarData->serialize() ); } else { if ($this->birthdayEventChanged($existing->getCalendarData(), $calendarData)) { $this->calendarBackend->updateCalendarObject( - $calendar->getId(), + [$calendar->getId(), $calendarInstance->getId()], $objectUri, - $calendarData + $calendarData->serialize() ); } } diff --git a/tests/Unit/BirthdayServiceTest.php b/tests/Unit/BirthdayServiceTest.php new file mode 100644 index 00000000..aac88a61 --- /dev/null +++ b/tests/Unit/BirthdayServiceTest.php @@ -0,0 +1,335 @@ +em = static::getContainer()->get(EntityManagerInterface::class); + $this->service = static::getContainer()->get(BirthdayService::class); + + $pdo = $this->em->getConnection()->getNativeConnection(); + $this->service->setBackend(new CalendarBackend($pdo)); + + $this->em->getConnection()->beginTransaction(); + } + + protected function tearDown(): void + { + $this->em->getConnection()->rollBack(); + parent::tearDown(); + } + + private function createAddressBook( + string $username = 'testuser', + bool $includedInBirthdayCalendar = true, + ): AddressBook { + $principal = (new Principal()) + ->setUri(Principal::PREFIX.$username) + ->setEmail($username.'@example.com') + ->setDisplayName($username); + $this->em->persist($principal); + + $addressBook = (new AddressBook()) + ->setPrincipalUri(Principal::PREFIX.$username) + ->setUri('default') + ->setDisplayName('Default') + ->setDescription('') + ->setSynctoken('1') + ->setIncludedInBirthdayCalendar($includedInBirthdayCalendar); + $this->em->persist($addressBook); + $this->em->flush(); + + return $addressBook; + } + + // ------------------------------------------------------------------------- + // buildDataFromContact + // ------------------------------------------------------------------------- + + public function testBuildDataFromContactReturnsNullForEmptyData(): void + { + $this->assertNull($this->service->buildDataFromContact('')); + } + + public function testBuildDataFromContactReturnsNullIfNoBday(): void + { + $vcard = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:John Doe\r\nEND:VCARD\r\n"; + $this->assertNull($this->service->buildDataFromContact($vcard)); + } + + public function testBuildDataFromContactReturnsNullIfNoFn(): void + { + $vcard = "BEGIN:VCARD\r\nVERSION:3.0\r\nBDAY:19900101\r\nEND:VCARD\r\n"; + $this->assertNull($this->service->buildDataFromContact($vcard)); + } + + public function testBuildDataFromContactReturnsVCalendarWithBday(): void + { + $vcard = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:John Doe\r\nUID:1234\r\nBDAY:19900615\r\nEND:VCARD\r\n"; + $result = $this->service->buildDataFromContact($vcard); + + $this->assertInstanceOf(VCalendar::class, $result); + $this->assertStringContainsString('John Doe', (string) $result->VEVENT->SUMMARY); + $this->assertStringContainsString('1990', (string) $result->VEVENT->SUMMARY); + $this->assertEquals('FREQ=YEARLY', (string) $result->VEVENT->RRULE); + $this->assertEquals('DATE', (string) $result->VEVENT->DTSTART['VALUE']); + } + + public function testBuildDataFromContactHandlesLeapDay(): void + { + $vcard = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:John Doe\r\nUID:1234\r\nBDAY:19920229\r\nEND:VCARD\r\n"; + $result = $this->service->buildDataFromContact($vcard); + + $this->assertInstanceOf(VCalendar::class, $result); + $this->assertStringContainsString('BYMONTH=2;BYMONTHDAY=-1', (string) $result->VEVENT->RRULE); + } + + public function testBuildDataFromContactHandlesOmitYear(): void + { + $vcard = "BEGIN:VCARD\r\nVERSION:4.0\r\nFN:John Doe\r\nUID:1234\r\nBDAY;X-APPLE-OMIT-YEAR=1604:16040615\r\nEND:VCARD\r\n"; + $result = $this->service->buildDataFromContact($vcard); + + $this->assertInstanceOf(VCalendar::class, $result); + $this->assertStringNotContainsString('(', (string) $result->VEVENT->SUMMARY); + } + + public function testBuildDataFromContactAddsAlarm(): void + { + $vcard = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:John Doe\r\nUID:1234\r\nBDAY:19900615\r\nEND:VCARD\r\n"; + $result = $this->service->buildDataFromContact($vcard); + + $this->assertNotNull($result->VEVENT->VALARM); + } + + // ------------------------------------------------------------------------- + // birthdayEventChanged + // ------------------------------------------------------------------------- + + public function testBirthdayEventChangedReturnsFalseWhenSame(): void + { + $cal = $this->service->buildDataFromContact("BEGIN:VCARD\r\nVERSION:3.0\r\nFN:John Doe\r\nUID:1234\r\nBDAY:19900615\r\nEND:VCARD\r\n"); + + $this->assertFalse($this->service->birthdayEventChanged($cal->serialize(), $cal)); + } + + public function testBirthdayEventChangedReturnsTrueWhenDifferentDate(): void + { + $cal1 = $this->service->buildDataFromContact("BEGIN:VCARD\r\nVERSION:3.0\r\nFN:John Doe\r\nUID:1234\r\nBDAY:19900615\r\nEND:VCARD\r\n"); + $cal2 = $this->service->buildDataFromContact("BEGIN:VCARD\r\nVERSION:3.0\r\nFN:John Doe\r\nUID:1234\r\nBDAY:19900616\r\nEND:VCARD\r\n"); + + $this->assertTrue($this->service->birthdayEventChanged($cal1->serialize(), $cal2)); + } + + public function testBirthdayEventChangedReturnsTrueWhenDifferentName(): void + { + $cal1 = $this->service->buildDataFromContact("BEGIN:VCARD\r\nVERSION:3.0\r\nFN:John Doe\r\nUID:1234\r\nBDAY:19900615\r\nEND:VCARD\r\n"); + $cal2 = $this->service->buildDataFromContact("BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Jane Doe\r\nUID:1234\r\nBDAY:19900615\r\nEND:VCARD\r\n"); + + $this->assertTrue($this->service->birthdayEventChanged($cal1->serialize(), $cal2)); + } + + public function testBirthdayEventChangedReturnsTrueOnInvalidExistingData(): void + { + $cal = $this->service->buildDataFromContact("BEGIN:VCARD\r\nVERSION:3.0\r\nFN:John Doe\r\nUID:1234\r\nBDAY:19900615\r\nEND:VCARD\r\n"); + + $this->assertTrue($this->service->birthdayEventChanged('invalid-data', $cal)); + } + + // ------------------------------------------------------------------------- + // onCardChanged + // ------------------------------------------------------------------------- + + public function testOnCardChangedSkipsIfNotIncludedInBirthdayCalendar(): void + { + $addressBook = $this->createAddressBook(includedInBirthdayCalendar: false); + + $this->service->onCardChanged( + $addressBook->getId(), + 'john.vcf', + "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:John Doe\r\nUID:1234\r\nBDAY:19900615\r\nEND:VCARD\r\n" + ); + + $this->em->clear(); + + $object = $this->em->getRepository(CalendarObject::class)->findOneBy(['uri' => 'default-john.vcf.ics']); + $this->assertNull($object); + } + + public function testOnCardChangedCreatesCalendarObject(): void + { + $addressBook = $this->createAddressBook(); + + $this->service->onCardChanged( + $addressBook->getId(), + 'john.vcf', + "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:John Doe\r\nUID:1234\r\nBDAY:19900615\r\nEND:VCARD\r\n" + ); + + $this->em->clear(); + + $object = $this->em->getRepository(CalendarObject::class)->findOneBy(['uri' => 'default-john.vcf.ics']); + $this->assertNotNull($object); + $this->assertStringContainsString('John Doe', $object->getCalendarData()); + } + + public function testOnCardChangedUpdatesExistingCalendarObject(): void + { + $addressBook = $this->createAddressBook(); + + $this->service->onCardChanged( + $addressBook->getId(), + 'john.vcf', + "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:John Doe\r\nUID:1234\r\nBDAY:19900615\r\nEND:VCARD\r\n" + ); + + $this->service->onCardChanged( + $addressBook->getId(), + 'john.vcf', + "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:John Updated\r\nUID:1234\r\nBDAY:19900615\r\nEND:VCARD\r\n" + ); + + $this->em->clear(); + + $object = $this->em->getRepository(CalendarObject::class)->findOneBy(['uri' => 'default-john.vcf.ics']); + $this->assertNotNull($object); + $this->assertStringContainsString('John Updated', $object->getCalendarData()); + + $this->em->clear(); + + $instanceBefore = $this->em->getRepository(CalendarInstance::class)->findOneBy([ + 'principalUri' => 'principals/testuser', + 'uri' => Constants::BIRTHDAY_CALENDAR_URI, + ]); + $syncTokenBefore = $instanceBefore->getCalendar()->getSynctoken(); + + $this->service->onCardChanged( + $addressBook->getId(), + 'john.vcf', + "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:John Updated Again\r\nUID:1234\r\nBDAY:19900615\r\nEND:VCARD\r\n" + ); + + $this->em->clear(); + + $instanceAfter = $this->em->getRepository(CalendarInstance::class)->findOneBy([ + 'principalUri' => 'principals/testuser', + 'uri' => Constants::BIRTHDAY_CALENDAR_URI, + ]); + $this->assertGreaterThan($syncTokenBefore, $instanceAfter->getCalendar()->getSynctoken()); + } + + public function testOnCardChangedDoesNotCreateObjectIfNoBday(): void + { + $addressBook = $this->createAddressBook(); + + $this->service->onCardChanged( + $addressBook->getId(), + 'john.vcf', + "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:John Doe\r\nUID:1234\r\nEND:VCARD\r\n" + ); + + $this->em->clear(); + + $object = $this->em->getRepository(CalendarObject::class)->findOneBy(['uri' => 'default-john.vcf.ics']); + $this->assertNull($object); + } + + // ------------------------------------------------------------------------- + // onCardDeleted + // ------------------------------------------------------------------------- + + public function testOnCardDeletedRemovesCalendarObject(): void + { + $addressBook = $this->createAddressBook(); + + $this->service->onCardChanged( + $addressBook->getId(), + 'john.vcf', + "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:John Doe\r\nUID:1234\r\nBDAY:19900615\r\nEND:VCARD\r\n" + ); + + $this->service->onCardDeleted($addressBook->getId(), 'john.vcf'); + + $this->em->clear(); + + $object = $this->em->getRepository(CalendarObject::class)->findOneBy(['uri' => 'default-john.vcf.ics']); + $this->assertNull($object); + } + + public function testOnCardDeletedIsNoopIfNoCalendarObject(): void + { + $addressBook = $this->createAddressBook(); + + // Should not throw even if no calendar object exists + $this->service->onCardDeleted($addressBook->getId(), 'nonexistent.vcf'); + + $this->addToAssertionCount(1); + } + + // ------------------------------------------------------------------------- + // ensureBirthdayCalendarExists + // ------------------------------------------------------------------------- + + public function testEnsureBirthdayCalendarExistsCreatesCalendar(): void + { + $this->service->ensureBirthdayCalendarExists('principals/testuser'); + + $instance = $this->em->getRepository(CalendarInstance::class)->findOneBy([ + 'principalUri' => 'principals/testuser', + 'uri' => Constants::BIRTHDAY_CALENDAR_URI, + ]); + + $this->assertNotNull($instance); + $this->assertNotNull($instance->getCalendar()); + } + + public function testEnsureBirthdayCalendarExistsIsIdempotent(): void + { + $this->service->ensureBirthdayCalendarExists('principals/testuser'); + $this->service->ensureBirthdayCalendarExists('principals/testuser'); + + $instances = $this->em->getRepository(CalendarInstance::class)->findBy([ + 'principalUri' => 'principals/testuser', + 'uri' => Constants::BIRTHDAY_CALENDAR_URI, + ]); + + $this->assertCount(1, $instances); + } + + // ------------------------------------------------------------------------- + // syncPrincipal + // ------------------------------------------------------------------------- + + public function testSyncPrincipalDeletesBirthdayCalendarIfNoAddressBooksIncluded(): void + { + $this->service->ensureBirthdayCalendarExists('principals/testuser'); + $this->createAddressBook(includedInBirthdayCalendar: false); + + $this->service->syncPrincipal('principals/testuser'); + + $instance = $this->em->getRepository(CalendarInstance::class)->findOneBy([ + 'principalUri' => 'principals/testuser', + 'uri' => Constants::BIRTHDAY_CALENDAR_URI, + ]); + $this->assertNull($instance); + } +} From b5d651990713465f523019c0086452fc1191de1f Mon Sep 17 00:00:00 2001 From: tchapi Date: Sat, 7 Mar 2026 21:58:56 +0100 Subject: [PATCH 5/5] chore --- .../Commands/SyncBirthdayCalendarTest.php | 185 ++++++++++++++++++ .../AddressBookControllerTest.php | 0 .../{ => Controllers}/ApiControllerTest.php | 0 .../CalendarControllerTest.php | 0 .../{ => Controllers}/DashboardTest.php | 0 .../{ => Controllers}/UserControllerTest.php | 0 .../Service}/BirthdayServiceTest.php | 0 7 files changed, 185 insertions(+) create mode 100644 tests/Functional/Commands/SyncBirthdayCalendarTest.php rename tests/Functional/{ => Controllers}/AddressBookControllerTest.php (100%) rename tests/Functional/{ => Controllers}/ApiControllerTest.php (100%) rename tests/Functional/{ => Controllers}/CalendarControllerTest.php (100%) rename tests/Functional/{ => Controllers}/DashboardTest.php (100%) rename tests/Functional/{ => Controllers}/UserControllerTest.php (100%) rename tests/{Unit => Functional/Service}/BirthdayServiceTest.php (100%) diff --git a/tests/Functional/Commands/SyncBirthdayCalendarTest.php b/tests/Functional/Commands/SyncBirthdayCalendarTest.php new file mode 100644 index 00000000..7d9664b5 --- /dev/null +++ b/tests/Functional/Commands/SyncBirthdayCalendarTest.php @@ -0,0 +1,185 @@ +em = static::getContainer()->get(EntityManagerInterface::class); + + $birthdayService = static::getContainer()->get(BirthdayService::class); + $pdo = $this->em->getConnection()->getNativeConnection(); + $birthdayService->setBackend(new CalendarBackend($pdo)); + + $application = new Application(self::$kernel); + $command = $application->find('dav:sync-birthday-calendar'); + $this->commandTester = new CommandTester($command); + + $this->em->getConnection()->beginTransaction(); + } + + protected function tearDown(): void + { + $this->em->getConnection()->rollBack(); + parent::tearDown(); + } + + private function createUser(string $username): User + { + $user = (new User()) + ->setUsername($username) + ->setPassword('hashed'); + $this->em->persist($user); + + $principal = (new Principal()) + ->setUri(Principal::PREFIX.$username) + ->setEmail($username.'@example.com') + ->setDisplayName($username); + $this->em->persist($principal); + + $this->em->flush(); + + return $user; + } + + private function createAddressBookWithCard(string $username, string $cardUri, string $cardData): AddressBook + { + $addressBook = (new AddressBook()) + ->setPrincipalUri(Principal::PREFIX.$username) + ->setUri('default') + ->setDisplayName('Default') + ->setDescription('') + ->setSynctoken('1') + ->setIncludedInBirthdayCalendar(true); + $this->em->persist($addressBook); + + $card = (new Card()) + ->setAddressBook($addressBook) + ->setUri($cardUri) + ->setCarddata($cardData) + ->setLastmodified(time()) + ->setSize(strlen($cardData)) + ->setEtag(md5($cardData)); + $this->em->persist($card); + + $this->em->flush(); + + return $addressBook; + } + + private function assertBirthdayEventExists(string $principalUri, string $addressBookUri, string $cardUri, string $expectedNameFragment): void + { + $instance = $this->em->getRepository(CalendarInstance::class)->findOneBy([ + 'principalUri' => $principalUri, + 'uri' => Constants::BIRTHDAY_CALENDAR_URI, + ]); + $this->assertNotNull($instance, "Birthday calendar instance not found for $principalUri"); + + $objectUri = $addressBookUri.'-'.$cardUri.'.ics'; + $object = $this->em->getRepository(CalendarObject::class)->findOneBy([ + 'calendar' => $instance->getCalendar(), + 'uri' => $objectUri, + ]); + $this->assertNotNull($object, "Calendar object $objectUri not found"); + $this->assertStringContainsString($expectedNameFragment, $object->getCalendarData()); + } + + public function testExecuteSyncsAllUsers(): void + { + $this->createUser('alice'); + $this->createUser('bob'); + $this->createAddressBookWithCard( + 'alice', + 'alice-contact.vcf', + "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Alice Contact\r\nUID:alice-1\r\nBDAY:19900615\r\nEND:VCARD\r\n" + ); + $this->createAddressBookWithCard( + 'bob', + 'bob-contact.vcf', + "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Bob Contact\r\nUID:bob-1\r\nBDAY:19850320\r\nEND:VCARD\r\n" + ); + + $this->commandTester->execute([]); + + $this->assertSame(0, $this->commandTester->getStatusCode()); + $this->assertStringContainsString('Start birthday calendar sync for all users', $this->commandTester->getDisplay()); + + $this->em->clear(); + + $this->assertBirthdayEventExists(Principal::PREFIX.'alice', 'default', 'alice-contact.vcf', 'Alice Contact'); + $this->assertBirthdayEventExists(Principal::PREFIX.'bob', 'default', 'bob-contact.vcf', 'Bob Contact'); + } + + public function testExecuteSyncsSingleUser(): void + { + $this->createUser('alice'); + $this->createUser('bob'); + $this->createAddressBookWithCard( + 'alice', + 'alice-contact.vcf', + "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Alice Contact\r\nUID:alice-1\r\nBDAY:19900615\r\nEND:VCARD\r\n" + ); + $this->createAddressBookWithCard( + 'bob', + 'bob-contact.vcf', + "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Bob Contact\r\nUID:bob-1\r\nBDAY:19850320\r\nEND:VCARD\r\n" + ); + + $this->commandTester->execute(['username' => 'alice']); + + $this->assertSame(0, $this->commandTester->getStatusCode()); + $this->assertStringContainsString('Start birthday calendar sync for alice', $this->commandTester->getDisplay()); + + $this->em->clear(); + + // Alice's birthday calendar should exist with the event + $this->assertBirthdayEventExists(Principal::PREFIX.'alice', 'default', 'alice-contact.vcf', 'Alice Contact'); + + // Bob's birthday calendar should NOT have been created + $bobInstance = $this->em->getRepository(CalendarInstance::class)->findOneBy([ + 'principalUri' => Principal::PREFIX.'bob', + 'uri' => Constants::BIRTHDAY_CALENDAR_URI, + ]); + $this->assertNull($bobInstance); + } + + public function testExecuteThrowsExceptionForUnknownUser(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('User is unknown.'); + + $this->commandTester->execute(['username' => 'unknown']); + } + + public function testExecuteWithNoUsersInDatabaseSucceeds(): void + { + $this->commandTester->execute([]); + + $this->assertSame(0, $this->commandTester->getStatusCode()); + + $instances = $this->em->getRepository(CalendarInstance::class)->findBy(['uri' => Constants::BIRTHDAY_CALENDAR_URI]); + $this->assertCount(0, $instances); + } +} diff --git a/tests/Functional/AddressBookControllerTest.php b/tests/Functional/Controllers/AddressBookControllerTest.php similarity index 100% rename from tests/Functional/AddressBookControllerTest.php rename to tests/Functional/Controllers/AddressBookControllerTest.php diff --git a/tests/Functional/ApiControllerTest.php b/tests/Functional/Controllers/ApiControllerTest.php similarity index 100% rename from tests/Functional/ApiControllerTest.php rename to tests/Functional/Controllers/ApiControllerTest.php diff --git a/tests/Functional/CalendarControllerTest.php b/tests/Functional/Controllers/CalendarControllerTest.php similarity index 100% rename from tests/Functional/CalendarControllerTest.php rename to tests/Functional/Controllers/CalendarControllerTest.php diff --git a/tests/Functional/DashboardTest.php b/tests/Functional/Controllers/DashboardTest.php similarity index 100% rename from tests/Functional/DashboardTest.php rename to tests/Functional/Controllers/DashboardTest.php diff --git a/tests/Functional/UserControllerTest.php b/tests/Functional/Controllers/UserControllerTest.php similarity index 100% rename from tests/Functional/UserControllerTest.php rename to tests/Functional/Controllers/UserControllerTest.php diff --git a/tests/Unit/BirthdayServiceTest.php b/tests/Functional/Service/BirthdayServiceTest.php similarity index 100% rename from tests/Unit/BirthdayServiceTest.php rename to tests/Functional/Service/BirthdayServiceTest.php