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,127 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service\Cron;
|
||||
|
||||
use App\Config\EmailTextKey;
|
||||
use App\Email\Mailer;
|
||||
use App\Entity\Embeddable\Communication;
|
||||
use App\Entity\Teamer;
|
||||
use App\Entity\TeamerWorkMonth;
|
||||
use App\Repository\TeamerWorkMonthRepository;
|
||||
use App\Service\Cron\WorkMonthReminderService;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
|
||||
/**
|
||||
* Pins the rules the reminder applies: which day it runs on, how long it waits after an
|
||||
* import, and who it can actually reach. The query itself is the repository's business.
|
||||
*/
|
||||
class WorkMonthReminderServiceTest extends TestCase
|
||||
{
|
||||
private TeamerWorkMonthRepository&MockObject $repository;
|
||||
private Mailer&MockObject $mailer;
|
||||
private WorkMonthReminderService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repository = $this->createMock(TeamerWorkMonthRepository::class);
|
||||
$this->mailer = $this->createMock(Mailer::class);
|
||||
|
||||
$this->service = new WorkMonthReminderService(
|
||||
$this->repository,
|
||||
$this->mailer,
|
||||
new NullLogger(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* app:cron runs daily, so the weekday gate is the only thing stopping a teamer being
|
||||
* chased every morning.
|
||||
*/
|
||||
public function testItOnlyRunsOnMondays(): void
|
||||
{
|
||||
$this->repository->expects($this->never())->method('findAwaitingDocument');
|
||||
$this->mailer->expects($this->never())->method('createAndSendText');
|
||||
|
||||
$this->assertSame(
|
||||
'Work month reminders are only sent on mondays',
|
||||
$this->service->sendReminders(new \DateTimeImmutable('2026-03-10')),
|
||||
);
|
||||
}
|
||||
|
||||
public function testItLeavesTheTeamerAGracePeriodAfterTheImport(): void
|
||||
{
|
||||
$this->repository
|
||||
->expects($this->once())
|
||||
->method('findAwaitingDocument')
|
||||
->with($this->callback(
|
||||
static fn (\DateTimeImmutable $cutoff) => '2026-03-04' === $cutoff->format('Y-m-d')
|
||||
))
|
||||
->willReturn([])
|
||||
;
|
||||
|
||||
// 9 March 2026 is a Monday; the import ran on the 10th of the month before.
|
||||
$this->service->sendReminders(new \DateTimeImmutable('2026-03-09'));
|
||||
}
|
||||
|
||||
public function testItMailsOneReminderPerOpenMonth(): void
|
||||
{
|
||||
$this->repository->method('findAwaitingDocument')->willReturn([
|
||||
$this->createWorkMonth(2026, 1),
|
||||
$this->createWorkMonth(2026, 2),
|
||||
]);
|
||||
|
||||
$sent = [];
|
||||
$this->mailer
|
||||
->expects($this->exactly(2))
|
||||
->method('createAndSendText')
|
||||
->willReturnCallback(function (EmailTextKey $key, array $placeholders, array $options) use (&$sent) {
|
||||
$sent[] = [$key, $placeholders, $options];
|
||||
})
|
||||
;
|
||||
|
||||
$this->assertSame(
|
||||
'Sent 2 work month reminders to teamers',
|
||||
$this->service->sendReminders(new \DateTimeImmutable('2026-03-09')),
|
||||
);
|
||||
|
||||
$this->assertSame(EmailTextKey::REMINDER_WORK_MONTH_UPLOAD, $sent[0][0]);
|
||||
$this->assertSame('Januar 2026', $sent[0][1]['workMonth']);
|
||||
$this->assertSame('Februar 2026', $sent[1][1]['workMonth']);
|
||||
$this->assertSame('[email protected]', $sent[0][2]['to']);
|
||||
}
|
||||
|
||||
public function testATeamerWithoutAnAddressIsSkippedSilently(): void
|
||||
{
|
||||
$this->repository->method('findAwaitingDocument')->willReturn([
|
||||
$this->createWorkMonth(2026, 1, null),
|
||||
$this->createWorkMonth(2026, 2, ' '),
|
||||
$this->createWorkMonth(2026, 3),
|
||||
]);
|
||||
|
||||
$this->mailer->expects($this->once())->method('createAndSendText');
|
||||
|
||||
$this->assertSame(
|
||||
'Sent 1 work month reminders to teamers',
|
||||
$this->service->sendReminders(new \DateTimeImmutable('2026-03-09')),
|
||||
);
|
||||
}
|
||||
|
||||
private function createWorkMonth(int $year, int $month, ?string $email = '[email protected]'): TeamerWorkMonth
|
||||
{
|
||||
$communication = new Communication();
|
||||
$communication->setEmail($email);
|
||||
|
||||
$teamer = new Teamer();
|
||||
$teamer->setFirstName('Theo')->setLastName('Test')->setCommunication($communication);
|
||||
|
||||
return (new TeamerWorkMonth())
|
||||
->setTeamer($teamer)
|
||||
->setYear($year)
|
||||
->setMonth($month)
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Service\Pdf;
|
||||
|
||||
use App\Entity\Teamer;
|
||||
use App\Entity\TeamerWorkMonth;
|
||||
use App\Model\WorkMonthDays;
|
||||
use App\Service\Pdf\WorkMonthRenderer;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Twig\Environment;
|
||||
|
||||
/**
|
||||
* Renders a real document rather than mocking Twig: the point of the test is that the
|
||||
* template, the duration filter and Dompdf agree with each other.
|
||||
*/
|
||||
class WorkMonthRendererTest extends WebTestCase
|
||||
{
|
||||
public function testItRendersAPdf(): void
|
||||
{
|
||||
$pdf = $this->render($this->createWorkMonth());
|
||||
|
||||
$this->assertStringStartsWith('%PDF', $pdf);
|
||||
$this->assertGreaterThan(2000, strlen($pdf), 'A month of rows should not fit in a stub.');
|
||||
}
|
||||
|
||||
/**
|
||||
* A month whose days the SaaS never reported still has to produce a full sheet, so the
|
||||
* teamer can write on the empty lines.
|
||||
*/
|
||||
public function testItPlacesTheLogoInTheTopRightCorner(): void
|
||||
{
|
||||
$pdf = $this->render($this->createWorkMonth());
|
||||
|
||||
// A4 is 595.28 x 841.89pt and the page margins are 15mm at the side, 18mm at the top,
|
||||
// so the content corner sits at 552.76 / 790.87. Asserting the placement rather than
|
||||
// the markup is the only way to know the logo actually landed there.
|
||||
$this->assertSame(1, preg_match(
|
||||
'#([\d.]+) 0 0 ([\d.]+) ([\d.]+) ([\d.]+) cm\s*/\w+ Do#',
|
||||
$this->contentStreams($pdf),
|
||||
$placement
|
||||
), 'The logo should be drawn exactly once.');
|
||||
|
||||
[, $width, $height, $x, $y] = array_map('floatval', $placement);
|
||||
|
||||
$this->assertEqualsWithDelta(552.76, $x + $width, 1.0, 'Flush with the right margin.');
|
||||
$this->assertEqualsWithDelta(790.87, $y + $height, 1.0, 'Flush with the top margin.');
|
||||
$this->assertEqualsWithDelta(230 / 70, $width / $height, 0.05, 'The logo must not be stretched.');
|
||||
}
|
||||
|
||||
/**
|
||||
* A working-time record is worth printing without its letterhead.
|
||||
*/
|
||||
public function testItRendersWithoutALogo(): void
|
||||
{
|
||||
$renderer = new WorkMonthRenderer(
|
||||
static::getContainer()->get(Environment::class),
|
||||
['project_dir' => sys_get_temp_dir()],
|
||||
);
|
||||
|
||||
$this->assertStringStartsWith('%PDF', $renderer->render($this->createWorkMonth()));
|
||||
}
|
||||
|
||||
private function contentStreams(string $pdf): string
|
||||
{
|
||||
preg_match_all('#stream\r?\n(.*?)endstream#s', $pdf, $matches);
|
||||
|
||||
$decoded = '';
|
||||
|
||||
foreach ($matches[1] as $stream) {
|
||||
$inflated = @gzuncompress(ltrim($stream, "\r\n"));
|
||||
|
||||
if (false !== $inflated) {
|
||||
$decoded .= $inflated;
|
||||
}
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
public function testItRendersAMonthWithoutAnyBookings(): void
|
||||
{
|
||||
$workMonth = (new TeamerWorkMonth())
|
||||
->setTeamer($this->createTeamer())
|
||||
->setYear(2026)
|
||||
->setMonth(2)
|
||||
->setDays(WorkMonthDays::fromArray([]))
|
||||
;
|
||||
|
||||
$this->assertStringStartsWith('%PDF', $this->render($workMonth));
|
||||
}
|
||||
|
||||
public function testTheFilenameCarriesTheTeamerAndTheMonth(): void
|
||||
{
|
||||
$this->assertSame(
|
||||
'Arbeitszeitnachweis_Test_2026-03.pdf',
|
||||
$this->createRenderer()->filename($this->createWorkMonth()),
|
||||
);
|
||||
}
|
||||
|
||||
public function testTheFilenameSurvivesAnAwkwardName(): void
|
||||
{
|
||||
$renderer = $this->createRenderer();
|
||||
$workMonth = $this->createWorkMonth();
|
||||
$workMonth->getTeamer()->setLastName('Müller-Lüdenscheidt');
|
||||
|
||||
$this->assertSame('Arbeitszeitnachweis_M_ller_L_denscheidt_2026-03.pdf', $renderer->filename($workMonth));
|
||||
}
|
||||
|
||||
private function render(TeamerWorkMonth $workMonth): string
|
||||
{
|
||||
return $this->createRenderer()->render($workMonth);
|
||||
}
|
||||
|
||||
private function createRenderer(): WorkMonthRenderer
|
||||
{
|
||||
$projectDir = static::getContainer()->getParameter('kernel.project_dir');
|
||||
|
||||
/** @var Environment $twig */
|
||||
$twig = static::getContainer()->get(Environment::class);
|
||||
|
||||
return new WorkMonthRenderer($twig, ['project_dir' => $projectDir]);
|
||||
}
|
||||
|
||||
private function createWorkMonth(): TeamerWorkMonth
|
||||
{
|
||||
return (new TeamerWorkMonth())
|
||||
->setTeamer($this->createTeamer())
|
||||
->setYear(2026)
|
||||
->setMonth(3)
|
||||
->setDays(WorkMonthDays::fromArray([
|
||||
'2026-03-01' => ['minutes' => 285],
|
||||
'2026-03-05' => ['minutes' => 0, 'absence' => 'Ruhetag 24/25'],
|
||||
'2026-03-12' => ['minutes' => 510],
|
||||
]))
|
||||
->setSourceIstMinutes(795)
|
||||
;
|
||||
}
|
||||
|
||||
private function createTeamer(): Teamer
|
||||
{
|
||||
return (new Teamer())->setFirstName('Theo')->setLastName('Test');
|
||||
}
|
||||
}
|
||||
@@ -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