feat: import work hours from SaaS and exchange signed time sheet
This commit is contained in:
1 parent
7cf39850e6
commit
1a7c272414
73 files changed
+10488
-72
No files matched your search
@@ -0,0 +1,339 @@
|
||||
<?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\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
|
||||
class WorkHoursSynchronizerTest extends TestCase
|
||||
{
|
||||
private ApiClient&MockObject $apiClient;
|
||||
private EmployeeDataProvider&MockObject $employeeDataProvider;
|
||||
private TeamerWorkMonthRepository&MockObject $workMonthRepository;
|
||||
private EntityManagerInterface&MockObject $entityManager;
|
||||
private WorkHoursSynchronizer $synchronizer;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->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('[email protected]', '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('[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->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 = '[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)
|
||||
;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user