refactor: add health check feature within cli and job.

This commit is contained in:
Jérémy Munsch 2026-08-27 14:39:59 +02:00
parent 34b76cb397
commit 403ea2c6c2
Signed by: forge-joniko
GPG key ID: D7EFE2F5CB6CC0E4
15 changed files with 757 additions and 1 deletions

View file

@ -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
View file

@ -27,3 +27,4 @@ _ide_helper.php
Homestead.json
Homestead.yaml
Thumbs.db
storage/framework/health_state.json

View file

@ -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
View 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);
};

View file

@ -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

View 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,
) {
}
}

View file

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Application\Health\Exceptions;
final class SystemHealthDegradedException extends \RuntimeException
{
}

View 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));
}
}

View 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),
],
);
}
}

View file

@ -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'))
);
});

View 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);
}
}

View file

@ -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>

View 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();
});
});

View 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();
});
});

View 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');
});
});