Files
myep-team/tests/Saas/ResponseParserTest.php
T

337 lines
12 KiB
PHP

<?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);
}
}