feat: import work hours from SaaS and exchange signed time sheet
This commit is contained in:
1 parent
7cf39850e6
commit
1a7c272414
73 files changed
+10488
-72
No files matched your search
@@ -0,0 +1,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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user