From 703c3ca67ddf99122aa6fe7ee088ae5adc8b5ab6 Mon Sep 17 00:00:00 2001 From: Marco0300 Date: Tue, 1 Sep 2026 19:34:41 +0200 Subject: [PATCH] feat: expand client jobcard and reporting workflows --- app/Domain/Client/ClientRecord.php | 124 ++++++++++++++++++++++ app/Domain/Jobcard/JobcardReference.php | 4 +- app/Domain/Jobcard/JobcardWorkflow.php | 134 ++++++++++++++++++++++++ app/Domain/Reporting/CsvExporter.php | 79 ++++++++++++++ database/schema.sql | 9 +- public/index.php | 97 ++++++++++++++--- tests/ClientRecordTest.php | 80 ++++++++++++++ tests/CsvExporterTest.php | 44 ++++++++ tests/JobcardDomainTest.php | 3 + tests/JobcardWorkflowTest.php | 52 +++++++++ 10 files changed, 608 insertions(+), 18 deletions(-) create mode 100644 app/Domain/Client/ClientRecord.php create mode 100644 app/Domain/Jobcard/JobcardWorkflow.php create mode 100644 app/Domain/Reporting/CsvExporter.php create mode 100644 tests/ClientRecordTest.php create mode 100644 tests/CsvExporterTest.php create mode 100644 tests/JobcardWorkflowTest.php diff --git a/app/Domain/Client/ClientRecord.php b/app/Domain/Client/ClientRecord.php new file mode 100644 index 0000000..9d10d3b --- /dev/null +++ b/app/Domain/Client/ClientRecord.php @@ -0,0 +1,124 @@ + */ + private const FIELDS = [ + 'name', + 'registration_number', + 'status', + 'support_email', + 'support_phone', + 'preferred_contact_method', + 'physical_address', + 'postal_address', + 'general_notes', + ]; + + /** @var list */ + private const DISPLAY_FIELDS = [ + 'id', + 'name', + 'registration_number', + 'status', + 'support_email', + 'support_phone', + 'preferred_contact_method', + 'physical_address', + 'postal_address', + 'general_notes', + ]; + + /** @return array */ + public function normalize(array $record): array + { + return [ + 'name' => $this->text($record['name'] ?? null) ?? '', + 'registration_number' => $this->text($record['registration_number'] ?? null), + 'status' => strtolower($this->text($record['status'] ?? null) ?? 'active'), + 'support_email' => $this->lowerText($record['support_email'] ?? null), + 'support_phone' => $this->text($record['support_phone'] ?? null), + 'preferred_contact_method' => $this->text($record['preferred_contact_method'] ?? null), + 'physical_address' => $this->text($record['physical_address'] ?? null), + 'postal_address' => $this->text($record['postal_address'] ?? null), + 'general_notes' => $this->text($record['general_notes'] ?? null), + ]; + } + + /** @return array{valid: bool, errors: array, name: string, registration_number: string|null, status: string, support_email: string|null, support_phone: string|null, preferred_contact_method: string|null, physical_address: string|null, postal_address: string|null, general_notes: string|null} */ + public function validate(array $record): array + { + $normalized = $this->normalize($record); + $errors = []; + + if ($normalized['name'] === '') { + $errors['name'] = 'Client name is required.'; + } elseif (mb_strlen($normalized['name']) > 190) { + $errors['name'] = 'Client name must be 190 characters or fewer.'; + } + if (!in_array($normalized['status'], ['active', 'inactive'], true)) { + $errors['status'] = 'Invalid client status.'; + } + if ($normalized['registration_number'] !== null && mb_strlen($normalized['registration_number']) > 120) { + $errors['registration_number'] = 'Registration number must be 120 characters or fewer.'; + } + if ($normalized['support_email'] !== null) { + if (filter_var($normalized['support_email'], FILTER_VALIDATE_EMAIL) === false) { + $errors['support_email'] = 'Support email must be a valid email address.'; + } elseif (mb_strlen($normalized['support_email']) > 190) { + $errors['support_email'] = 'Support email must be 190 characters or fewer.'; + } + } + if ($normalized['support_phone'] !== null && !$this->validPhone($normalized['support_phone'])) { + $errors['support_phone'] = 'Support phone must contain a valid phone number.'; + } + + return [...$normalized, 'valid' => $errors === [], 'errors' => $errors]; + } + + /** @return array */ + public function display(array $record): array + { + $safe = []; + foreach (self::DISPLAY_FIELDS as $field) { + if (array_key_exists($field, $record)) { + $safe[$field] = $record[$field]; + } + } + return $safe; + } + + /** @return array */ + public function toDisplay(array $record): array + { + return $this->display($record); + } + + private function text(mixed $value): ?string + { + if ($value === null) return null; + $text = trim(is_scalar($value) ? (string) $value : ''); + return $text === '' ? null : $text; + } + + private function lowerText(mixed $value): ?string + { + $text = $this->text($value); + return $text === null ? null : strtolower($text); + } + + private function validPhone(string $phone): bool + { + if (mb_strlen($phone) > 60 || preg_match('/^[0-9+().\-\s]+$/', $phone) !== 1) { + return false; + } + return preg_match('/\d.*\d.*\d.*\d.*\d.*\d.*\d/', $phone) === 1; + } +} diff --git a/app/Domain/Jobcard/JobcardReference.php b/app/Domain/Jobcard/JobcardReference.php index ecd1948..3c9b571 100644 --- a/app/Domain/Jobcard/JobcardReference.php +++ b/app/Domain/Jobcard/JobcardReference.php @@ -7,8 +7,8 @@ final class JobcardReference { public static function generate(int $sequence, ?int $year = null): string { - if ($sequence < 1) { - throw new \InvalidArgumentException('Sequence must be positive.'); + if ($sequence < 1 || $sequence > 999999) { + throw new \InvalidArgumentException('Sequence must be between 1 and 999999.'); } $year ??= (int) date('Y'); if ($year < 2000 || $year > 9999) { diff --git a/app/Domain/Jobcard/JobcardWorkflow.php b/app/Domain/Jobcard/JobcardWorkflow.php new file mode 100644 index 0000000..94bb38a --- /dev/null +++ b/app/Domain/Jobcard/JobcardWorkflow.php @@ -0,0 +1,134 @@ + */ + public function allowedInitialStatuses(): array + { + return [self::INITIAL_STATUS]; + } + + /** @return list */ + public function allowedPriorities(): array + { + return self::PRIORITIES; + } + + /** + * @return array{valid: bool, client_id: ?int, work_requested: string, priority: string, status: string, errors: array} + */ + public function validateCommand(array $command): array + { + $errors = []; + $clientId = $this->positiveInteger($command['client_id'] ?? null); + $workRequested = is_scalar($command['work_requested'] ?? null) + ? trim((string) $command['work_requested']) + : ''; + $priority = is_scalar($command['priority'] ?? null) + ? (string) $command['priority'] + : ''; + + if ($clientId === null) { + $errors['client_id'] = 'Client ID must be a positive integer.'; + } + if ($workRequested === '') { + $errors['work_requested'] = 'Work requested is required.'; + } elseif (mb_strlen($workRequested) > self::MAX_WORK_REQUESTED_LENGTH) { + $errors['work_requested'] = 'Work requested must be 10000 characters or fewer.'; + } + if (!in_array($priority, self::PRIORITIES, true)) { + $errors['priority'] = 'Invalid jobcard priority.'; + } + + return [ + 'valid' => $errors === [], + 'client_id' => $clientId, + 'work_requested' => $workRequested, + 'priority' => $priority, + 'status' => self::INITIAL_STATUS, + 'errors' => $errors, + ]; + } + + /** + * Validate a status change and the timestamps required by terminal statuses. + * + * @return array{valid: bool, from: string, to: string, completed_at: ?string, closed_at: ?string, errors: array} + */ + public function validateTransition( + string $from, + string $to, + ?string $completedAt = null, + ?string $closedAt = null, + ): array { + $errors = []; + $transitionValidator = $this->transitions ?? new StatusTransitionValidator(); + if (!$transitionValidator->canTransition($from, $to)) { + $errors['transition'] = 'Jobcard status transition is not allowed.'; + } + + $completed = $this->parseTimestamp($completedAt); + $closed = $this->parseTimestamp($closedAt); + if ($completedAt !== null && $completed === null) { + $errors['completed_at'] = 'Completion timestamp must be a valid datetime.'; + } + if ($closedAt !== null && $closed === null) { + $errors['closed_at'] = 'Closure timestamp must be a valid datetime.'; + } + if (in_array($to, ['completed', 'closed'], true) && $completedAt === null) { + $errors['completed_at'] = 'Completion timestamp is required.'; + } + if ($to === 'closed' && $closedAt === null) { + $errors['closed_at'] = 'Closure timestamp is required.'; + } + if ($completed !== null && $closed !== null && $closed < $completed) { + $errors['timestamps'] = 'Closure timestamp must not precede completion timestamp.'; + } + + return [ + 'valid' => $errors === [], + 'from' => $from, + 'to' => $to, + 'completed_at' => $completedAt, + 'closed_at' => $closedAt, + 'errors' => $errors, + ]; + } + + private function positiveInteger(mixed $value): ?int + { + if (is_bool($value) || (is_int($value) && $value > 0)) { + return is_int($value) && $value > 0 ? $value : null; + } + if (is_string($value) && preg_match('/^[1-9]\d*$/', $value) === 1) { + $integer = filter_var($value, FILTER_VALIDATE_INT); + return $integer !== false && $integer > 0 ? $integer : null; + } + return null; + } + + private function parseTimestamp(?string $value): ?\DateTimeImmutable + { + if ($value === null) return null; + $parsed = \DateTimeImmutable::createFromFormat('!Y-m-d H:i:s', $value); + $errors = \DateTimeImmutable::getLastErrors(); + if ($parsed === false || ($errors !== false && ($errors['warning_count'] > 0 || $errors['error_count'] > 0))) { + return null; + } + return $parsed->format('Y-m-d H:i:s') === $value ? $parsed : null; + } +} diff --git a/app/Domain/Reporting/CsvExporter.php b/app/Domain/Reporting/CsvExporter.php new file mode 100644 index 0000000..bf8fec2 --- /dev/null +++ b/app/Domain/Reporting/CsvExporter.php @@ -0,0 +1,79 @@ + $headers + * @param list> $rows + */ + public function export(array $headers, array $rows, bool $withBom = false): string + { + $records = []; + $records[] = $this->formatRecord($headers); + + $headerCount = count($headers); + $rowNumber = 0; + foreach ($rows as $row) { + $rowNumber++; + if (!is_array($row)) { + throw new InvalidArgumentException(sprintf('CSV row %d must be an array.', $rowNumber)); + } + + $fieldCount = count($row); + if ($fieldCount !== $headerCount) { + throw new InvalidArgumentException(sprintf( + 'CSV row %d has %d fields; expected %d fields for %d headers.', + $rowNumber, + $fieldCount, + $headerCount, + $headerCount, + )); + } + + $records[] = $this->formatRecord($row); + } + + $csv = implode("\r\n", $records) . "\r\n"; + return $withBom ? self::UTF8_BOM . $csv : $csv; + } + + /** + * @param list $fields + */ + private function formatRecord(array $fields): string + { + return implode(',', array_map( + fn (mixed $field): string => $this->escapeField($field), + $fields, + )); + } + + private function escapeField(mixed $field): string + { + if ($field === null) { + $value = ''; + } elseif (is_scalar($field)) { + $value = (string)$field; + } else { + throw new InvalidArgumentException('CSV fields must be scalar values or null.'); + } + + // Prefix formula-like values so spreadsheet programs treat them as text. + if ($value !== '' && preg_match('/^[=+\-@]/', $value) === 1) { + $value = "'" . $value; + } + + if (strpbrk($value, ",\"\r\n") === false) { + return $value; + } + + return '"' . str_replace('"', '""', $value) . '"'; + } +} diff --git a/database/schema.sql b/database/schema.sql index 0dd353a..5927165 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -58,11 +58,18 @@ CREATE TABLE IF NOT EXISTS client_contacts ( email VARCHAR(190) NULL, phone VARCHAR(60) NULL, is_primary BOOLEAN NOT NULL DEFAULT FALSE, + primary_client_id BIGINT UNSIGNED AS (IF(is_primary, client_id, NULL)) STORED, notes TEXT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (client_id) REFERENCES clients(id) ON DELETE CASCADE, - INDEX contacts_client_idx (client_id) + INDEX contacts_client_idx (client_id), + UNIQUE KEY one_primary_contact (primary_client_id) +) ENGINE=InnoDB; + +CREATE TABLE IF NOT EXISTS jobcard_sequences ( + sequence_year SMALLINT UNSIGNED PRIMARY KEY, + next_sequence INT UNSIGNED NOT NULL ) ENGINE=InnoDB; CREATE TABLE IF NOT EXISTS sla_agreements ( diff --git a/public/index.php b/public/index.php index 30331b9..369bd4b 100644 --- a/public/index.php +++ b/public/index.php @@ -3,7 +3,11 @@ declare(strict_types=1); require_once __DIR__ . '/../config/bootstrap.php'; require_once __DIR__ . '/../app/Domain/Client/ClientValidator.php'; +require_once __DIR__ . '/../app/Domain/Client/ClientContactValidator.php'; +require_once __DIR__ . '/../app/Domain/Client/ClientRecord.php'; require_once __DIR__ . '/../app/Domain/Jobcard/JobcardReference.php'; +require_once __DIR__ . '/../app/Domain/Jobcard/JobcardWorkflow.php'; +require_once __DIR__ . '/../app/Domain/Reporting/CsvExporter.php'; ini_set('session.use_strict_mode', '1'); $forwardedHttps = getenv('TRUST_PROXY') === '1' && scalar_input($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https'; @@ -79,26 +83,37 @@ if ($route === 'jobcards') { if ($_SERVER['REQUEST_METHOD'] === 'POST') { require_permission('jobcards.manage'); verify_csrf(); - $clientId = filter_var(scalar_input($_POST['client_id'] ?? null), FILTER_VALIDATE_INT); - $workRequested = trim(scalar_input($_POST['work_requested'] ?? null)); - $priority = scalar_input($_POST['priority'] ?? null, 'normal'); - if (!$clientId || $workRequested === '' || mb_strlen($workRequested) > 10000 || !in_array($priority, ['low', 'normal', 'high', 'critical'], true)) { - $errors[] = 'Select a client, enter the requested work, and choose a valid priority.'; - } else { + $command = (new \App\Domain\Jobcard\JobcardWorkflow())->validateCommand($_POST); + $clientId = $command['client_id']; + $workRequested = $command['work_requested']; + $priority = $command['priority']; + $errors = array_values($command['errors']); + if (!$errors) { $clientCheck = db()->prepare("SELECT id FROM clients WHERE id = :id AND status = 'active'"); $clientCheck->execute(['id' => $clientId]); if (!$clientCheck->fetchColumn()) $errors[] = 'The selected client is not active or does not exist.'; } if (!$errors) { $year = (int)date('Y'); - $sequenceStmt = db()->prepare('SELECT COALESCE(MAX(CAST(SUBSTRING(reference_no, 9) AS UNSIGNED)), 0) + 1 FROM jobcards WHERE reference_no LIKE :prefix'); - $sequenceStmt->execute(['prefix' => 'JC-' . $year . '-%']); - $reference = \App\Domain\Jobcard\JobcardReference::generate((int)$sequenceStmt->fetchColumn(), $year); - $stmt = db()->prepare('INSERT INTO jobcards (reference_no, client_id, created_by, priority, status, work_requested) VALUES (:reference, :client, :created_by, :priority, \'new\', :requested)'); - $stmt->execute(['reference' => $reference, 'client' => $clientId, 'created_by' => $user['id'], 'priority' => $priority, 'requested' => $workRequested]); - $jobcardId = (int)db()->lastInsertId(); - audit('jobcard_created', 'jobcard', $jobcardId, ['reference_no' => $reference]); - header('Location: /?route=jobcards&created=1'); exit; + $pdo = db(); + try { + $pdo->beginTransaction(); + $sequenceStmt = $pdo->prepare('INSERT INTO jobcard_sequences (sequence_year, next_sequence) VALUES (:year, 2) ON DUPLICATE KEY UPDATE next_sequence = next_sequence + 1'); + $sequenceStmt->execute(['year' => $year]); + $sequenceStmt = $pdo->prepare('SELECT next_sequence - 1 FROM jobcard_sequences WHERE sequence_year = :year FOR UPDATE'); + $sequenceStmt->execute(['year' => $year]); + $sequence = (int)$sequenceStmt->fetchColumn(); + $reference = \App\Domain\Jobcard\JobcardReference::generate($sequence, $year); + $stmt = $pdo->prepare('INSERT INTO jobcards (reference_no, client_id, created_by, priority, status, work_requested) VALUES (:reference, :client, :created_by, :priority, \'new\', :requested)'); + $stmt->execute(['reference' => $reference, 'client' => $clientId, 'created_by' => $user['id'], 'priority' => $priority, 'requested' => $workRequested]); + $jobcardId = (int)$pdo->lastInsertId(); + audit('jobcard_created', 'jobcard', $jobcardId, ['reference_no' => $reference]); + $pdo->commit(); + header('Location: /?route=jobcards&created=1'); exit; + } catch (Throwable $exception) { + if ($pdo->inTransaction()) $pdo->rollBack(); + $errors[] = 'The jobcard could not be created. Please try again.'; + } } } $clients = db()->query("SELECT id, name FROM clients WHERE status = 'active' ORDER BY name")->fetchAll(); @@ -125,13 +140,41 @@ if ($route === 'client') { $stmt->execute(['id' => $clientId]); $client = $stmt->fetch(); if (!$client) { http_response_code(404); exit('Client not found'); } + $contactErrors = []; + $contactOld = ['name' => '', 'email' => '', 'phone' => '', 'is_primary' => false]; + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + require_permission('clients.manage'); + verify_csrf(); + $contact = validate_client_contact($_POST); + $contactOld = $contact; + $contactErrors = $contact['errors']; + if ($contactErrors === []) { + $pdo = db(); + try { + $pdo->beginTransaction(); + if ($contact['is_primary']) { + $pdo->prepare('UPDATE client_contacts SET is_primary = 0 WHERE client_id = :client')->execute(['client' => $clientId]); + } + $contactInsert = $pdo->prepare('INSERT INTO client_contacts (client_id, name, email, phone, is_primary) VALUES (:client, :name, :email, :phone, :primary)'); + $contactInsert->execute(['client' => $clientId, 'name' => $contact['name'], 'email' => $contact['email'], 'phone' => $contact['phone'], 'primary' => $contact['is_primary'] ? 1 : 0]); + $contactId = (int)$pdo->lastInsertId(); + audit('client_contact_created', 'client_contact', $contactId, ['client_id' => $clientId]); + $pdo->commit(); + header('Location: /?route=client&id=' . $clientId . '&contact_created=1'); exit; + } catch (Throwable $exception) { + if ($pdo->inTransaction()) $pdo->rollBack(); + $contactErrors[] = 'The contact could not be created. Please try again.'; + } + } + } $contactsStmt = db()->prepare('SELECT name, email, phone, is_primary, notes FROM client_contacts WHERE client_id = :id ORDER BY is_primary DESC, name'); $contactsStmt->execute(['id' => $clientId]); $contacts = $contactsStmt->fetchAll(); render_header('Client details'); - echo '
← Back to clients

' . e($client['name']) . '

Client profile and support contacts.

' . e(ucfirst($client['status'])) . '

Support information

Email
' . e((string)($client['support_email'] ?? '—')) . '
Phone
' . e((string)($client['support_phone'] ?? '—')) . '
Preferred method
' . e((string)($client['preferred_contact_method'] ?? '—')) . '
Address
' . nl2br(e((string)($client['physical_address'] ?? '—'))) . '

Contacts

'; + echo '
← Back to clients

' . e($client['name']) . '

Client profile and support contacts.

' . e(ucfirst($client['status'])) . '
' . (isset($_GET['contact_created']) ? '
Contact added successfully.
' : '') . ($contactErrors ? '
' . e(implode(' ', $contactErrors)) . '
' : '') . '

Support information

Email
' . e((string)($client['support_email'] ?? '—')) . '
Phone
' . e((string)($client['support_phone'] ?? '—')) . '
Preferred method
' . e((string)($client['preferred_contact_method'] ?? '—')) . '
Address
' . nl2br(e((string)($client['physical_address'] ?? '—'))) . '

Contacts

'; if (!$contacts) echo '

No contacts recorded.

'; foreach ($contacts as $contact) echo '
' . e($contact['name']) . ($contact['is_primary'] ? ' Primary' : '') . '
' . e((string)($contact['email'] ?? '')) . ' ' . e((string)($contact['phone'] ?? '')) . '
'; + if (can('clients.manage')) echo '

Add contact

'; echo '
'; render_footer(); exit; @@ -176,6 +219,30 @@ if ($route === 'clients') { exit; } +if ($route === 'reports') { + require_permission('reports.view'); + $format = scalar_input($_GET['format'] ?? null); + if ($format === 'csv') require_permission('reports.export'); + $reportRows = db()->query('SELECT c.id AS client_id, c.name AS client_name, COUNT(DISTINCT j.id) AS jobcards, COALESCE(SUM(te.hours), 0) AS hours FROM clients c LEFT JOIN jobcards j ON j.client_id = c.id LEFT JOIN time_entries te ON te.jobcard_id = j.id GROUP BY c.id, c.name ORDER BY c.name')->fetchAll(); + $rows = array_map(static fn (array $row): array => [$row['client_name'], (int)$row['jobcards'], round((float)$row['hours'], 2)], $reportRows); + if ($format === 'csv') { + $csv = (new CsvExporter())->export(['Client', 'Jobcards', 'Hours'], $rows, true); + header('Content-Type: text/csv; charset=UTF-8'); + header('Content-Disposition: attachment; filename="hours-per-client.csv"'); + header('Cache-Control: no-store'); + echo $csv; + exit; + } + render_header('Reports'); + echo '

Reports

Internal hours summary by client.

'; + if (can('reports.export')) echo 'Export CSV'; + echo '
'; + if (!$reportRows) echo ''; + foreach ($reportRows as $row) echo ''; + echo '
ClientJobcardsHours
No report data available.
' . e($row['client_name']) . '' . (int)$row['jobcards'] . '' . e(number_format((float)$row['hours'], 2)) . '
'; + render_footer(); exit; +} + if (isset($permissionByRoute[$route])) { require_permission($permissionByRoute[$route]); render_header(ucfirst($route)); ?>

This module is scaffolded for the next implementation phase.

The route is permission-protected and ready for its domain workflow.
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"); diff --git a/tests/CsvExporterTest.php b/tests/CsvExporterTest.php new file mode 100644 index 0000000..4b8b400 --- /dev/null +++ b/tests/CsvExporterTest.php @@ -0,0 +1,44 @@ +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"); diff --git a/tests/JobcardDomainTest.php b/tests/JobcardDomainTest.php index c1c768f..9c7df7b 100644 --- a/tests/JobcardDomainTest.php +++ b/tests/JobcardDomainTest.php @@ -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.'); diff --git a/tests/JobcardWorkflowTest.php b/tests/JobcardWorkflowTest.php new file mode 100644 index 0000000..6f8e98f --- /dev/null +++ b/tests/JobcardWorkflowTest.php @@ -0,0 +1,52 @@ +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");