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