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,103 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Logging;
|
||||
|
||||
use App\Logging\RedactSaasCredentialsProcessor;
|
||||
use Monolog\Level;
|
||||
use Monolog\LogRecord;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class RedactSaasCredentialsProcessorTest extends TestCase
|
||||
{
|
||||
private const KEY = 'sk-live-0123456789abcdef';
|
||||
|
||||
/**
|
||||
* The line Symfony's http client writes for every request it makes.
|
||||
*/
|
||||
public function testItRedactsTheKeyOutOfARequestLine(): void
|
||||
{
|
||||
$record = $this->process(sprintf(
|
||||
'Request: "GET https://saas.example/api/v2/time/account/%s/theo%%40example.com/2026/2"',
|
||||
self::KEY,
|
||||
));
|
||||
|
||||
$this->assertStringNotContainsString(self::KEY, $record->message);
|
||||
$this->assertStringContainsString('/time/account/[redacted]', $record->message);
|
||||
$this->assertStringContainsString('theo%40example.com/2026/2', $record->message, 'Only the key goes.');
|
||||
}
|
||||
|
||||
/**
|
||||
* The one a silenced client would have missed: an HTTP error repeats the url inside the
|
||||
* exception message, which then travels into whichever channel catches it.
|
||||
*/
|
||||
public function testItRedactsTheKeyOutOfAnExceptionMessage(): void
|
||||
{
|
||||
$record = $this->process(
|
||||
'Work hours import failed',
|
||||
['error' => sprintf('HTTP 500 returned for "https://saas.example/api/v2/time/account/%s/[email protected]/2026/2".', self::KEY)],
|
||||
);
|
||||
|
||||
$this->assertStringNotContainsString(self::KEY, json_encode($record->context));
|
||||
$this->assertStringContainsString('[redacted]', $record->context['error']);
|
||||
}
|
||||
|
||||
public function testItRedactsTheRosterAndDepartmentEndpointsToo(): void
|
||||
{
|
||||
$record = $this->process(sprintf(
|
||||
'GET https://saas.example/api/v2/employees/%s and /api/v2/employee/departments/%s',
|
||||
self::KEY,
|
||||
self::KEY,
|
||||
));
|
||||
|
||||
$this->assertStringNotContainsString(self::KEY, $record->message);
|
||||
$this->assertStringContainsString('/employees/[redacted]', $record->message);
|
||||
$this->assertStringContainsString('/employee/departments/[redacted]', $record->message);
|
||||
}
|
||||
|
||||
/**
|
||||
* The roster hands out a key per employee. None is configured here, so only the pattern
|
||||
* can catch those - which is why the pattern pass exists alongside the exact one.
|
||||
*/
|
||||
public function testAnUnknownKeyInAKeyPositionIsStillRedacted(): void
|
||||
{
|
||||
$record = $this->process('Request: "GET https://saas.example/api/v2/time/account/some-other-key/[email protected]/2026/2"');
|
||||
|
||||
$this->assertStringNotContainsString('some-other-key', $record->message);
|
||||
}
|
||||
|
||||
public function testItReachesIntoNestedContext(): void
|
||||
{
|
||||
$record = $this->process('failed', [
|
||||
'request' => ['url' => 'https://saas.example/api/v2/employees/'.self::KEY],
|
||||
]);
|
||||
|
||||
$this->assertStringNotContainsString(self::KEY, json_encode($record->context));
|
||||
}
|
||||
|
||||
public function testItLeavesOrdinaryRecordsAlone(): void
|
||||
{
|
||||
$record = $this->process('Work hours imported', ['teamer' => 'Test, Theo', 'minutes' => 480]);
|
||||
|
||||
$this->assertSame('Work hours imported', $record->message);
|
||||
$this->assertSame(['teamer' => 'Test, Theo', 'minutes' => 480], $record->context);
|
||||
}
|
||||
|
||||
/**
|
||||
* An unconfigured key is an empty string, which must never be treated as a secret to
|
||||
* replace - that would rewrite every character of every message.
|
||||
*/
|
||||
public function testAnUnconfiguredKeyRedactsNothingByValue(): void
|
||||
{
|
||||
$processor = new RedactSaasCredentialsProcessor('');
|
||||
$record = $processor(new LogRecord(new \DateTimeImmutable(), 'app', Level::Info, 'plain message'));
|
||||
|
||||
$this->assertSame('plain message', $record->message);
|
||||
}
|
||||
|
||||
private function process(string $message, array $context = []): LogRecord
|
||||
{
|
||||
$processor = new RedactSaasCredentialsProcessor(self::KEY);
|
||||
|
||||
return $processor(new LogRecord(new \DateTimeImmutable(), 'app', Level::Info, $message, $context));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Model;
|
||||
|
||||
use App\Model\WorkMonthDays;
|
||||
use App\Saas\Model\TimeAccount;
|
||||
use App\Saas\Model\TimeAccountDay;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class WorkMonthDaysTest extends TestCase
|
||||
{
|
||||
public function testItRoundTripsThroughTheStoredShape(): void
|
||||
{
|
||||
$stored = [
|
||||
'2026-03-01' => ['minutes' => 285],
|
||||
'2026-03-05' => ['minutes' => 0, 'absence' => 'Ruhetag 24/25'],
|
||||
];
|
||||
|
||||
$this->assertSame($stored, WorkMonthDays::fromArray($stored)->toArray());
|
||||
}
|
||||
|
||||
public function testItSortsAndDropsEntriesItCannotRead(): void
|
||||
{
|
||||
$days = WorkMonthDays::fromArray([
|
||||
'2026-03-05' => ['minutes' => 60],
|
||||
'not-a-date' => ['minutes' => 120],
|
||||
'2026-03-01' => ['minutes' => 30, 'absence' => ''],
|
||||
'2026-03-09' => 'not an entry',
|
||||
]);
|
||||
|
||||
$this->assertSame(['2026-03-01', '2026-03-05'], array_keys($days->toArray()));
|
||||
$this->assertArrayNotHasKey('absence', $days->toArray()['2026-03-01'], 'An empty label is no label.');
|
||||
}
|
||||
|
||||
public function testAMissingMinutesKeyCountsAsZero(): void
|
||||
{
|
||||
$days = WorkMonthDays::fromArray(['2026-03-01' => ['absence' => 'Ferien']]);
|
||||
|
||||
$this->assertSame(0, $days->getTotalMinutes());
|
||||
$this->assertSame(1, $days->countAbsenceDays());
|
||||
$this->assertSame(0, $days->countWorkedDays());
|
||||
}
|
||||
|
||||
public function testItSummarisesTheMonth(): void
|
||||
{
|
||||
$days = WorkMonthDays::fromArray([
|
||||
'2026-03-01' => ['minutes' => 285],
|
||||
'2026-03-05' => ['minutes' => 0, 'absence' => 'Ruhetag 24/25'],
|
||||
'2026-03-06' => ['minutes' => 600],
|
||||
]);
|
||||
|
||||
$this->assertSame(885, $days->getTotalMinutes());
|
||||
$this->assertSame(2, $days->countWorkedDays());
|
||||
$this->assertSame(1, $days->countAbsenceDays());
|
||||
}
|
||||
|
||||
public function testItBuildsFromATimeAccount(): void
|
||||
{
|
||||
$account = new TimeAccount('e-1', 2026, 3, 345, 9900, 'Operations', [
|
||||
'2026-03-01' => new TimeAccountDay(new \DateTimeImmutable('2026-03-01'), 285, null, '09:00', '14:45', 60),
|
||||
'2026-03-05' => new TimeAccountDay(new \DateTimeImmutable('2026-03-05'), 60, 'Ferien'),
|
||||
], []);
|
||||
|
||||
$this->assertSame([
|
||||
'2026-03-01' => ['minutes' => 285, 'break' => 60, 'start' => '09:00', 'end' => '14:45'],
|
||||
'2026-03-05' => ['minutes' => 60, 'absence' => 'Ferien'],
|
||||
], WorkMonthDays::fromTimeAccount($account)->toArray());
|
||||
}
|
||||
|
||||
public function testASpanNeedsBothEnds(): void
|
||||
{
|
||||
$days = WorkMonthDays::fromArray([
|
||||
'2026-03-01' => ['minutes' => 60, 'start' => '09:00'],
|
||||
'2026-03-02' => ['minutes' => 60, 'start' => '09:00', 'end' => 'noon'],
|
||||
'2026-03-03' => ['minutes' => 60, 'start' => '09:00', 'end' => '10:00'],
|
||||
])->forCalendarMonth(2026, 3);
|
||||
|
||||
$this->assertFalse($days[0]->hasSpan());
|
||||
$this->assertFalse($days[1]->hasSpan());
|
||||
$this->assertTrue($days[2]->hasSpan());
|
||||
}
|
||||
|
||||
public function testItTotalsTheBreaks(): void
|
||||
{
|
||||
$days = WorkMonthDays::fromArray([
|
||||
'2026-03-01' => ['minutes' => 285, 'break' => 60],
|
||||
'2026-03-02' => ['minutes' => 405, 'break' => 150],
|
||||
'2026-03-03' => ['minutes' => 180],
|
||||
]);
|
||||
|
||||
$this->assertSame(210, $days->getTotalBreakMinutes());
|
||||
$this->assertSame(0, $days->forCalendarMonth(2026, 3)[2]->getBreakMinutes());
|
||||
}
|
||||
|
||||
/**
|
||||
* The calendar, not the payload, decides how many rows a month has: days the SaaS never
|
||||
* reported still need a line in the table and in the PDF.
|
||||
*/
|
||||
public function testItFillsEveryCalendarDayOfALongMonth(): void
|
||||
{
|
||||
$days = WorkMonthDays::fromArray(['2026-03-05' => ['minutes' => 0, 'absence' => 'Ruhetag 24/25']]);
|
||||
$calendar = $days->forCalendarMonth(2026, 3);
|
||||
|
||||
$this->assertCount(31, $calendar);
|
||||
$this->assertSame('2026-03-01', $calendar[0]->getDate()->format('Y-m-d'));
|
||||
$this->assertSame('2026-03-31', $calendar[30]->getDate()->format('Y-m-d'));
|
||||
}
|
||||
|
||||
public function testItFillsEveryCalendarDayOfAShortMonth(): void
|
||||
{
|
||||
$this->assertCount(28, WorkMonthDays::fromArray([])->forCalendarMonth(2026, 2));
|
||||
$this->assertCount(29, WorkMonthDays::fromArray([])->forCalendarMonth(2024, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* "Not reported" and "reported as zero" are different things: a rest day is a real
|
||||
* booking of nothing, an unreported day is a gap.
|
||||
*/
|
||||
public function testItSeparatesUnreportedDaysFromZeroDays(): void
|
||||
{
|
||||
$calendar = WorkMonthDays::fromArray([
|
||||
'2026-03-05' => ['minutes' => 0, 'absence' => 'Ruhetag 24/25'],
|
||||
])->forCalendarMonth(2026, 3);
|
||||
|
||||
$restDay = $calendar[4];
|
||||
$this->assertSame('2026-03-05', $restDay->getDate()->format('Y-m-d'));
|
||||
$this->assertTrue($restDay->isReported());
|
||||
$this->assertSame(0, $restDay->getMinutes());
|
||||
$this->assertSame('Ruhetag 24/25', $restDay->getAbsence());
|
||||
|
||||
$gap = $calendar[0];
|
||||
$this->assertFalse($gap->isReported());
|
||||
$this->assertNull($gap->getMinutes());
|
||||
$this->assertFalse($gap->isAbsence());
|
||||
}
|
||||
|
||||
public function testItKnowsWeekends(): void
|
||||
{
|
||||
$calendar = WorkMonthDays::fromArray([])->forCalendarMonth(2026, 3);
|
||||
|
||||
$this->assertTrue($calendar[0]->isWeekend(), '1 March 2026 is a Sunday.');
|
||||
$this->assertFalse($calendar[1]->isWeekend());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Repository;
|
||||
|
||||
use App\Entity\TeamerWorkMonth;
|
||||
use App\Enum\WorkMonthStatus;
|
||||
use App\Model\WorkMonthFilterDto;
|
||||
use App\Repository\TeamerWorkMonthRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\Query;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
|
||||
/**
|
||||
* The list's defaults are the part worth pinning: what an admin sees when they have chosen
|
||||
* no filter at all is decided in DQL, not in the template.
|
||||
*/
|
||||
class TeamerWorkMonthRepositoryTest extends KernelTestCase
|
||||
{
|
||||
public function testTheListHidesApprovedMonthsUntilAskedFor(): void
|
||||
{
|
||||
$query = $this->createListQuery(new WorkMonthFilterDto());
|
||||
|
||||
$this->assertStringContainsString('work_month.status != :approved', $query->getDQL());
|
||||
$this->assertSame(WorkMonthStatus::APPROVED, $query->getParameter('approved')->getValue());
|
||||
}
|
||||
|
||||
public function testApprovedMonthsComeBackWhenTheFlagIsSet(): void
|
||||
{
|
||||
$query = $this->createListQuery((new WorkMonthFilterDto())->setIncludeApproved(true));
|
||||
|
||||
$this->assertStringNotContainsString(':approved', $query->getDQL());
|
||||
}
|
||||
|
||||
/**
|
||||
* An explicit status choice has to win over the "hide approved" default, or picking
|
||||
* "Geprüft" from the filter would return nothing at all.
|
||||
*/
|
||||
public function testAnExplicitStatusChoiceOverridesTheDefault(): void
|
||||
{
|
||||
$query = $this->createListQuery(
|
||||
(new WorkMonthFilterDto())->setStatuses([WorkMonthStatus::APPROVED])
|
||||
);
|
||||
|
||||
$dql = $query->getDQL();
|
||||
|
||||
$this->assertStringContainsString('work_month.status IN (:statuses)', $dql);
|
||||
$this->assertStringNotContainsString(':approved', $dql);
|
||||
$this->assertSame([WorkMonthStatus::APPROVED], $query->getParameter('statuses')->getValue());
|
||||
}
|
||||
|
||||
public function testFilteringByMonthSplitsThePeriodIntoItsColumns(): void
|
||||
{
|
||||
$query = $this->createListQuery(
|
||||
(new WorkMonthFilterDto())->setPeriod(new \DateTimeImmutable('2026-03-01'))
|
||||
);
|
||||
|
||||
$this->assertStringContainsString('work_month.year = :year', $query->getDQL());
|
||||
$this->assertStringContainsString('work_month.month = :month', $query->getDQL());
|
||||
$this->assertSame(2026, $query->getParameter('year')->getValue());
|
||||
$this->assertSame(3, $query->getParameter('month')->getValue(), 'The calendar month, not the API index.');
|
||||
}
|
||||
|
||||
/**
|
||||
* The reminder must not chase someone who has already sent the sheet in, nor a deleted
|
||||
* teamer.
|
||||
*/
|
||||
public function testTheReminderQueryOnlyLooksAtMonthsStillWaiting(): void
|
||||
{
|
||||
$repository = $this->createRepository();
|
||||
$months = $repository->findAwaitingDocument(new \DateTimeImmutable('2026-03-04'));
|
||||
|
||||
// db_test starts empty, so this asserts the query runs at all - the semantics above
|
||||
// are what the DQL assertions cover.
|
||||
$this->assertSame([], $months);
|
||||
}
|
||||
|
||||
private function createListQuery(WorkMonthFilterDto $filterDto): Query
|
||||
{
|
||||
return $this->createRepository()->getListQuery($filterDto);
|
||||
}
|
||||
|
||||
private function createRepository(): TeamerWorkMonthRepository
|
||||
{
|
||||
self::bootKernel();
|
||||
|
||||
/** @var EntityManagerInterface $entityManager */
|
||||
$entityManager = self::getContainer()->get(EntityManagerInterface::class);
|
||||
|
||||
/** @var TeamerWorkMonthRepository $repository */
|
||||
$repository = $entityManager->getRepository(TeamerWorkMonth::class);
|
||||
|
||||
return $repository;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,510 @@
|
||||
{
|
||||
"surcharge": 0,
|
||||
"PAYOFF_TIMEACCOUNT": 0,
|
||||
"year": 2026,
|
||||
"istOnWeekend": 0,
|
||||
"TIMEACCOUNT_PAYOFF": 0,
|
||||
"lastMonth": 0,
|
||||
"outOfWork": 19.0,
|
||||
"ist": 480,
|
||||
"autoOvertime": 0,
|
||||
"employee": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
|
||||
"hasTime": true,
|
||||
"current": null,
|
||||
"isReadOnly": false,
|
||||
"inactive": false,
|
||||
"PAYOFF_OVERTIMEACCOUNT": 0,
|
||||
"today": {
|
||||
"soll": 0,
|
||||
"ist": 0
|
||||
},
|
||||
"OVERTIMEACCOUNT_TIMEACCOUNT": 0,
|
||||
"end": null,
|
||||
"traffic": {
|
||||
"active": false
|
||||
},
|
||||
"TIMEACCOUNT_OVERTIMEACCOUNT": 0,
|
||||
"start": "2021-10-22",
|
||||
"soll": 2661,
|
||||
"hasProject": false,
|
||||
"saldo": -2181,
|
||||
"list": [
|
||||
{
|
||||
"date": "2026-08-01",
|
||||
"surcharge": 0.0,
|
||||
"startOriginalDate": "2026-08-01",
|
||||
"endDate": "2026-08-01",
|
||||
"platformStart": "Manuell",
|
||||
"breakCalculationActive": true,
|
||||
"ist": 360,
|
||||
"uuid": "cb0aaf79-d41d-4001-9ace-bc95f60dd856",
|
||||
"hasSurcharge": false,
|
||||
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
|
||||
"startOriginal": "2026-08-01 10:00:00",
|
||||
"responsibility": null,
|
||||
"sollType": "MONTH",
|
||||
"warning": 0,
|
||||
"startTime": "10:00:00",
|
||||
"end": "2026-08-01 16:00:00",
|
||||
"department": "Operations",
|
||||
"endOriginal": "2026-08-01 16:00:00",
|
||||
"annotation": 8,
|
||||
"endOriginalDate": "2026-08-01",
|
||||
"startOriginalTime": "10:00:00",
|
||||
"start": "2026-08-01 10:00:00",
|
||||
"soll": 0,
|
||||
"pause": 0,
|
||||
"surchargeMinutes": 0,
|
||||
"transfer": null,
|
||||
"holidayType": null,
|
||||
"platformEnd": "Manuell",
|
||||
"pausemin": 0,
|
||||
"kilometer": 0.0,
|
||||
"endTime": "16:00:00",
|
||||
"startDate": "2026-08-01",
|
||||
"isDummy": 0,
|
||||
"status": "ACCEPTED",
|
||||
"endOriginalTime": "16:00:00"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-01",
|
||||
"annotation": -1,
|
||||
"endDate": "2026-08-01",
|
||||
"start": "2026-08-01 16:00:00",
|
||||
"soll": 0,
|
||||
"ist": 0,
|
||||
"pause": 180,
|
||||
"surchargeMinutes": 0,
|
||||
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
|
||||
"holidayType": null,
|
||||
"sollType": "MONTH",
|
||||
"pausemin": 0,
|
||||
"warning": 0,
|
||||
"startTime": "16:00:00",
|
||||
"end": "2026-08-01 19:00:00",
|
||||
"endTime": "19:00:00",
|
||||
"isDummy": 0,
|
||||
"startDate": "2026-08-01"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-01",
|
||||
"surcharge": 0.0,
|
||||
"endDate": "2026-08-01",
|
||||
"platformStart": "Manuell",
|
||||
"breakCalculationActive": true,
|
||||
"ist": 120,
|
||||
"uuid": "9652275f-0b76-40b5-b67a-21ef8b7f0f5f",
|
||||
"hasSurcharge": false,
|
||||
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
|
||||
"startOriginal": null,
|
||||
"responsibility": null,
|
||||
"sollType": "MONTH",
|
||||
"warning": 0,
|
||||
"startTime": "19:00:00",
|
||||
"end": "2026-08-01 21:00:00",
|
||||
"department": "Operations",
|
||||
"endOriginal": null,
|
||||
"annotation": 8,
|
||||
"start": "2026-08-01 19:00:00",
|
||||
"soll": 0,
|
||||
"pause": 0,
|
||||
"surchargeMinutes": 0,
|
||||
"transfer": null,
|
||||
"holidayType": null,
|
||||
"platformEnd": "Manuell",
|
||||
"pausemin": 0,
|
||||
"kilometer": 0.0,
|
||||
"endTime": "21:00:00",
|
||||
"startDate": "2026-08-01",
|
||||
"isDummy": 0,
|
||||
"status": "ACCEPTED"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-02",
|
||||
"annotation": -4,
|
||||
"endDate": null,
|
||||
"start": "2026-08-02 12:40:01",
|
||||
"breakCalculationActive": true,
|
||||
"soll": 0,
|
||||
"ist": 0,
|
||||
"pause": 0,
|
||||
"surchargeMinutes": 0,
|
||||
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
|
||||
"holidayType": "Ferien",
|
||||
"sollType": "MONTH",
|
||||
"pausemin": 0,
|
||||
"warning": 0,
|
||||
"startTime": "23:59:59",
|
||||
"endTime": null,
|
||||
"isDummy": 0,
|
||||
"startDate": "2026-08-02"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-03",
|
||||
"annotation": -4,
|
||||
"endDate": null,
|
||||
"start": "2026-08-03 12:40:01",
|
||||
"breakCalculationActive": true,
|
||||
"soll": 0,
|
||||
"ist": 0,
|
||||
"pause": 0,
|
||||
"surchargeMinutes": 0,
|
||||
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
|
||||
"holidayType": "Ferien",
|
||||
"sollType": "MONTH",
|
||||
"pausemin": 0,
|
||||
"warning": 0,
|
||||
"startTime": "23:59:59",
|
||||
"endTime": null,
|
||||
"isDummy": 0,
|
||||
"startDate": "2026-08-03"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-04",
|
||||
"annotation": -4,
|
||||
"endDate": null,
|
||||
"start": "2026-08-04 12:40:01",
|
||||
"breakCalculationActive": true,
|
||||
"soll": 0,
|
||||
"ist": 0,
|
||||
"pause": 0,
|
||||
"surchargeMinutes": 0,
|
||||
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
|
||||
"holidayType": "Ferien",
|
||||
"sollType": "MONTH",
|
||||
"pausemin": 0,
|
||||
"warning": 0,
|
||||
"startTime": "23:59:59",
|
||||
"endTime": null,
|
||||
"isDummy": 0,
|
||||
"startDate": "2026-08-04"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-05",
|
||||
"annotation": -4,
|
||||
"endDate": null,
|
||||
"start": "2026-08-05 12:40:01",
|
||||
"breakCalculationActive": true,
|
||||
"soll": 0,
|
||||
"ist": 0,
|
||||
"pause": 0,
|
||||
"surchargeMinutes": 0,
|
||||
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
|
||||
"holidayType": "Ferien",
|
||||
"sollType": "MONTH",
|
||||
"pausemin": 0,
|
||||
"warning": 0,
|
||||
"startTime": "23:59:59",
|
||||
"endTime": null,
|
||||
"isDummy": 0,
|
||||
"startDate": "2026-08-05"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-06",
|
||||
"annotation": -4,
|
||||
"endDate": null,
|
||||
"start": "2026-08-06 12:40:01",
|
||||
"breakCalculationActive": true,
|
||||
"soll": 0,
|
||||
"ist": 0,
|
||||
"pause": 0,
|
||||
"surchargeMinutes": 0,
|
||||
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
|
||||
"holidayType": "Ferien",
|
||||
"sollType": "MONTH",
|
||||
"pausemin": 0,
|
||||
"warning": 0,
|
||||
"startTime": "23:59:59",
|
||||
"endTime": null,
|
||||
"isDummy": 0,
|
||||
"startDate": "2026-08-06"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-07",
|
||||
"annotation": -4,
|
||||
"endDate": null,
|
||||
"start": "2026-08-07 12:40:01",
|
||||
"breakCalculationActive": true,
|
||||
"soll": 0,
|
||||
"ist": 0,
|
||||
"pause": 0,
|
||||
"surchargeMinutes": 0,
|
||||
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
|
||||
"holidayType": "Ferien",
|
||||
"sollType": "MONTH",
|
||||
"pausemin": 0,
|
||||
"warning": 0,
|
||||
"startTime": "23:59:59",
|
||||
"endTime": null,
|
||||
"isDummy": 0,
|
||||
"startDate": "2026-08-07"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-08",
|
||||
"annotation": -4,
|
||||
"endDate": null,
|
||||
"start": "2026-08-08 12:40:01",
|
||||
"breakCalculationActive": true,
|
||||
"soll": 0,
|
||||
"ist": 0,
|
||||
"pause": 0,
|
||||
"surchargeMinutes": 0,
|
||||
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
|
||||
"holidayType": "Ferien",
|
||||
"sollType": "MONTH",
|
||||
"pausemin": 0,
|
||||
"warning": 0,
|
||||
"startTime": "23:59:59",
|
||||
"endTime": null,
|
||||
"isDummy": 0,
|
||||
"startDate": "2026-08-08"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-09",
|
||||
"annotation": -4,
|
||||
"endDate": null,
|
||||
"start": "2026-08-09 12:40:01",
|
||||
"breakCalculationActive": true,
|
||||
"soll": 0,
|
||||
"ist": 0,
|
||||
"pause": 0,
|
||||
"surchargeMinutes": 0,
|
||||
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
|
||||
"holidayType": "Ferien",
|
||||
"sollType": "MONTH",
|
||||
"pausemin": 0,
|
||||
"warning": 0,
|
||||
"startTime": "23:59:59",
|
||||
"endTime": null,
|
||||
"isDummy": 0,
|
||||
"startDate": "2026-08-09"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-10",
|
||||
"annotation": -4,
|
||||
"endDate": null,
|
||||
"start": "2026-08-10 12:40:01",
|
||||
"breakCalculationActive": true,
|
||||
"soll": 0,
|
||||
"ist": 0,
|
||||
"pause": 0,
|
||||
"surchargeMinutes": 0,
|
||||
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
|
||||
"holidayType": "Ferien",
|
||||
"sollType": "MONTH",
|
||||
"pausemin": 0,
|
||||
"warning": 0,
|
||||
"startTime": "23:59:59",
|
||||
"endTime": null,
|
||||
"isDummy": 0,
|
||||
"startDate": "2026-08-10"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-11",
|
||||
"annotation": -4,
|
||||
"endDate": null,
|
||||
"start": "2026-08-11 12:40:01",
|
||||
"breakCalculationActive": true,
|
||||
"soll": 0,
|
||||
"ist": 0,
|
||||
"pause": 0,
|
||||
"surchargeMinutes": 0,
|
||||
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
|
||||
"holidayType": "Ferien",
|
||||
"sollType": "MONTH",
|
||||
"pausemin": 0,
|
||||
"warning": 0,
|
||||
"startTime": "23:59:59",
|
||||
"endTime": null,
|
||||
"isDummy": 0,
|
||||
"startDate": "2026-08-11"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-12",
|
||||
"annotation": -4,
|
||||
"endDate": null,
|
||||
"start": "2026-08-12 12:40:01",
|
||||
"breakCalculationActive": true,
|
||||
"soll": 0,
|
||||
"ist": 0,
|
||||
"pause": 0,
|
||||
"surchargeMinutes": 0,
|
||||
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
|
||||
"holidayType": "Ferien",
|
||||
"sollType": "MONTH",
|
||||
"pausemin": 0,
|
||||
"warning": 0,
|
||||
"startTime": "23:59:59",
|
||||
"endTime": null,
|
||||
"isDummy": 0,
|
||||
"startDate": "2026-08-12"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-13",
|
||||
"annotation": -4,
|
||||
"endDate": null,
|
||||
"start": "2026-08-13 12:40:01",
|
||||
"breakCalculationActive": true,
|
||||
"soll": 0,
|
||||
"ist": 0,
|
||||
"pause": 0,
|
||||
"surchargeMinutes": 0,
|
||||
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
|
||||
"holidayType": "Ferien",
|
||||
"sollType": "MONTH",
|
||||
"pausemin": 0,
|
||||
"warning": 0,
|
||||
"startTime": "23:59:59",
|
||||
"endTime": null,
|
||||
"isDummy": 0,
|
||||
"startDate": "2026-08-13"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-14",
|
||||
"annotation": -4,
|
||||
"endDate": null,
|
||||
"start": "2026-08-14 12:40:01",
|
||||
"breakCalculationActive": true,
|
||||
"soll": 0,
|
||||
"ist": 0,
|
||||
"pause": 0,
|
||||
"surchargeMinutes": 0,
|
||||
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
|
||||
"holidayType": "Ferien",
|
||||
"sollType": "MONTH",
|
||||
"pausemin": 0,
|
||||
"warning": 0,
|
||||
"startTime": "23:59:59",
|
||||
"endTime": null,
|
||||
"isDummy": 0,
|
||||
"startDate": "2026-08-14"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-15",
|
||||
"annotation": -4,
|
||||
"endDate": null,
|
||||
"start": "2026-08-15 12:40:01",
|
||||
"breakCalculationActive": true,
|
||||
"soll": 0,
|
||||
"ist": 0,
|
||||
"pause": 0,
|
||||
"surchargeMinutes": 0,
|
||||
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
|
||||
"holidayType": "Ferien",
|
||||
"sollType": "MONTH",
|
||||
"pausemin": 0,
|
||||
"warning": 0,
|
||||
"startTime": "23:59:59",
|
||||
"endTime": null,
|
||||
"isDummy": 0,
|
||||
"startDate": "2026-08-15"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-16",
|
||||
"annotation": -4,
|
||||
"endDate": null,
|
||||
"start": "2026-08-16 12:40:01",
|
||||
"breakCalculationActive": true,
|
||||
"soll": 0,
|
||||
"ist": 0,
|
||||
"pause": 0,
|
||||
"surchargeMinutes": 0,
|
||||
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
|
||||
"holidayType": "Ferien",
|
||||
"sollType": "MONTH",
|
||||
"pausemin": 0,
|
||||
"warning": 0,
|
||||
"startTime": "23:59:59",
|
||||
"endTime": null,
|
||||
"isDummy": 0,
|
||||
"startDate": "2026-08-16"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-17",
|
||||
"annotation": -4,
|
||||
"endDate": null,
|
||||
"start": "2026-08-17 12:40:01",
|
||||
"breakCalculationActive": true,
|
||||
"soll": 0,
|
||||
"ist": 0,
|
||||
"pause": 0,
|
||||
"surchargeMinutes": 0,
|
||||
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
|
||||
"holidayType": "Ferien",
|
||||
"sollType": "MONTH",
|
||||
"pausemin": 0,
|
||||
"warning": 0,
|
||||
"startTime": "23:59:59",
|
||||
"endTime": null,
|
||||
"isDummy": 0,
|
||||
"startDate": "2026-08-17"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-18",
|
||||
"annotation": -4,
|
||||
"endDate": null,
|
||||
"start": "2026-08-18 12:40:01",
|
||||
"breakCalculationActive": true,
|
||||
"soll": 0,
|
||||
"ist": 0,
|
||||
"pause": 0,
|
||||
"surchargeMinutes": 0,
|
||||
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
|
||||
"holidayType": "Ferien",
|
||||
"sollType": "MONTH",
|
||||
"pausemin": 0,
|
||||
"warning": 0,
|
||||
"startTime": "23:59:59",
|
||||
"endTime": null,
|
||||
"isDummy": 0,
|
||||
"startDate": "2026-08-18"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-19",
|
||||
"annotation": -4,
|
||||
"endDate": null,
|
||||
"start": "2026-08-19 12:40:01",
|
||||
"breakCalculationActive": true,
|
||||
"soll": 0,
|
||||
"ist": 0,
|
||||
"pause": 0,
|
||||
"surchargeMinutes": 0,
|
||||
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
|
||||
"holidayType": "Ferien",
|
||||
"sollType": "MONTH",
|
||||
"pausemin": 0,
|
||||
"warning": 0,
|
||||
"startTime": "23:59:59",
|
||||
"endTime": null,
|
||||
"isDummy": 0,
|
||||
"startDate": "2026-08-19"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-20",
|
||||
"annotation": -4,
|
||||
"endDate": null,
|
||||
"start": "2026-08-20 12:40:01",
|
||||
"breakCalculationActive": true,
|
||||
"soll": 0,
|
||||
"ist": 0,
|
||||
"pause": 0,
|
||||
"surchargeMinutes": 0,
|
||||
"employeeUUID": "aaaaaaaa-894a-4e63-9c8d-5777e6afdc91",
|
||||
"holidayType": "Ferien",
|
||||
"sollType": "MONTH",
|
||||
"pausemin": 0,
|
||||
"warning": 0,
|
||||
"startTime": "23:59:59",
|
||||
"endTime": null,
|
||||
"isDummy": 0,
|
||||
"startDate": "2026-08-20"
|
||||
}
|
||||
],
|
||||
"overtimeAccountMonth": 5929,
|
||||
"saldo2": -2181,
|
||||
"onWork": 1,
|
||||
"unpaidOvertime": 0,
|
||||
"month": 8,
|
||||
"OVERTIMEACCOUNT_PAYOFF": 0,
|
||||
"overtimeAccount": 5929
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Saas;
|
||||
|
||||
use App\Saas\ApiClient;
|
||||
use App\Saas\ApiClientException;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Component\HttpClient\MockHttpClient;
|
||||
use Symfony\Component\HttpClient\Response\MockResponse;
|
||||
|
||||
class ApiClientTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* The whole point of the calendar-month argument: the app never has to know that the
|
||||
* API counts from zero.
|
||||
*
|
||||
* @dataProvider provideMonths
|
||||
*/
|
||||
public function testItTranslatesTheCalendarMonthIntoTheApiIndex(int $month, string $expectedPath): void
|
||||
{
|
||||
$requested = null;
|
||||
|
||||
$client = $this->createClient(function (string $method, string $url) use (&$requested) {
|
||||
$requested = $url;
|
||||
|
||||
return new MockResponse('{"ist": 0, "list": []}', ['response_headers' => ['content-type' => 'application/json']]);
|
||||
});
|
||||
|
||||
$client->getTimeAccount('[email protected]', 2026, $month);
|
||||
|
||||
$this->assertSame('https://saas.example'.$expectedPath, $requested);
|
||||
}
|
||||
|
||||
public static function provideMonths(): iterable
|
||||
{
|
||||
yield 'January is index 0' => [1, '/api/v2/time/account/secret%2Fkey/theo%40example.com/2026/0'];
|
||||
yield 'March is index 2' => [3, '/api/v2/time/account/secret%2Fkey/theo%40example.com/2026/2'];
|
||||
yield 'December is index 11' => [12, '/api/v2/time/account/secret%2Fkey/theo%40example.com/2026/11'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Both the key and the address sit in the path, so both have to survive a slash or an
|
||||
* "@" without changing the route.
|
||||
*/
|
||||
public function testItEscapesTheKeyAndTheAddress(): void
|
||||
{
|
||||
$requested = null;
|
||||
|
||||
$client = $this->createClient(function (string $method, string $url) use (&$requested) {
|
||||
$requested = $url;
|
||||
|
||||
return new MockResponse('{"ist": 0, "list": []}', ['response_headers' => ['content-type' => 'application/json']]);
|
||||
});
|
||||
|
||||
$client->getTimeAccount('a [email protected]', 2026, 3);
|
||||
|
||||
$this->assertStringContainsString('/secret%2Fkey/', $requested);
|
||||
$this->assertStringContainsString('/a%20b%2Bc%40example.com/', $requested);
|
||||
}
|
||||
|
||||
public function testItReturnsTheDecodedPayload(): void
|
||||
{
|
||||
$client = $this->createClient(fn () => new MockResponse(
|
||||
'{"ist": 480, "employee": "e-1", "list": []}',
|
||||
['response_headers' => ['content-type' => 'application/json']],
|
||||
));
|
||||
|
||||
$this->assertSame(
|
||||
['ist' => 480, 'employee' => 'e-1', 'list' => []],
|
||||
$client->getTimeAccount('[email protected]', 2026, 9),
|
||||
);
|
||||
}
|
||||
|
||||
public function testAnErrorStatusBecomesAnApiClientException(): void
|
||||
{
|
||||
$client = $this->createClient(fn () => new MockResponse('nope', ['http_code' => 500]));
|
||||
|
||||
$this->expectException(ApiClientException::class);
|
||||
$this->expectExceptionMessageMatches('/[email protected] \(2026-03\)/');
|
||||
|
||||
$client->getTimeAccount('[email protected]', 2026, 3);
|
||||
}
|
||||
|
||||
public function testUnreadableJsonBecomesAnApiClientException(): void
|
||||
{
|
||||
$client = $this->createClient(fn () => new MockResponse(
|
||||
'not json',
|
||||
['response_headers' => ['content-type' => 'application/json']],
|
||||
));
|
||||
|
||||
$this->expectException(ApiClientException::class);
|
||||
|
||||
$client->getTimeAccount('[email protected]', 2026, 3);
|
||||
}
|
||||
|
||||
public function testItRefusesAMonthThatIsNotACalendarMonth(): void
|
||||
{
|
||||
$client = $this->createClient(fn () => new MockResponse('{}'));
|
||||
|
||||
$this->expectException(ApiClientException::class);
|
||||
$this->expectExceptionMessage('0 is not a calendar month.');
|
||||
|
||||
$client->getTimeAccount('[email protected]', 2026, 0);
|
||||
}
|
||||
|
||||
public function testAnUnconfiguredBaseUrlFailsAtCallTimeNotAtBoot(): void
|
||||
{
|
||||
// The command has to stay usable - "--help" included - on a machine that has no
|
||||
// credentials, so this cannot be a container-time failure.
|
||||
$client = new ApiClient(
|
||||
new MockHttpClient(fn () => new MockResponse('{}')),
|
||||
new NullLogger(),
|
||||
['base_url' => '', 'api_key' => 'k'],
|
||||
);
|
||||
|
||||
$this->expectException(ApiClientException::class);
|
||||
$this->expectExceptionMessage('APP_SAAS_BASE_URL is not configured.');
|
||||
|
||||
$client->getTimeAccount('[email protected]', 2026, 3);
|
||||
}
|
||||
|
||||
private function createClient(callable $handler): ApiClient
|
||||
{
|
||||
return new ApiClient(
|
||||
new MockHttpClient($handler),
|
||||
new NullLogger(),
|
||||
['base_url' => 'https://saas.example', 'api_key' => 'secret/key'],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Saas;
|
||||
|
||||
use App\Saas\Model\EmployeeDirectory;
|
||||
use App\Saas\ResponseParser;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class EmployeeDirectoryTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* The point of the projection: the roster answers with a per-employee api key next to a
|
||||
* birthday, a personnel number and an RFID token, and the result of this is cached to the
|
||||
* filesystem. None of it may get that far.
|
||||
*/
|
||||
public function testTheProjectionKeepsNoCredentialsOrPersonalData(): void
|
||||
{
|
||||
$directory = (new ResponseParser())->parseEmployees($this->roster());
|
||||
$serialised = json_encode($directory->toCacheable(), JSON_THROW_ON_ERROR);
|
||||
|
||||
$this->assertStringNotContainsString('s3cr3t', $serialised, 'The api key must not survive.');
|
||||
$this->assertStringNotContainsString('apiKey', $serialised);
|
||||
$this->assertStringNotContainsString('1990', $serialised, 'The birthday must not survive.');
|
||||
$this->assertStringNotContainsString('RFID', $serialised);
|
||||
$this->assertStringNotContainsString('personnelNumber', $serialised);
|
||||
}
|
||||
|
||||
public function testItReadsTheFieldsWeActuallyUse(): void
|
||||
{
|
||||
$directory = (new ResponseParser())->parseEmployees($this->roster());
|
||||
$theo = $directory->find('[email protected]');
|
||||
|
||||
$this->assertNotNull($theo);
|
||||
$this->assertSame('Operations', $theo->getDepartment());
|
||||
$this->assertSame('aaaaaaaa-894a-4e63-9c8d-5777e6afdc91', $theo->getUuid());
|
||||
$this->assertFalse($theo->hasLeft());
|
||||
}
|
||||
|
||||
public function testADateOfSeparationMarksSomeoneAsGone(): void
|
||||
{
|
||||
$directory = (new ResponseParser())->parseEmployees($this->roster());
|
||||
|
||||
$this->assertTrue($directory->find('[email protected]')->hasLeft());
|
||||
}
|
||||
|
||||
/**
|
||||
* Both systems have the address typed in by hand, so the one place they have to agree on
|
||||
* it cannot be case-sensitive.
|
||||
*/
|
||||
public function testLookupIgnoresCaseAndSurroundingSpace(): void
|
||||
{
|
||||
$directory = (new ResponseParser())->parseEmployees($this->roster());
|
||||
|
||||
$this->assertTrue($directory->has('[email protected]'));
|
||||
$this->assertTrue($directory->has(' [email protected] '));
|
||||
$this->assertFalse($directory->has('[email protected]'));
|
||||
$this->assertFalse($directory->has(null));
|
||||
}
|
||||
|
||||
public function testAnEntryWithoutAnAddressIsDropped(): void
|
||||
{
|
||||
$directory = (new ResponseParser())->parseEmployees([
|
||||
['mainDepartment' => 'Operations', 'email' => ''],
|
||||
['mainDepartment' => 'Operations'],
|
||||
'not an entry',
|
||||
]);
|
||||
|
||||
$this->assertTrue($directory->isEmpty());
|
||||
}
|
||||
|
||||
public function testItSurvivesTheCacheRoundTrip(): void
|
||||
{
|
||||
$directory = (new ResponseParser())->parseEmployees($this->roster());
|
||||
$restored = EmployeeDirectory::fromCacheable($directory->toCacheable());
|
||||
|
||||
$this->assertSame($directory->count(), $restored->count());
|
||||
$this->assertSame('Operations', $restored->find('[email protected]')->getDepartment());
|
||||
$this->assertTrue($restored->find('[email protected]')->hasLeft());
|
||||
}
|
||||
|
||||
/**
|
||||
* An unreachable roster produces an empty one, and the importer reads that as "no roster"
|
||||
* rather than "nobody is registered".
|
||||
*/
|
||||
public function testAnEmptyDirectoryIsRecognisable(): void
|
||||
{
|
||||
$this->assertTrue(EmployeeDirectory::fromEmployees([])->isEmpty());
|
||||
}
|
||||
|
||||
private function roster(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'personnelNumber' => '4711',
|
||||
'mainDepartment' => 'Standard-Abteilung',
|
||||
'email' => '[email protected]',
|
||||
'givenName' => 'Admin',
|
||||
'lastName' => 'Hausmanagement',
|
||||
'birthday' => '1990-05-01',
|
||||
'joinDate' => '2021-00-01',
|
||||
'dateOfSeparation' => null,
|
||||
'RFID' => 'abc',
|
||||
'uuid' => 'bbbbbbbb-0000-0000-0000-000000000000',
|
||||
'apiKey' => 's3cr3t-admin',
|
||||
],
|
||||
[
|
||||
'personnelNumber' => '',
|
||||
'mainDepartment' => 'Operations',
|
||||
'email' => '[email protected]',
|
||||
'givenName' => 'Theo',
|
||||
'lastName' => 'Test',
|
||||
'birthday' => null,
|
||||
'joinDate' => '2021-10-22',
|
||||
'dateOfSeparation' => null,
|
||||
'RFID' => '',
|
||||
'uuid' => 'aaaaaaaa-894a-4e63-9c8d-5777e6afdc91',
|
||||
'apiKey' => 's3cr3t-theo',
|
||||
],
|
||||
[
|
||||
'mainDepartment' => 'Operations',
|
||||
'email' => '[email protected]',
|
||||
'dateOfSeparation' => '2025-05-31',
|
||||
'uuid' => 'cccccccc-0000-0000-0000-000000000000',
|
||||
'apiKey' => 's3cr3t-ex',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Saas;
|
||||
|
||||
use App\Saas\Model\TimeAccount;
|
||||
use App\Saas\ResponseParser;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* The fixtures are real responses with the free-text comments, IP addresses, coordinates,
|
||||
* device names and the employee name removed - none of which this application stores.
|
||||
*
|
||||
* Both were requested with a zero-based month, so their date strings are one month behind
|
||||
* the days they describe: the "september" fixture was asked for month 8 and the "march" one
|
||||
* for month 2.
|
||||
*/
|
||||
class ResponseParserTest extends TestCase
|
||||
{
|
||||
public function testSeptemberFixtureAddsUpToTheReportedTotal(): void
|
||||
{
|
||||
$account = $this->parse('saas_time_account_september.json', 2026, 9);
|
||||
|
||||
$this->assertSame('aaaaaaaa-894a-4e63-9c8d-5777e6afdc91', $account->getEmployeeUuid());
|
||||
$this->assertSame(480, $account->getIstMinutes());
|
||||
$this->assertSame(480, $account->getTotalMinutes(), 'Nothing is dropped, so the days must match the header.');
|
||||
$this->assertSame([], $account->getWarnings());
|
||||
}
|
||||
|
||||
public function testSeptemberFixtureIsRebasedOutOfAugust(): void
|
||||
{
|
||||
$account = $this->parse('saas_time_account_september.json', 2026, 9);
|
||||
$keys = array_keys($account->getDays());
|
||||
|
||||
$this->assertCount(20, $keys);
|
||||
$this->assertSame('2026-09-01', reset($keys));
|
||||
$this->assertSame('2026-09-20', end($keys));
|
||||
}
|
||||
|
||||
public function testSeptemberFixtureMarksHolidays(): void
|
||||
{
|
||||
$account = $this->parse('saas_time_account_september.json', 2026, 9);
|
||||
|
||||
$this->assertSame(19, $account->countAbsenceDays());
|
||||
$this->assertSame(1, $account->countWorkedDays());
|
||||
$this->assertSame('Ferien', $account->getDays()['2026-09-02']->getHolidayType());
|
||||
$this->assertSame(480, $account->getDays()['2026-09-01']->getMinutes());
|
||||
}
|
||||
|
||||
public function testMarchFixtureAddsUpToTheReportedTotal(): void
|
||||
{
|
||||
$account = $this->parse('saas_time_account_march.json', 2026, 3);
|
||||
|
||||
$this->assertSame(11748, $account->getIstMinutes());
|
||||
$this->assertSame(11748, $account->getTotalMinutes());
|
||||
}
|
||||
|
||||
/**
|
||||
* The decisive case: the payload's last day is "2026-02-31". February has 28 days, so a
|
||||
* date parser would have rolled it into March 3rd and collided with a real row.
|
||||
*/
|
||||
public function testMarchFixtureKeepsAllThirtyOneDays(): void
|
||||
{
|
||||
$account = $this->parse('saas_time_account_march.json', 2026, 3);
|
||||
$keys = array_keys($account->getDays());
|
||||
|
||||
$this->assertCount(31, $keys);
|
||||
$this->assertSame('2026-03-01', reset($keys));
|
||||
$this->assertSame('2026-03-31', end($keys));
|
||||
$this->assertSame(450, $account->getDays()['2026-03-31']->getMinutes());
|
||||
}
|
||||
|
||||
/**
|
||||
* A rest day arrives as two rows on the same date - a zero-length booking and the
|
||||
* absence marker - and must still be one day worth zero minutes.
|
||||
*/
|
||||
public function testRestDaysAreCountedOnce(): void
|
||||
{
|
||||
$account = $this->parse('saas_time_account_march.json', 2026, 3);
|
||||
|
||||
$this->assertSame(4, $account->countAbsenceDays());
|
||||
|
||||
foreach (['2026-03-05', '2026-03-09', '2026-03-16', '2026-03-24'] as $key) {
|
||||
$this->assertSame('Ruhetag 24/25', $account->getDays()[$key]->getHolidayType());
|
||||
$this->assertSame(0, $account->getDays()[$key]->getMinutes());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 2026-03-12 has no manually booked row at all and still carries a full day of work,
|
||||
* which is why zero minutes can never stand in for "day off".
|
||||
*/
|
||||
public function testAWorkedDayWithoutManualRowsStillCounts(): void
|
||||
{
|
||||
$account = $this->parse('saas_time_account_march.json', 2026, 3);
|
||||
$day = $account->getDays()['2026-03-12'];
|
||||
|
||||
$this->assertSame(510, $day->getMinutes());
|
||||
$this->assertFalse($day->isAbsence());
|
||||
}
|
||||
|
||||
public function testUnknownRowKindIsReported(): void
|
||||
{
|
||||
$account = $this->parse('saas_time_account_march.json', 2026, 3);
|
||||
|
||||
$this->assertSame(['Unknown row kind -2 on 2026-03-11.'], $account->getWarnings());
|
||||
}
|
||||
|
||||
/**
|
||||
* January is index 0 and December index 11, so their date strings are "2026-00-xx" and
|
||||
* "2026-11-31" - the two the fixtures cannot show and the two that break naive parsing.
|
||||
*/
|
||||
public function testJanuaryIsRebasedFromIndexZero(): void
|
||||
{
|
||||
$account = (new ResponseParser())->parseTimeAccount([
|
||||
'employee' => 'e-1',
|
||||
'year' => 2026,
|
||||
'month' => 0,
|
||||
'ist' => 120,
|
||||
'list' => [
|
||||
['date' => '2026-00-15', 'annotation' => 8, 'ist' => 120],
|
||||
],
|
||||
], 2026, 1);
|
||||
|
||||
$this->assertSame(['2026-01-15'], array_keys($account->getDays()));
|
||||
$this->assertSame([], $account->getWarnings());
|
||||
}
|
||||
|
||||
public function testDecemberIsRebasedPastAShorterNovember(): void
|
||||
{
|
||||
$account = (new ResponseParser())->parseTimeAccount([
|
||||
'employee' => 'e-1',
|
||||
'year' => 2026,
|
||||
'month' => 11,
|
||||
'ist' => 60,
|
||||
'list' => [
|
||||
['date' => '2026-11-31', 'annotation' => 128, 'ist' => 60],
|
||||
],
|
||||
], 2026, 12);
|
||||
|
||||
$this->assertSame(['2026-12-31'], array_keys($account->getDays()));
|
||||
$this->assertSame([], $account->getWarnings());
|
||||
}
|
||||
|
||||
public function testDaysOutsideTheRequestedMonthAreSkippedAndReported(): void
|
||||
{
|
||||
$account = (new ResponseParser())->parseTimeAccount([
|
||||
'year' => 2026,
|
||||
'month' => 2,
|
||||
'ist' => 0,
|
||||
'list' => [
|
||||
['date' => '2026-03-01', 'annotation' => 8, 'ist' => 90],
|
||||
['date' => 'nonsense', 'annotation' => 8, 'ist' => 30],
|
||||
],
|
||||
], 2026, 3);
|
||||
|
||||
$this->assertSame([], $account->getDays());
|
||||
$this->assertCount(2, $account->getWarnings());
|
||||
$this->assertStringContainsString('not a day of 2026-03', $account->getWarnings()[0]);
|
||||
$this->assertStringContainsString('unreadable date', $account->getWarnings()[1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* If the days and the header ever stop agreeing, the date convention or the row
|
||||
* semantics have moved. That has to be loud rather than quietly wrong.
|
||||
*/
|
||||
public function testATotalThatDisagreesWithTheHeaderIsReported(): void
|
||||
{
|
||||
$account = (new ResponseParser())->parseTimeAccount([
|
||||
'year' => 2026,
|
||||
'month' => 2,
|
||||
'ist' => 999,
|
||||
'list' => [
|
||||
['date' => '2026-02-01', 'annotation' => 8, 'ist' => 90],
|
||||
],
|
||||
], 2026, 3);
|
||||
|
||||
$this->assertSame(90, $account->getTotalMinutes());
|
||||
$this->assertSame(['The days add up to 90 minutes but the month reports 999.'], $account->getWarnings());
|
||||
}
|
||||
|
||||
public function testAnEmptyPayloadIsTolerated(): void
|
||||
{
|
||||
$account = (new ResponseParser())->parseTimeAccount([], 2026, 3);
|
||||
|
||||
$this->assertNull($account->getEmployeeUuid());
|
||||
$this->assertSame([], $account->getDays());
|
||||
$this->assertSame(0, $account->getTotalMinutes());
|
||||
$this->assertSame([], $account->getWarnings());
|
||||
}
|
||||
|
||||
public function testItReadsTheMonthlyTarget(): void
|
||||
{
|
||||
$this->assertSame(9900, $this->parse('saas_time_account_march.json', 2026, 3)->getTargetMinutes());
|
||||
$this->assertSame(2661, $this->parse('saas_time_account_september.json', 2026, 9)->getTargetMinutes());
|
||||
}
|
||||
|
||||
public function testItReadsTheDepartment(): void
|
||||
{
|
||||
$this->assertSame('Operations', $this->parse('saas_time_account_march.json', 2026, 3)->getDepartment());
|
||||
$this->assertSame('Operations', $this->parse('saas_time_account_september.json', 2026, 9)->getDepartment());
|
||||
}
|
||||
|
||||
/**
|
||||
* Only the full booking rows carry it, so a month of nothing but holiday has nowhere to
|
||||
* read it from. That is a missing value, not an error.
|
||||
*/
|
||||
public function testAMonthWithoutBookingsHasNoDepartment(): void
|
||||
{
|
||||
$account = (new ResponseParser())->parseTimeAccount([
|
||||
'year' => 2026,
|
||||
'month' => 2,
|
||||
'ist' => 0,
|
||||
'list' => [
|
||||
['date' => '2026-02-02', 'annotation' => -4, 'ist' => 0, 'holidayType' => 'Ferien'],
|
||||
],
|
||||
], 2026, 3);
|
||||
|
||||
$this->assertNull($account->getDepartment());
|
||||
$this->assertSame([], $account->getWarnings());
|
||||
}
|
||||
|
||||
public function testTwoDepartmentsInOneMonthAreReported(): void
|
||||
{
|
||||
$account = (new ResponseParser())->parseTimeAccount([
|
||||
'year' => 2026,
|
||||
'month' => 2,
|
||||
'ist' => 120,
|
||||
'list' => [
|
||||
['date' => '2026-02-02', 'annotation' => 8, 'ist' => 60, 'department' => 'Operations'],
|
||||
['date' => '2026-02-03', 'annotation' => 8, 'ist' => 60, 'department' => 'Service'],
|
||||
],
|
||||
], 2026, 3);
|
||||
|
||||
$this->assertSame('Operations', $account->getDepartment(), 'The first one wins.');
|
||||
$this->assertSame(
|
||||
['The month reports more than one department: Operations, Service.'],
|
||||
$account->getWarnings()
|
||||
);
|
||||
}
|
||||
|
||||
public function testItSpansTheDayFromFirstClockInToLastClockOut(): void
|
||||
{
|
||||
$day = $this->parse('saas_time_account_september.json', 2026, 9)->getDays()['2026-09-01'];
|
||||
|
||||
// Booked 10:00-16:00, 16:00-19:00 as a break, then 19:00-21:00.
|
||||
$this->assertSame('10:00', $day->getStartTime());
|
||||
$this->assertSame('21:00', $day->getEndTime());
|
||||
$this->assertSame(180, $day->getBreakMinutes());
|
||||
$this->assertSame(480, $day->getMinutes());
|
||||
}
|
||||
|
||||
/**
|
||||
* A day often opens with a zero-length row at the moment the teamer clocked in. Skipping
|
||||
* those moved the start of 3 March from 08:00 to 09:00, the end of its first break.
|
||||
*/
|
||||
public function testAZeroLengthClockInStillOpensTheDay(): void
|
||||
{
|
||||
$day = $this->parse('saas_time_account_march.json', 2026, 3)->getDays()['2026-03-03'];
|
||||
|
||||
$this->assertSame('08:00', $day->getStartTime());
|
||||
$this->assertSame('19:30', $day->getEndTime());
|
||||
}
|
||||
|
||||
/**
|
||||
* Rest days carry a zero-length marker too, and absences a placeholder 23:59:59. Neither
|
||||
* is a working day, so neither gets a span.
|
||||
*/
|
||||
public function testADayWithNothingBookedHasNoSpan(): void
|
||||
{
|
||||
$restDay = $this->parse('saas_time_account_march.json', 2026, 3)->getDays()['2026-03-05'];
|
||||
$holiday = $this->parse('saas_time_account_september.json', 2026, 9)->getDays()['2026-09-02'];
|
||||
|
||||
$this->assertNull($restDay->getStartTime());
|
||||
$this->assertNull($restDay->getEndTime());
|
||||
$this->assertNull($holiday->getStartTime());
|
||||
$this->assertSame(0, $holiday->getBreakMinutes());
|
||||
}
|
||||
|
||||
/**
|
||||
* The invariant behind the whole sheet: the span a day was worked in equals the hours on
|
||||
* it plus the breaks inside it.
|
||||
*
|
||||
* 11 March is the one exception in this data, and deliberately excluded: it carries the
|
||||
* unknown -2 row, five and a half hours the SaaS counts as neither work nor break. That
|
||||
* row is already reported as a warning.
|
||||
*/
|
||||
public function testEverySpanAccountsForItsHoursAndBreaks(): void
|
||||
{
|
||||
$account = $this->parse('saas_time_account_march.json', 2026, 3);
|
||||
$checked = 0;
|
||||
|
||||
foreach ($account->getDays() as $key => $day) {
|
||||
if (null === $day->getStartTime() || '2026-03-11' === $key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
[$startHour, $startMinute] = explode(':', $day->getStartTime());
|
||||
[$endHour, $endMinute] = explode(':', $day->getEndTime());
|
||||
$span = ($endHour * 60 + $endMinute) - ($startHour * 60 + $startMinute);
|
||||
|
||||
$this->assertSame(
|
||||
$day->getMinutes() + $day->getBreakMinutes(),
|
||||
$span,
|
||||
sprintf('%s does not add up.', $key)
|
||||
);
|
||||
++$checked;
|
||||
}
|
||||
|
||||
$this->assertSame(26, $checked);
|
||||
}
|
||||
|
||||
public function testBreakMinutesAddUpToTheMonth(): void
|
||||
{
|
||||
$account = $this->parse('saas_time_account_march.json', 2026, 3);
|
||||
|
||||
$total = 0;
|
||||
|
||||
foreach ($account->getDays() as $day) {
|
||||
$total += $day->getBreakMinutes();
|
||||
}
|
||||
|
||||
$this->assertSame(3265, $total);
|
||||
}
|
||||
|
||||
private function parse(string $filename, int $year, int $month): TimeAccount
|
||||
{
|
||||
$payload = json_decode(
|
||||
file_get_contents(__DIR__.'/../Resources/'.$filename),
|
||||
true,
|
||||
512,
|
||||
JSON_THROW_ON_ERROR,
|
||||
);
|
||||
|
||||
return (new ResponseParser())->parseTimeAccount($payload, $year, $month);
|
||||
}
|
||||
}
|
||||
@@ -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