feat: expand client jobcard and reporting workflows
This commit is contained in:
@@ -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");
|
||||
@@ -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");
|
||||
@@ -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.');
|
||||
|
||||
@@ -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");
|
||||
Reference in New Issue
Block a user