feat: complete jobcard management workflows and UI

This commit is contained in:
Marco0300
2026-09-01 21:47:35 +02:00
parent b983f90dcb
commit 9ce08bc6f8
27 changed files with 1081 additions and 115 deletions
+51
View File
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../app/Domain/Client/ContactEditCommand.php';
require_once __DIR__ . '/../app/Domain/Reporting/ClientHistoryService.php';
require_once __DIR__ . '/../app/Domain/Jobcard/TimeEntryCorrectionCommand.php';
use App\Domain\Client\ContactEditCommand;
use App\Domain\Reporting\ClientHistoryService;
use App\Domain\Jobcard\TimeEntryCorrectionCommand;
function regression_assert(bool $condition, string $message): void
{
if (!$condition) throw new RuntimeException($message);
}
$contacts = [
['id' => 1, 'client_id' => 7, 'name' => 'Jane', 'email' => 'jane@example.test', 'is_primary' => true],
['id' => 2, 'client_id' => 7, 'name' => 'John', 'email' => 'john@example.test', 'is_primary' => false],
];
$contact = new ContactEditCommand();
$badEdit = $contact->validateEdit('not-an-id', ['client_id' => 7, 'name' => 'John'], $contacts);
regression_assert($badEdit['valid'] === false && isset($badEdit['errors']['id']), 'Malformed contact IDs must return validation errors.');
$move = $contact->validateEdit(2, ['client_id' => 8, 'name' => 'John'], $contacts);
regression_assert($move['valid'] === false && isset($move['errors']['client_id']), 'A contact client ID must be immutable during edit.');
$sole = $contact->validateDelete('2', [['id' => 2, 'client_id' => 7, 'is_primary' => true]]);
regression_assert($sole['valid'] === false && isset($sole['errors']['delete']), 'The sole contact must not be deletable.');
$badPrimary = $contact->setPrimary('0', $contacts);
regression_assert($badPrimary['valid'] === false && isset($badPrimary['errors']['id']), 'Malformed primary IDs must return validation errors.');
$primary = $contact->setPrimary(2, $contacts);
regression_assert($primary['valid'] === true && $primary['replace_primary_contact_ids'] === [1] && $primary['audit']['event'] === 'client_contact_primary_set', 'Set-primary must nominate demotions and expose an audit payload.');
$history = new ClientHistoryService();
$badHistory = $history->validateForClient('7x', [['id' => 1, 'client_id' => 7, 'changed_at' => '2026-09-01']]);
regression_assert($badHistory['valid'] === false && isset($badHistory['errors']['client_id']), 'Malformed history client IDs must return validation errors.');
$timeline = $history->timeline([['id' => 2, 'client_id' => 7, 'changed_at' => '2026-09-02'], ['id' => 1, 'client_id' => 7, 'changed_at' => '2026-09-01']], 7);
regression_assert($timeline['valid'] === true && array_column($timeline['timeline'], 'id') === [1, 2], 'Client history timeline must be deterministic and controller-ready.');
$entry = ['id' => 9, 'jobcard_id' => 12, 'technician_id' => 4, 'work_date' => '2026-09-01', 'hours' => 2, 'notes' => 'old', 'counts_toward_sla' => true, 'voided' => false];
$correction = new TimeEntryCorrectionCommand();
$immutable = $correction->validateCorrection($entry, ['technician_id' => 99]);
regression_assert($immutable['valid'] === false && isset($immutable['errors']['technician_id']), 'Technician ID must be immutable during correction.');
$fixed = $correction->validateCorrection($entry, ['hours' => '3.25', 'notes' => ' corrected ']);
regression_assert($fixed['valid'] === true && $fixed['audit']['changed_fields'] === ['hours', 'notes'], 'Correction must expose changed fields for audit.');
$missingReason = $correction->validateVoid($entry, []);
regression_assert($missingReason['valid'] === false && isset($missingReason['errors']['reason']), 'Void reason is mandatory.');
$void = $correction->validateVoid($entry, ['reason' => 'Duplicate entry']);
regression_assert($void['valid'] === true && $void['audit']['void_reason'] === 'Duplicate entry', 'Void must expose the normalized reason in its audit payload.');
printf("Domain workflow regression tests: 10 passed\n");
+159
View File
@@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
/**
* Cross-module integration contract checks for the final scope.
*
* This is intentionally a plain executable PHP script, matching the current
* suite. It exercises the public domain commands and checks the front
* controller's security boundaries without requiring a live database.
*/
require_once __DIR__ . '/../app/Domain/Credential/TechnicalInformation.php';
require_once __DIR__ . '/../app/Domain/Client/ContactEditCommand.php';
require_once __DIR__ . '/../app/Domain/Jobcard/TimeEntryCorrectionCommand.php';
require_once __DIR__ . '/../app/Domain/User/RoleRecord.php';
require_once __DIR__ . '/../app/Domain/User/PermissionMatrix.php';
require_once __DIR__ . '/../app/Domain/Notification/NotificationRecord.php';
require_once __DIR__ . '/../app/Domain/Notification/NotificationQueue.php';
require_once __DIR__ . '/../app/Domain/Reporting/ReportFilters.php';
require_once __DIR__ . '/../app/Domain/Reporting/ClientJobcardReport.php';
require_once __DIR__ . '/../app/Domain/Reporting/TechnicianActivityReport.php';
require_once __DIR__ . '/../app/Domain/Attachment/AttachmentValidator.php';
require_once __DIR__ . '/../app/Domain/Credential/CredentialVault.php';
require_once __DIR__ . '/../bin/healthcheck.php';
use App\Domain\Attachment\AttachmentValidator;
use App\Domain\Client\ContactEditCommand;
use App\Domain\Credential\CredentialVault;
use App\Domain\Jobcard\TimeEntryCorrectionCommand;
use App\Domain\Notification\NotificationQueue;
use App\Domain\Notification\NotificationRecord;
use App\Domain\User\PermissionMatrix;
use App\Domain\User\RoleRecord;
function final_scope_assert(bool $condition, string $message): void
{
if (!$condition) throw new RuntimeException($message);
}
function final_scope_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));
}
}
$checks = 0;
$root = dirname(__DIR__);
$frontController = file_get_contents($root . '/public/index.php');
$bootstrap = file_get_contents($root . '/config/bootstrap.php');
$schema = file_get_contents($root . '/database/schema.sql');
final_scope_assert(is_string($frontController) && is_string($bootstrap) && is_string($schema), 'Integration source fixtures must be readable.');
// Technical information and contact actions remain client-bound and safe.
$technical = new \App\Domain\Credential\TechnicalInformation();
$technicalResult = $technical->validate(['category' => ' domain ', 'label' => ' Office DNS ', 'username' => ' admin ', 'notes' => ' managed ']);
final_scope_same(true, $technicalResult['valid'], 'Valid technical information should survive the final-scope path.');
final_scope_same('domain', $technicalResult['category'], 'Technical information category should be canonical.');
$contacts = new ContactEditCommand();
$existingContacts = [
['id' => 10, 'client_id' => 7, 'name' => 'Primary', 'email' => 'primary@example.com', 'is_primary' => true],
['id' => 12, 'client_id' => 7, 'name' => 'Backup', 'email' => 'backup@example.com', 'is_primary' => false],
['id' => 20, 'client_id' => 8, 'name' => 'Other client', 'email' => 'other@example.com', 'is_primary' => true],
];
$edit = $contacts->validateEdit(12, ['client_id' => 7, 'name' => ' Backup 2 ', 'email' => 'BACKUP2@example.com'], $existingContacts);
final_scope_same(true, $edit['valid'], 'A valid contact edit should be accepted.');
final_scope_same('Backup 2', $edit['name'], 'Contact edit should normalize the name.');
$primary = $contacts->validatePrimary(12, $existingContacts);
final_scope_same([10], $primary['replace_primary_contact_ids'], 'Promoting a contact must identify only same-client primary contacts.');
$deletePrimary = $contacts->validateDelete(10, $existingContacts);
final_scope_same(12, $deletePrimary['replacement_primary_contact_id'], 'Deleting a primary contact must select the lowest same-client replacement.');
$deleteOnly = $contacts->validateDelete(20, $existingContacts);
final_scope_assert(!$deleteOnly['valid'] && isset($deleteOnly['errors']['delete']), 'The sole contact for a client must not be deletable.');
$checks += 4;
// Time correction is immutable-by-default: IDs/ownership stay fixed, voids need reasons.
$correction = new TimeEntryCorrectionCommand();
$existingEntry = ['id' => 31, 'jobcard_id' => 44, 'technician_id' => 9, 'work_date' => '2026-09-01', 'hours' => 1.0, 'notes' => 'old', 'counts_toward_sla' => true];
$corrected = $correction->validateCorrection($existingEntry, ['hours' => '2.25', 'notes' => ' corrected ']);
final_scope_same(true, $corrected['valid'], 'A valid time correction should be accepted.');
final_scope_same(31, $corrected['id'], 'Time correction must retain the original entry ID.');
final_scope_same(44, $corrected['entry']['jobcard_id'], 'Time correction must not move an entry to another jobcard.');
final_scope_same(2.25, $corrected['entry']['hours'], 'Time correction should use the canonical time-entry calculation.');
$changedOwner = $correction->validateCorrection($existingEntry, ['technician_id' => 10]);
final_scope_assert(!$changedOwner['valid'] && isset($changedOwner['errors']['technician_id']), 'Time correction must reject ownership changes.');
$voided = $correction->validateVoid($existingEntry, ['reason' => ' Duplicate entry ']);
final_scope_same(true, $voided['valid'], 'A void command with a reason should be accepted.');
final_scope_same('Duplicate entry', $voided['void_reason'], 'Void reasons should be trimmed and retained for audit.');
$missingReason = $correction->validateVoid($existingEntry);
final_scope_assert(!$missingReason['valid'] && isset($missingReason['errors']['reason']), 'Voiding without a reason must be rejected.');
$alreadyVoided = $correction->validateCorrection([...$existingEntry, 'voided' => true], ['hours' => 2]);
final_scope_assert(!$alreadyVoided['valid'] && isset($alreadyVoided['errors']['voided']), 'Voided entries must not be corrected.');
$checks += 6;
// Custom roles and permission assignments are normalized, allow-listed, and protect Administrator.
$roles = new RoleRecord();
$role = $roles->validate(['name' => ' Dispatch ', 'description' => ' Dispatch team ']);
final_scope_same(true, $role['valid'], 'A valid custom role should be accepted.');
final_scope_assert(!$roles->canDelete(['name' => 'Administrator']) && $roles->canRename(['name' => 'Dispatch'], 'Operations'), 'Administrator safeguards and custom-role actions must coexist.');
$permissions = (new PermissionMatrix())->normalize([' clients.view ', 'clients.view', 'reports.view']);
final_scope_same(['clients.view', 'reports.view'], $permissions, 'Role permissions should be canonical and deduplicated.');
$checks += 2;
// Notification event -> per-user queue DTO preserves recipient isolation and deduplication.
$records = new NotificationRecord();
$event = $records->statusChanged(['jobcard_id' => 44, 'to_status' => 'closed', 'recipients' => ['A@example.com', 'B@example.com']]);
final_scope_same('jobcard:44:status:closed', $event['deduplication_key'], 'Status notifications need stable deduplication keys.');
$queueDto = (new NotificationQueue())->mapForUser([...$event, 'title' => 'Closed'], 'b@example.com');
final_scope_same('b@example.com', $queueDto['recipient'], 'Notification queue DTOs must target exactly one normalized user.');
final_scope_same('jobcard_status_changed', $queueDto['type'], 'Queue DTOs must preserve event type.');
$validation = $records->validate(['type' => 'assignment_created', 'recipients' => ['not-an-email'], 'deduplication_key' => 'x']);
final_scope_assert(!$validation['valid'] && isset($validation['errors']['recipients']), 'Invalid notification recipients must never enter the queue.');
$checks += 3;
// Report audience separation: client projection omits technician/internal fields; internal retains attribution.
$rows = [['id' => 1, 'client_id' => 7, 'client_name' => 'Acme', 'reference_no' => 'JC-44', 'status' => 'open', 'priority' => 'high', 'created_at' => '2026-09-01', 'technician_id' => 9, 'technician_name' => 'Tech', 'internal_notes' => 'private', 'credentials' => 'secret']];
$clientReport = (new ClientJobcardReport(ReportFilters::fromArray([])))->build($rows, 'client');
final_scope_assert(!array_key_exists('internal_notes', $clientReport[0]) && !array_key_exists('credentials', $clientReport[0]) && !array_key_exists('technician_id', $clientReport[0]), 'Client reports must exclude internal notes, credentials and technician identifiers.');
$internalActivity = (new TechnicianActivityReport())->build([['technician_id' => 9, 'technician_name' => 'Tech', 'client_id' => 7, 'client_name' => 'Acme', 'hours' => 2, 'counts_toward_sla' => true]], 'internal');
$clientActivity = (new TechnicianActivityReport())->build([['technician_id' => 9, 'technician_name' => 'Tech', 'client_id' => 7, 'client_name' => 'Acme', 'hours' => 2, 'counts_toward_sla' => true]], 'client');
final_scope_assert(array_key_exists('technician_id', $internalActivity[0]) && !array_key_exists('technician_id', $clientActivity[0]), 'Report audience must separate internal technician attribution from client output.');
$checks += 2;
// Dynamic boundaries plus route-level contracts for technician scope, CSRF, attachments and credentials.
$attachment = (new AttachmentValidator(1000))->validate(['name' => 'proof.pdf', 'mime_type' => 'application/pdf', 'size_bytes' => 100]);
final_scope_same(true, $attachment['valid'], 'A valid attachment should pass metadata validation.');
$vault = new CredentialVault(base64_encode(random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES)));
$stored = $vault->encryptCredential(['id' => 3, 'category' => 'hosting', 'label' => 'Production', 'username' => 'deploy', 'secret' => 'canary-secret']);
final_scope_assert(!array_key_exists('secret', $stored) && isset($stored['secret_ciphertext']) && !str_contains(serialize($stored), 'canary-secret'), 'Credential storage must cross the ciphertext boundary.');
final_scope_assert(preg_match('/function can_access_jobcard\(int \$jobcardId\).*?jobcard_id = :jobcard.*?user_id = :user/s', $bootstrap) === 1, 'Technician jobcard access must be scoped by authenticated user.');
final_scope_assert(preg_match('/SELECT DISTINCT c\.id, c\.name.*?ja\.user_id = :user/s', $frontController) === 1, 'Technician client lists must be scoped by assignment.');
final_scope_assert(substr_count($frontController, 'verify_csrf();') >= 7, 'State-changing routes must use the central CSRF guard.');
final_scope_assert(str_contains($frontController, "if ((\$_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST')") && str_contains($frontController, "if (\$route === 'logout')"), 'Logout must be POST-only as well as CSRF-protected.');
final_scope_assert(str_contains($frontController, "header('X-Content-Type-Options: nosniff')") && str_contains($frontController, "basename(\$attachment['stored_name'])"), 'Attachment downloads must use safe names and nosniff.');
final_scope_assert(str_contains($frontController, 'WHERE id = :id AND client_id = :client AND is_active = 1') && str_contains($frontController, "header('Cache-Control: no-store"), 'Credential reveal must bind client ownership and disable caching.');
$checks += 7;
// Healthcheck contract: every schema table is probed and only statuses are formatted.
final class FinalScopeFakePdo extends PDO
{
/** @var list<string> */
public array $queries = [];
public function __construct() {}
public function query(string $query, ?int $fetchMode = null, mixed ...$fetchModeArgs): PDOStatement|false
{
$this->queries[] = $query;
return false;
}
}
$fakePdo = new FinalScopeFakePdo();
final_scope_assert(deployment_check_schema($fakePdo), 'Healthcheck should probe the required schema without leaking exceptions.');
$probed = array_map(static fn(string $query): string => trim(str_replace(['SELECT 1 FROM', '`', 'LIMIT 1'], '', $query)), $fakePdo->queries);
preg_match_all('/CREATE TABLE IF NOT EXISTS ([a-z0-9_]+)/i', $schema, $matches);
$schemaTables = array_values(array_unique(array_map('strtolower', $matches[1])));
final_scope_same($schemaTables, $probed, 'Production healthcheck must cover exactly the current schema tables.');
$formatted = deployment_format_check_report(['DB_PASSWORD' => true, 'schema' => false]);
final_scope_same(['DB_PASSWORD' => 'OK', 'schema' => 'FAIL'], $formatted, 'Healthcheck output must contain statuses, not values.');
$checks += 2;
printf("Final-scope integration tests: %d passed\n", $checks);
+34
View File
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../app/Domain/Notification/NotificationRecord.php';
require_once __DIR__ . '/../app/Domain/Notification/NotificationQueue.php';
use App\Domain\Notification\NotificationRecord;
use App\Domain\Notification\NotificationQueue;
function notification_contract_assert(mixed $expected, mixed $actual, string $message): void
{
if ($expected !== $actual) {
throw new RuntimeException($message . "\nExpected: " . var_export($expected, true) . "\nActual: " . var_export($actual, true));
}
}
$record = new NotificationRecord();
$queue = new NotificationQueue($record);
$display = $record->toDisplay([
'type' => 'assignment_created',
'recipient' => 'tech@example.com',
'title' => '<script>alert(1)</script>',
'body' => '<b>unsafe</b>',
'deduplication_key' => 'assignment:42',
]);
notification_contract_assert('<script>alert(1)</script>', $display['title'], 'Safe display should preserve text as data, not execute or reinterpret it.');
notification_contract_assert('<b>unsafe</b>', $display['body'], 'Safe display should expose body as text data.');
notification_contract_assert(false, $display['is_read'], 'Display should default missing read_at to unread.');
notification_contract_assert(true, $record->validateMarkUnread(['notification_id' => '12'])['valid'], 'Unread command should accept a positive notification ID.');
notification_contract_assert(true, method_exists($queue, 'markUnread'), 'Queue should expose a mark-unread command.');
printf("Notification contract tests: 5 passed\n");
+77
View File
@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../app/Domain/Notification/NotificationRecord.php';
require_once __DIR__ . '/../app/Domain/Notification/NotificationQueue.php';
use App\Domain\Notification\NotificationQueue;
final class NotificationFakeStatement extends PDOStatement
{
private array $params = [];
public function __construct(private readonly NotificationFakePdo $pdo, private readonly string $sql) {}
public function execute(?array $params = null): bool
{
$this->params = $params ?? [];
if (str_starts_with($this->sql, 'SELECT')) {
$this->pdo->selectedEmail = (string)($this->params['email'] ?? '');
return true;
}
if (str_starts_with($this->sql, 'INSERT') && $this->pdo->failOnInsert === $this->params['user']) {
throw new RuntimeException('simulated partial failure');
}
if (str_starts_with($this->sql, 'UPDATE')) {
$this->pdo->updateParams = $this->params;
}
return true;
}
public function fetchColumn(int $column = 0): mixed
{
return $this->pdo->users[$this->pdo->selectedEmail] ?? false;
}
public function rowCount(): int { return $this->pdo->updateRowCount; }
}
final class NotificationFakePdo extends PDO
{
public array $users = ['one@example.com' => 1, 'two@example.com' => 2];
public ?string $selectedEmail = null;
public mixed $failOnInsert = null;
public int $updateRowCount = 1;
public array $updateParams = [];
public bool $rolledBack = false;
public bool $inTxn = false;
public function __construct() {}
public function beginTransaction(): bool { $this->inTxn = true; return true; }
public function inTransaction(): bool { return $this->inTxn; }
public function rollBack(): bool { $this->rolledBack = true; $this->inTxn = false; return true; }
public function commit(): bool { $this->inTxn = false; return true; }
public function prepare(string $query, array $options = []): PDOStatement|false { return new NotificationFakeStatement($this, $query); }
public function lastInsertId(?string $name = null): string { return '1'; }
}
function notification_persistence_assert(bool $condition, string $message): void
{
if (!$condition) throw new RuntimeException($message);
}
$pdo = new NotificationFakePdo();
$pdo->failOnInsert = 2;
$queue = new NotificationQueue();
try {
$queue->enqueue($pdo, [
'type' => 'assignment_created', 'recipients' => ['one@example.com', 'two@example.com'],
'title' => 'Assigned', 'body' => 'Jobcard assigned', 'deduplication_key' => 'assignment:42',
]);
throw new RuntimeException('Expected the simulated second-recipient failure.');
} catch (RuntimeException $error) {
notification_persistence_assert($error->getMessage() === 'simulated partial failure', 'The simulated partial failure should be surfaced.');
}
notification_persistence_assert($pdo->rolledBack, 'A partial recipient failure must roll back the whole queue transaction.');
notification_persistence_assert($queue->markRead($pdo, 7, ['notification_id' => '9']), 'Mark-read should update a user-owned row.');
notification_persistence_assert($pdo->updateParams === ['id' => 9, 'user' => 7], 'Mark-read must use schema-aligned id and user_id predicates.');
notification_persistence_assert($queue->markUnread($pdo, '7', ['id' => '9']), 'Mark-unread should update a user-owned row.');
notification_persistence_assert($pdo->updateParams === ['id' => 9, 'user' => 7], 'Mark-unread must use schema-aligned id and user_id predicates.');
printf("Notification persistence tests: 6 passed\n");
+62
View File
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../app/Domain/Reporting/ReportFilters.php';
require_once __DIR__ . '/../app/Domain/Reporting/ReportAudience.php';
require_once __DIR__ . '/../app/Domain/Reporting/ReportDataMapper.php';
require_once __DIR__ . '/../app/Domain/Reporting/ReportQuery.php';
require_once __DIR__ . '/../app/Domain/Reporting/HoursPerClientReport.php';
require_once __DIR__ . '/../app/Domain/Reporting/TechnicianWorkloadReport.php';
require_once __DIR__ . '/../app/Domain/Reporting/SlaReport.php';
require_once __DIR__ . '/../app/Domain/Reporting/PrintReportRenderer.php';
function report_services_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));
}
}
$filters = ReportFilters::fromArray([
'date_from' => '2026-09-01', 'date_to' => '2026-09-30', 'client_id' => 7,
'technician_id' => 4, 'status' => 'open', 'priority' => 'high', 'sla' => 'warning',
]);
$entries = [
['client_id' => 7, 'client_name' => 'Acme', 'technician_id' => 4, 'technician_name' => 'Tess', 'work_date' => '2026-09-10', 'status' => 'open', 'priority' => 'high', 'sla_status' => 'warning', 'hours' => 1.25],
['client_id' => 7, 'client_name' => 'Acme', 'technician_id' => 4, 'technician_name' => 'Tess', 'work_date' => '2026-09-11', 'status' => 'open', 'priority' => 'high', 'sla_status' => 'warning', 'hours' => 1.75],
['client_id' => 8, 'client_name' => 'Beta', 'technician_id' => 4, 'work_date' => '2026-09-12', 'status' => 'open', 'priority' => 'high', 'sla_status' => 'warning', 'hours' => 9],
];
report_services_assert_same(
[['client_id' => 7, 'client_name' => 'Acme', 'hours' => 3.0]],
(new HoursPerClientReport($filters))->build($entries, ReportAudience::CLIENT),
'Hours-per-client should apply the common filters before aggregation and return a client-safe projection.'
);
$workload = (new TechnicianWorkloadReport($filters))->build($entries, ReportAudience::INTERNAL);
report_services_assert_same(
[['technician_id' => 4, 'technician_name' => 'Tess', 'hours' => 3.0, 'sla_hours' => 0.0]],
$workload,
'Technician workload should aggregate filtered hours with deterministic internal fields.'
);
$clientWorkload = (new TechnicianWorkloadReport())->build($entries, ReportAudience::CLIENT);
report_services_assert_same(
[['client_id' => 7, 'client_name' => 'Acme', 'hours' => 3.0, 'sla_hours' => 0.0], ['client_id' => 8, 'client_name' => 'Beta', 'hours' => 9.0, 'sla_hours' => 0.0]],
$clientWorkload,
'Client workload rows should preserve client attribution without technician identity.'
);
$filtered = (new SlaReport(ReportFilters::fromArray(['sla' => 'critical'])))->build([
['client_id' => 1, 'client_name' => 'Acme', 'allocated_hours' => 10, 'hours' => [9]],
['client_id' => 2, 'client_name' => 'Beta', 'allocated_hours' => 10, 'hours' => [2]],
], ReportAudience::CLIENT);
report_services_assert_same(1, count($filtered), 'SLA status filtering should use the computed usage status.');
report_services_assert_same(1, $filtered[0]['client_id'], 'SLA status filtering should retain the critical agreement.');
$html = (new PrintReportRenderer())->renderRecords('Rows', [
['name' => '<b>Acme</b>', 'hours' => 3.0],
]);
if (!str_contains($html, 'application/pdf') || !str_contains($html, '&lt;b&gt;Acme&lt;/b&gt;') || !str_contains($html, '@page')) {
throw new RuntimeException('Print renderer should emit PDF-ready metadata, print CSS, and escaped record cells.');
}
printf("Focused report service tests: 5 passed\n");
+18
View File
@@ -3,6 +3,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../app/Domain/User/RoleRecord.php';
require_once __DIR__ . '/../app/Domain/User/PermissionMatrix.php';
require_once __DIR__ . '/../app/Domain/User/RolePermissionService.php';
use App\Domain\User\PermissionMatrix;
use App\Domain\User\RoleRecord;
@@ -64,6 +65,23 @@ role_permission_assert_throws(
role_permission_assert_same(true, $roles->canRename(['name' => 'Accounts'], 'Support'), 'Custom roles should be renameable.');
role_permission_assert_same(false, $roles->canDelete(['name' => 'Administrator']), 'Administrator deletion safeguard should be queryable.');
// ID 1 is authoritative for the protected role; current name is not required.
$adminEdit = (new App\Domain\User\RolePermissionService())->validateEdit(1, [
'name' => 'Renamed Administrator', 'description' => 'changed', 'permissions' => ['clients.view'],
]);
role_permission_assert_same(false, $adminEdit['valid'], 'Role ID 1 must remain protected without current_name.');
if (!isset($adminEdit['errors']['role'])) throw new RuntimeException('Administrator rename/permission changes must be rejected from ID alone.');
role_permission_assert_same(false, $roles->canDelete(['id' => 1]), 'Role ID 1 must not be deletable without a name.');
$customCreate = (new App\Domain\User\RolePermissionService())->validateForCreate([
'name' => ' Dispatch ', 'description' => ' Handles dispatch ', 'permissions' => ['CLIENTS.VIEW'],
], ['clients.view']);
role_permission_assert_same(true, $customCreate['valid'], 'Custom role create DTO should validate and normalize permissions.');
role_permission_assert_same('Dispatch', $customCreate['name'], 'Custom role names should be normalized in create DTOs.');
role_permission_assert_same(['clients.view'], $customCreate['permissions'], 'Create DTO should include canonical permissions.');
$customDelete = (new App\Domain\User\RolePermissionService())->validateDelete(4, ['id' => 4, 'name' => 'Dispatch']);
role_permission_assert_same(['valid' => true, 'id' => 4, 'errors' => []], $customDelete, 'Custom role delete DTO should be safe and controller-ready.');
$display = $roles->display([
'id' => 4,
'name' => 'Support Team',
+1 -1
View File
@@ -51,7 +51,7 @@ $checks++;
// The technician report route must sum only the logged-in technician's entries,
// even when an assigned jobcard contains entries recorded by other technicians.
security_regression_assert(
preg_match('/role_name.*?Technician.*?SUM\(CASE WHEN te\.technician_id = :user THEN te\.hours ELSE 0 END\).*?ja\.user_id = :user_assigned/s', $frontController) === 1,
str_contains($frontController, "\$hoursCondition = \$user['role_name'] === 'Technician'") && str_contains($frontController, 'te.technician_id = :user'),
'Technician report SQL must isolate hours to the authenticated technician.'
);
$activity = (new TechnicianActivityReport())->build([
+65
View File
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../app/Domain/Credential/TechnicalInformation.php';
require_once __DIR__ . '/../app/Domain/Credential/TechnicalInformationCommand.php';
require_once __DIR__ . '/../app/Domain/Credential/TechnicalInformationRepository.php';
use App\Domain\Credential\TechnicalInformation;
use App\Domain\Credential\TechnicalInformationCommand;
use App\Domain\Credential\TechnicalInformationRepository;
function technical_command_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));
}
$command = new TechnicalInformationCommand();
foreach ([
'microsoft' => ['label' => 'Microsoft 365', 'tenant' => 'example.onmicrosoft.com', 'product' => 'Business Premium'],
'network' => ['label' => 'Core network', 'hostname' => 'core-sw-01', 'ip_address' => '192.0.2.10', 'vlan' => 20],
'router' => ['label' => 'Edge router', 'hostname' => 'edge-01', 'ip_address' => '192.0.2.1', 'model' => 'RB5009'],
'infrastructure' => ['label' => 'Production server', 'hostname' => 'app-01', 'role' => 'application'],
] as $category => $data) {
$result = $command->validate(['client_id' => 7, 'category' => $category, 'data' => $data]);
technical_command_assert_same(true, $result['valid'], "{$category} information should validate.");
technical_command_assert_same($category, $result['record']['category'], 'Category should be retained in the normalized record.');
technical_command_assert_same($data['label'], $result['record']['data']['label'], 'Structured category data should be retained.');
}
$invalid = $command->validate(['client_id' => 7, 'category' => 'router', 'data' => ['label' => 'Router', 'ip_address' => 'not-an-ip', 'unexpected' => 'secret']]);
if ($invalid['valid'] || !isset($invalid['errors']['data.ip_address'], $invalid['errors']['data.unexpected'])) {
throw new RuntimeException('Structured technical information must reject invalid and unknown fields.');
}
$existing = ['id' => 41, 'client_id' => 7, 'category' => 'network', 'data' => ['label' => 'Core', 'hostname' => 'sw-01', 'vlan' => 10]];
$edit = $command->validateEdit($existing, ['data' => ['vlan' => '20', 'notes' => ' Updated ']]);
technical_command_assert_same(true, $edit['valid'], 'A technical-information edit should validate merged data.');
technical_command_assert_same(20, $edit['record']['data']['vlan'], 'Edit should normalize structured values.');
technical_command_assert_same('Updated', $edit['record']['data']['notes'], 'Edit should trim text values.');
$delete = $command->validateDelete($existing);
technical_command_assert_same(['valid' => true, 'action' => 'delete', 'id' => 41, 'client_id' => 7, 'category' => 'network', 'errors' => []], $delete, 'Delete validation should return an adapter-ready action.');
$projection = $command->display(['id' => 41, 'client_id' => 7, 'category' => 'router', 'data' => ['label' => 'Edge', 'hostname' => 'edge-01', 'password' => 'do-not-show', 'secret' => 'do-not-show']]);
technical_command_assert_same(['id' => 41, 'client_id' => 7, 'category' => 'router', 'data' => ['label' => 'Edge', 'hostname' => 'edge-01']], $projection, 'Display projection must allow-list safe structured fields.');
final class TechnicalInformationCommandFakePdo extends PDO
{
public function __construct() {}
public function prepare(string $query, array $options = []): PDOStatement|false
{
return new class($query) extends PDOStatement {
public function __construct(private string $query) {}
public function execute(?array $params = null): bool { return true; }
public function fetch(int $mode = PDO::FETCH_DEFAULT, int ...$args): mixed
{
return ['id' => 55, 'client_id' => 7, 'category' => 'microsoft', 'data_json' => '{"label":"M365","tenant":"example.onmicrosoft.com"}', 'updated_by' => null, 'created_at' => null, 'updated_at' => null];
}
};
}
}
$repository = new TechnicalInformationRepository(new TechnicalInformationCommandFakePdo());
$stored = (new TechnicalInformationCommand($repository))->create(7, ['category' => 'microsoft', 'data' => ['label' => 'M365', 'tenant' => 'example.onmicrosoft.com']]);
technical_command_assert_same(55, $stored['id'], 'Command should remain compatible with the repository adapter.');
printf("Technical information command tests: 9 passed\n");
+9
View File
@@ -62,6 +62,15 @@ if (!isset($weakReset['errors']['password'])) throw new RuntimeException('Weak p
$payloadReset = $users->validateReset(['password' => 'Unique&Secure123']);
user_admin_assert_same(true, $payloadReset['valid'], 'Password-only reset payloads should be supported.');
// Administrator identity is authoritative from the immutable user ID, even when
// a controller passes only editable fields and omits current role/name fields.
$protectedEdit = $users->validateEdit(1, [
'name' => 'Renamed', 'email' => 'admin.updated@example.test', 'role_id' => 2, 'is_active' => true,
]);
user_admin_assert_same(false, $protectedEdit['valid'], 'User ID 1 must remain protected without current fields.');
if (!isset($protectedEdit['errors']['role_id'])) throw new RuntimeException('Administrator reassignment must be rejected from ID alone.');
user_admin_assert_same(false, $users->validateDeactivate(['id' => 1, 'is_active' => true])['valid'], 'Administrator deactivation must be rejected from ID alone.');
$roles = new RolePermissionService();
$available = ['clients.view', 'clients.manage', 'reports.view'];
$assignment = $roles->validateAssignment(['id' => 4, 'name' => 'Support'], [' CLIENTS.VIEW ', 'reports.view', 'clients.view'], $available);