feat(health): add oracle probe and normalize health checks

Refactor health check DTOs around a common CheckResult contract and remove the\nlegacy HealthChecks aggregate object.\n\nAdd a dedicated /api/v1/health/oracle endpoint with response mapping, OpenAPI\nschema updates, and Bruno request coverage.\n\nAlign feature, unit, integration, architecture and OpenAPI tests with the new\nset-based checks representation and make Oracle health tests deterministic in CI.\n\nUpdate command documentation to the uwil:* command namespace.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Jérémy Munsch 2026-10-06 14:51:46 +02:00
parent 71f6f64f7f
commit 62c221f6b6
Signed by: forge-joniko
GPG key ID: D7EFE2F5CB6CC0E4
34 changed files with 641 additions and 328 deletions

View file

@ -50,27 +50,27 @@ vendor/bin/phpcbf
```bash
# Vérifier l'état de l'application
php artisan health
php artisan uwil:health
# List all Arcadia brands
php artisan arcadia:brand:list
php artisan uwil:arcadia:brand:list
# List all Arcadia clients
php artisan arcadia:client:list
php artisan uwil:arcadia:client:list
# Create a new Arcadia client (interactive wizard)
php artisan arcadia:client:create
php artisan uwil:arcadia:client:create
# Regenerate the secret of an API client
php artisan arcadia:client:regenerate-secret
php artisan uwil:arcadia:client:regenerate-secret
# Toggle the status of an API client
php artisan arcadia:client:toggle-status
php artisan uwil:arcadia:client:toggle-status
# Check schema and query conformity of the replicated Arcadia Oracle database
php artisan arcadia:database:check-conformity
php artisan uwil:arcadia:database:check-conformity
# Display or export security audit events since a specified date
php artisan arcadia:security:audit-logs
php artisan uwil:arcadia:security:audit-logs
```

View file

@ -159,6 +159,6 @@ curl -X GET "https://api.arcadia.ldc.fr/api/v1/brands" \
#### Commande CLI Artisan
```bash
php artisan arcadia:brand:list
php artisan uwil:arcadia:brand:list
```

View file

@ -0,0 +1,15 @@
info:
name: Oracle Health Check
type: http
seq: 5
http:
method: GET
url: "{{ENDPOINT}}/api/{{APIV1}}/health/oracle"
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5

View file

@ -60,6 +60,30 @@ paths:
schema:
$ref: "#/components/schemas/HealthCheckResponse"
/health/oracle:
get:
tags:
- System
summary: Sonde dédiée à la connectivité Oracle
description: |
Vérifie exclusivement la connectivité à la base Oracle Arcadia.
Cette sonde est destinée au monitoring externe (ex: UptimeRobot) et ne teste ni cache ni SMTP.
operationId: checkOracleHealth
security: []
responses:
"200":
description: Connectivité Oracle opérationnelle.
content:
application/json:
schema:
$ref: "#/components/schemas/OracleHealthCheckResponse"
"503":
description: Connectivité Oracle indisponible.
content:
application/json:
schema:
$ref: "#/components/schemas/OracleHealthCheckResponse"
/employees:
get:
tags:
@ -681,6 +705,44 @@ components:
- "null"
description: Message d'erreur éventuel en cas d'échec.
OracleHealthCheckResponse:
type: object
description: Résultat de la sonde de connectivité Oracle.
required:
- status
- timestamp
- oracle
properties:
status:
type: string
enum:
- healthy
- unhealthy
description: État de santé global de la connectivité Oracle.
example: "healthy"
timestamp:
type: string
format: date-time
description: Horodatage de la vérification au format RFC 3339 UTC.
example: "2026-08-21T14:30:00Z"
oracle:
type: object
required:
- status
- connection
properties:
status:
type: string
enum:
- up
- down
description: État de la connectivité Oracle.
example: "up"
connection:
type: string
description: Nom de la connexion Oracle vérifiée.
example: "arcadia"
Brand:
type: object
description: Représentation canonique d'une marque dans Arcadia API.

View file

@ -9,6 +9,7 @@ use Presentation\Api\V1\Http\Controllers\Brands\BrandsIndexController;
use Presentation\Api\V1\Http\Controllers\Employees\EmployeeShowController;
use Presentation\Api\V1\Http\Controllers\Employees\EmployeesIndexController;
use Presentation\Api\V1\Http\Controllers\Health\HealthCheckController;
use Presentation\Api\V1\Http\Controllers\Health\OracleHealthCheckController;
use Presentation\Api\V1\Http\Controllers\IncoCompositions\IncoCompositionShowController;
use Presentation\Api\V1\Http\Controllers\IncoSheets\IncoSheetsIndexController;
use Presentation\Api\V1\Http\Controllers\IncoSheets\IncoSheetsSearchController;
@ -32,6 +33,7 @@ Route::get('/', fn () => response()->json([
// * HEALTH CHECK
Route::get('/health', HealthCheckController::class)->name('health');
Route::get('/health/oracle', OracleHealthCheckController::class)->name('health.oracle');
// * OPENAPI
Route::get('/openapi', function () {

View file

@ -11,5 +11,7 @@ interface HealthChecker
{
public function check(): HealthCheckResult;
public function checkArcadiaConnectivity(): HealthCheckResult;
public function checkArcadiaConformity(): ArcadiaConformityResult;
}

View file

@ -6,7 +6,7 @@ namespace Application\Health\Dto;
use Application\Health\Enums\ComponentStatus;
final readonly class CacheCheckResult
final readonly class CacheCheckResult implements CheckResult
{
public function __construct(
public ComponentStatus $status,
@ -14,4 +14,14 @@ final readonly class CacheCheckResult
public ?string $message = null,
) {
}
public function getName(): string
{
return $this->store;
}
public function getStatus(): ComponentStatus
{
return $this->status;
}
}

View file

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Application\Health\Dto;
use Application\Health\Enums\ComponentStatus;
interface CheckResult
{
public function getName(): string;
public function getStatus(): ComponentStatus;
}

View file

@ -6,7 +6,7 @@ namespace Application\Health\Dto;
use Application\Health\Enums\ComponentStatus;
final readonly class DatabaseCheckResult
final readonly class DatabaseCheckResult implements CheckResult
{
public function __construct(
public ComponentStatus $status,
@ -14,4 +14,14 @@ final readonly class DatabaseCheckResult
public ?string $message = null,
) {
}
public function getName(): string
{
return $this->connection;
}
public function getStatus(): ComponentStatus
{
return $this->status;
}
}

View file

@ -8,10 +8,11 @@ use Application\Health\Enums\HealthStatus;
final readonly class HealthCheckResult
{
/** @param \Application\Health\Dto\CheckResult[] $checks */
public function __construct(
public HealthStatus $status,
public \DateTimeImmutable $timestamp,
public HealthChecks $checks,
public array $checks,
) {
}

View file

@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace Application\Health\Dto;
use Application\Health\Enums\ComponentStatus;
final readonly class HealthChecks
{
public function __construct(
public DatabaseCheckResult $databaseDefault,
public DatabaseCheckResult $databaseArcadia,
public CacheCheckResult $cache,
public MailCheckResult $mailSmtp,
) {
}
public function isAllUp(): bool
{
return $this->databaseDefault->status === ComponentStatus::UP
&& $this->databaseArcadia->status === ComponentStatus::UP
&& $this->cache->status === ComponentStatus::UP
&& $this->mailSmtp->status === ComponentStatus::UP;
}
}

View file

@ -6,7 +6,7 @@ namespace Application\Health\Dto;
use Application\Health\Enums\ComponentStatus;
final readonly class MailCheckResult
final readonly class MailCheckResult implements CheckResult
{
public function __construct(
public ComponentStatus $status,
@ -14,4 +14,14 @@ final readonly class MailCheckResult
public ?string $message = null,
) {
}
public function getName(): string
{
return $this->transport;
}
public function getStatus(): ComponentStatus
{
return $this->status;
}
}

View file

@ -6,7 +6,11 @@ namespace Infra\Jobs;
use Application\Health\Config\HealthAlertMailConfig;
use Application\Health\Contracts\HealthChecker;
use Application\Health\Dto\CacheCheckResult;
use Application\Health\Dto\CheckResult;
use Application\Health\Dto\DatabaseCheckResult;
use Application\Health\Dto\HealthCheckResult;
use Application\Health\Dto\MailCheckResult;
use Application\Health\Enums\HealthStatus;
use Application\Health\Exceptions\SystemHealthDegradedException;
use Illuminate\Contracts\Mail\Mailer;
@ -64,12 +68,10 @@ final class HealthCheckJob
LoggerInterface $logger,
): void {
$failedComponents = [];
$checksArray = $this->formatChecks($result);
$checks = [];
foreach ($checksArray as $component => $data) {
if ($data['status'] === 'down') {
$failedComponents[] = sprintf('%s (%s)', $component, $data['message'] ?? 'down');
}
foreach ($result->checks as $check) {
$checks[$check->getName()] = $this->mapCheckToArray($check);
}
$reason = implode(', ', $failedComponents);
@ -77,7 +79,7 @@ final class HealthCheckJob
'status' => $result->status->value,
'previousStatus' => $previousStatus?->value,
'timestamp' => Utc::toRfc3339($result->timestamp),
'checks' => $checksArray,
'checks' => $checks,
]);
report(new SystemHealthDegradedException("System health check degraded: {$reason}"));
@ -85,7 +87,7 @@ final class HealthCheckJob
$mailable = new SystemHealthAlertMail(
status: $result->status,
occurredAt: $result->timestamp,
checks: $checksArray,
checks: $checks,
previousStatus: $previousStatus,
);
@ -99,7 +101,11 @@ final class HealthCheckJob
HealthAlertMailConfig $mailConfig,
LoggerInterface $logger,
): void {
$checksArray = $this->formatChecks($result);
$checks = [];
foreach ($result->checks as $check) {
$checks[$check->getName()] = $this->mapCheckToArray($check);
}
$logger->info('System health check recovered: all components are healthy.', [
'status' => $result->status->value,
@ -110,7 +116,7 @@ final class HealthCheckJob
$mailable = new SystemHealthAlertMail(
status: $result->status,
occurredAt: $result->timestamp,
checks: $checksArray,
checks: $checks,
previousStatus: $previousStatus,
);
@ -145,62 +151,43 @@ final class HealthCheckJob
File::makeDirectory($dir, 0755, true);
}
$checks = [];
foreach ($result->checks as $check) {
$checks[$check->getName()] = $this->mapCheckToArray($check);
}
$payload = [
'status' => $result->status->value,
'timestamp' => Utc::toRfc3339($result->timestamp),
'checks' => $this->formatChecks($result),
'checks' => $checks,
];
File::put($filePath, (string) json_encode($payload, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES));
}
/**
* @return array{
* database_default: array{status: string, connection: string, message?: string|null},
* database_arcadia: array{status: string, connection: string, message?: string|null},
* cache: array{status: string, store: string, message?: string|null},
* mail_smtp: array{status: string, transport: string, message?: string|null}
* }
*/
private function formatChecks(HealthCheckResult $result): array
/** @return array<string, string> */
private function mapCheckToArray(CheckResult $check): array
{
$defaultDb = [
'status' => $result->checks->databaseDefault->status->value,
'connection' => $result->checks->databaseDefault->connection,
];
if ($result->checks->databaseDefault->message !== null) {
$defaultDb['message'] = $result->checks->databaseDefault->message;
}
$noMessageMapper = fn (string | null $message) => $message ? $message : 'N/A';
$arcadiaDb = [
'status' => $result->checks->databaseArcadia->status->value,
'connection' => $result->checks->databaseArcadia->connection,
];
if ($result->checks->databaseArcadia->message !== null) {
$arcadiaDb['message'] = $result->checks->databaseArcadia->message;
}
$cache = [
'status' => $result->checks->cache->status->value,
'store' => $result->checks->cache->store,
];
if ($result->checks->cache->message !== null) {
$cache['message'] = $result->checks->cache->message;
}
$mail = [
'status' => $result->checks->mailSmtp->status->value,
'transport' => $result->checks->mailSmtp->transport,
];
if ($result->checks->mailSmtp->message !== null) {
$mail['message'] = $result->checks->mailSmtp->message;
}
return [
'database_default' => $defaultDb,
'database_arcadia' => $arcadiaDb,
'cache' => $cache,
'mail_smtp' => $mail,
];
return match (true) {
$check instanceof DatabaseCheckResult => [
'status' => $check->status->value,
'connection' => $check->connection,
'message' => $noMessageMapper($check->message),
],
$check instanceof CacheCheckResult => [
'status' => $check->status->value,
'store' => $check->store,
'message' => $noMessageMapper($check->message),
],
$check instanceof MailCheckResult => [
'status' => $check->status->value,
'transport' => $check->transport,
'message' => $noMessageMapper($check->message),
],
default => throw new \InvalidArgumentException('Unsupported check result type'),
};
}
}

View file

@ -10,7 +10,6 @@ use Application\Health\Dto\CacheCheckResult;
use Application\Health\Dto\DatabaseCheckResult;
use Application\Health\Dto\DirectoryConformityCheck;
use Application\Health\Dto\HealthCheckResult;
use Application\Health\Dto\HealthChecks;
use Application\Health\Dto\MailCheckResult;
use Application\Health\Enums\ComponentStatus;
use Application\Health\Enums\HealthStatus;
@ -21,6 +20,7 @@ use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
use Infra\Config\ArcadiaStorageConfig;
use Shared\Time\Utc;
use Application\Health\Dto\CheckResult;
final readonly class LaravelSystemHealthChecker implements HealthChecker
{
@ -37,14 +37,24 @@ final readonly class LaravelSystemHealthChecker implements HealthChecker
$cache = $this->checkCache();
$mailSmtp = $this->checkMailSmtp();
$checks = new HealthChecks(
databaseDefault: $databaseDefault,
databaseArcadia: $databaseArcadia,
cache: $cache,
mailSmtp: $mailSmtp,
);
$checks = [
$databaseDefault,
$databaseArcadia,
$cache,
$mailSmtp,
];
$status = $checks->isAllUp() ? HealthStatus::HEALTHY : HealthStatus::UNHEALTHY;
$status = array_reduce(
$checks,
function (HealthStatus $carry, CheckResult $check) {
if ($carry !== HealthStatus::HEALTHY) {
return $carry;
}
return $check->getStatus() === ComponentStatus::UP ?
HealthStatus::HEALTHY : HealthStatus::UNHEALTHY;
},
HealthStatus::HEALTHY
);
return new HealthCheckResult(
status: $status,
@ -53,6 +63,19 @@ final readonly class LaravelSystemHealthChecker implements HealthChecker
);
}
#[\Override]
public function checkArcadiaConnectivity(): HealthCheckResult
{
$databaseArcadia = $this->checkArcadiaDatabase();
return new HealthCheckResult(
status: $databaseArcadia->getStatus() === ComponentStatus::UP ?
HealthStatus::HEALTHY : HealthStatus::UNHEALTHY,
timestamp: Utc::now(),
checks: [$databaseArcadia],
);
}
#[\Override]
public function checkArcadiaConformity(): ArcadiaConformityResult
{

View file

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace Presentation\Api\V1\Http\Controllers\Health;
use Application\Health\Contracts\HealthChecker;
use Application\Health\Dto\DatabaseCheckResult;
use Application\Health\Enums\ComponentStatus;
use Illuminate\Http\JsonResponse;
use Presentation\Api\V1\Http\Responses\Health\OracleHealthCheckResponse;
final readonly class OracleHealthCheckController
{
public function __construct(
private HealthChecker $healthChecker,
) {
}
public function __invoke(): JsonResponse
{
$result = $this->healthChecker->checkArcadiaConnectivity();
$arcadia = null;
foreach ($result->checks as $check) {
if ($check instanceof DatabaseCheckResult) {
$arcadia = $check;
break;
}
}
if (! $arcadia instanceof DatabaseCheckResult) {
$arcadia = new DatabaseCheckResult(
status: ComponentStatus::DOWN,
connection: 'arcadia',
message: 'Arcadia connectivity check result is missing.',
);
}
return (new OracleHealthCheckResponse(
result: $arcadia,
timestamp: $result->timestamp,
))->present();
}
}

View file

@ -4,7 +4,12 @@ declare(strict_types=1);
namespace Presentation\Api\V1\Http\Responses\Health;
use Application\Health\Dto\CacheCheckResult;
use Application\Health\Dto\CheckResult;
use Application\Health\Dto\DatabaseCheckResult;
use Application\Health\Dto\HealthCheckResult;
use Application\Health\Dto\MailCheckResult;
use Application\Health\Enums\HealthStatus;
use Illuminate\Http\JsonResponse;
use Presentation\Api\V1\Contracts\ApiResponse;
use Shared\Time\Utc;
@ -31,61 +36,44 @@ final readonly class HealthCheckResponse implements ApiResponse
);
}
/**
* @return array{
* status: 'healthy'|'unhealthy',
* timestamp: string,
* checks: array{
* database_default: array{status: 'up'|'down', connection: string, message?: string|null},
* database_arcadia: array{status: 'up'|'down', connection: string, message?: string|null},
* cache: array{status: 'up'|'down', store: string, message?: string|null},
* mail_smtp: array{status: 'up'|'down', transport: string, message?: string|null}
* }
* }
*/
/** @return array{status: 'healthy'|'unhealthy', timestamp: string, checks: array<string, array<string, string>>} */
public function toArray(): array
{
$defaultDb = [
'status' => $this->result->checks->databaseDefault->status->value,
'connection' => $this->result->checks->databaseDefault->connection,
];
if ($this->result->checks->databaseDefault->message !== null) {
$defaultDb['message'] = $this->result->checks->databaseDefault->message;
}
$checks = [];
$arcadiaDb = [
'status' => $this->result->checks->databaseArcadia->status->value,
'connection' => $this->result->checks->databaseArcadia->connection,
];
if ($this->result->checks->databaseArcadia->message !== null) {
$arcadiaDb['message'] = $this->result->checks->databaseArcadia->message;
}
$cache = [
'status' => $this->result->checks->cache->status->value,
'store' => $this->result->checks->cache->store,
];
if ($this->result->checks->cache->message !== null) {
$cache['message'] = $this->result->checks->cache->message;
}
$mail = [
'status' => $this->result->checks->mailSmtp->status->value,
'transport' => $this->result->checks->mailSmtp->transport,
];
if ($this->result->checks->mailSmtp->message !== null) {
$mail['message'] = $this->result->checks->mailSmtp->message;
foreach ($this->result->checks as $check) {
$checks[$check->getName()] = $this->mapCheckToArray($check);
}
return [
'status' => $this->result->status->value,
'status' => $this->result->status === HealthStatus::HEALTHY ? 'healthy' : 'unhealthy',
'timestamp' => Utc::toRfc3339($this->result->timestamp),
'checks' => [
'database_default' => $defaultDb,
'database_arcadia' => $arcadiaDb,
'cache' => $cache,
'mail_smtp' => $mail,
],
'checks' => $checks,
];
}
/** @return array<string, string> */
private function mapCheckToArray(CheckResult $check): array
{
$noMessageMapper = fn (string | null $message) => $message ? $message : 'N/A';
return match (true) {
$check instanceof DatabaseCheckResult => [
'status' => $check->status->value,
'connection' => $check->connection,
'message' => $noMessageMapper($check->message),
],
$check instanceof CacheCheckResult => [
'status' => $check->status->value,
'store' => $check->store,
'message' => $noMessageMapper($check->message),
],
$check instanceof MailCheckResult => [
'status' => $check->status->value,
'transport' => $check->transport,
'message' => $noMessageMapper($check->message),
],
default => throw new \InvalidArgumentException('Unsupported check result type'),
};
}
}

View file

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Presentation\Api\V1\Http\Responses\Health;
use Application\Health\Dto\DatabaseCheckResult;
use Application\Health\Enums\ComponentStatus;
use Illuminate\Http\JsonResponse;
use Presentation\Api\V1\Contracts\ApiResponse;
use Shared\Time\Utc;
final readonly class OracleHealthCheckResponse implements ApiResponse
{
public function __construct(
private DatabaseCheckResult $result,
private \DateTimeInterface $timestamp,
) {
}
#[\Override]
public function toResponse($request): JsonResponse
{
return $this->present();
}
#[\Override]
public function present(): JsonResponse
{
return new JsonResponse(
data: $this->toArray(),
status: $this->result->status === ComponentStatus::UP ? 200 : 503,
);
}
/**
* @return array{
* status: 'healthy'|'unhealthy',
* timestamp: string,
* oracle: array{
* status: 'up'|'down',
* connection: string
* }
* }
*/
public function toArray(): array
{
return [
'status' => $this->result->status === ComponentStatus::UP ? 'healthy' : 'unhealthy',
'timestamp' => Utc::toRfc3339($this->timestamp),
'oracle' => [
'status' => $this->result->status->value,
'connection' => $this->result->connection,
],
];
}
}

View file

@ -5,7 +5,11 @@ declare(strict_types=1);
namespace Presentation\Console\Commands;
use Application\Health\Contracts\HealthChecker;
use Application\Health\Dto\CacheCheckResult;
use Application\Health\Dto\CheckResult;
use Application\Health\Dto\DatabaseCheckResult;
use Application\Health\Dto\HealthCheckResult;
use Application\Health\Dto\MailCheckResult;
use Illuminate\Console\Command;
use Presentation\Api\V1\Http\Responses\Health\HealthCheckResponse;
@ -56,33 +60,34 @@ final class HealthCheckCommand extends Command
private function renderTable(HealthCheckResult $result): void
{
$rows = [
[
'Default Database Connectivity',
$result->checks->databaseDefault->status->value,
$result->checks->databaseDefault->connection . ($result->checks->databaseDefault->message !== null ?
" ({$result->checks->databaseDefault->message})" : ''),
],
[
'Arcadia Database Connectivity',
$result->checks->databaseArcadia->status->value,
$result->checks->databaseArcadia->connection . ($result->checks->databaseArcadia->message !== null ?
" ({$result->checks->databaseArcadia->message})" : ''),
],
[
'Cache Store Connectivity',
$result->checks->cache->status->value,
$result->checks->cache->store . ($result->checks->cache->message !== null ?
" ({$result->checks->cache->message})" : ''),
],
[
'Mail SMTP Connectivity',
$result->checks->mailSmtp->status->value,
$result->checks->mailSmtp->transport . ($result->checks->mailSmtp->message !== null ?
" ({$result->checks->mailSmtp->message})" : ''),
],
];
$rows = [];
foreach ($result->checks as $check) {
$rows[] = $this->mapCheckToArray($check);
}
$this->table(['Component', 'Status', 'Details'], $rows);
}
/** @return array{name: string, status: string, message: string} */
private function mapCheckToArray(CheckResult $check): array
{
return match (true) {
$check instanceof DatabaseCheckResult => [
'name' => $check->getName(),
'status' => $check->status->value,
'message' => $check->message ?? 'N/A',
],
$check instanceof CacheCheckResult => [
'name' => $check->getName(),
'status' => $check->status->value,
'message' => $check->message ?? 'N/A',
],
$check instanceof MailCheckResult => [
'name' => $check->getName(),
'status' => $check->status->value,
'message' => $check->message ?? 'N/A',
],
default => throw new \InvalidArgumentException('Unsupported check result type'),
};
}
}

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Tests\Architecture;
use Application\Health\Dto\CheckResult;
use Application\Security\Models\ValueObjects\Request\HttpMethod;
use Shared\DDD\Event\Event;
use Shared\DDD\Port\Directory;
@ -111,7 +112,10 @@ describe('Application Architecture Rules', function (): void {
->expect('Application\Health\Dto')
->classes()
->toBeReadonly()
->toBeFinal();
->toBeFinal()
->ignoring([
CheckResult::class,
]);
arch('Application Config classes are final and readonly')
->expect([

View file

@ -33,6 +33,10 @@ describe('Health Check API (GET /api/v1/health)', function (): void {
it('returns 200 healthy when all infrastructure components are operational', function (): void {
// GIVEN
/** @var TestCase $this */
$databaseDefault = Config::string('database.default');
$databaseArcadia = Config::string('database.arcadia_meta_model');
$cacheStore = Config::string('cache.default');
$mailTransport = Config::string('mail.default');
// WHEN
$response = $this->getJson('/api/v1/health');
@ -42,28 +46,20 @@ describe('Health Check API (GET /api/v1/health)', function (): void {
->assertJsonStructure([
'status',
'timestamp',
'checks' => [
'database_default' => ['status', 'connection'],
'database_arcadia' => ['status', 'connection'],
'cache' => ['status', 'store'],
'mail_smtp' => ['status', 'transport'],
],
'checks',
])
->assertJson([
'status' => 'healthy',
'checks' => [
'database_default' => ['status' => 'up'],
'database_arcadia' => ['status' => 'up'],
'cache' => ['status' => 'up'],
'mail_smtp' => ['status' => 'up'],
],
]);
->assertJsonPath('status', 'healthy')
->assertJsonPath('checks.' . $databaseDefault . '.status', 'up')
->assertJsonPath('checks.' . $databaseArcadia . '.status', 'up')
->assertJsonPath('checks.' . $cacheStore . '.status', 'up')
->assertJsonPath('checks.' . $mailTransport . '.status', 'up');
});
it('returns 503 unhealthy when default database connection fails', function () use (&$originalDbDefault): void {
// GIVEN
/** @var TestCase $this */
Config::set('database.default', 'invalid_connection');
$databaseDefault = Config::string('database.default');
try {
// WHEN
@ -71,12 +67,8 @@ describe('Health Check API (GET /api/v1/health)', function (): void {
// THEN
$response->assertStatus(503)
->assertJson([
'status' => 'unhealthy',
'checks' => [
'database_default' => ['status' => 'down'],
],
]);
->assertJsonPath('status', 'unhealthy')
->assertJsonPath('checks.' . $databaseDefault . '.status', 'down');
} finally {
Config::set('database.default', $originalDbDefault);
}
@ -92,6 +84,7 @@ describe('Health Check API (GET /api/v1/health)', function (): void {
'database' => 'invalid_arcadia_db',
]);
Config::set('database.arcadia_meta_model', 'invalid_arcadia_connection');
$databaseArcadia = Config::string('database.arcadia_meta_model');
try {
// WHEN
@ -99,12 +92,8 @@ describe('Health Check API (GET /api/v1/health)', function (): void {
// THEN
$response->assertStatus(503)
->assertJson([
'status' => 'unhealthy',
'checks' => [
'database_arcadia' => ['status' => 'down'],
],
]);
->assertJsonPath('status', 'unhealthy')
->assertJsonPath('checks.' . $databaseArcadia . '.status', 'down');
} finally {
Config::set('database.arcadia_meta_model', $originalArcadiaDb);
}
@ -114,6 +103,7 @@ describe('Health Check API (GET /api/v1/health)', function (): void {
// GIVEN
/** @var TestCase $this */
Config::set('cache.default', 'invalid_cache_store');
$cacheStore = Config::string('cache.default');
try {
// WHEN
@ -121,12 +111,8 @@ describe('Health Check API (GET /api/v1/health)', function (): void {
// THEN
$response->assertStatus(503)
->assertJson([
'status' => 'unhealthy',
'checks' => [
'cache' => ['status' => 'down'],
],
]);
->assertJsonPath('status', 'unhealthy')
->assertJsonPath('checks.' . $cacheStore . '.status', 'down');
} finally {
Config::set('cache.default', $originalCache);
}
@ -142,6 +128,7 @@ describe('Health Check API (GET /api/v1/health)', function (): void {
'port' => 65432, // Non-existent port
'timeout' => 0.1,
]);
$mailTransport = Config::string('mail.default');
try {
// WHEN
@ -149,12 +136,8 @@ describe('Health Check API (GET /api/v1/health)', function (): void {
// THEN
$response->assertStatus(503)
->assertJson([
'status' => 'unhealthy',
'checks' => [
'mail_smtp' => ['status' => 'down'],
],
]);
->assertJsonPath('status', 'unhealthy')
->assertJsonPath('checks.' . $mailTransport . '.status', 'down');
} finally {
Config::set('mail.default', $originalMail);
Config::set('mail.mailers.smtp', $originalMailer);

View file

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\V1\Health;
use Illuminate\Support\Facades\Config;
use Tests\TestCase;
describe('Oracle Health Check API (GET /api/v1/health/oracle)', function (): void {
$originalArcadiaDb = null;
beforeEach(function () use (&$originalArcadiaDb): void {
$originalArcadiaDb = Config::get('database.arcadia_meta_model');
});
afterEach(function () use (&$originalArcadiaDb): void {
Config::set('database.arcadia_meta_model', $originalArcadiaDb);
});
it('returns 200 healthy when oracle connectivity is operational', function (): void {
// GIVEN
/** @var TestCase $this */
Config::set('database.arcadia_meta_model', 'sqlite');
// WHEN
$response = $this->getJson('/api/v1/health/oracle');
// THEN
$response->assertStatus(200)
->assertJsonStructure([
'status',
'timestamp',
'oracle' => ['status', 'connection'],
])
->assertJson([
'status' => 'healthy',
'oracle' => ['status' => 'up'],
]);
});
it('returns 503 unhealthy when oracle connectivity fails', function () use (&$originalArcadiaDb): void {
// GIVEN
/** @var TestCase $this */
Config::set('database.connections.invalid_arcadia_connection', [
'driver' => 'mysql',
'host' => '127.0.0.1',
'port' => 65433,
'database' => 'invalid_arcadia_db',
]);
Config::set('database.arcadia_meta_model', 'invalid_arcadia_connection');
try {
// WHEN
$response = $this->getJson('/api/v1/health/oracle');
// THEN
$response->assertStatus(503)
->assertJson([
'status' => 'unhealthy',
'oracle' => ['status' => 'down'],
]);
} finally {
Config::set('database.arcadia_meta_model', $originalArcadiaDb);
}
});
});

View file

@ -31,7 +31,7 @@ describe('CheckArcadiaConformityCommand', function (): void {
$this->app->instance(HealthChecker::class, $healthChecker);
// WHEN
$this->artisan('arcadia:database:check-conformity')
$this->artisan('uwil:arcadia:database:check-conformity')
// THEN
->expectsOutput('Arcadia database conformity check passed. All directory probes are conform.')
->assertSuccessful();
@ -52,7 +52,7 @@ describe('CheckArcadiaConformityCommand', function (): void {
$this->app->instance(HealthChecker::class, $healthChecker);
// WHEN
$this->artisan('arcadia:database:check-conformity')
$this->artisan('uwil:arcadia:database:check-conformity')
// THEN
->expectsOutput('Arcadia database conformity check failed. Dissonances detected.')
->assertFailed();
@ -73,7 +73,7 @@ describe('CheckArcadiaConformityCommand', function (): void {
$this->app->instance(HealthChecker::class, $healthChecker);
// WHEN
$this->artisan('arcadia:database:check-conformity', ['--json' => true])
$this->artisan('uwil:arcadia:database:check-conformity', ['--json' => true])
// THEN
->expectsOutputToContain('"status": "healthy"')
->assertSuccessful();
@ -94,7 +94,7 @@ describe('CheckArcadiaConformityCommand', function (): void {
$this->app->instance(HealthChecker::class, $healthChecker);
// WHEN
$this->artisan('arcadia:database:check-conformity', ['--json' => true])
$this->artisan('uwil:arcadia:database:check-conformity', ['--json' => true])
// THEN
->expectsOutputToContain('"status": "unhealthy"')
->assertFailed();

View file

@ -16,7 +16,7 @@ describe('CreateClientCommand', function (): void {
$clientName = 'Production Gateway';
// WHEN
$this->artisan('arcadia:client:create')
$this->artisan('uwil:arcadia:client:create')
// THEN
->expectsQuestion('Client name (e.g. "Ones Branding website")', $clientName)
->expectsConfirmation('Create this API client?', 'yes')
@ -38,7 +38,7 @@ describe('CreateClientCommand', function (): void {
$clientName = 'Cancelled Gateway';
// WHEN
$this->artisan('arcadia:client:create')
$this->artisan('uwil:arcadia:client:create')
// THEN
->expectsQuestion('Client name (e.g. "Ones Branding website")', $clientName)
->expectsConfirmation('Create this API client?', 'no')
@ -51,7 +51,8 @@ describe('CreateClientCommand', function (): void {
it('fails when an empty name is provided', function (): void {
// GIVEN & WHEN & THEN
$this->artisan('arcadia:client:create')
/** @var \Tests\TestCase $this */
$this->artisan('uwil:arcadia:client:create')
->expectsQuestion('Client name (e.g. "Ones Branding website")', '')
->assertFailed();
});
@ -65,7 +66,7 @@ describe('CreateClientCommand', function (): void {
]);
// WHEN & THEN
$this->artisan('arcadia:client:create')
$this->artisan('uwil:arcadia:client:create')
->expectsQuestion('Client name (e.g. "Ones Branding website")', 'Existing Gateway')
->assertFailed();
});

View file

@ -32,7 +32,7 @@ describe('ExportSecurityAuditLogsCommand', function (): void {
]);
// WHEN
$this->artisan('arcadia:security:audit-logs', [
$this->artisan('uwil:arcadia:security:audit-logs', [
'--since' => '2026-08-01',
'--to' => '2026-08-21 23:59:59',
])
@ -58,7 +58,7 @@ describe('ExportSecurityAuditLogsCommand', function (): void {
/** @var \Tests\TestCase $this */
// WHEN
$this->artisan('arcadia:security:audit-logs', [
$this->artisan('uwil:arcadia:security:audit-logs', [
'--since' => '2026-08-01',
'--to' => '2026-08-02',
])
@ -82,7 +82,7 @@ describe('ExportSecurityAuditLogsCommand', function (): void {
]);
// WHEN
$this->artisan('arcadia:security:audit-logs', [
$this->artisan('uwil:arcadia:security:audit-logs', [
'--since' => '2026-08-01',
'--csv' => true,
])
@ -102,7 +102,7 @@ describe('ExportSecurityAuditLogsCommand', function (): void {
it('fails when an invalid date format is provided for --since', function (): void {
// GIVEN & WHEN & THEN
/** @var \Tests\TestCase $this */
$this->artisan('arcadia:security:audit-logs', [
$this->artisan('uwil:arcadia:security:audit-logs', [
'--since' => 'invalid-date-string',
])
->expectsOutputToContain("Invalid date format for --since: 'invalid-date-string'.")

View file

@ -34,7 +34,7 @@ describe('HealthCheckCommand', function (): void {
/** @var \Tests\TestCase $this */
// WHEN
$this->artisan('health:check')
$this->artisan('uwil:health:check')
// THEN
->expectsOutput('Health check passed. All components are healthy.')
->assertSuccessful();
@ -46,7 +46,7 @@ describe('HealthCheckCommand', function (): void {
Config::set('database.default', 'invalid_connection');
// WHEN
$this->artisan('health:check')
$this->artisan('uwil:health:check')
// THEN
->expectsOutput('Health check failed. One or more components are unhealthy.')
->assertFailed();
@ -57,7 +57,7 @@ describe('HealthCheckCommand', function (): void {
/** @var \Tests\TestCase $this */
// WHEN
$this->artisan('health:check', ['--json' => true])
$this->artisan('uwil:health:check', ['--json' => true])
// THEN
->expectsOutputToContain('"status": "healthy"')
->assertSuccessful();
@ -69,7 +69,7 @@ describe('HealthCheckCommand', function (): void {
Config::set('database.default', 'invalid_connection');
// WHEN
$this->artisan('health:check', ['--json' => true])
$this->artisan('uwil:health:check', ['--json' => true])
// THEN
->expectsOutputToContain('"status": "unhealthy"')
->assertFailed();

View file

@ -22,7 +22,7 @@ describe('ListBrandsCommand', function (): void {
/** @var \Tests\TestCase $this */
// WHEN
$this->artisan('arcadia:brand:list')
$this->artisan('uwil:arcadia:brand:list')
// THEN
->expectsOutput('No brands found.')
->assertSuccessful();
@ -48,7 +48,7 @@ describe('ListBrandsCommand', function (): void {
]);
// WHEN
$this->artisan('arcadia:brand:list')
$this->artisan('uwil:arcadia:brand:list')
// THEN
->expectsOutput('Showing page 1/1 (Total: 2 brand(s))')
->assertSuccessful();
@ -68,7 +68,7 @@ describe('ListBrandsCommand', function (): void {
}
// WHEN
$this->artisan('arcadia:brand:list', ['--page' => '2', '--per-page' => '2'])
$this->artisan('uwil:arcadia:brand:list', ['--page' => '2', '--per-page' => '2'])
// THEN
->expectsOutput('Showing page 2/2 (Total: 3 brand(s))')
->assertSuccessful();

View file

@ -18,7 +18,7 @@ describe('ListClientsCommand', function (): void {
ApiClient::query()->delete();
// WHEN
$this->artisan('arcadia:client:list')
$this->artisan('uwil:arcadia:client:list')
// THEN
->expectsOutput('No clients found.')
->assertSuccessful();
@ -48,7 +48,7 @@ describe('ListClientsCommand', function (): void {
]);
// WHEN
$this->artisan('arcadia:client:list')
$this->artisan('uwil:arcadia:client:list')
// THEN
->expectsTable(
['ID', 'Name', 'Identifier', 'Status', 'Last Updated At'],
@ -90,7 +90,7 @@ describe('ListClientsCommand', function (): void {
}
// WHEN
$this->artisan('arcadia:client:list', ['--page' => '2', '--per-page' => '2'])
$this->artisan('uwil:arcadia:client:list', ['--page' => '2', '--per-page' => '2'])
// THEN
->expectsOutput('Showing page 2/2 (Total: 3 client(s))')
->assertSuccessful();

View file

@ -18,7 +18,7 @@ describe('RegenerateClientSecretCommand', function (): void {
$invalidIdentifier = 'invalid-id';
// WHEN
$this->artisan('arcadia:client:regenerate-secret', ['identifier' => $invalidIdentifier])
$this->artisan('uwil:arcadia:client:regenerate-secret', ['identifier' => $invalidIdentifier])
// THEN
->expectsOutput("Invalid client identifier '{$invalidIdentifier}'. Expected a valid client identifier.")
->assertFailed();
@ -30,7 +30,7 @@ describe('RegenerateClientSecretCommand', function (): void {
$nonExistentIdentifier = 'arcadia_id_' . str_repeat('f', 32);
// WHEN
$this->artisan('arcadia:client:regenerate-secret', ['identifier' => $nonExistentIdentifier])
$this->artisan('uwil:arcadia:client:regenerate-secret', ['identifier' => $nonExistentIdentifier])
// THEN
->expectsOutput("Client '{$nonExistentIdentifier}' not found.")
->assertFailed();
@ -52,7 +52,7 @@ describe('RegenerateClientSecretCommand', function (): void {
]);
// WHEN
$this->artisan('arcadia:client:regenerate-secret', ['identifier' => $identifier])
$this->artisan('uwil:arcadia:client:regenerate-secret', ['identifier' => $identifier])
// THEN
->expectsConfirmation('Do you want to proceed?', 'yes')
->expectsOutputToContain("Secret for client '{$identifier}' regenerated successfully.")
@ -80,7 +80,7 @@ describe('RegenerateClientSecretCommand', function (): void {
]);
// WHEN
$this->artisan('arcadia:client:regenerate-secret', ['identifier' => $identifier])
$this->artisan('uwil:arcadia:client:regenerate-secret', ['identifier' => $identifier])
// THEN
->expectsConfirmation('Do you want to proceed?', 'no')
->expectsOutput('Secret regeneration cancelled.')
@ -106,7 +106,7 @@ describe('RegenerateClientSecretCommand', function (): void {
]);
// WHEN
$this->artisan('arcadia:client:regenerate-secret')
$this->artisan('uwil:arcadia:client:regenerate-secret')
// THEN
->expectsQuestion('Search client (by name, ID, identifier, or slug):', $identifier)
->expectsChoice('Search client (by name, ID, identifier, or slug):', $identifier, [

View file

@ -18,7 +18,7 @@ describe('ToggleClientStatusCommand', function (): void {
$invalidId = 'not-a-valid-identifier';
// WHEN
$this->artisan('arcadia:client:toggle-status', ['identifier' => $invalidId])
$this->artisan('uwil:arcadia:client:toggle-status', ['identifier' => $invalidId])
// THEN
->expectsOutput("Invalid client identifier '{$invalidId}'. Expected a valid client identifier.")
->assertFailed();
@ -30,7 +30,7 @@ describe('ToggleClientStatusCommand', function (): void {
$nonExistentId = 'arcadia_id_' . str_repeat('9', 32);
// WHEN
$this->artisan('arcadia:client:toggle-status', ['identifier' => $nonExistentId])
$this->artisan('uwil:arcadia:client:toggle-status', ['identifier' => $nonExistentId])
// THEN
->expectsOutput("Client '{$nonExistentId}' not found.")
->assertFailed();
@ -49,7 +49,7 @@ describe('ToggleClientStatusCommand', function (): void {
]);
// WHEN
$this->artisan('arcadia:client:toggle-status', ['identifier' => (string) $client->identifier->value])
$this->artisan('uwil:arcadia:client:toggle-status', ['identifier' => (string) $client->identifier->value])
// THEN
->expectsOutput("Client 'Production Gateway' (ID: {$client->id}) is now Inactive.")
->assertSuccessful();
@ -71,7 +71,7 @@ describe('ToggleClientStatusCommand', function (): void {
]);
// WHEN
$this->artisan('arcadia:client:toggle-status', ['identifier' => (string) $client->identifier->value])
$this->artisan('uwil:arcadia:client:toggle-status', ['identifier' => (string) $client->identifier->value])
// THEN
->expectsOutput("Client 'Backup Gateway' (ID: {$client->id}) is now Active.")
->assertSuccessful();
@ -93,7 +93,7 @@ describe('ToggleClientStatusCommand', function (): void {
]);
// WHEN
$this->artisan('arcadia:client:toggle-status', ['identifier' => (string) $client->identifier->value, 'status' => 'activate'])
$this->artisan('uwil:arcadia:client:toggle-status', ['identifier' => (string) $client->identifier->value, 'status' => 'activate'])
// THEN
->expectsOutput("Client 'Special Gateway' (ID: {$client->id}) is now Active.")
->assertSuccessful();
@ -115,7 +115,7 @@ describe('ToggleClientStatusCommand', function (): void {
]);
// WHEN
$this->artisan('arcadia:client:toggle-status', ['identifier' => (string) $client->identifier->value, 'status' => 'deactivate'])
$this->artisan('uwil:arcadia:client:toggle-status', ['identifier' => (string) $client->identifier->value, 'status' => 'deactivate'])
// THEN
->expectsOutput("Client 'Special Gateway' (ID: {$client->id}) is now Inactive.")
->assertSuccessful();
@ -138,7 +138,7 @@ describe('ToggleClientStatusCommand', function (): void {
]);
// WHEN
$this->artisan('arcadia:client:toggle-status', ['identifier' => $identifier, 'status' => 'unknown'])
$this->artisan('uwil:arcadia:client:toggle-status', ['identifier' => $identifier, 'status' => 'unknown'])
// THEN
->expectsOutput("Invalid status 'unknown'. Expected 'activate' or 'deactivate'.")
->assertFailed();
@ -158,7 +158,7 @@ describe('ToggleClientStatusCommand', function (): void {
]);
// WHEN
$this->artisan('arcadia:client:toggle-status')
$this->artisan('uwil:arcadia:client:toggle-status')
// THEN
->expectsQuestion('Search client (by name, ID, identifier, or slug):', $identifier)
->expectsChoice('Search client (by name, ID, identifier, or slug):', $identifier, [

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Tests\Feature\OpenApi\V1\Health;
use Illuminate\Support\Facades\Config;
use Spectator\Spectator;
use Tests\TestCase;
@ -26,4 +27,24 @@ describe('Health Check OpenAPI Conformance', function (): void {
$response->assertValidRequest();
$response->assertValidResponse(200);
});
it('validates GET /api/v1/health/oracle response against openapi spec', function (): void {
// GIVEN
/** @var TestCase $this */
$originalArcadiaDb = Config::get('database.arcadia_meta_model');
Config::set('database.arcadia_meta_model', 'sqlite');
try {
// WHEN
/** @var \Illuminate\Testing\TestResponse<\Symfony\Component\HttpFoundation\Response> $response */
$response = $this->getJson('/api/v1/health/oracle');
// THEN
$response->assertStatus(200);
$response->assertValidRequest();
$response->assertValidResponse(200);
} finally {
Config::set('database.arcadia_meta_model', $originalArcadiaDb);
}
});
});

View file

@ -9,7 +9,6 @@ use Application\Health\Contracts\HealthChecker;
use Application\Health\Dto\CacheCheckResult;
use Application\Health\Dto\DatabaseCheckResult;
use Application\Health\Dto\HealthCheckResult;
use Application\Health\Dto\HealthChecks;
use Application\Health\Dto\MailCheckResult;
use Application\Health\Enums\ComponentStatus;
use Application\Health\Enums\HealthStatus;
@ -51,12 +50,12 @@ describe('HealthCheckJob Integration Test', function (): void {
$healthyResult = new HealthCheckResult(
status: HealthStatus::HEALTHY,
timestamp: Utc::now(),
checks: new HealthChecks(
databaseDefault: new DatabaseCheckResult(ComponentStatus::UP, 'sqlite'),
databaseArcadia: new DatabaseCheckResult(ComponentStatus::UP, 'arcadia'),
cache: new CacheCheckResult(ComponentStatus::UP, 'file'),
mailSmtp: new MailCheckResult(ComponentStatus::UP, 'log'),
),
checks: [
'database_default' => new DatabaseCheckResult(ComponentStatus::UP, 'sqlite'),
'database_arcadia' => new DatabaseCheckResult(ComponentStatus::UP, 'arcadia'),
'cache' => new CacheCheckResult(ComponentStatus::UP, 'file'),
'mail_smtp' => new MailCheckResult(ComponentStatus::UP, 'log'),
],
);
$healthChecker->shouldReceive('check')->once()->andReturn($healthyResult);
@ -83,12 +82,12 @@ describe('HealthCheckJob Integration Test', function (): void {
$unhealthyResult = new HealthCheckResult(
status: HealthStatus::UNHEALTHY,
timestamp: Utc::now(),
checks: new HealthChecks(
databaseDefault: new DatabaseCheckResult(ComponentStatus::DOWN, 'sqlite', 'Connection refused'),
databaseArcadia: new DatabaseCheckResult(ComponentStatus::UP, 'arcadia'),
cache: new CacheCheckResult(ComponentStatus::UP, 'file'),
mailSmtp: new MailCheckResult(ComponentStatus::UP, 'log'),
),
checks: [
'database_default' => new DatabaseCheckResult(ComponentStatus::DOWN, 'sqlite', 'Connection refused'),
'database_arcadia' => new DatabaseCheckResult(ComponentStatus::UP, 'arcadia'),
'cache' => new CacheCheckResult(ComponentStatus::UP, 'file'),
'mail_smtp' => new MailCheckResult(ComponentStatus::UP, 'log'),
],
);
$healthChecker->shouldReceive('check')->once()->andReturn($unhealthyResult);
@ -124,12 +123,12 @@ describe('HealthCheckJob Integration Test', function (): void {
$unhealthyResult = new HealthCheckResult(
status: HealthStatus::UNHEALTHY,
timestamp: Utc::now(),
checks: new HealthChecks(
databaseDefault: new DatabaseCheckResult(ComponentStatus::UP, 'sqlite'),
databaseArcadia: new DatabaseCheckResult(ComponentStatus::DOWN, 'arcadia', 'DB down'),
cache: new CacheCheckResult(ComponentStatus::UP, 'file'),
mailSmtp: new MailCheckResult(ComponentStatus::UP, 'log'),
),
checks: [
'database_default' => new DatabaseCheckResult(ComponentStatus::UP, 'sqlite'),
'database_arcadia' => new DatabaseCheckResult(ComponentStatus::DOWN, 'arcadia', 'DB down'),
'cache' => new CacheCheckResult(ComponentStatus::UP, 'file'),
'mail_smtp' => new MailCheckResult(ComponentStatus::UP, 'log'),
],
);
$healthChecker->shouldReceive('check')->once()->andReturn($unhealthyResult);
@ -161,12 +160,12 @@ describe('HealthCheckJob Integration Test', function (): void {
$healthyResult = new HealthCheckResult(
status: HealthStatus::HEALTHY,
timestamp: Utc::now(),
checks: new HealthChecks(
databaseDefault: new DatabaseCheckResult(ComponentStatus::UP, 'sqlite'),
databaseArcadia: new DatabaseCheckResult(ComponentStatus::UP, 'arcadia'),
cache: new CacheCheckResult(ComponentStatus::UP, 'file'),
mailSmtp: new MailCheckResult(ComponentStatus::UP, 'log'),
),
checks: [
'database_default' => new DatabaseCheckResult(ComponentStatus::UP, 'sqlite'),
'database_arcadia' => new DatabaseCheckResult(ComponentStatus::UP, 'arcadia'),
'cache' => new CacheCheckResult(ComponentStatus::UP, 'file'),
'mail_smtp' => new MailCheckResult(ComponentStatus::UP, 'log'),
],
);
$healthChecker->shouldReceive('check')->once()->andReturn($healthyResult);
@ -198,12 +197,12 @@ describe('HealthCheckJob Integration Test', function (): void {
$healthyResult = new HealthCheckResult(
status: HealthStatus::HEALTHY,
timestamp: Utc::now(),
checks: new HealthChecks(
databaseDefault: new DatabaseCheckResult(ComponentStatus::UP, 'sqlite'),
databaseArcadia: new DatabaseCheckResult(ComponentStatus::UP, 'arcadia'),
cache: new CacheCheckResult(ComponentStatus::UP, 'file'),
mailSmtp: new MailCheckResult(ComponentStatus::UP, 'log'),
),
checks: [
'database_default' => new DatabaseCheckResult(ComponentStatus::UP, 'sqlite'),
'database_arcadia' => new DatabaseCheckResult(ComponentStatus::UP, 'arcadia'),
'cache' => new CacheCheckResult(ComponentStatus::UP, 'file'),
'mail_smtp' => new MailCheckResult(ComponentStatus::UP, 'log'),
],
);
$healthChecker->shouldReceive('check')->once()->andReturn($healthyResult);

View file

@ -7,7 +7,6 @@ namespace Tests\Unit\Application\Health;
use Application\Health\Dto\CacheCheckResult;
use Application\Health\Dto\DatabaseCheckResult;
use Application\Health\Dto\HealthCheckResult;
use Application\Health\Dto\HealthChecks;
use Application\Health\Dto\MailCheckResult;
use Application\Health\Enums\ComponentStatus;
use Application\Health\Enums\HealthStatus;
@ -79,36 +78,15 @@ describe('Health Check DTOs & Enums Unit Tests', function (): void {
->and($mailFailed->message)->toBe('Connection refused');
});
it('evaluates HealthChecks allUp correctly', function (): void {
// GIVEN
$checksUp = new HealthChecks(
databaseDefault: new DatabaseCheckResult(ComponentStatus::UP, 'sqlite'),
databaseArcadia: new DatabaseCheckResult(ComponentStatus::UP, 'arcadia'),
cache: new CacheCheckResult(ComponentStatus::UP, 'database'),
mailSmtp: new MailCheckResult(ComponentStatus::UP, 'log'),
);
$checksDown = new HealthChecks(
databaseDefault: new DatabaseCheckResult(ComponentStatus::DOWN, 'sqlite', 'Failed'),
databaseArcadia: new DatabaseCheckResult(ComponentStatus::UP, 'arcadia'),
cache: new CacheCheckResult(ComponentStatus::UP, 'database'),
mailSmtp: new MailCheckResult(ComponentStatus::UP, 'log'),
);
// WHEN & THEN
expect($checksUp->isAllUp())->toBeTrue()
->and($checksDown->isAllUp())->toBeFalse();
});
it('instantiates HealthCheckResult and evaluates isHealthy', function (): void {
// GIVEN
$date = new \DateTimeImmutable('2026-08-21 14:30:00 UTC');
$checks = new HealthChecks(
databaseDefault: new DatabaseCheckResult(ComponentStatus::UP, 'sqlite'),
databaseArcadia: new DatabaseCheckResult(ComponentStatus::DOWN, 'arcadia', 'Timeout connecting to DB'),
cache: new CacheCheckResult(ComponentStatus::UP, 'database'),
mailSmtp: new MailCheckResult(ComponentStatus::UP, 'smtp'),
);
$checks = [
'database_default' => new DatabaseCheckResult(ComponentStatus::UP, 'sqlite'),
'database_arcadia' => new DatabaseCheckResult(ComponentStatus::DOWN, 'arcadia', 'Timeout connecting to DB'),
'cache' => new CacheCheckResult(ComponentStatus::UP, 'database'),
'mail_smtp' => new MailCheckResult(ComponentStatus::UP, 'smtp'),
];
$result = new HealthCheckResult(
status: HealthStatus::UNHEALTHY,
@ -120,6 +98,6 @@ describe('Health Check DTOs & Enums Unit Tests', function (): void {
expect($result->isHealthy())->toBeFalse()
->and($result->status)->toBe(HealthStatus::UNHEALTHY)
->and($result->timestamp)->toBe($date)
->and($result->checks->databaseArcadia->message)->toBe('Timeout connecting to DB');
->and($result->checks['database_arcadia']->message)->toBe('Timeout connecting to DB');
});
});

View file

@ -7,7 +7,6 @@ namespace Tests\Unit\Presentation\Api\V1\Http\Responses\Health;
use Application\Health\Dto\CacheCheckResult;
use Application\Health\Dto\DatabaseCheckResult;
use Application\Health\Dto\HealthCheckResult;
use Application\Health\Dto\HealthChecks;
use Application\Health\Dto\MailCheckResult;
use Application\Health\Enums\ComponentStatus;
use Application\Health\Enums\HealthStatus;
@ -19,12 +18,15 @@ describe('HealthCheckResponse Unit Test', function (): void {
it('serializes healthy HealthCheckResult into 200 JsonResponse and array', function (): void {
// GIVEN
$date = new \DateTimeImmutable('2026-08-27 15:00:00 UTC');
$checks = new HealthChecks(
databaseDefault: new DatabaseCheckResult(ComponentStatus::UP, 'sqlite'),
databaseArcadia: new DatabaseCheckResult(ComponentStatus::UP, 'arcadia'),
cache: new CacheCheckResult(ComponentStatus::UP, 'file'),
mailSmtp: new MailCheckResult(ComponentStatus::UP, 'log'),
);
$checks = [
'database_default' => new DatabaseCheckResult(ComponentStatus::UP, 'sqlite'),
'database_arcadia' => new DatabaseCheckResult(ComponentStatus::UP, 'arcadia'),
'cache' => new CacheCheckResult(ComponentStatus::UP, 'file'),
'mail_smtp' => new MailCheckResult(ComponentStatus::UP, 'log'),
];
$databaseDefaultKey = $checks['database_default']->getName();
$databaseArcadiaKey = $checks['database_arcadia']->getName();
$result = new HealthCheckResult(HealthStatus::HEALTHY, $date, $checks);
$response = new HealthCheckResponse($result);
@ -37,19 +39,21 @@ describe('HealthCheckResponse Unit Test', function (): void {
expect($json->getStatusCode())->toBe(200)
->and($array['status'])->toBe('healthy')
->and($array['timestamp'])->toBe('2026-08-27T15:00:00+00:00')
->and($array['checks']['database_default']['status'])->toBe('up')
->and($array['checks']['database_arcadia']['status'])->toBe('up');
->and($array['checks'][$databaseDefaultKey]['status'])->toBe('up')
->and($array['checks'][$databaseArcadiaKey]['status'])->toBe('up');
});
it('serializes unhealthy HealthCheckResult into 503 JsonResponse and array', function (): void {
// GIVEN
$date = new \DateTimeImmutable('2026-08-27 15:00:00 UTC');
$checks = new HealthChecks(
databaseDefault: new DatabaseCheckResult(ComponentStatus::DOWN, 'sqlite', 'Connection refused'),
databaseArcadia: new DatabaseCheckResult(ComponentStatus::UP, 'arcadia'),
cache: new CacheCheckResult(ComponentStatus::UP, 'file'),
mailSmtp: new MailCheckResult(ComponentStatus::UP, 'log'),
);
$checks = [
'database_default' => new DatabaseCheckResult(ComponentStatus::DOWN, 'sqlite', 'Connection refused'),
'database_arcadia' => new DatabaseCheckResult(ComponentStatus::UP, 'arcadia'),
'cache' => new CacheCheckResult(ComponentStatus::UP, 'file'),
'mail_smtp' => new MailCheckResult(ComponentStatus::UP, 'log'),
];
$databaseDefaultKey = $checks['database_default']->getName();
$result = new HealthCheckResult(HealthStatus::UNHEALTHY, $date, $checks);
$response = new HealthCheckResponse($result);
@ -61,6 +65,6 @@ describe('HealthCheckResponse Unit Test', function (): void {
// THEN
expect($json->getStatusCode())->toBe(503)
->and($array['status'])->toBe('unhealthy')
->and($array['checks']['database_default']['message'])->toBe('Connection refused');
->and($array['checks'][$databaseDefaultKey]['message'])->toBe('Connection refused');
});
});

View file

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Presentation\Api\V1\Http\Responses\Health;
use Application\Health\Dto\DatabaseCheckResult;
use Application\Health\Enums\ComponentStatus;
use Presentation\Api\V1\Http\Responses\Health\OracleHealthCheckResponse;
covers(OracleHealthCheckResponse::class);
describe('OracleHealthCheckResponse Unit Test', function (): void {
it('serializes healthy oracle connectivity into 200 JsonResponse and array', function (): void {
// GIVEN
$date = new \DateTimeImmutable('2026-10-06 10:00:00 UTC');
$response = new OracleHealthCheckResponse(
result: new DatabaseCheckResult(ComponentStatus::UP, 'arcadia'),
timestamp: $date,
);
// WHEN
$array = $response->toArray();
$json = $response->present();
// THEN
expect($json->getStatusCode())->toBe(200)
->and($array['status'])->toBe('healthy')
->and($array['timestamp'])->toBe('2026-10-06T10:00:00+00:00')
->and($array['oracle']['status'])->toBe('up')
->and($array['oracle']['connection'])->toBe('arcadia');
});
it('serializes unhealthy oracle connectivity into 503 JsonResponse and array', function (): void {
// GIVEN
$date = new \DateTimeImmutable('2026-10-06 10:00:00 UTC');
$response = new OracleHealthCheckResponse(
result: new DatabaseCheckResult(ComponentStatus::DOWN, 'arcadia', 'Connection refused'),
timestamp: $date,
);
// WHEN
$array = $response->toArray();
$json = $response->present();
// THEN
expect($json->getStatusCode())->toBe(503)
->and($array['status'])->toBe('unhealthy')
->and($array['oracle']['status'])->toBe('down');
});
});