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,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'],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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