feat: bootstrap JOBcard CRM foundation
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../app/Domain/Client/ClientContactValidator.php';
|
||||
|
||||
$invalid = validate_client_contact([
|
||||
'name' => ' ',
|
||||
'email' => 'not-an-email',
|
||||
'phone' => str_repeat('1', 61),
|
||||
'is_primary' => 'maybe',
|
||||
]);
|
||||
foreach (['name', 'email', 'phone', 'is_primary'] as $key) {
|
||||
if (!isset($invalid['errors'][$key])) {
|
||||
throw new RuntimeException("Expected validation error for {$key}");
|
||||
}
|
||||
}
|
||||
|
||||
$valid = validate_client_contact([
|
||||
'name' => ' Jane Doe ',
|
||||
'email' => ' JANE@example.com ',
|
||||
'phone' => ' +27 11 555 0100 ',
|
||||
'is_primary' => '1',
|
||||
]);
|
||||
if ($valid['errors'] !== []
|
||||
|| $valid['name'] !== 'Jane Doe'
|
||||
|| $valid['email'] !== 'jane@example.com'
|
||||
|| $valid['phone'] !== '+27 11 555 0100'
|
||||
|| $valid['is_primary'] !== true
|
||||
) {
|
||||
throw new RuntimeException('Expected contact input to be normalized');
|
||||
}
|
||||
|
||||
$optional = normalize_client_contact(['name' => 'Sam']);
|
||||
if ($optional !== ['name' => 'Sam', 'email' => null, 'phone' => null, 'is_primary' => false]) {
|
||||
throw new RuntimeException('Expected optional contact fields to normalize to null/defaults');
|
||||
}
|
||||
|
||||
printf("Client contact validator tests: 3 passed\n");
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../app/Domain/Client/ClientValidator.php';
|
||||
require_once __DIR__ . '/../app/Domain/Client/ClientHelpers.php';
|
||||
|
||||
$cases = [
|
||||
[[], 'name'],
|
||||
[['name' => str_repeat('A', 191)], 'name'],
|
||||
[['name' => 'Acme', 'status' => 'paused'], 'status'],
|
||||
];
|
||||
foreach ($cases as [$input, $errorKey]) {
|
||||
$result = validate_client($input);
|
||||
if (!isset($result['errors'][$errorKey])) {
|
||||
throw new RuntimeException("Expected validation error for {$errorKey}");
|
||||
}
|
||||
}
|
||||
$valid = validate_client(['name' => ' Acme IT ', 'status' => 'active']);
|
||||
if ($valid['errors'] !== [] || $valid['name'] !== 'Acme IT') {
|
||||
throw new RuntimeException('Expected valid client input to be normalized');
|
||||
}
|
||||
if (!client_name_is_duplicate(' acme IT ', ['Acme IT', 'Other'])) {
|
||||
throw new RuntimeException('Expected duplicate client names to be detected case-insensitively');
|
||||
}
|
||||
if (client_name_is_duplicate('New Client', ['Acme IT'])) {
|
||||
throw new RuntimeException('Expected unique client name to be accepted');
|
||||
}
|
||||
|
||||
printf("Client validator tests: %d passed\n", count($cases) + 2);
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../app/Domain/Jobcard/TimeCalculator.php';
|
||||
require_once __DIR__ . '/../app/Domain/SLA/SlaCalculator.php';
|
||||
|
||||
if (calculate_duration_hours('09:00', '11:30') !== 2.5) {
|
||||
throw new RuntimeException('Expected 2.5 hours from start/end');
|
||||
}
|
||||
if (calculate_duration_hours(null, null, 1.25) !== 1.25) {
|
||||
throw new RuntimeException('Expected manual duration');
|
||||
}
|
||||
if (calculate_duration_hours('11:30', '09:00') !== null) {
|
||||
throw new RuntimeException('Expected invalid reverse time to be rejected');
|
||||
}
|
||||
$sla = calculate_sla_usage(20.0, [2.0, 1.5, 1.0]);
|
||||
if ($sla !== ['used' => 4.5, 'remaining' => 15.5, 'percentage' => 22.5, 'status' => 'within_limit']) {
|
||||
throw new RuntimeException('Unexpected SLA calculation: ' . json_encode($sla));
|
||||
}
|
||||
$over = calculate_sla_usage(10.0, [8.0, 4.0]);
|
||||
if ($over['status'] !== 'exceeded' || $over['remaining'] !== 0.0) {
|
||||
throw new RuntimeException('Expected exceeded SLA to clamp remaining hours to zero');
|
||||
}
|
||||
printf("Jobcard calculation tests: 5 passed\n");
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../app/Domain/Jobcard/JobcardReference.php';
|
||||
require_once __DIR__ . '/../app/Domain/Jobcard/StatusTransitionValidator.php';
|
||||
require_once __DIR__ . '/../app/Domain/Jobcard/TimeEntryValidator.php';
|
||||
require_once __DIR__ . '/../app/Domain/Jobcard/TimeAggregator.php';
|
||||
|
||||
use App\Domain\Jobcard\JobcardReference;
|
||||
use App\Domain\Jobcard\StatusTransitionValidator;
|
||||
use App\Domain\Jobcard\TimeEntryValidator;
|
||||
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.');
|
||||
|
||||
$transitions = new StatusTransitionValidator();
|
||||
if (!$transitions->canTransition('new', 'assigned')) throw new RuntimeException('new should transition to assigned.');
|
||||
if ($transitions->canTransition('new', 'completed')) throw new RuntimeException('new should not skip to completed.');
|
||||
if (!$transitions->canTransition('in_progress', 'in_progress')) throw new RuntimeException('Same status should be allowed.');
|
||||
|
||||
$validEntry = (new TimeEntryValidator())->validate(['work_date' => '2026-09-01', 'start_time' => '09:00', 'end_time' => '11:30']);
|
||||
if (!$validEntry['valid'] || $validEntry['hours'] !== 2.5 || $validEntry['errors'] !== []) throw new RuntimeException('Expected valid time entry.');
|
||||
$invalidEntry = (new TimeEntryValidator())->validate(['work_date' => 'not-a-date', 'start_time' => '11:00', 'end_time' => '10:00']);
|
||||
if ($invalidEntry['valid'] || count($invalidEntry['errors']) !== 2) throw new RuntimeException('Expected invalid time entry errors.');
|
||||
$nonNumericEntry = (new TimeEntryValidator())->validate(['work_date' => '2026-09-01', 'hours' => 'not-a-number']);
|
||||
if ($nonNumericEntry['valid'] || !isset($nonNumericEntry['errors']['hours'])) throw new RuntimeException('Expected non-numeric hours to be rejected.');
|
||||
|
||||
$aggregator = new TimeAggregator();
|
||||
if ($aggregator->total([['hours' => 2.25], ['hours' => 1.5], ['hours' => -4]]) !== 3.75) throw new RuntimeException('Expected positive time aggregation.');
|
||||
if ($aggregator->slaTotal([['hours' => 2, 'counts_toward_sla' => true], ['hours' => 3, 'counts_toward_sla' => false]]) !== 2.0) throw new RuntimeException('Expected SLA-filtered aggregation.');
|
||||
printf("Jobcard domain tests: 12 passed\n");
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../app/Domain/Reporting/ReportDataMapper.php';
|
||||
require_once __DIR__ . '/../app/Domain/Reporting/HoursPerClientReport.php';
|
||||
require_once __DIR__ . '/../app/Domain/Reporting/SlaReport.php';
|
||||
|
||||
function reporting_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));
|
||||
}
|
||||
}
|
||||
|
||||
$record = [
|
||||
'id' => 7,
|
||||
'name' => 'Acme IT',
|
||||
'status' => 'active',
|
||||
'support_email' => 'support@example.test',
|
||||
'internal_notes' => 'never disclose',
|
||||
'password' => 'secret',
|
||||
'credentials' => 'token',
|
||||
'technical_ip' => '10.0.0.1',
|
||||
'unknown_field' => 'not approved',
|
||||
];
|
||||
$mapper = new ReportDataMapper();
|
||||
reporting_assert_same(
|
||||
['id' => 7, 'name' => 'Acme IT', 'status' => 'active', 'support_email' => 'support@example.test'],
|
||||
$mapper->clientFacing($record),
|
||||
'Client-facing report data must be allow-listed.'
|
||||
);
|
||||
reporting_assert_same(
|
||||
['internal_notes' => 'never disclose', 'password' => 'secret', 'credentials' => 'token', 'technical_ip' => '10.0.0.1', 'unknown_field' => 'not approved'],
|
||||
$mapper->internal($record),
|
||||
'Internal report data must remain separate from client-facing data.'
|
||||
);
|
||||
|
||||
$hours = new HoursPerClientReport();
|
||||
reporting_assert_same(
|
||||
[
|
||||
['client_id' => 7, 'client_name' => 'Acme IT', 'hours' => 3.0],
|
||||
['client_id' => 2, 'client_name' => 'Beta', 'hours' => 3.5],
|
||||
],
|
||||
$hours->aggregate([
|
||||
['client_id' => 7, 'client_name' => 'Acme IT', 'hours' => 1.25],
|
||||
['client_id' => 2, 'client_name' => 'Beta', 'hours' => 3.5, 'internal_notes' => 'omit'],
|
||||
['client_id' => 7, 'client_name' => 'Acme IT', 'hours' => 1.75, 'password' => 'omit'],
|
||||
]),
|
||||
'Hours must aggregate deterministically per client.'
|
||||
);
|
||||
|
||||
$sla = new SlaReport();
|
||||
reporting_assert_same(
|
||||
[
|
||||
['client_id' => 7, 'client_name' => 'Acme IT', 'allocated_hours' => 10.0, 'used_hours' => 9.0, 'remaining_hours' => 1.0, 'usage_percentage' => 90.0, 'status' => 'critical'],
|
||||
],
|
||||
$sla->rows([[
|
||||
'client_id' => 7,
|
||||
'client_name' => 'Acme IT',
|
||||
'allocated_hours' => 10,
|
||||
'hours' => [4, 5, -2],
|
||||
'internal_notes' => 'omit',
|
||||
'credentials' => 'omit',
|
||||
]]),
|
||||
'SLA rows must expose only safe, deterministic report fields.'
|
||||
);
|
||||
|
||||
printf("Reporting contract tests: 3 passed\n");
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../app/Domain/SLA/SlaThresholdClassifier.php';
|
||||
|
||||
use App\Domain\SLA\SlaThresholdClassifier;
|
||||
|
||||
$classifier = new SlaThresholdClassifier();
|
||||
if ($classifier->classify(7.5, 10.0) !== 'warning') throw new RuntimeException('75% should be warning.');
|
||||
if ($classifier->classify(9.0, 10.0) !== 'critical') throw new RuntimeException('90% should be critical.');
|
||||
if ($classifier->classify(10.01, 10.0) !== 'exceeded') throw new RuntimeException('Over allocation should be exceeded.');
|
||||
if ($classifier->classify(0.0, 0.0) !== 'within_limit') throw new RuntimeException('No allocation and no usage should be within limit.');
|
||||
printf("SLA domain tests: 4 passed\n");
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
putenv('JOBcard_TEST_VALUE=present');
|
||||
require_once __DIR__ . '/../config/bootstrap.php';
|
||||
|
||||
$checks = 0;
|
||||
assert(env_required('JOBcard_TEST_VALUE') === 'present');
|
||||
$checks++;
|
||||
assert(e('<script>alert("x")</script>') === '<script>alert("x")</script>');
|
||||
$checks++;
|
||||
|
||||
$missingRaised = false;
|
||||
try {
|
||||
env_required('JOBcard_MISSING_VALUE');
|
||||
} catch (RuntimeException $exception) {
|
||||
$missingRaised = str_contains($exception->getMessage(), 'JOBcard_MISSING_VALUE');
|
||||
}
|
||||
assert($missingRaised === true);
|
||||
$checks++;
|
||||
|
||||
printf("Foundation smoke tests: %d passed\n", $checks);
|
||||
Reference in New Issue
Block a user