feat: complete jobcard management workflows and UI
This commit is contained in:
@@ -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);
|
||||
Reference in New Issue
Block a user