set('cache.default', 'array'); config()->set('monitoring.health.token', 'geheimes-monitor-token'); config()->set('monitoring.health.disk_min_free_percent', 0); config()->set('queue.default', 'sync'); }); afterEach(function () { @unlink(HealthProbe::heartbeatPath()); }); function healthHeaders(): array { return ['Authorization' => 'Bearer geheimes-monitor-token']; } test('the endpoint does not exist without a configured token or with a wrong one', function () { app(HealthProbe::class)->recordHeartbeat(); $this->get('/health', healthHeaders())->assertOk(); $this->get('/health', ['Authorization' => 'Bearer falsch'])->assertNotFound(); $this->get('/health')->assertNotFound(); config()->set('monitoring.health.token', null); $this->get('/health', healthHeaders())->assertNotFound(); }); test('a healthy system answers 200 with every check', function () { config()->set('app.version', 'v1.1.0'); $this->artisan('monitoring:heartbeat')->assertSuccessful(); $this->get('/health?token=geheimes-monitor-token') ->assertOk() ->assertHeader('Cache-Control', 'no-store, private') ->assertJsonPath('status', 'ok') ->assertJsonPath('version', 'v1.1.0') ->assertJsonPath('checks.database.status', 'ok') ->assertJsonPath('checks.cache.status', 'ok') ->assertJsonPath('checks.queue.status', 'ok') ->assertJsonPath('checks.failed_jobs.status', 'ok') ->assertJsonPath('checks.scheduler.status', 'ok') ->assertJsonStructure(['checks' => ['disk' => ['status', 'detail']]]); }); test('a missing or stale heartbeat makes the endpoint fail', function () { $this->get('/health', healthHeaders()) ->assertStatus(503) ->assertJsonPath('checks.scheduler.status', 'critical'); app(HealthProbe::class)->recordHeartbeat(); $this->travel(4)->minutes(); $this->get('/health', healthHeaders())->assertOk()->assertJsonPath('checks.scheduler.status', 'warn'); $this->travel(7)->minutes(); $this->get('/health', healthHeaders())->assertStatus(503)->assertJsonPath('checks.scheduler.status', 'critical'); }); test('a database queue backlog older than five minutes makes the endpoint fail', function () { config()->set('queue.default', 'database'); app(HealthProbe::class)->recordHeartbeat(); DB::table('jobs')->insert([ 'queue' => 'default', 'payload' => '{}', 'attempts' => 0, 'reserved_at' => null, 'available_at' => now()->subMinutes(6)->getTimestamp(), 'created_at' => now()->subMinutes(6)->getTimestamp(), ]); $this->get('/health', healthHeaders()) ->assertStatus(503) ->assertJsonPath('checks.queue.status', 'critical'); }); test('with the redis driver a stopped horizon makes the endpoint fail', function () { config()->set('queue.default', 'redis'); app(HealthProbe::class)->recordHeartbeat(); Redis::shouldReceive('connection->ping')->andReturn(true); $this->mock(MasterSupervisorRepository::class)->shouldReceive('all')->andReturn([]); $this->get('/health', healthHeaders()) ->assertStatus(503) ->assertJsonPath('checks.queue.status', 'critical') ->assertJsonPath('checks.queue.detail', 'Treiber redis — Horizon läuft nicht. Daemon in Ploi prüfen.'); }); test('recently failed jobs are a warning, not an outage', function () { app(HealthProbe::class)->recordHeartbeat(); DB::table('failed_jobs')->insert([ 'uuid' => 'a1b2c3', 'connection' => 'redis', 'queue' => 'default', 'payload' => json_encode(['displayName' => 'App\\Jobs\\SyncStripeInvoiceReference']), 'exception' => "RuntimeException: Stripe nicht erreichbar\n#0 ...", 'failed_at' => now()->subHour(), ]); $this->get('/health', healthHeaders()) ->assertOk() ->assertJsonPath('status', 'warn') ->assertJsonPath('checks.failed_jobs.status', 'warn'); }); test('the endpoint skips the session and the web middleware group', function () { app(HealthProbe::class)->recordHeartbeat(); $this->get('/health', healthHeaders()) ->assertOk() ->assertCookieMissing(config('session.cookie')); });