refactor: add health check feature within cli and job.
This commit is contained in:
parent
34b76cb397
commit
403ea2c6c2
15 changed files with 757 additions and 1 deletions
|
|
@ -73,6 +73,7 @@ MAIL_USERNAME=null
|
|||
MAIL_PASSWORD=null
|
||||
MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
HEALTH_ALERT_EMAIL="health@example.com"
|
||||
SECURITY_ALERT_EMAIL="security@example.com"
|
||||
|
||||
AWS_ACCESS_KEY_ID=
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -27,3 +27,4 @@ _ide_helper.php
|
|||
Homestead.json
|
||||
Homestead.yaml
|
||||
Thumbs.db
|
||||
storage/framework/health_state.json
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ $app = Application::configure(Env::get('APP_BASE_PATH', dirname(__DIR__)))
|
|||
])
|
||||
->withMiddleware(require __DIR__ . '/middlewares.php')
|
||||
->withExceptions(require __DIR__ . '/exceptions.php')
|
||||
->withSchedule(require __DIR__ . '/schedule.php')
|
||||
->create();
|
||||
|
||||
$app->useAppPath($app->basePath('src/Presentation'));
|
||||
|
|
|
|||
12
bootstrap/schedule.php
Normal file
12
bootstrap/schedule.php
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Console\Scheduling\Schedule;
|
||||
use Infra\Jobs\HealthCheckJob;
|
||||
|
||||
return function (Schedule $schedule): void {
|
||||
$schedule->job(new HealthCheckJob())
|
||||
->everyTenMinutes()
|
||||
->withoutOverlapping(15);
|
||||
};
|
||||
|
|
@ -117,6 +117,17 @@ return [
|
|||
'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Health Alerts Recipient
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The email address that receives administrative and critical health alerts.
|
||||
|
|
||||
*/
|
||||
|
||||
'health_recipient' => env('HEALTH_ALERT_EMAIL', 'health@example.com'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Security Alerts Recipient
|
||||
|
|
|
|||
15
src/Application/Health/Config/HealthAlertMailConfig.php
Normal file
15
src/Application/Health/Config/HealthAlertMailConfig.php
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Application\Health\Config;
|
||||
|
||||
use Shared\ValueObjects\Email;
|
||||
|
||||
final readonly class HealthAlertMailConfig
|
||||
{
|
||||
public function __construct(
|
||||
public Email $recipientEmail,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Application\Health\Exceptions;
|
||||
|
||||
final class SystemHealthDegradedException extends \RuntimeException
|
||||
{
|
||||
}
|
||||
166
src/Infra/Jobs/HealthCheckJob.php
Normal file
166
src/Infra/Jobs/HealthCheckJob.php
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Infra\Jobs;
|
||||
|
||||
use Application\Health\Config\HealthAlertMailConfig;
|
||||
use Application\Health\Contracts\HealthChecker;
|
||||
use Application\Health\Dto\HealthCheckResult;
|
||||
use Application\Health\Enums\HealthStatus;
|
||||
use Application\Health\Exceptions\SystemHealthDegradedException;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Mail\Mailer;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailables\Address;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Infra\Mail\SystemHealthAlertMail;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shared\Time\Utc;
|
||||
|
||||
final class HealthCheckJob implements ShouldQueue
|
||||
{
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public int $tries = 1;
|
||||
|
||||
public function __construct(
|
||||
private readonly ?string $stateFilePath = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(
|
||||
HealthChecker $healthChecker,
|
||||
Mailer $mailer,
|
||||
HealthAlertMailConfig $mailConfig,
|
||||
LoggerInterface $logger,
|
||||
): void {
|
||||
$result = $healthChecker->check();
|
||||
$stateFile = $this->stateFilePath ?? storage_path('framework/health_state.json');
|
||||
|
||||
$previousStatus = $this->readPreviousStatus($stateFile);
|
||||
$currentStatus = $result->status;
|
||||
|
||||
$this->saveCurrentState($stateFile, $result);
|
||||
|
||||
if ($previousStatus === null) {
|
||||
if (! $result->isHealthy()) {
|
||||
$this->notifyDegraded($result, null, $mailer, $mailConfig, $logger);
|
||||
} else {
|
||||
$logger->info('Initial system health check completed: all components healthy.');
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($currentStatus !== $previousStatus) {
|
||||
if ($result->isHealthy()) {
|
||||
$this->notifyRecovered($result, $previousStatus, $mailer, $mailConfig, $logger);
|
||||
} else {
|
||||
$this->notifyDegraded($result, $previousStatus, $mailer, $mailConfig, $logger);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function notifyDegraded(
|
||||
HealthCheckResult $result,
|
||||
?HealthStatus $previousStatus,
|
||||
Mailer $mailer,
|
||||
HealthAlertMailConfig $mailConfig,
|
||||
LoggerInterface $logger,
|
||||
): void {
|
||||
$failedComponents = [];
|
||||
$checksArray = $result->toArray()['checks'];
|
||||
|
||||
foreach ($checksArray as $component => $data) {
|
||||
if ($data['status'] === 'down') {
|
||||
$failedComponents[] = sprintf('%s (%s)', $component, $data['message'] ?? 'down');
|
||||
}
|
||||
}
|
||||
|
||||
$reason = implode(', ', $failedComponents);
|
||||
$logger->critical("System health check degraded: {$reason}", [
|
||||
'status' => $result->status->value,
|
||||
'previousStatus' => $previousStatus?->value,
|
||||
'timestamp' => Utc::toRfc3339($result->timestamp),
|
||||
'checks' => $checksArray,
|
||||
]);
|
||||
|
||||
report(new SystemHealthDegradedException("System health check degraded: {$reason}"));
|
||||
|
||||
$mailable = new SystemHealthAlertMail(
|
||||
status: $result->status,
|
||||
occurredAt: $result->timestamp,
|
||||
checks: $checksArray,
|
||||
previousStatus: $previousStatus,
|
||||
);
|
||||
|
||||
$mailer->to(new Address($mailConfig->recipientEmail->value))->send($mailable);
|
||||
}
|
||||
|
||||
private function notifyRecovered(
|
||||
HealthCheckResult $result,
|
||||
HealthStatus $previousStatus,
|
||||
Mailer $mailer,
|
||||
HealthAlertMailConfig $mailConfig,
|
||||
LoggerInterface $logger,
|
||||
): void {
|
||||
$checksArray = $result->toArray()['checks'];
|
||||
|
||||
$logger->info('System health check recovered: all components are healthy.', [
|
||||
'status' => $result->status->value,
|
||||
'previousStatus' => $previousStatus->value,
|
||||
'timestamp' => Utc::toRfc3339($result->timestamp),
|
||||
]);
|
||||
|
||||
$mailable = new SystemHealthAlertMail(
|
||||
status: $result->status,
|
||||
occurredAt: $result->timestamp,
|
||||
checks: $checksArray,
|
||||
previousStatus: $previousStatus,
|
||||
);
|
||||
|
||||
$mailer->to(new Address($mailConfig->recipientEmail->value))->send($mailable);
|
||||
}
|
||||
|
||||
private function readPreviousStatus(string $filePath): ?HealthStatus
|
||||
{
|
||||
if (! File::exists($filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$content = (string) File::get($filePath);
|
||||
/** @var array{status?: string}|null $data */
|
||||
$data = json_decode($content, true);
|
||||
|
||||
if (is_array($data) && isset($data['status']) && is_string($data['status'])) {
|
||||
return HealthStatus::tryFrom($data['status']);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function saveCurrentState(string $filePath, HealthCheckResult $result): void
|
||||
{
|
||||
$dir = dirname($filePath);
|
||||
if (! File::exists($dir)) {
|
||||
File::makeDirectory($dir, 0755, true);
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'status' => $result->status->value,
|
||||
'timestamp' => Utc::toRfc3339($result->timestamp),
|
||||
'checks' => $result->toArray()['checks'],
|
||||
];
|
||||
|
||||
File::put($filePath, (string) json_encode($payload, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES));
|
||||
}
|
||||
}
|
||||
54
src/Infra/Mail/SystemHealthAlertMail.php
Normal file
54
src/Infra/Mail/SystemHealthAlertMail.php
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Infra\Mail;
|
||||
|
||||
use Application\Health\Enums\HealthStatus;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
final class SystemHealthAlertMail extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $checks
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly HealthStatus $status,
|
||||
public readonly \DateTimeInterface $occurredAt,
|
||||
public readonly array $checks,
|
||||
public readonly ?HealthStatus $previousStatus = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
$isRecovery = $this->status === HealthStatus::HEALTHY;
|
||||
$subject = $isRecovery
|
||||
? '[Arcadia Health] 🟢 System Health Recovered- Component UP'
|
||||
: '[Arcadia Health] 🛑 System Health Alert - Component DOWN';
|
||||
|
||||
return new Envelope(subject: $subject);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
markdown: 'mails.health-alert',
|
||||
with: [
|
||||
'status' => $this->status->value,
|
||||
'isRecovery' => $this->status === HealthStatus::HEALTHY,
|
||||
'occurredAt' => $this->occurredAt,
|
||||
'previousStatus' => $this->previousStatus?->value,
|
||||
'checksJson' => (string) json_encode($this->checks, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ namespace Infra\Providers;
|
|||
|
||||
use Application\Arcadia\Config\AppConfig;
|
||||
use Application\Arcadia\Config\ThrottlingConfig;
|
||||
use Application\Health\Config\HealthAlertMailConfig;
|
||||
use Application\Security\Config\DevClientCredentials;
|
||||
use Application\Security\Config\HmacAuthConfig;
|
||||
use Application\Security\Config\SecurityAlertMailConfig;
|
||||
|
|
@ -46,7 +47,13 @@ final class ConfigServiceProvider extends ServiceProvider
|
|||
|
||||
$this->app->bind(SecurityAlertMailConfig::class, static function (): SecurityAlertMailConfig {
|
||||
return new SecurityAlertMailConfig(
|
||||
recipientEmail: new Email(Config::string('mail.security_recipient', 'security@example.com')),
|
||||
recipientEmail: new Email(Config::string('mail.security_recipient')),
|
||||
);
|
||||
});
|
||||
|
||||
$this->app->bind(HealthAlertMailConfig::class, static function (): HealthAlertMailConfig {
|
||||
return new HealthAlertMailConfig(
|
||||
recipientEmail: new Email(Config::string('mail.health_recipient'))
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
87
src/Presentation/Console/Commands/HealthCheckCommand.php
Normal file
87
src/Presentation/Console/Commands/HealthCheckCommand.php
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Presentation\Console\Commands;
|
||||
|
||||
use Application\Health\Contracts\HealthChecker;
|
||||
use Application\Health\Dto\HealthCheckResult;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
final class HealthCheckCommand extends Command
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'health:check
|
||||
{--json : Output the result as JSON}';
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Run system health checks on database, cache, and mail infrastructure';
|
||||
|
||||
public function __construct(
|
||||
private readonly HealthChecker $healthChecker,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$result = $this->healthChecker->check();
|
||||
|
||||
if ($this->option('json')) {
|
||||
$this->line((string) json_encode(
|
||||
$result->toArray(),
|
||||
\JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES
|
||||
));
|
||||
|
||||
return $result->isHealthy() ? self::SUCCESS : self::FAILURE;
|
||||
}
|
||||
|
||||
$this->renderTable($result);
|
||||
|
||||
if (! $result->isHealthy()) {
|
||||
$this->error('Health check failed. One or more components are unhealthy.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info('Health check passed. All components are healthy.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
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})" : ''),
|
||||
],
|
||||
];
|
||||
|
||||
$this->table(['Component', 'Status', 'Details'], $rows);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<x-mail::message>
|
||||
@if ($isRecovery)
|
||||
# 🟢 Rétablissement Système - Arcadia API
|
||||
|
||||
Tous les composants de l'infrastructure Arcadia API sont de nouveau opérationnels.
|
||||
@else
|
||||
# 🛑 Alerte Incident Système - Arcadia API
|
||||
|
||||
Un ou plusieurs composants de l'infrastructure Arcadia API sont actuellement indisponibles.
|
||||
@endif
|
||||
|
||||
<x-mail::panel>
|
||||
**Statut Global :** {{ $isRecovery ? '🟢 Opérationnel (HEALTHY)' : '🔴 Dégradé (UNHEALTHY)' }}
|
||||
@if ($previousStatus !== null)
|
||||
**Statut Précédent :** `{{ $previousStatus }}`
|
||||
@endif
|
||||
**Date & Heure :** {{ $occurredAt->format('d/m/Y H:i:s') }} UTC
|
||||
|
||||
**Détails des vérifications (JSON) :**
|
||||
```json
|
||||
{!! $checksJson !!}
|
||||
```
|
||||
</x-mail::panel>
|
||||
|
||||
Ce message est généré automatiquement par le système de surveillance continue Arcadia API.
|
||||
</x-mail::message>
|
||||
77
tests/Feature/Console/Commands/HealthCheckCommandTest.php
Normal file
77
tests/Feature/Console/Commands/HealthCheckCommandTest.php
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Console\Commands;
|
||||
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Presentation\Console\Commands\HealthCheckCommand;
|
||||
|
||||
covers(HealthCheckCommand::class);
|
||||
|
||||
describe('HealthCheckCommand', function (): void {
|
||||
$originalDbDefault = null;
|
||||
$originalArcadiaDb = null;
|
||||
$originalCache = null;
|
||||
$originalMail = null;
|
||||
|
||||
beforeEach(function () use (&$originalDbDefault, &$originalArcadiaDb, &$originalCache, &$originalMail): void {
|
||||
$originalDbDefault = Config::get('database.default');
|
||||
$originalArcadiaDb = Config::get('database.arcadia_meta_model');
|
||||
$originalCache = Config::get('cache.default');
|
||||
$originalMail = Config::get('mail.default');
|
||||
});
|
||||
|
||||
afterEach(function () use (&$originalDbDefault, &$originalArcadiaDb, &$originalCache, &$originalMail): void {
|
||||
Config::set('database.default', $originalDbDefault);
|
||||
Config::set('database.arcadia_meta_model', $originalArcadiaDb);
|
||||
Config::set('cache.default', $originalCache);
|
||||
Config::set('mail.default', $originalMail);
|
||||
});
|
||||
|
||||
it('returns success when all components are healthy', function (): void {
|
||||
// GIVEN
|
||||
/** @var \Tests\TestCase $this */
|
||||
|
||||
// WHEN
|
||||
$this->artisan('health:check')
|
||||
// THEN
|
||||
->expectsOutput('Health check passed. All components are healthy.')
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
it('returns failure when a component is unhealthy', function () use (&$originalDbDefault): void {
|
||||
// GIVEN
|
||||
/** @var \Tests\TestCase $this */
|
||||
Config::set('database.default', 'invalid_connection');
|
||||
|
||||
// WHEN
|
||||
$this->artisan('health:check')
|
||||
// THEN
|
||||
->expectsOutput('Health check failed. One or more components are unhealthy.')
|
||||
->assertFailed();
|
||||
});
|
||||
|
||||
it('outputs JSON when --json option is passed and succeeds when healthy', function (): void {
|
||||
// GIVEN
|
||||
/** @var \Tests\TestCase $this */
|
||||
|
||||
// WHEN
|
||||
$this->artisan('health:check', ['--json' => true])
|
||||
// THEN
|
||||
->expectsOutputToContain('"status": "healthy"')
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
it('outputs JSON when --json option is passed and fails when unhealthy', function () use (&$originalArcadiaDb): void {
|
||||
// GIVEN
|
||||
/** @var \Tests\TestCase $this */
|
||||
Config::set('database.arcadia_meta_model', 'invalid_arcadia_connection');
|
||||
|
||||
// WHEN
|
||||
$this->artisan('health:check', ['--json' => true])
|
||||
// THEN
|
||||
->expectsOutputToContain('"status": "unhealthy"')
|
||||
->assertFailed();
|
||||
});
|
||||
});
|
||||
222
tests/Integration/Infra/Jobs/HealthCheckJobTest.php
Normal file
222
tests/Integration/Infra/Jobs/HealthCheckJobTest.php
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Integration\Infra\Jobs;
|
||||
|
||||
use Application\Health\Config\HealthAlertMailConfig;
|
||||
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;
|
||||
use Illuminate\Contracts\Mail\Mailer;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Infra\Jobs\HealthCheckJob;
|
||||
use Infra\Mail\SystemHealthAlertMail;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shared\Time\Utc;
|
||||
use Tests\TestCase;
|
||||
|
||||
covers(HealthCheckJob::class);
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
describe('HealthCheckJob Integration Test', function (): void {
|
||||
$tempStateFile = null;
|
||||
|
||||
beforeEach(function () use (&$tempStateFile): void {
|
||||
$tempStateFile = sys_get_temp_dir() . '/health_check_test_' . uniqid() . '.json';
|
||||
if (File::exists($tempStateFile)) {
|
||||
File::delete($tempStateFile);
|
||||
}
|
||||
Mail::fake();
|
||||
});
|
||||
|
||||
afterEach(function () use (&$tempStateFile): void {
|
||||
if ($tempStateFile !== null && File::exists($tempStateFile)) {
|
||||
File::delete($tempStateFile);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not send email on initial run when healthy and creates state file', function () use (&$tempStateFile): void {
|
||||
// GIVEN
|
||||
$job = new HealthCheckJob($tempStateFile);
|
||||
$healthChecker = mock(HealthChecker::class);
|
||||
$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'),
|
||||
),
|
||||
);
|
||||
|
||||
$healthChecker->shouldReceive('check')->once()->andReturn($healthyResult);
|
||||
|
||||
// WHEN
|
||||
$job->handle(
|
||||
$healthChecker,
|
||||
app(Mailer::class),
|
||||
app(HealthAlertMailConfig::class),
|
||||
app(LoggerInterface::class),
|
||||
);
|
||||
|
||||
// THEN
|
||||
expect(File::exists($tempStateFile))->toBeTrue();
|
||||
$saved = json_decode((string) File::get($tempStateFile), true);
|
||||
expect($saved['status'])->toBe('healthy');
|
||||
Mail::assertNothingQueued();
|
||||
});
|
||||
|
||||
it('sends degraded alert email on initial run when unhealthy', function () use (&$tempStateFile): void {
|
||||
// GIVEN
|
||||
$job = new HealthCheckJob($tempStateFile);
|
||||
$healthChecker = mock(HealthChecker::class);
|
||||
$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'),
|
||||
),
|
||||
);
|
||||
|
||||
$healthChecker->shouldReceive('check')->once()->andReturn($unhealthyResult);
|
||||
|
||||
// WHEN
|
||||
$job->handle(
|
||||
$healthChecker,
|
||||
app(Mailer::class),
|
||||
app(HealthAlertMailConfig::class),
|
||||
app(LoggerInterface::class),
|
||||
);
|
||||
|
||||
// THEN
|
||||
expect(File::exists($tempStateFile))->toBeTrue();
|
||||
$saved = json_decode((string) File::get($tempStateFile), true);
|
||||
expect($saved['status'])->toBe('unhealthy');
|
||||
|
||||
Mail::assertQueued(SystemHealthAlertMail::class, static function (SystemHealthAlertMail $mail): bool {
|
||||
return $mail->status === HealthStatus::UNHEALTHY
|
||||
&& $mail->previousStatus === null;
|
||||
});
|
||||
});
|
||||
|
||||
it('sends alert when status transitions from healthy to unhealthy', function () use (&$tempStateFile): void {
|
||||
// GIVEN: existing healthy state
|
||||
File::put($tempStateFile, (string) json_encode([
|
||||
'status' => 'healthy',
|
||||
'timestamp' => Utc::now()->format(\DateTimeInterface::RFC3339),
|
||||
]));
|
||||
|
||||
$job = new HealthCheckJob($tempStateFile);
|
||||
$healthChecker = mock(HealthChecker::class);
|
||||
$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'),
|
||||
),
|
||||
);
|
||||
|
||||
$healthChecker->shouldReceive('check')->once()->andReturn($unhealthyResult);
|
||||
|
||||
// WHEN
|
||||
$job->handle(
|
||||
$healthChecker,
|
||||
app(Mailer::class),
|
||||
app(HealthAlertMailConfig::class),
|
||||
app(LoggerInterface::class),
|
||||
);
|
||||
|
||||
// THEN
|
||||
Mail::assertQueued(SystemHealthAlertMail::class, static function (SystemHealthAlertMail $mail): bool {
|
||||
return $mail->status === HealthStatus::UNHEALTHY
|
||||
&& $mail->previousStatus === HealthStatus::HEALTHY;
|
||||
});
|
||||
});
|
||||
|
||||
it('sends recovery alert when status transitions from unhealthy to healthy', function () use (&$tempStateFile): void {
|
||||
// GIVEN: existing unhealthy state
|
||||
File::put($tempStateFile, (string) json_encode([
|
||||
'status' => 'unhealthy',
|
||||
'timestamp' => Utc::now()->format(\DateTimeInterface::RFC3339),
|
||||
]));
|
||||
|
||||
$job = new HealthCheckJob($tempStateFile);
|
||||
$healthChecker = mock(HealthChecker::class);
|
||||
$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'),
|
||||
),
|
||||
);
|
||||
|
||||
$healthChecker->shouldReceive('check')->once()->andReturn($healthyResult);
|
||||
|
||||
// WHEN
|
||||
$job->handle(
|
||||
$healthChecker,
|
||||
app(Mailer::class),
|
||||
app(HealthAlertMailConfig::class),
|
||||
app(LoggerInterface::class),
|
||||
);
|
||||
|
||||
// THEN
|
||||
Mail::assertQueued(SystemHealthAlertMail::class, static function (SystemHealthAlertMail $mail): bool {
|
||||
return $mail->status === HealthStatus::HEALTHY
|
||||
&& $mail->previousStatus === HealthStatus::UNHEALTHY;
|
||||
});
|
||||
});
|
||||
|
||||
it('does not send email when status remains unchanged', function () use (&$tempStateFile): void {
|
||||
// GIVEN: existing healthy state
|
||||
File::put($tempStateFile, (string) json_encode([
|
||||
'status' => 'healthy',
|
||||
'timestamp' => Utc::now()->format(\DateTimeInterface::RFC3339),
|
||||
]));
|
||||
|
||||
$job = new HealthCheckJob($tempStateFile);
|
||||
$healthChecker = mock(HealthChecker::class);
|
||||
$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'),
|
||||
),
|
||||
);
|
||||
|
||||
$healthChecker->shouldReceive('check')->once()->andReturn($healthyResult);
|
||||
|
||||
// WHEN
|
||||
$job->handle(
|
||||
$healthChecker,
|
||||
app(Mailer::class),
|
||||
app(HealthAlertMailConfig::class),
|
||||
app(LoggerInterface::class),
|
||||
);
|
||||
|
||||
// THEN
|
||||
Mail::assertNothingQueued();
|
||||
});
|
||||
});
|
||||
67
tests/Unit/Infra/Mail/SystemHealthAlertMailTest.php
Normal file
67
tests/Unit/Infra/Mail/SystemHealthAlertMailTest.php
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Infra\Mail;
|
||||
|
||||
use Application\Health\Enums\HealthStatus;
|
||||
use Infra\Mail\SystemHealthAlertMail;
|
||||
|
||||
covers(SystemHealthAlertMail::class);
|
||||
|
||||
describe('SystemHealthAlertMail Unit Test', function (): void {
|
||||
it('constructs degraded mail with alert subject and json markdown content', function (): void {
|
||||
// GIVEN
|
||||
$occurredAt = new \DateTimeImmutable('2026-08-26 12:00:00 UTC');
|
||||
$checks = [
|
||||
'database_default' => ['status' => 'down', 'connection' => 'mysql', 'message' => 'Connection refused'],
|
||||
'database_arcadia' => ['status' => 'up', 'connection' => 'arcadia'],
|
||||
];
|
||||
|
||||
// WHEN
|
||||
$mail = new SystemHealthAlertMail(
|
||||
status: HealthStatus::UNHEALTHY,
|
||||
occurredAt: $occurredAt,
|
||||
checks: $checks,
|
||||
previousStatus: HealthStatus::HEALTHY,
|
||||
);
|
||||
|
||||
$envelope = $mail->envelope();
|
||||
$content = $mail->content();
|
||||
|
||||
// THEN
|
||||
expect($envelope->subject)->toBe('[Arcadia Health] 🛑 System Health Alert - Component DOWN')
|
||||
->and($content->markdown)->toBe('mails.health-alert')
|
||||
->and($content->with['status'])->toBe('unhealthy')
|
||||
->and($content->with['isRecovery'])->toBeFalse()
|
||||
->and($content->with['previousStatus'])->toBe('healthy')
|
||||
->and($content->with['checksJson'])->toContain('Connection refused');
|
||||
});
|
||||
|
||||
it('constructs recovery mail with recovery subject and json markdown content', function (): void {
|
||||
// GIVEN
|
||||
$occurredAt = new \DateTimeImmutable('2026-08-26 12:05:00 UTC');
|
||||
$checks = [
|
||||
'database_default' => ['status' => 'up', 'connection' => 'mysql'],
|
||||
'database_arcadia' => ['status' => 'up', 'connection' => 'arcadia'],
|
||||
];
|
||||
|
||||
// WHEN
|
||||
$mail = new SystemHealthAlertMail(
|
||||
status: HealthStatus::HEALTHY,
|
||||
occurredAt: $occurredAt,
|
||||
checks: $checks,
|
||||
previousStatus: HealthStatus::UNHEALTHY,
|
||||
);
|
||||
|
||||
$envelope = $mail->envelope();
|
||||
$content = $mail->content();
|
||||
|
||||
// THEN
|
||||
expect($envelope->subject)->toBe('[Arcadia Health] 🟢 System Health Recovered- Component UP')
|
||||
->and($content->markdown)->toBe('mails.health-alert')
|
||||
->and($content->with['status'])->toBe('healthy')
|
||||
->and($content->with['isRecovery'])->toBeTrue()
|
||||
->and($content->with['previousStatus'])->toBe('unhealthy');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue