apiClient = $this->createMock(ApiClient::class); $this->workMonthRepository = $this->createMock(TeamerWorkMonthRepository::class); $this->entityManager = $this->createMock(EntityManagerInterface::class); $this->employeeDataProvider = $this->createMock(EmployeeDataProvider::class); // Registered by default; the tests that care say otherwise. $this->employeeDataProvider->method('getDirectory')->willReturn( EmployeeDirectory::fromEmployees([ new Employee('theo@example.com', 'e-1', 'Operations', false), ]) ); $this->synchronizer = new WorkHoursSynchronizer( $this->apiClient, new ResponseParser(), $this->employeeDataProvider, $this->createMock(TeamerRepository::class), $this->workMonthRepository, $this->entityManager, new NullLogger(), ); } public function testATeamerWithoutAnAddressIsSkippedRatherThanFailed(): void { $this->apiClient->expects($this->never())->method('getTimeAccount'); $result = $this->synchronizer->syncTeamer($this->createTeamer(null), 2026, 3); $this->assertSame(WorkHoursSyncOutcome::SKIPPED, $result->getOutcome()); $this->assertFalse($result->isFailure()); } /** * One unreachable teamer must not take the rest of the month's import down with it. */ public function testARequestFailureIsReportedWithoutWriting(): void { $this->apiClient->method('getTimeAccount')->willThrowException(new ApiClientException('gateway said no')); $this->entityManager->expects($this->never())->method('flush'); $result = $this->synchronizer->syncTeamer($this->createTeamer(), 2026, 3); $this->assertSame(WorkHoursSyncOutcome::FAILED, $result->getOutcome()); $this->assertTrue($result->isFailure()); $this->assertSame('gateway said no', $result->getMessage()); } /** * Catching this up front turns N failed requests into one legible line. */ public function testATeamerMissingFromTheRosterIsSkippedWithoutAnyRequest(): void { $provider = $this->createMock(EmployeeDataProvider::class); $provider->method('getDirectory')->willReturn( EmployeeDirectory::fromEmployees([new Employee('someone.else@example.com', 'e-9', 'Service', false)]) ); $apiClient = $this->createMock(ApiClient::class); $apiClient->expects($this->never())->method('getTimeAccount'); $synchronizer = new WorkHoursSynchronizer( $apiClient, new ResponseParser(), $provider, $this->createMock(TeamerRepository::class), $this->workMonthRepository, $this->entityManager, new NullLogger(), ); $result = $synchronizer->syncTeamer($this->createTeamer(), 2026, 3); $this->assertSame(WorkHoursSyncOutcome::SKIPPED, $result->getOutcome()); $this->assertStringContainsString('Nicht in der SaaS registriert', $result->getMessage()); } /** * An unreachable roster comes back empty. Reading that as "nobody is registered" would * silently skip the entire month. */ public function testAnUnreachableRosterDoesNotSkipEveryone(): void { $provider = $this->createMock(EmployeeDataProvider::class); $provider->method('getDirectory')->willReturn(EmployeeDirectory::fromEmployees([])); $this->apiClient->method('getTimeAccount')->willReturn($this->payload()); $this->workMonthRepository->method('findOneForTeamerAndMonth')->willReturn(null); $synchronizer = new WorkHoursSynchronizer( $this->apiClient, new ResponseParser(), $provider, $this->createMock(TeamerRepository::class), $this->workMonthRepository, $this->entityManager, new NullLogger(), ); $this->assertSame( WorkHoursSyncOutcome::IMPORTED, $synchronizer->syncTeamer($this->createTeamer(), 2026, 3)->getOutcome(), ); } /** * A month of nothing but holiday carries no booking to read a department from; the roster * is the fallback that keeps the sheet's header complete. */ public function testTheRosterSuppliesTheDepartmentWhenTheBookingsCannot(): void { $teamer = $this->createTeamer(); $this->workMonthRepository->method('findOneForTeamerAndMonth')->willReturn(null); $this->apiClient->method('getTimeAccount')->willReturn([ 'employee' => 'e-1', 'year' => 2026, 'month' => 2, 'ist' => 0, 'list' => [ ['date' => '2026-02-02', 'annotation' => -4, 'ist' => 0, 'holidayType' => 'Ferien'], ], ]); $persisted = null; $this->entityManager->method('persist')->willReturnCallback(function (object $entity) use (&$persisted) { $persisted = $entity; }); $this->synchronizer->syncTeamer($teamer, 2026, 3); $this->assertSame('Operations', $persisted->getDepartment()); } public function testANewMonthIsCreatedAndLearnsTheEmployeeId(): void { $teamer = $this->createTeamer(); $this->apiClient->method('getTimeAccount')->willReturn($this->payload()); $this->workMonthRepository->method('findOneForTeamerAndMonth')->willReturn(null); $persisted = null; $this->entityManager ->expects($this->once()) ->method('persist') ->willReturnCallback(function (object $entity) use (&$persisted) { $persisted = $entity; }) ; $this->entityManager->expects($this->once())->method('flush'); $result = $this->synchronizer->syncTeamer($teamer, 2026, 3); $this->assertSame(WorkHoursSyncOutcome::IMPORTED, $result->getOutcome()); $this->assertInstanceOf(TeamerWorkMonth::class, $persisted); $this->assertSame(2026, $persisted->getYear()); $this->assertSame(3, $persisted->getMonth()); $this->assertSame(345, $persisted->getTotalMinutes()); $this->assertSame(345, $persisted->getSourceIstMinutes()); $this->assertSame(2, $persisted->getWorkedDays()); $this->assertSame(1, $persisted->getAbsenceDays()); $this->assertSame(WorkMonthStatus::OPEN, $persisted->getStatus()); $this->assertSame('e-1', $teamer->getSaasEmployeeUuid()); } public function testImportingTwiceLeavesOneRecordWithTheSameFigures(): void { $teamer = $this->createTeamer(); $existing = $this->createWorkMonth($teamer, WorkMonthStatus::OPEN); $this->apiClient->method('getTimeAccount')->willReturn($this->payload()); $this->workMonthRepository->method('findOneForTeamerAndMonth')->willReturn($existing); $this->entityManager->expects($this->never())->method('persist'); $this->synchronizer->syncTeamer($teamer, 2026, 3); $this->synchronizer->syncTeamer($teamer, 2026, 3); $this->assertSame(345, $existing->getTotalMinutes()); } /** * The heart of the lock: a signed month keeps the hours that were signed, and the fresh * figures only leave a trace that the SaaS has changed its mind. */ public function testASubmittedMonthIsComparedButNeverOverwritten(): void { $teamer = $this->createTeamer(); $existing = $this->createWorkMonth($teamer, WorkMonthStatus::SUBMITTED); $this->apiClient->method('getTimeAccount')->willReturn($this->payload(420, 60)); $this->workMonthRepository->method('findOneForTeamerAndMonth')->willReturn($existing); $result = $this->synchronizer->syncTeamer($teamer, 2026, 3); $this->assertSame(WorkHoursSyncOutcome::LOCKED, $result->getOutcome()); $this->assertSame(120, $existing->getTotalMinutes(), 'The signed hours must survive.'); $this->assertTrue($existing->hasMismatch()); $this->assertSame(480, $existing->getMismatchTotalMinutes()); $this->assertStringContainsString('480', $result->getMessage()); } public function testAnApprovedMonthThatStillAgreesClearsAnEarlierMismatch(): void { $teamer = $this->createTeamer(); $existing = $this->createWorkMonth($teamer, WorkMonthStatus::APPROVED); $existing->recordMismatch(999); $this->apiClient->method('getTimeAccount')->willReturn($this->payload(120, 0)); $this->workMonthRepository->method('findOneForTeamerAndMonth')->willReturn($existing); $result = $this->synchronizer->syncTeamer($teamer, 2026, 3); $this->assertSame(WorkHoursSyncOutcome::LOCKED, $result->getOutcome()); $this->assertFalse($existing->hasMismatch()); } /** * A rejection usually means the hours themselves were wrong, so that month is not * frozen - the next import has to be able to correct it. */ public function testARejectedMonthIsStillImported(): void { $teamer = $this->createTeamer(); $existing = $this->createWorkMonth($teamer, WorkMonthStatus::REJECTED); $this->apiClient->method('getTimeAccount')->willReturn($this->payload()); $this->workMonthRepository->method('findOneForTeamerAndMonth')->willReturn($existing); $result = $this->synchronizer->syncTeamer($teamer, 2026, 3); $this->assertSame(WorkHoursSyncOutcome::IMPORTED, $result->getOutcome()); $this->assertSame(345, $existing->getTotalMinutes()); } public function testADryRunWritesNothing(): void { $this->apiClient->method('getTimeAccount')->willReturn($this->payload()); $this->workMonthRepository->method('findOneForTeamerAndMonth')->willReturn(null); $this->entityManager->expects($this->never())->method('flush'); $result = $this->synchronizer->syncTeamer($this->createTeamer(), 2026, 3, true); $this->assertSame(WorkHoursSyncOutcome::IMPORTED, $result->getOutcome()); } public function testParserWarningsTravelOntoTheRecord(): void { $teamer = $this->createTeamer(); $payload = $this->payload(); $payload['list'][] = ['date' => '2026-02-11', 'annotation' => -2, 'ist' => 0]; $this->apiClient->method('getTimeAccount')->willReturn($payload); $this->workMonthRepository->method('findOneForTeamerAndMonth')->willReturn(null); $persisted = null; $this->entityManager->method('persist')->willReturnCallback(function (object $entity) use (&$persisted) { $persisted = $entity; }); $result = $this->synchronizer->syncTeamer($teamer, 2026, 3); $this->assertSame(['Unknown row kind -2 on 2026-03-11.'], $result->getWarnings()); $this->assertSame(['Unknown row kind -2 on 2026-03-11.'], $persisted->getWarnings()); } /** * March, requested as index 2, so the payload's dates read "2026-02-xx". */ private function payload(int $firstDay = 285, int $secondDay = 60): array { return [ 'employee' => 'e-1', 'year' => 2026, 'month' => 2, 'ist' => $firstDay + $secondDay, 'list' => [ ['date' => '2026-02-02', 'annotation' => 8, 'ist' => $firstDay], ['date' => '2026-02-03', 'annotation' => 8, 'ist' => $secondDay], ['date' => '2026-02-05', 'annotation' => -4, 'ist' => 0, 'holidayType' => 'Ferien'], ], ]; } private function createTeamer(?string $email = 'theo@example.com'): Teamer { $teamer = new Teamer(); $teamer->setFirstName('Theo')->setLastName('Test'); $communication = new Communication(); $communication->setEmail($email); $teamer->setCommunication($communication); return $teamer; } private function createWorkMonth(Teamer $teamer, WorkMonthStatus $status): TeamerWorkMonth { return (new TeamerWorkMonth()) ->setTeamer($teamer) ->setYear(2026) ->setMonth(3) ->setDays(WorkMonthDays::fromArray(['2026-03-02' => ['minutes' => 120]])) ->setSourceIstMinutes(120) ->setStatus($status) ; } }