337 lines
12 KiB
PHP
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);
|
|
}
|
|
}
|