Files
myep-team/tests/Service/Saas/WorkHoursSynchronizerTest.php
2026-10-01 10:07:05 +02:00

343 lines
13 KiB
PHP

<?php
namespace App\Tests\Service\Saas;
use App\Entity\Embeddable\Communication;
use App\Entity\Teamer;
use App\Entity\TeamerWorkMonth;
use App\Enum\WorkHoursSyncOutcome;
use App\Enum\WorkMonthStatus;
use App\Model\WorkMonthDays;
use App\Repository\TeamerRepository;
use App\Repository\TeamerWorkMonthRepository;
use App\Saas\ApiClient;
use App\Saas\ApiClientException;
use App\Saas\DataProvider\EmployeeDataProvider;
use App\Saas\Model\Employee;
use App\Saas\Model\EmployeeDirectory;
use App\Saas\ResponseParser;
use App\Service\Saas\WorkHoursSynchronizer;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\MockObject\Stub;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
#[AllowMockObjectsWithoutExpectations]
class WorkHoursSynchronizerTest extends TestCase
{
private ApiClient&MockObject $apiClient;
private EmployeeDataProvider&Stub $employeeDataProvider;
private TeamerWorkMonthRepository&Stub $workMonthRepository;
private EntityManagerInterface&MockObject $entityManager;
private WorkHoursSynchronizer $synchronizer;
protected function setUp(): void
{
$this->apiClient = $this->createMock(ApiClient::class);
$this->workMonthRepository = $this->createStub(TeamerWorkMonthRepository::class);
$this->entityManager = $this->createMock(EntityManagerInterface::class);
$this->employeeDataProvider = $this->createStub(EmployeeDataProvider::class);
// Registered by default; the tests that care say otherwise.
$this->employeeDataProvider->method('getDirectory')->willReturn(
EmployeeDirectory::fromEmployees([
new Employee('[email protected]', 'e-1', 'Operations', false),
])
);
$this->synchronizer = new WorkHoursSynchronizer(
$this->apiClient,
new ResponseParser(),
$this->employeeDataProvider,
$this->createStub(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->createStub(EmployeeDataProvider::class);
$provider->method('getDirectory')->willReturn(
EmployeeDirectory::fromEmployees([new Employee('[email protected]', 'e-9', 'Service', false)])
);
$apiClient = $this->createMock(ApiClient::class);
$apiClient->expects($this->never())->method('getTimeAccount');
$synchronizer = new WorkHoursSynchronizer(
$apiClient,
new ResponseParser(),
$provider,
$this->createStub(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->createStub(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->createStub(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 = '[email protected]'): 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)
;
}
}