feat: complete jobcard operations and access controls
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../app/Domain/Jobcard/AssignmentValidator.php';
|
||||
require_once __DIR__ . '/../app/Domain/Jobcard/TimeEntryCommand.php';
|
||||
|
||||
use App\Domain\Jobcard\AssignmentValidator;
|
||||
use App\Domain\Jobcard\TimeEntryCommand;
|
||||
|
||||
function assignment_time_entry_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));
|
||||
}
|
||||
}
|
||||
|
||||
$assignment = (new AssignmentValidator())->validate([
|
||||
'jobcard_id' => '12',
|
||||
'user_ids' => ['7', 9, '7'],
|
||||
]);
|
||||
assignment_time_entry_assert_same(true, $assignment['valid'], 'Valid assignment payloads should be accepted.');
|
||||
assignment_time_entry_assert_same(12, $assignment['jobcard_id'], 'Jobcard IDs should normalize to integers.');
|
||||
assignment_time_entry_assert_same([7, 9], $assignment['user_ids'], 'Technician IDs should normalize and de-duplicate.');
|
||||
assignment_time_entry_assert_same([], $assignment['errors'], 'Valid assignment payloads should not contain errors.');
|
||||
|
||||
$invalidJobcard = (new AssignmentValidator())->validate(['jobcard_id' => 0, 'user_ids' => [7]]);
|
||||
assignment_time_entry_assert_same(false, $invalidJobcard['valid'], 'Jobcard IDs must be positive integers.');
|
||||
if (!isset($invalidJobcard['errors']['jobcard_id'])) {
|
||||
throw new RuntimeException('Invalid jobcard IDs should produce a jobcard_id error.');
|
||||
}
|
||||
|
||||
$invalidTechnicians = (new AssignmentValidator())->validate([
|
||||
'jobcard_id' => 12,
|
||||
'user_ids' => [7, '0', true],
|
||||
]);
|
||||
assignment_time_entry_assert_same(false, $invalidTechnicians['valid'], 'Every technician ID must be a positive integer.');
|
||||
if (!isset($invalidTechnicians['errors']['user_ids'])) {
|
||||
throw new RuntimeException('Invalid technician assignment payloads should produce a user_ids error.');
|
||||
}
|
||||
|
||||
$missingTechnicians = (new AssignmentValidator())->validate(['jobcard_id' => 12]);
|
||||
assignment_time_entry_assert_same(false, $missingTechnicians['valid'], 'At least one assigned technician is required.');
|
||||
if (!isset($missingTechnicians['errors']['user_ids'])) {
|
||||
throw new RuntimeException('Missing technician assignments should produce a user_ids error.');
|
||||
}
|
||||
|
||||
$timeEntry = (new TimeEntryCommand())->validate([
|
||||
'jobcard_id' => '12',
|
||||
'technician_id' => '7',
|
||||
'work_date' => '2026-09-01',
|
||||
'start_time' => '09:00',
|
||||
'end_time' => '11:30',
|
||||
'notes' => ' Replaced cable ',
|
||||
'counts_toward_sla' => '0',
|
||||
]);
|
||||
assignment_time_entry_assert_same(true, $timeEntry['valid'], 'Valid time-entry commands should be accepted.');
|
||||
assignment_time_entry_assert_same(12, $timeEntry['jobcard_id'], 'Time-entry jobcard IDs should normalize to integers.');
|
||||
assignment_time_entry_assert_same(7, $timeEntry['technician_id'], 'Technician IDs should normalize to integers.');
|
||||
assignment_time_entry_assert_same(2.5, $timeEntry['hours'], 'Start/end times should calculate normalized hours.');
|
||||
assignment_time_entry_assert_same('Replaced cable', $timeEntry['notes'], 'Time-entry notes should be trimmed.');
|
||||
assignment_time_entry_assert_same(false, $timeEntry['counts_toward_sla'], 'Boolean-like SLA values should normalize to booleans.');
|
||||
assignment_time_entry_assert_same([], $timeEntry['errors'], 'Valid time-entry commands should not contain errors.');
|
||||
|
||||
$manualEntry = (new TimeEntryCommand())->validate([
|
||||
'jobcard_id' => 12,
|
||||
'technician_id' => 7,
|
||||
'work_date' => '2026-09-02',
|
||||
'hours' => '1.255',
|
||||
]);
|
||||
assignment_time_entry_assert_same(true, $manualEntry['valid'], 'Positive manual hours should be accepted without times.');
|
||||
assignment_time_entry_assert_same(1.26, $manualEntry['hours'], 'Manual hours should reuse time-entry rounding.');
|
||||
assignment_time_entry_assert_same(true, $manualEntry['counts_toward_sla'], 'SLA counting should default to true.');
|
||||
|
||||
$invalidIds = (new TimeEntryCommand())->validate([
|
||||
'jobcard_id' => -1,
|
||||
'technician_id' => '0',
|
||||
'work_date' => '2026-09-02',
|
||||
'hours' => 1,
|
||||
]);
|
||||
assignment_time_entry_assert_same(false, $invalidIds['valid'], 'Time-entry IDs must be positive integers.');
|
||||
if (!isset($invalidIds['errors']['jobcard_id'], $invalidIds['errors']['technician_id'])) {
|
||||
throw new RuntimeException('Invalid time-entry IDs should produce field errors.');
|
||||
}
|
||||
|
||||
$ambiguousDuration = (new TimeEntryCommand())->validate([
|
||||
'jobcard_id' => 12,
|
||||
'technician_id' => 7,
|
||||
'work_date' => '2026-09-02',
|
||||
'hours' => 1,
|
||||
'start_time' => '09:00',
|
||||
'end_time' => '10:00',
|
||||
]);
|
||||
assignment_time_entry_assert_same(false, $ambiguousDuration['valid'], 'Manual hours and start/end times must be mutually exclusive.');
|
||||
if (!isset($ambiguousDuration['errors']['time'])) {
|
||||
throw new RuntimeException('Ambiguous duration input should produce a time error.');
|
||||
}
|
||||
|
||||
$invalidEntry = (new TimeEntryCommand())->validate([
|
||||
'jobcard_id' => 12,
|
||||
'technician_id' => 7,
|
||||
'work_date' => '2026-02-30',
|
||||
'start_time' => '11:00',
|
||||
'end_time' => '10:00',
|
||||
]);
|
||||
assignment_time_entry_assert_same(false, $invalidEntry['valid'], 'Invalid dates and time ranges should be rejected.');
|
||||
if (!isset($invalidEntry['errors']['work_date'], $invalidEntry['errors']['time'])) {
|
||||
throw new RuntimeException('TimeEntryValidator errors should be retained by the command.');
|
||||
}
|
||||
|
||||
$longNotes = (new TimeEntryCommand())->validate([
|
||||
'jobcard_id' => 12,
|
||||
'technician_id' => 7,
|
||||
'work_date' => '2026-09-02',
|
||||
'hours' => 1,
|
||||
'notes' => str_repeat('N', TimeEntryCommand::MAX_NOTES_LENGTH + 1),
|
||||
]);
|
||||
assignment_time_entry_assert_same(false, $longNotes['valid'], 'Overlong time-entry notes should be rejected.');
|
||||
if (!isset($longNotes['errors']['notes'])) {
|
||||
throw new RuntimeException('Overlong notes should produce a notes error.');
|
||||
}
|
||||
|
||||
$invalidSlaFlag = (new TimeEntryCommand())->validate([
|
||||
'jobcard_id' => 12,
|
||||
'technician_id' => 7,
|
||||
'work_date' => '2026-09-02',
|
||||
'hours' => 1,
|
||||
'counts_toward_sla' => 'sometimes',
|
||||
]);
|
||||
assignment_time_entry_assert_same(false, $invalidSlaFlag['valid'], 'Unknown SLA flag values should be rejected.');
|
||||
if (!isset($invalidSlaFlag['errors']['counts_toward_sla'])) {
|
||||
throw new RuntimeException('Invalid SLA flags should produce a counts_toward_sla error.');
|
||||
}
|
||||
|
||||
$falseSlaFlag = (new TimeEntryCommand())->validate([
|
||||
'jobcard_id' => 12,
|
||||
'technician_id' => 7,
|
||||
'work_date' => '2026-09-02',
|
||||
'hours' => 1,
|
||||
'counts_toward_sla' => ' false ',
|
||||
]);
|
||||
assignment_time_entry_assert_same(false, $falseSlaFlag['counts_toward_sla'], 'Recognized false-like SLA flags should normalize to false.');
|
||||
assignment_time_entry_assert_same(true, $falseSlaFlag['valid'], 'Recognized false-like SLA flags should remain valid.');
|
||||
|
||||
printf("Assignment and time-entry tests: 12 passed\n");
|
||||
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../app/Domain/SLA/SlaAgreement.php';
|
||||
|
||||
use App\Domain\SLA\SlaAgreement;
|
||||
|
||||
function sla_agreement_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));
|
||||
}
|
||||
}
|
||||
|
||||
$agreement = new SlaAgreement();
|
||||
|
||||
sla_agreement_assert_same([
|
||||
'client_id' => 42,
|
||||
'enabled' => true,
|
||||
'agreement_type' => 'Premium Support',
|
||||
'allocated_hours' => 12.5,
|
||||
'period_type' => 'annual',
|
||||
'start_date' => '2026-01-01',
|
||||
'end_date' => '2026-12-31',
|
||||
'rollover_enabled' => false,
|
||||
'notes' => 'Priority client',
|
||||
], $agreement->normalize([
|
||||
'client_id' => ' 42 ',
|
||||
'enabled' => 'yes',
|
||||
'agreement_type' => ' Premium Support ',
|
||||
'allocated_hours' => '12.50',
|
||||
'period_type' => ' ANNUAL ',
|
||||
'start_date' => ' 2026-01-01 ',
|
||||
'end_date' => ' 2026-12-31 ',
|
||||
'rollover_enabled' => 'off',
|
||||
'notes' => ' Priority client ',
|
||||
]), 'SLA agreement fields should normalize deterministically.');
|
||||
|
||||
$valid = $agreement->validate([
|
||||
'client_id' => '7',
|
||||
'enabled' => '1',
|
||||
'allocated_hours' => '0',
|
||||
'period_type' => 'monthly',
|
||||
'rollover_enabled' => '0',
|
||||
]);
|
||||
sla_agreement_assert_same(true, $valid['valid'], 'A minimal SLA agreement should be valid.');
|
||||
sla_agreement_assert_same([], $valid['errors'], 'A valid SLA agreement should have no errors.');
|
||||
sla_agreement_assert_same(null, $valid['agreement_type'], 'Optional agreement type should normalize to null.');
|
||||
sla_agreement_assert_same(null, $valid['start_date'], 'Optional start date should normalize to null.');
|
||||
sla_agreement_assert_same(null, $valid['end_date'], 'Optional end date should normalize to null.');
|
||||
sla_agreement_assert_same(null, $valid['notes'], 'Optional notes should normalize to null.');
|
||||
|
||||
$invalid = $agreement->validate([
|
||||
'client_id' => '4.2',
|
||||
'enabled' => 'sometimes',
|
||||
'agreement_type' => str_repeat('A', 121),
|
||||
'allocated_hours' => '-0.01',
|
||||
'period_type' => [],
|
||||
'start_date' => [],
|
||||
'end_date' => [],
|
||||
'rollover_enabled' => [],
|
||||
'notes' => [],
|
||||
]);
|
||||
foreach (['client_id', 'enabled', 'agreement_type', 'allocated_hours', 'period_type', 'start_date', 'end_date', 'rollover_enabled', 'notes'] as $field) {
|
||||
if (!isset($invalid['errors'][$field])) {
|
||||
throw new RuntimeException("Expected validation error for {$field}.");
|
||||
}
|
||||
}
|
||||
sla_agreement_assert_same(false, $invalid['valid'], 'Invalid SLA agreement fields should report valid=false.');
|
||||
|
||||
$reversed = $agreement->validate([
|
||||
'client_id' => 7,
|
||||
'allocated_hours' => 10,
|
||||
'start_date' => '2026-12-31',
|
||||
'end_date' => '2026-01-01',
|
||||
]);
|
||||
if (!isset($reversed['errors']['end_date'])) {
|
||||
throw new RuntimeException('An end date before the start date must be rejected.');
|
||||
}
|
||||
|
||||
$badStartOnly = $agreement->validate([
|
||||
'client_id' => 7,
|
||||
'allocated_hours' => 'not-a-number',
|
||||
'start_date' => 'not-a-date',
|
||||
'end_date' => '2026-12-31',
|
||||
]);
|
||||
if (!isset($badStartOnly['errors']['allocated_hours'], $badStartOnly['errors']['start_date'])) {
|
||||
throw new RuntimeException('Non-numeric allocation and malformed dates must be rejected.');
|
||||
}
|
||||
if (isset($badStartOnly['errors']['end_date'])) {
|
||||
throw new RuntimeException('A valid end date must not receive a range error when the start date is malformed.');
|
||||
}
|
||||
|
||||
$equalDates = $agreement->validate([
|
||||
'client_id' => 7,
|
||||
'start_date' => '2026-06-01',
|
||||
'end_date' => '2026-06-01',
|
||||
]);
|
||||
sla_agreement_assert_same(true, $equalDates['valid'], 'Equal start and end dates should be accepted.');
|
||||
|
||||
$display = $agreement->display([
|
||||
'id' => 9,
|
||||
'client_id' => 7,
|
||||
'enabled' => true,
|
||||
'agreement_type' => 'Premium',
|
||||
'allocated_hours' => 12.5,
|
||||
'period_type' => 'monthly',
|
||||
'start_date' => '2026-01-01',
|
||||
'end_date' => '2026-12-31',
|
||||
'rollover_enabled' => false,
|
||||
'notes' => 'Visible note',
|
||||
'password_hash' => 'omit',
|
||||
'credentials' => 'omit',
|
||||
'internal_token' => 'omit',
|
||||
]);
|
||||
sla_agreement_assert_same([
|
||||
'id' => 9,
|
||||
'client_id' => 7,
|
||||
'enabled' => true,
|
||||
'agreement_type' => 'Premium',
|
||||
'allocated_hours' => 12.5,
|
||||
'period_type' => 'monthly',
|
||||
'start_date' => '2026-01-01',
|
||||
'end_date' => '2026-12-31',
|
||||
'rollover_enabled' => false,
|
||||
'notes' => 'Visible note',
|
||||
], $display, 'Display projection must be explicitly allow-listed.');
|
||||
sla_agreement_assert_same($display, $agreement->toDisplay([
|
||||
...$display,
|
||||
'credentials' => 'omit',
|
||||
]), 'toDisplay should provide the same safe projection.');
|
||||
|
||||
printf("SLA agreement tests: 7 passed\n");
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../app/Domain/User/PasswordPolicy.php';
|
||||
require_once __DIR__ . '/../app/Domain/User/UserRecord.php';
|
||||
|
||||
use App\Domain\User\PasswordPolicy;
|
||||
use App\Domain\User\UserRecord;
|
||||
|
||||
function user_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 UserRecord();
|
||||
|
||||
$normalized = $service->normalize([
|
||||
'name' => ' Alice Example ',
|
||||
'email' => ' ALICE@EXAMPLE.TEST ',
|
||||
'role_id' => '2',
|
||||
'active' => 'yes',
|
||||
]);
|
||||
user_record_assert_same([
|
||||
'name' => 'Alice Example',
|
||||
'email' => 'alice@example.test',
|
||||
'role_id' => 2,
|
||||
'is_active' => true,
|
||||
], $normalized, 'User records should normalize accepted fields deterministically.');
|
||||
|
||||
$invalid = $service->validate([
|
||||
'name' => ' ',
|
||||
'email' => 'not-an-email',
|
||||
'role_id' => 0,
|
||||
'is_active' => 'sometimes',
|
||||
]);
|
||||
user_record_assert_same(false, $invalid['valid'], 'Invalid user records should report valid=false.');
|
||||
foreach (['name', 'email', 'role_id', 'is_active'] as $field) {
|
||||
if (!isset($invalid['errors'][$field])) {
|
||||
throw new RuntimeException("Expected validation error for {$field}.");
|
||||
}
|
||||
}
|
||||
|
||||
$valid = $service->validate([
|
||||
'name' => ' Alice Example ',
|
||||
'email' => ' ALICE@EXAMPLE.TEST ',
|
||||
'role_id' => '3',
|
||||
'is_active' => 'off',
|
||||
]);
|
||||
user_record_assert_same(true, $valid['valid'], 'Valid user records should report valid=true.');
|
||||
user_record_assert_same([], $valid['errors'], 'Valid user records should contain no field errors.');
|
||||
user_record_assert_same(false, $valid['is_active'], 'False form values should normalize to false.');
|
||||
|
||||
$tooLong = $service->validate([
|
||||
'name' => str_repeat('N', 121),
|
||||
'email' => str_repeat('e', 179) . '@example.test',
|
||||
'role_id' => 1,
|
||||
]);
|
||||
if (!isset($tooLong['errors']['name'], $tooLong['errors']['email'])) {
|
||||
throw new RuntimeException('Expected schema-sized name and email limits to be enforced.');
|
||||
}
|
||||
|
||||
$passwordPolicy = new PasswordPolicy();
|
||||
user_record_assert_same(true, $passwordPolicy->validate('Long&Strong123')['valid'], 'A password meeting every strength rule should pass.');
|
||||
|
||||
$weakPasswords = [
|
||||
'Short1!' => 'minimum length',
|
||||
'alllowercase1!' => 'uppercase letter',
|
||||
'ALLUPPERCASE1!' => 'lowercase letter',
|
||||
'NoDigitsHere!' => 'number',
|
||||
'NoSymbolsHere1' => 'symbol',
|
||||
'Password123!' => 'common placeholder',
|
||||
'Ch@ngeMe123!' => 'common placeholder',
|
||||
];
|
||||
foreach ($weakPasswords as $password => $expectedRule) {
|
||||
$result = $passwordPolicy->validate($password);
|
||||
if ($result['valid'] || !in_array($expectedRule, $result['errors'], true)) {
|
||||
throw new RuntimeException("Expected password '{$password}' to fail the {$expectedRule} rule.");
|
||||
}
|
||||
}
|
||||
|
||||
$initial = $service->validateForCreate([
|
||||
'name' => 'New User',
|
||||
'email' => 'new.user@example.test',
|
||||
'role_id' => 1,
|
||||
'password' => 'Password123!',
|
||||
]);
|
||||
if ($initial['valid'] || !isset($initial['errors']['password'])) {
|
||||
throw new RuntimeException('Initial passwords must satisfy the password policy.');
|
||||
}
|
||||
if (array_key_exists('password', $initial)) {
|
||||
throw new RuntimeException('Validation results must not return a plaintext password.');
|
||||
}
|
||||
|
||||
$strongInitial = $service->validateForCreate([
|
||||
'name' => 'New User',
|
||||
'email' => 'new.user@example.test',
|
||||
'role_id' => 1,
|
||||
'password' => 'Unique&Secure123',
|
||||
]);
|
||||
user_record_assert_same(true, $strongInitial['valid'], 'A strong initial password should pass user creation validation.');
|
||||
user_record_assert_same(false, $passwordPolicy->validateReset('Welcome123!')['valid'], 'Reset passwords must reject common placeholders.');
|
||||
user_record_assert_same(true, $passwordPolicy->validateReset('Another$Safe456')['valid'], 'Strong reset passwords should pass.');
|
||||
|
||||
$display = $service->display([
|
||||
'id' => 42,
|
||||
'name' => 'Alice Example',
|
||||
'email' => 'alice@example.test',
|
||||
'role_id' => 2,
|
||||
'role_name' => 'Accounts',
|
||||
'is_active' => true,
|
||||
'last_login_at' => '2026-09-01 08:00:00',
|
||||
'password' => 'plaintext',
|
||||
'password_hash' => '$2y$secret',
|
||||
'reset_password' => 'reset-secret',
|
||||
'reset_token' => 'token-secret',
|
||||
'unknown' => 'omit',
|
||||
]);
|
||||
user_record_assert_same([
|
||||
'id' => 42,
|
||||
'name' => 'Alice Example',
|
||||
'email' => 'alice@example.test',
|
||||
'role_id' => 2,
|
||||
'role_name' => 'Accounts',
|
||||
'is_active' => true,
|
||||
'last_login_at' => '2026-09-01 08:00:00',
|
||||
], $display, 'Display data must be explicitly allow-listed and exclude all password material.');
|
||||
user_record_assert_same($display, $service->toDisplay([
|
||||
...$display,
|
||||
'password_hash' => 'must-not-leak',
|
||||
]), 'toDisplay should provide the same safe projection.');
|
||||
|
||||
printf("User record tests: 5 passed\n");
|
||||
Reference in New Issue
Block a user