feat: import work hours from SaaS and exchange signed time sheet

This commit is contained in:
fromme committed 2026-09-26 12:13:59 +02:00
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));
}
}
+144
View File
@@ -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
}
+131
View File
@@ -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'],
);
}
}
+128
View File
@@ -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',
],
];
}
}
+336
View File
@@ -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)
;
}
}
+143
View File
@@ -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)
;
}
}