feat: expand client jobcard and reporting workflows

This commit is contained in:
Marco0300
2026-09-01 19:34:41 +02:00
parent 323afaf832
commit 703c3ca67d
10 changed files with 608 additions and 18 deletions
+80
View File
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../app/Domain/Client/ClientRecord.php';
use App\Domain\Client\ClientRecord;
function client_record_assert_same(mixed $expected, mixed $actual, string $message): void
{
if ($expected !== $actual) {
throw new RuntimeException($message . "\nExpected: " . var_export($expected, true) . "\nActual: " . var_export($actual, true));
}
}
$service = new ClientRecord();
$normalized = $service->normalize([
'name' => ' Acme IT ',
'registration_number' => ' REG-42 ',
'status' => ' ACTIVE ',
'support_email' => ' SUPPORT@EXAMPLE.TEST ',
'support_phone' => ' +27 11 555 0100 ',
'preferred_contact_method' => ' email ',
'physical_address' => ' 1 Main Street ',
'postal_address' => '',
'general_notes' => ' Call first ',
]);
client_record_assert_same([
'name' => 'Acme IT',
'registration_number' => 'REG-42',
'status' => 'active',
'support_email' => 'support@example.test',
'support_phone' => '+27 11 555 0100',
'preferred_contact_method' => 'email',
'physical_address' => '1 Main Street',
'postal_address' => null,
'general_notes' => 'Call first',
], $normalized, 'Client records should normalize accepted fields deterministically.');
$invalid = $service->validate([
'name' => 'Client',
'support_email' => 'not-an-email',
'support_phone' => 'abc',
'registration_number' => str_repeat('R', 121),
]);
if ($invalid['valid'] !== false || !isset($invalid['errors']['support_email'])
|| !isset($invalid['errors']['support_phone']) || !isset($invalid['errors']['registration_number'])) {
throw new RuntimeException('Expected optional contact and registration fields to be validated.');
}
$valid = $service->validate(['name' => ' Client ']);
client_record_assert_same([], $valid['errors'], 'A client may omit optional contact and registration fields.');
client_record_assert_same(true, $valid['valid'], 'Valid client records should report valid=true.');
client_record_assert_same(null, $valid['support_email'], 'Missing email should normalize to null.');
client_record_assert_same(null, $valid['support_phone'], 'Missing phone should normalize to null.');
client_record_assert_same(null, $valid['registration_number'], 'Missing registration should normalize to null.');
$display = $service->display([
'id' => 7,
'name' => 'Acme IT',
'registration_number' => 'REG-42',
'status' => 'active',
'support_email' => 'support@example.test',
'support_phone' => '+27 11 555 0100',
'password' => 'secret',
'password_hash' => 'hash',
'credentials' => 'token',
'created_by' => 99,
'unknown_field' => 'omit',
]);
client_record_assert_same([
'id' => 7,
'name' => 'Acme IT',
'registration_number' => 'REG-42',
'status' => 'active',
'support_email' => 'support@example.test',
'support_phone' => '+27 11 555 0100',
], $display, 'Display projection must be explicitly allow-listed.');
printf("Client record tests: 4 passed\n");
+44
View File
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../app/Domain/Reporting/CsvExporter.php';
function csv_exporter_assert_same(mixed $expected, mixed $actual, string $message): void
{
if ($expected !== $actual) {
throw new RuntimeException($message . "\nExpected: " . var_export($expected, true) . "\nActual: " . var_export($actual, true));
}
}
$exporter = new CsvExporter();
csv_exporter_assert_same(
"Name,Notes,Amount\r\nAlice,\"Comma, quote \"\"inside\"\"\",12.5\r\n\"Bob\r\nJones\",plain,\r\n",
$exporter->export(
['Name', 'Notes', 'Amount'],
[
['Alice', 'Comma, quote "inside"', 12.5],
["Bob\r\nJones", 'plain', null],
],
),
'CSV fields must follow RFC 4180 quoting and deterministic CRLF records.'
);
$withoutBom = $exporter->export(['Name'], [['Alice']]);
$withBom = $exporter->export(['Name'], [['Alice']], true);
csv_exporter_assert_same("Name\r\nAlice\r\n", $withoutBom, 'CSV must not include a BOM by default.');
csv_exporter_assert_same("\xEF\xBB\xBFName\r\nAlice\r\n", $withBom, 'CSV must include a UTF-8 BOM only when requested.');
csv_exporter_assert_same("Name\r\n'=SUM(A1:A2)\r\n", $exporter->export(['Name'], [['=SUM(A1:A2)']]), 'CSV must neutralize spreadsheet formulas.');
$mismatchRaised = false;
try {
$exporter->export(['Name', 'Amount'], [['Alice']]);
} catch (InvalidArgumentException $exception) {
$mismatchRaised = str_contains($exception->getMessage(), 'row 1')
&& str_contains($exception->getMessage(), '2 headers')
&& str_contains($exception->getMessage(), '1 fields');
}
if (!$mismatchRaised) {
throw new RuntimeException('Mismatched row lengths must be rejected with a useful error.');
}
printf("CSV exporter tests: 4 passed\n");
+3
View File
@@ -14,6 +14,9 @@ use App\Domain\Jobcard\TimeAggregator;
$reference = JobcardReference::generate(42, 2026);
if ($reference !== 'JC-2026-000042') throw new RuntimeException('Expected generated jobcard reference.');
if (!JobcardReference::isValid($reference) || JobcardReference::isValid('bad-reference')) throw new RuntimeException('Expected reference format validation.');
$tooLargeRejected = false;
try { JobcardReference::generate(1000000, 2026); } catch (InvalidArgumentException $exception) { $tooLargeRejected = true; }
if (!$tooLargeRejected) throw new RuntimeException('Reference sequences above six digits must be rejected.');
$transitions = new StatusTransitionValidator();
if (!$transitions->canTransition('new', 'assigned')) throw new RuntimeException('new should transition to assigned.');
+52
View File
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../app/Domain/Jobcard/StatusTransitionValidator.php';
require_once __DIR__ . '/../app/Domain/Jobcard/JobcardWorkflow.php';
use App\Domain\Jobcard\JobcardWorkflow;
$workflow = new JobcardWorkflow();
if ($workflow->allowedInitialStatuses() !== ['new']) {
throw new RuntimeException('Expected new to be the only allowed initial status.');
}
if ($workflow->allowedPriorities() !== ['low', 'normal', 'high', 'critical']) {
throw new RuntimeException('Expected the four supported priorities.');
}
$valid = $workflow->validateCommand([
'client_id' => '42',
'work_requested' => ' Replace the failed pump ',
'priority' => 'high',
]);
if (!$valid['valid'] || $valid['errors'] !== [] || $valid['client_id'] !== 42 || $valid['work_requested'] !== 'Replace the failed pump') {
throw new RuntimeException('Expected a valid jobcard command to be normalized.');
}
$invalid = $workflow->validateCommand(['client_id' => 0, 'work_requested' => '', 'priority' => 'urgent']);
if ($invalid['valid'] || !isset($invalid['errors']['client_id'], $invalid['errors']['work_requested'], $invalid['errors']['priority'])) {
throw new RuntimeException('Expected invalid jobcard command fields to be reported.');
}
$transition = $workflow->validateTransition('in_progress', 'completed', '2026-09-01 12:00:00');
if (!$transition['valid'] || $transition['errors'] !== []) {
throw new RuntimeException('Expected completion transition with a timestamp to be valid.');
}
$closed = $workflow->validateTransition('completed', 'closed', '2026-09-01 12:00:00', '2026-09-01 13:00:00');
if (!$closed['valid'] || $closed['errors'] !== []) {
throw new RuntimeException('Expected closure transition with ordered timestamps to be valid.');
}
$invalidTransition = $workflow->validateTransition('new', 'completed');
if ($invalidTransition['valid'] || !isset($invalidTransition['errors']['transition'], $invalidTransition['errors']['completed_at'])) {
throw new RuntimeException('Expected invalid transition and missing completion timestamp errors.');
}
$invalidDates = $workflow->validateTransition('completed', 'closed', '2026-09-01 14:00:00', '2026-09-01 13:00:00');
if ($invalidDates['valid'] || !isset($invalidDates['errors']['timestamps'])) {
throw new RuntimeException('Expected closure before completion to be rejected.');
}
printf("Jobcard workflow tests: 7 passed\n");