diff --git a/.changes/unreleased/Feature-20230706-213428.yaml b/.changes/unreleased/Feature-20230706-213428.yaml new file mode 100644 index 000000000..092e5a2e0 --- /dev/null +++ b/.changes/unreleased/Feature-20230706-213428.yaml @@ -0,0 +1,5 @@ +kind: Feature +body: 'Sync user absence / presence through microsoft outlook / graph api. ' +time: 2023-07-06T21:34:28.973144334+02:00 +custom: + Issue: "124" diff --git a/.changes/unreleased/Fixed-20230706-220125.yaml b/.changes/unreleased/Fixed-20230706-220125.yaml new file mode 100644 index 000000000..b9449d514 --- /dev/null +++ b/.changes/unreleased/Fixed-20230706-220125.yaml @@ -0,0 +1,6 @@ +kind: Fixed +body: 'Command to subscribe on MS Graph users calendars: improve the loop to be more + efficient' +time: 2023-07-06T22:01:25.847374805+02:00 +custom: + Issue: "" diff --git a/src/Bundle/ChillCalendarBundle/Command/MapAndSubscribeUserCalendarCommand.php b/src/Bundle/ChillCalendarBundle/Command/MapAndSubscribeUserCalendarCommand.php index b90fb83d4..193b04934 100644 --- a/src/Bundle/ChillCalendarBundle/Command/MapAndSubscribeUserCalendarCommand.php +++ b/src/Bundle/ChillCalendarBundle/Command/MapAndSubscribeUserCalendarCommand.php @@ -18,9 +18,12 @@ declare(strict_types=1); namespace Chill\CalendarBundle\Command; +use Chill\CalendarBundle\Exception\UserAbsenceSyncException; use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\EventsOnUserSubscriptionCreator; use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\MapCalendarToUser; use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\MSGraphUserRepository; +use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\MSUserAbsenceSync; +use Chill\MainBundle\Repository\UserRepositoryInterface; use DateInterval; use DateTimeImmutable; use Doctrine\ORM\EntityManagerInterface; @@ -30,32 +33,17 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -class MapAndSubscribeUserCalendarCommand extends Command +final class MapAndSubscribeUserCalendarCommand extends Command { - private EntityManagerInterface $em; - - private EventsOnUserSubscriptionCreator $eventsOnUserSubscriptionCreator; - - private LoggerInterface $logger; - - private MapCalendarToUser $mapCalendarToUser; - - private MSGraphUserRepository $userRepository; - public function __construct( - EntityManagerInterface $em, - EventsOnUserSubscriptionCreator $eventsOnUserSubscriptionCreator, - LoggerInterface $logger, - MapCalendarToUser $mapCalendarToUser, - MSGraphUserRepository $userRepository + private readonly EntityManagerInterface $em, + private readonly EventsOnUserSubscriptionCreator $eventsOnUserSubscriptionCreator, + private readonly LoggerInterface $logger, + private readonly MapCalendarToUser $mapCalendarToUser, + private readonly UserRepositoryInterface $userRepository, + private readonly MSUserAbsenceSync $userAbsenceSync, ) { parent::__construct('chill:calendar:msgraph-user-map-subscribe'); - - $this->em = $em; - $this->eventsOnUserSubscriptionCreator = $eventsOnUserSubscriptionCreator; - $this->logger = $logger; - $this->mapCalendarToUser = $mapCalendarToUser; - $this->userRepository = $userRepository; } public function execute(InputInterface $input, OutputInterface $output): int @@ -67,83 +55,109 @@ class MapAndSubscribeUserCalendarCommand extends Command /** @var DateInterval $interval the interval before the end of the expiration */ $interval = new DateInterval('P1D'); $expiration = (new DateTimeImmutable('now'))->add(new DateInterval($input->getOption('subscription-duration'))); - $total = $this->userRepository->countByMostOldSubscriptionOrWithoutSubscriptionOrData($interval); + $users = $this->userRepository->findAllAsArray('fr'); $created = 0; $renewed = 0; - $this->logger->info(self::class . ' the number of user to get - renew', [ - 'total' => $total, + $this->logger->info(self::class . ' start user to get - renew', [ 'expiration' => $expiration->format(DateTimeImmutable::ATOM), ]); - while ($offset < $total) { - $users = $this->userRepository->findByMostOldSubscriptionOrWithoutSubscriptionOrData( - $interval, - $limit, - $offset - ); + foreach ($users as $u) { + ++$offset; - foreach ($users as $user) { - if (!$this->mapCalendarToUser->hasUserId($user)) { - $this->mapCalendarToUser->writeMetadata($user); - } - - if ($this->mapCalendarToUser->hasUserId($user)) { - // we first try to renew an existing subscription, if any. - // if not, or if it fails, we try to create a new one - if ($this->mapCalendarToUser->hasActiveSubscription($user)) { - $this->logger->debug(self::class . ' renew a subscription for', [ - 'userId' => $user->getId(), - 'username' => $user->getUsernameCanonical(), - ]); - - ['secret' => $secret, 'id' => $id, 'expiration' => $expirationTs] - = $this->eventsOnUserSubscriptionCreator->renewSubscriptionForUser($user, $expiration); - $this->mapCalendarToUser->writeSubscriptionMetadata($user, $expirationTs, $id, $secret); - - if (0 !== $expirationTs) { - ++$renewed; - } else { - $this->logger->warning(self::class . ' could not renew subscription for a user', [ - 'userId' => $user->getId(), - 'username' => $user->getUsernameCanonical(), - ]); - } - } - - if (!$this->mapCalendarToUser->hasActiveSubscription($user)) { - $this->logger->debug(self::class . ' create a subscription for', [ - 'userId' => $user->getId(), - 'username' => $user->getUsernameCanonical(), - ]); - - ['secret' => $secret, 'id' => $id, 'expiration' => $expirationTs] - = $this->eventsOnUserSubscriptionCreator->createSubscriptionForUser($user, $expiration); - $this->mapCalendarToUser->writeSubscriptionMetadata($user, $expirationTs, $id, $secret); - - if (0 !== $expirationTs) { - ++$created; - } else { - $this->logger->warning(self::class . ' could not create subscription for a user', [ - 'userId' => $user->getId(), - 'username' => $user->getUsernameCanonical(), - ]); - } - } - } - - ++$offset; + if (false === $u['enabled']) { + continue; } - $this->em->flush(); - $this->em->clear(); + $user = $this->userRepository->find($u['id']); + + if (null === $user) { + $this->logger->error("could not find user by id", ['uid' => $u['id']]); + $output->writeln("could not find user by id : " . $u['id']); + continue; + } + + if (!$this->mapCalendarToUser->hasUserId($user)) { + $user = $this->mapCalendarToUser->writeMetadata($user); + + // if user still does not have userid, continue + if (!$this->mapCalendarToUser->hasUserId($user)) { + $this->logger->warning("user does not have a counterpart on ms api", ['userId' => $user->getId(), 'email' => $user->getEmail()]); + $output->writeln(sprintf("giving up for user with email %s and id %s", $user->getEmail(), $user->getId())); + + continue; + } + } + + // sync user absence + try { + $this->userAbsenceSync->syncUserAbsence($user); + } catch (UserAbsenceSyncException $e) { + $this->logger->error("could not sync user absence", ['userId' => $user->getId(), 'email' => $user->getEmail(), 'exception' => $e->getTraceAsString(), "message" => $e->getMessage()]); + $output->writeln(sprintf("Could not sync user absence: id: %s and email: %s", $user->getId(), $user->getEmail())); + throw $e; + } + + // we first try to renew an existing subscription, if any. + // if not, or if it fails, we try to create a new one + if ($this->mapCalendarToUser->hasActiveSubscription($user)) { + $this->logger->debug(self::class . ' renew a subscription for', [ + 'userId' => $user->getId(), + 'username' => $user->getUsernameCanonical(), + ]); + + ['secret' => $secret, 'id' => $id, 'expiration' => $expirationTs] + = $this->eventsOnUserSubscriptionCreator->renewSubscriptionForUser($user, $expiration); + $this->mapCalendarToUser->writeSubscriptionMetadata($user, $expirationTs, $id, $secret); + + if (0 !== $expirationTs) { + ++$renewed; + } else { + $this->logger->warning(self::class . ' could not renew subscription for a user', [ + 'userId' => $user->getId(), + 'username' => $user->getUsernameCanonical(), + ]); + } + } + + if (!$this->mapCalendarToUser->hasActiveSubscription($user)) { + $this->logger->debug(self::class . ' create a subscription for', [ + 'userId' => $user->getId(), + 'username' => $user->getUsernameCanonical(), + ]); + + ['secret' => $secret, 'id' => $id, 'expiration' => $expirationTs] + = $this->eventsOnUserSubscriptionCreator->createSubscriptionForUser($user, $expiration); + $this->mapCalendarToUser->writeSubscriptionMetadata($user, $expirationTs, $id, $secret); + + if (0 !== $expirationTs) { + ++$created; + } else { + $this->logger->warning(self::class . ' could not create subscription for a user', [ + 'userId' => $user->getId(), + 'username' => $user->getUsernameCanonical(), + ]); + } + } + + + if (0 === $offset % $limit) { + $this->em->flush(); + $this->em->clear(); + } } + $this->em->flush(); + $this->em->clear(); + $this->logger->warning(self::class . ' process executed', [ 'created' => $created, 'renewed' => $renewed, ]); + $output->writeln("users synchronized"); + return 0; } @@ -152,7 +166,7 @@ class MapAndSubscribeUserCalendarCommand extends Command parent::configure(); $this - ->setDescription('MSGraph: collect user metadata and create subscription on events for users') + ->setDescription('MSGraph: collect user metadata and create subscription on events for users, and sync the user absence-presence') ->addOption( 'renew-before-end-interval', 'r', diff --git a/src/Bundle/ChillCalendarBundle/Exception/UserAbsenceSyncException.php b/src/Bundle/ChillCalendarBundle/Exception/UserAbsenceSyncException.php new file mode 100644 index 000000000..a5e5a679a --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Exception/UserAbsenceSyncException.php @@ -0,0 +1,20 @@ +'msgraph' ?? 'subscription_events_expiration' - OR (attributes->'msgraph' ?? 'subscription_events_expiration' AND (attributes->'msgraph'->>'subscription_events_expiration')::int < EXTRACT(EPOCH FROM (NOW() + :interval::interval))) - LIMIT :limit OFFSET :offset - ; - SQL; - - private EntityManagerInterface $entityManager; - - public function __construct(EntityManagerInterface $entityManager) - { - $this->entityManager = $entityManager; - } - - public function countByMostOldSubscriptionOrWithoutSubscriptionOrData(DateInterval $interval): int - { - $rsm = new ResultSetMapping(); - $rsm->addScalarResult('c', 'c'); - - $sql = strtr(self::MOST_OLD_SUBSCRIPTION_OR_ANY_MS_GRAPH, [ - '{select}' => 'COUNT(u) AS c', - 'LIMIT :limit OFFSET :offset' => '', - ]); - - return $this->entityManager->createNativeQuery($sql, $rsm)->setParameters([ - 'interval' => $interval, - ])->getSingleScalarResult(); - } - - /** - * @return array|User[] - */ - public function findByMostOldSubscriptionOrWithoutSubscriptionOrData(DateInterval $interval, int $limit = 50, int $offset = 0): array - { - $rsm = new ResultSetMappingBuilder($this->entityManager); - $rsm->addRootEntityFromClassMetadata(User::class, 'u'); - - return $this->entityManager->createNativeQuery( - strtr(self::MOST_OLD_SUBSCRIPTION_OR_ANY_MS_GRAPH, ['{select}' => $rsm->generateSelectClause()]), - $rsm - )->setParameters([ - 'interval' => $interval, - 'limit' => $limit, - 'offset' => $offset, - ])->getResult(); - } -} diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/MSUserAbsenceReader.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/MSUserAbsenceReader.php new file mode 100644 index 000000000..c70072a47 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/MSUserAbsenceReader.php @@ -0,0 +1,69 @@ +mapCalendarToUser->getUserId($user); + + if (null === $id) { + return null; + } + + try { + $automaticRepliesSettings = $this->machineHttpClient + ->request('GET', 'users/' . $id . '/mailboxSettings/automaticRepliesSetting') + ->toArray(true); + } catch (ClientExceptionInterface|DecodingExceptionInterface|RedirectionExceptionInterface|TransportExceptionInterface $e) { + throw new UserAbsenceSyncException("Error receiving response for mailboxSettings", 0, $e); + } catch (ServerExceptionInterface $e) { + throw new UserAbsenceSyncException("Server error receiving response for mailboxSettings", 0, $e); + } + + if (!array_key_exists("status", $automaticRepliesSettings)) { + throw new \LogicException("no key \"status\" on automatic replies settings: " . json_encode($automaticRepliesSettings, JSON_THROW_ON_ERROR)); + } + + return match ($automaticRepliesSettings['status']) { + 'disabled' => false, + 'alwaysEnabled' => true, + 'scheduled' => + RemoteEventConverter::convertStringDateWithoutTimezone($automaticRepliesSettings['scheduledStartDateTime']['dateTime']) < $this->clock->now() + && RemoteEventConverter::convertStringDateWithoutTimezone($automaticRepliesSettings['scheduledEndDateTime']['dateTime']) > $this->clock->now(), + default => throw new UserAbsenceSyncException("this status is not documented by Microsoft") + }; + } + +} diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/MSUserAbsenceReaderInterface.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/MSUserAbsenceReaderInterface.php new file mode 100644 index 000000000..a918bb7ea --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/MSGraph/MSUserAbsenceReaderInterface.php @@ -0,0 +1,22 @@ +absenceReader->isUserAbsent($user); + + if (null === $absence) { + return; + } + + if ($absence === $user->isAbsent()) { + // nothing to do + return; + } + + $this->logger->info("will change user absence", ['userId' => $user->getId()]); + + if ($absence) { + $this->logger->debug("make user absent", ['userId' => $user->getId()]); + $user->setAbsenceStart($this->clock->now()); + } else { + $this->logger->debug("make user present", ['userId' => $user->getId()]); + $user->setAbsenceStart(null); + } + } +} diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/DependencyInjection/RemoteCalendarCompilerPass.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/DependencyInjection/RemoteCalendarCompilerPass.php index f56735de7..6a986c9db 100644 --- a/src/Bundle/ChillCalendarBundle/RemoteCalendar/DependencyInjection/RemoteCalendarCompilerPass.php +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/DependencyInjection/RemoteCalendarCompilerPass.php @@ -23,6 +23,8 @@ use Chill\CalendarBundle\Command\MapAndSubscribeUserCalendarCommand; use Chill\CalendarBundle\Controller\RemoteCalendarConnectAzureController; use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\MachineHttpClient; use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\MachineTokenStorage; +use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\MSUserAbsenceReaderInterface; +use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\MSUserAbsenceSync; use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraphRemoteCalendarConnector; use Chill\CalendarBundle\RemoteCalendar\Connector\NullRemoteCalendarConnector; use Chill\CalendarBundle\RemoteCalendar\Connector\RemoteCalendarConnectorInterface; @@ -37,17 +39,13 @@ class RemoteCalendarCompilerPass implements CompilerPassInterface public function process(ContainerBuilder $container) { $config = $container->getParameter('chill_calendar'); - $connector = null; - if (!$config['remote_calendars_sync']['enabled']) { - $connector = NullRemoteCalendarConnector::class; - } - - if ($config['remote_calendars_sync']['microsoft_graph']['enabled']) { + if (true === $config['remote_calendars_sync']['microsoft_graph']['enabled']) { $connector = MSGraphRemoteCalendarConnector::class; $container->setAlias(HttpClientInterface::class . ' $machineHttpClient', MachineHttpClient::class); } else { + $connector = NullRemoteCalendarConnector::class; // remove services which cannot be loaded $container->removeDefinition(MapAndSubscribeUserCalendarCommand::class); $container->removeDefinition(AzureGrantAdminConsentAndAcquireToken::class); @@ -55,16 +53,14 @@ class RemoteCalendarCompilerPass implements CompilerPassInterface $container->removeDefinition(MachineTokenStorage::class); $container->removeDefinition(MachineHttpClient::class); $container->removeDefinition(MSGraphRemoteCalendarConnector::class); + $container->removeDefinition(MSUserAbsenceReaderInterface::class); + $container->removeDefinition(MSUserAbsenceSync::class); } if (!$container->hasAlias(Azure::class) && $container->hasDefinition('knpu.oauth2.client.azure')) { $container->setAlias(Azure::class, 'knpu.oauth2.provider.azure'); } - if (null === $connector) { - throw new RuntimeException('Could not configure remote calendar'); - } - foreach ([ NullRemoteCalendarConnector::class, MSGraphRemoteCalendarConnector::class, ] as $serviceId) { diff --git a/src/Bundle/ChillCalendarBundle/Tests/RemoteCalendar/Connector/MSGraph/MSUserAbsenceReaderTest.php b/src/Bundle/ChillCalendarBundle/Tests/RemoteCalendar/Connector/MSGraph/MSUserAbsenceReaderTest.php new file mode 100644 index 000000000..089477fda --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Tests/RemoteCalendar/Connector/MSGraph/MSUserAbsenceReaderTest.php @@ -0,0 +1,176 @@ +prophesize(MapCalendarToUser::class); + $mapUser->getUserId($user)->willReturn('1234'); + $clock = new MockClock(new \DateTimeImmutable('2023-07-07T12:00:00')); + + $absenceReader = new MSUserAbsenceReader($client, $mapUser->reveal(), $clock); + + self::assertEquals($expected, $absenceReader->isUserAbsent($user), $message); + } + + public function testIsUserAbsentWithoutRemoteId(): void + { + $user = new User(); + $client = new MockHttpClient(); + + $mapUser = $this->prophesize(MapCalendarToUser::class); + $mapUser->getUserId($user)->willReturn(null); + $clock = new MockClock(new \DateTimeImmutable('2023-07-07T12:00:00')); + + $absenceReader = new MSUserAbsenceReader($client, $mapUser->reveal(), $clock); + + self::assertNull($absenceReader->isUserAbsent($user), "when no user found, absence should be null"); + } + + public function provideDataTestUserAbsence(): iterable + { + // contains data that was retrieved from microsoft graph api on 2023-07-06 + + yield [ + <<<'JSON' + { + "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users('4feb0ae3-7ffb-48dd-891e-c86b2cdeefd4')/mailboxSettings/automaticRepliesSetting", + "status": "disabled", + "externalAudience": "none", + "internalReplyMessage": "Je suis en congé.", + "externalReplyMessage": "", + "scheduledStartDateTime": { + "dateTime": "2023-07-06T12:00:00.0000000", + "timeZone": "UTC" + }, + "scheduledEndDateTime": { + "dateTime": "2023-07-07T12:00:00.0000000", + "timeZone": "UTC" + } + } + JSON, + false, + "User is present" + ]; + + yield [ + <<<'JSON' + { + "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users('4feb0ae3-7ffb-48dd-891e-c86b2cdeefd4')/mailboxSettings/automaticRepliesSetting", + "status": "scheduled", + "externalAudience": "none", + "internalReplyMessage": "Je suis en congé.", + "externalReplyMessage": "", + "scheduledStartDateTime": { + "dateTime": "2023-07-06T11:00:00.0000000", + "timeZone": "UTC" + }, + "scheduledEndDateTime": { + "dateTime": "2023-07-21T11:00:00.0000000", + "timeZone": "UTC" + } + } + JSON, + true, + 'User is absent with absence scheduled, we are within this period' + ]; + + yield [ + <<<'JSON' + { + "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users('4feb0ae3-7ffb-48dd-891e-c86b2cdeefd4')/mailboxSettings/automaticRepliesSetting", + "status": "scheduled", + "externalAudience": "none", + "internalReplyMessage": "Je suis en congé.", + "externalReplyMessage": "", + "scheduledStartDateTime": { + "dateTime": "2023-07-08T11:00:00.0000000", + "timeZone": "UTC" + }, + "scheduledEndDateTime": { + "dateTime": "2023-07-21T11:00:00.0000000", + "timeZone": "UTC" + } + } + JSON, + false, + 'User is present: absence is scheduled for later' + ]; + + yield [ + <<<'JSON' + { + "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users('4feb0ae3-7ffb-48dd-891e-c86b2cdeefd4')/mailboxSettings/automaticRepliesSetting", + "status": "scheduled", + "externalAudience": "none", + "internalReplyMessage": "Je suis en congé.", + "externalReplyMessage": "", + "scheduledStartDateTime": { + "dateTime": "2023-07-05T11:00:00.0000000", + "timeZone": "UTC" + }, + "scheduledEndDateTime": { + "dateTime": "2023-07-06T11:00:00.0000000", + "timeZone": "UTC" + } + } + JSON, + false, + 'User is present: absence is past' + ]; + + yield [ + <<<'JSON' + { + "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users('4feb0ae3-7ffb-48dd-891e-c86b2cdeefd4')/mailboxSettings/automaticRepliesSetting", + "status": "alwaysEnabled", + "externalAudience": "none", + "internalReplyMessage": "Je suis en congé.", + "externalReplyMessage": "", + "scheduledStartDateTime": { + "dateTime": "2023-07-06T12:00:00.0000000", + "timeZone": "UTC" + }, + "scheduledEndDateTime": { + "dateTime": "2023-07-07T12:00:00.0000000", + "timeZone": "UTC" + } + } + JSON, + true, + "User is absent: absence is always enabled" + ]; + } + +} diff --git a/src/Bundle/ChillCalendarBundle/Tests/RemoteCalendar/Connector/MSGraph/MSUserAbsenceSyncTest.php b/src/Bundle/ChillCalendarBundle/Tests/RemoteCalendar/Connector/MSGraph/MSUserAbsenceSyncTest.php new file mode 100644 index 000000000..1b0f1e416 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Tests/RemoteCalendar/Connector/MSGraph/MSUserAbsenceSyncTest.php @@ -0,0 +1,68 @@ +prophesize(MSUserAbsenceReaderInterface::class); + $userAbsenceReader->isUserAbsent($user)->willReturn($absenceFromMicrosoft); + + $clock = new MockClock(new \DateTimeImmutable('2023-07-01T12:00:00')); + + $syncer = new MSUserAbsenceSync($userAbsenceReader->reveal(), $clock, new NullLogger()); + + $syncer->syncUserAbsence($user); + + self::assertEquals($expectedAbsence, $user->isAbsent(), $message); + self::assertEquals($expectedAbsenceStart, $user->getAbsenceStart(), $message); + } + + public function provideDataTestSyncUserAbsence(): iterable + { + yield [new User(), false, false, null, "user present remains present"]; + yield [new User(), true, true, new \DateTimeImmutable('2023-07-01T12:00:00'), "user present becomes absent"]; + + $user = new User(); + $user->setAbsenceStart($abs = new \DateTimeImmutable("2023-07-01T12:00:00")); + yield [$user, true, true, $abs, "user absent remains absent"]; + + $user = new User(); + $user->setAbsenceStart($abs = new \DateTimeImmutable("2023-07-01T12:00:00")); + yield [$user, false, false, null, "user absent becomes present"]; + + yield [new User(), null, false, null, "user not syncable: presence do not change"]; + + $user = new User(); + $user->setAbsenceStart($abs = new \DateTimeImmutable("2023-07-01T12:00:00")); + yield [$user, null, true, $abs, "user not syncable: absence do not change"]; + } +}