35 - Add prod image SPA serving, health endpoint, and Forgejo CI
This commit is contained in:
parent
196cbfbb69
commit
f16e279f58
7 changed files with 477 additions and 15 deletions
256
.forgejo/workflows/ci.yml
Normal file
256
.forgejo/workflows/ci.yml
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
name: CI
|
||||
|
||||
# Tests + static analysis + lint. Runs on every push to a release branch and on
|
||||
# PRs targeting main. The production image is built/pushed separately and ONLY on
|
||||
# a v* tag (see image.yml) — never here.
|
||||
on:
|
||||
push:
|
||||
branches: ['release/*']
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
|
||||
services:
|
||||
database:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_DB: buckets
|
||||
POSTGRES_USER: buckets
|
||||
POSTGRES_PASSWORD: buckets
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U buckets -d buckets"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
|
||||
env:
|
||||
# Point the test suite at the CI postgres service (host = service name).
|
||||
DATABASE_URL: postgresql://buckets:buckets@database:5432/buckets?serverVersion=16&charset=utf8
|
||||
|
||||
steps:
|
||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
||||
|
||||
- name: Set up PHP
|
||||
uses: https://github.com/shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.4'
|
||||
extensions: pdo_pgsql, mbstring, xml, dom, intl, zip
|
||||
coverage: pcov
|
||||
|
||||
- name: Cache Composer dependencies
|
||||
uses: https://data.forgejo.org/actions/cache@v4
|
||||
with:
|
||||
path: ~/.composer/cache
|
||||
key: composer-${{ hashFiles('composer.lock') }}
|
||||
restore-keys: composer-
|
||||
|
||||
- name: Install Composer dependencies
|
||||
run: composer install --no-interaction --prefer-dist
|
||||
|
||||
- name: Lint (php-cs-fixer)
|
||||
run: vendor/bin/php-cs-fixer check --diff
|
||||
env:
|
||||
PHP_CS_FIXER_IGNORE_ENV: '1'
|
||||
|
||||
- name: Static analysis (PHPStan)
|
||||
run: vendor/bin/phpstan analyse --memory-limit=1G -c phpstan.dist.neon
|
||||
|
||||
- name: Create test database
|
||||
run: php bin/console --env=test doctrine:database:create --if-not-exists
|
||||
|
||||
- name: Run migrations
|
||||
run: php bin/console --env=test doctrine:migrations:migrate --no-interaction
|
||||
|
||||
- name: Tests with coverage
|
||||
run: php bin/phpunit --coverage-clover coverage.xml
|
||||
|
||||
# Backend sits at ~98.8% clover element coverage (a few framework-glue
|
||||
# elements are @codeCoverageIgnore'd in the text report but still counted
|
||||
# in the clover XML). Gate at a 95% floor: catches real backslide without
|
||||
# demanding an unreachable 100% off the raw clover metric (mirrors the
|
||||
# frontend's vite.config thresholds, which are floors, not 100).
|
||||
- name: Enforce coverage threshold
|
||||
run: |
|
||||
MIN=95
|
||||
COVERAGE=$(php -r '
|
||||
$xml = @simplexml_load_file("coverage.xml");
|
||||
if ($xml === false || !isset($xml->project->metrics)) { echo "0"; exit; }
|
||||
$m = $xml->project->metrics;
|
||||
$el = (int) $m["elements"];
|
||||
$cov = (int) $m["coveredelements"];
|
||||
echo $el > 0 ? round(($cov / $el) * 100, 2) : 0;
|
||||
')
|
||||
echo "Coverage: ${COVERAGE}% (minimum ${MIN}%)"
|
||||
php -r "exit((float)\$argv[1] >= (float)\$argv[2] ? 0 : 1);" "$COVERAGE" "$MIN" \
|
||||
|| { echo "::error::Coverage ${COVERAGE}% is below the ${MIN}% threshold"; exit 1; }
|
||||
|
||||
frontend:
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: frontend
|
||||
|
||||
steps:
|
||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
||||
|
||||
# Node is preinstalled in catthehacker/ubuntu:act-latest (no setup-node
|
||||
# needed). npm ci is lockfile-driven, so the image's npm version is fine;
|
||||
# the *production* frontend build pins NPM_VERSION inside the Dockerfile.
|
||||
- name: Cache npm dependencies
|
||||
uses: https://data.forgejo.org/actions/cache@v4
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: npm-${{ hashFiles('frontend/package-lock.json') }}
|
||||
restore-keys: npm-
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Lint (oxlint)
|
||||
run: npm run lint
|
||||
|
||||
- name: Format check (Prettier)
|
||||
run: npm run format:check
|
||||
|
||||
- name: Type check (tsc)
|
||||
run: npx tsc -b
|
||||
|
||||
# Coverage thresholds are enforced inside vite.config.ts (test:coverage
|
||||
# fails on backslide) — no separate gate step needed.
|
||||
- name: Tests with coverage
|
||||
run: npm run test:coverage
|
||||
|
||||
# End-to-end smoke tests. Unlike local (where compose stands up dev services),
|
||||
# CI builds the PRODUCTION image and runs Playwright against it — so e2e
|
||||
# exercises the real shipped artifact: the SPA served by Caddy with same-origin
|
||||
# /api/* routing (Phase A). The prod entrypoint waits for the DB and runs
|
||||
# migrations itself, so no separate migrate step is needed here.
|
||||
#
|
||||
# NOTE: this BUILDS the prod image to test against; it never PUSHES it. Image
|
||||
# publishing stays tag-gated (see image.yml). Building-to-test on release/* is
|
||||
# deliberate and does not cross the publish lock.
|
||||
e2e:
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
|
||||
needs: [backend, frontend]
|
||||
|
||||
steps:
|
||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: https://data.forgejo.org/docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build production image
|
||||
uses: https://data.forgejo.org/docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
target: frankenphp_prod
|
||||
build-args: |
|
||||
NPM_VERSION=11.17.0
|
||||
push: false
|
||||
load: true
|
||||
tags: buckets-prod:e2e
|
||||
|
||||
# Stand up postgres + the app as plain `docker run` containers on an
|
||||
# explicitly-created network. We do NOT use a `services:` postgres + the
|
||||
# `job.container.network` context here: that field is GitHub-specific and
|
||||
# support in Forgejo's act_runner is unreliable, so an empty value would
|
||||
# silently drop `app` onto the default bridge where it can't resolve the
|
||||
# DB by hostname. Creating the network ourselves keeps name resolution
|
||||
# under our control and portable across runner versions.
|
||||
#
|
||||
# Pre-clean first: if a prior run crashed mid-job on a shared Docker
|
||||
# daemon, the leftover containers/network would make `create`/`run --name`
|
||||
# fail on "name already in use". `|| true` keeps a clean first run quiet.
|
||||
- name: Create the e2e network
|
||||
run: |
|
||||
docker rm -f app database 2>/dev/null || true
|
||||
docker network rm e2e-net 2>/dev/null || true
|
||||
docker network create e2e-net
|
||||
|
||||
# Playwright runs INSIDE this job container and targets the app by hostname
|
||||
# (E2E_BASE_URL=http://app:80), so the job container must share e2e-net to
|
||||
# resolve `app`. With a `services:` block the runner wires this up for us;
|
||||
# since we manage the network ourselves, connect the job container — its
|
||||
# $HOSTNAME is the container id act_runner assigned us (Docker's default
|
||||
# hostname). If a future runner overrides the hostname this step fails
|
||||
# LOUDLY (network connect errors) rather than silently — acceptable since
|
||||
# the whole e2e job is unverified on the real runner until the first
|
||||
# release/* push.
|
||||
- name: Join the job container to the e2e network
|
||||
run: docker network connect e2e-net "$HOSTNAME"
|
||||
|
||||
- name: Start the database
|
||||
run: |
|
||||
docker run -d --name database --network e2e-net \
|
||||
-e POSTGRES_DB=buckets \
|
||||
-e POSTGRES_USER=buckets \
|
||||
-e POSTGRES_PASSWORD=buckets \
|
||||
--health-cmd "pg_isready -U buckets -d buckets" \
|
||||
--health-interval 5s --health-timeout 5s --health-retries 10 \
|
||||
postgres:16-alpine
|
||||
|
||||
- name: Wait for the database to be healthy
|
||||
run: |
|
||||
for i in $(seq 1 30); do
|
||||
if [ "$(docker inspect -f '{{.State.Health.Status}}' database)" = "healthy" ]; then
|
||||
echo "Database is up."; exit 0
|
||||
fi
|
||||
echo "Waiting for database... ($i)"; sleep 2
|
||||
done
|
||||
echo "Database did not become healthy — dumping logs:"; docker logs database; exit 1
|
||||
|
||||
- name: Start the app
|
||||
run: |
|
||||
docker run -d --name app --network e2e-net \
|
||||
-e SERVER_NAME=":80" \
|
||||
-e APP_SECRET=ci-e2e-secret \
|
||||
-e DATABASE_URL="postgresql://buckets:buckets@database:5432/buckets?serverVersion=16&charset=utf8" \
|
||||
-e MERCURE_PUBLISHER_JWT_KEY="!ChangeThisMercureHubJWTSecretKey!" \
|
||||
-e MERCURE_SUBSCRIBER_JWT_KEY="!ChangeThisMercureHubJWTSecretKey!" \
|
||||
buckets-prod:e2e
|
||||
|
||||
# Wait for a FULLY healthy app, not just a reachable one: check the body
|
||||
# for "db":"ok" (same precision as the compose healthcheck), so a 503 from
|
||||
# an app that booted but can't reach the DB fails HERE with a clear "did
|
||||
# not become healthy" message instead of confusingly mid-Playwright.
|
||||
- name: Wait for the app to be healthy
|
||||
run: |
|
||||
for i in $(seq 1 60); do
|
||||
if docker exec app php -r 'exit(str_contains((string) @file_get_contents("http://127.0.0.1:80/api/health"), "\"db\":\"ok\"") ? 0 : 1);' 2>/dev/null; then
|
||||
echo "App is up."; exit 0
|
||||
fi
|
||||
echo "Waiting for app... ($i)"; sleep 2
|
||||
done
|
||||
echo "App did not become healthy — dumping logs:"; docker logs app; exit 1
|
||||
|
||||
# Node is preinstalled in the runner image (see frontend job note), but the
|
||||
# Chromium binary is not — install it (with OS deps) before running.
|
||||
- name: Install Playwright dependencies
|
||||
working-directory: e2e
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright browser
|
||||
working-directory: e2e
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run Playwright tests
|
||||
working-directory: e2e
|
||||
env:
|
||||
# Target the app container by name on the shared job network.
|
||||
E2E_BASE_URL: http://app:80
|
||||
run: npm test
|
||||
|
||||
- name: Dump app logs on failure
|
||||
if: failure()
|
||||
run: docker logs app || true
|
||||
46
.forgejo/workflows/image.yml
Normal file
46
.forgejo/workflows/image.yml
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
name: Build and Push Production Image
|
||||
|
||||
# Builds the production image and pushes it to the Forgejo registry.
|
||||
# TAG-PUSH ONLY: triggers exclusively on a v* tag (e.g. v0.3.0) — never on a
|
||||
# branch push or PR. This matches the release flow: merge to main → CI green →
|
||||
# cut a v* tag → that tag (and only that tag) builds + publishes the image.
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
|
||||
steps:
|
||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: https://data.forgejo.org/docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Forgejo Registry
|
||||
uses: https://data.forgejo.org/docker/login-action@v3
|
||||
with:
|
||||
registry: forge.lvl0.xyz
|
||||
# Forgejo mirrors GitHub's context naming, so `github.actor` resolves to
|
||||
# the user who pushed the tag (there is no `forgejo.actor`).
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: https://data.forgejo.org/docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
# The repo Dockerfile is multi-target; the production stage is
|
||||
# frankenphp_prod (compiles the frontend in frontend_builder + bundles
|
||||
# the dist into the FrankenPHP image).
|
||||
target: frankenphp_prod
|
||||
build-args: |
|
||||
NPM_VERSION=11.17.0
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: |
|
||||
forge.lvl0.xyz/lvl0/buckets:${{ github.ref_name }}
|
||||
forge.lvl0.xyz/lvl0/buckets:latest
|
||||
25
Dockerfile
25
Dockerfile
|
|
@ -8,6 +8,23 @@ FROM dunglas/frankenphp:1-php8.4 AS frankenphp_upstream
|
|||
# https://docs.docker.com/reference/compose-file/build/#target
|
||||
|
||||
|
||||
# Frontend build: compile the React/Vite SPA to static assets (frontend/dist),
|
||||
# copied into the prod image's web root below. npm pinned to match the project
|
||||
# (NPM_VERSION — same single source as the dev images, passed as a build arg).
|
||||
FROM node:22-alpine AS frontend_builder
|
||||
|
||||
ARG NPM_VERSION
|
||||
WORKDIR /frontend
|
||||
|
||||
RUN npm install -g "npm@${NPM_VERSION}"
|
||||
|
||||
# Install deps from the lockfile first (cache-friendly), then build.
|
||||
COPY --link frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY --link frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
|
||||
# Base FrankenPHP image
|
||||
FROM frankenphp_upstream AS frankenphp_base
|
||||
|
||||
|
|
@ -98,6 +115,9 @@ RUN composer install --no-cache --prefer-dist --no-dev --no-autoloader --no-scri
|
|||
# copy sources
|
||||
COPY --link --exclude=frankenphp/ . ./
|
||||
|
||||
# Built SPA → a dedicated static root Caddy serves for all non-/api routes.
|
||||
COPY --link --from=frontend_builder /frontend/dist ./frontend-dist
|
||||
|
||||
RUN <<-EOF
|
||||
mkdir -p var/cache var/log var/share
|
||||
composer dump-autoload --classmap-authoritative --no-dev
|
||||
|
|
@ -117,8 +137,9 @@ RUN <<-'EOF'
|
|||
apt-get update
|
||||
apt-get install -y --no-install-recommends libtree
|
||||
mkdir -p /tmp/libs
|
||||
BINARIES=(frankenphp php file)
|
||||
for target in $(printf '%s\n' "${BINARIES[@]}" | xargs -I{} which {}) \
|
||||
# POSIX sh — podman runs heredocs under /bin/sh regardless of the SHELL
|
||||
# directive, so no bash arrays here (space-separated list + word splitting).
|
||||
for target in $(for b in frankenphp php file; do which "$b"; done) \
|
||||
$(find "$(php -r 'echo ini_get("extension_dir");')" -maxdepth 2 -name "*.so"); do
|
||||
libtree -pv "$target" 2>/dev/null | grep -oP '(?:── )\K/\S+(?= \[)' | while IFS= read -r lib; do
|
||||
[ -f "$lib" ] && cp -n "$lib" /tmp/libs/
|
||||
|
|
|
|||
|
|
@ -30,6 +30,30 @@ services:
|
|||
extra_hosts:
|
||||
# Ensure that host.docker.internal is correctly defined on Linux
|
||||
- host.docker.internal:host-gateway
|
||||
# Marks php healthy only once the backend can serve a request AND reach the
|
||||
# DB (GET /api/health → {status:ok,db:ok}). Overrides the image's baked-in
|
||||
# HEALTHCHECK (which only probes Caddy's :2019/metrics = "web server alive",
|
||||
# no DB). This is observability only — nothing in the dev path *blocks* on it
|
||||
# (the e2e race-prevention lives in the CI e2e job, which waits on
|
||||
# /api/health before Playwright; gating `frontend` on it here only risks
|
||||
# hanging `dev-up`).
|
||||
# Use 127.0.0.1, NOT localhost: the container resolves localhost to ::1
|
||||
# (IPv6) first, but FrankenPHP binds :80 on IPv4 → an IPv6 probe is refused
|
||||
# and the container never goes healthy (same gotcha as #52). The probe is a
|
||||
# SINGLE `php -r` expression: podman feeds the test args straight to the
|
||||
# binary and does NOT preserve a multi-statement script (newlines collapse →
|
||||
# parse error), so keep it to one expression — `str_contains` the body for
|
||||
# the healthy marker, exit 0/1.
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- php
|
||||
- -r
|
||||
- exit(str_contains((string) @file_get_contents("http://127.0.0.1:80/api/health"), "\"db\":\"ok\"") ? 0 : 1);
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
tty: true
|
||||
|
||||
# Standalone React/Vite SPA (dev only — not in compose.prod.yaml; see #33/#35).
|
||||
|
|
@ -50,6 +74,11 @@ services:
|
|||
- "${VITE_PORT:-5173}:5173"
|
||||
command: sh -c "npm install && npm run dev -- --host 0.0.0.0 --port 5173"
|
||||
depends_on:
|
||||
# Start php first, but DON'T gate on its health — a not-yet-ready backend
|
||||
# only means the Vite proxy 502s a few early /api calls until php boots,
|
||||
# which self-heals. Gating on `service_healthy` here turned a flaky/slow
|
||||
# php healthcheck into a `dev-up` hang. e2e (CI) does the real readiness
|
||||
# wait on /api/health itself before driving the browser.
|
||||
- php
|
||||
environment:
|
||||
# Reliable file-watching under the bind-mount.
|
||||
|
|
|
|||
|
|
@ -44,23 +44,39 @@
|
|||
# Disable Topics tracking if not enabled explicitly: https://github.com/jkarlin/topics
|
||||
header ?Permissions-Policy "browsing-topics=()"
|
||||
|
||||
@phpRoute {
|
||||
not path /.well-known/mercure*
|
||||
not file {path}
|
||||
}
|
||||
rewrite @phpRoute index.php
|
||||
# Backend (Symfony) owns /api/* and Mercure; everything else is the SPA.
|
||||
# `route` preserves declaration order so the backend matchers win before the
|
||||
# SPA catch-all.
|
||||
@backend path /api/* /.well-known/mercure*
|
||||
|
||||
@frontController path index.php
|
||||
php @frontController {
|
||||
{$FRANKENPHP_SITE_CONFIG}
|
||||
handle @backend {
|
||||
@phpRoute {
|
||||
not path /.well-known/mercure*
|
||||
not file {path}
|
||||
}
|
||||
rewrite @phpRoute index.php
|
||||
|
||||
worker {
|
||||
file ./public/index.php
|
||||
{$FRANKENPHP_WORKER_CONFIG}
|
||||
@frontController path index.php
|
||||
php @frontController {
|
||||
{$FRANKENPHP_SITE_CONFIG}
|
||||
|
||||
worker {
|
||||
file ./public/index.php
|
||||
{$FRANKENPHP_WORKER_CONFIG}
|
||||
}
|
||||
}
|
||||
|
||||
file_server {
|
||||
hide *.php
|
||||
}
|
||||
}
|
||||
|
||||
file_server {
|
||||
hide *.php
|
||||
# Single-page app: serve built static assets, falling back to index.html so
|
||||
# client-side routes (/login, /register, …) resolve. The dist is copied to
|
||||
# /app/frontend-dist by the prod image build (see Dockerfile).
|
||||
handle {
|
||||
root * /app/frontend-dist
|
||||
try_files {path} /index.html
|
||||
file_server
|
||||
}
|
||||
}
|
||||
|
|
|
|||
37
src/Controller/HealthController.php
Normal file
37
src/Controller/HealthController.php
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class HealthController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* Liveness/readiness probe — public, used by container healthchecks and CI.
|
||||
* Returns 200 when the app is up and the database is reachable, 503 if the
|
||||
* DB ping fails.
|
||||
*/
|
||||
#[Route('/api/health', name: 'api_health', methods: ['GET'])]
|
||||
public function health(Connection $connection, LoggerInterface $logger): JsonResponse
|
||||
{
|
||||
try {
|
||||
$connection->executeQuery('SELECT 1');
|
||||
} catch (\Throwable $e) {
|
||||
// The 503 body stays generic (no leak), but log the cause so a
|
||||
// flapping health check is diagnosable from app logs, not just from
|
||||
// the healthcheck's own retry count.
|
||||
$logger->error('Health check failed: database unreachable', ['exception' => $e]);
|
||||
|
||||
return $this->json(
|
||||
['status' => 'error', 'db' => 'unreachable'],
|
||||
JsonResponse::HTTP_SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
|
||||
return $this->json(['status' => 'ok', 'db' => 'ok']);
|
||||
}
|
||||
}
|
||||
57
tests/Functional/HealthApiTest.php
Normal file
57
tests/Functional/HealthApiTest.php
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
<?php
|
||||
|
||||
namespace App\Tests\Functional;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
|
||||
final class HealthApiTest extends WebTestCase
|
||||
{
|
||||
public function testItReportsHealthyWithoutAuthentication(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$client->request('GET', '/api/health');
|
||||
|
||||
$response = $client->getResponse();
|
||||
|
||||
self::assertSame(
|
||||
200,
|
||||
$response->getStatusCode(),
|
||||
'GET /api/health must return 200 and require no authentication.',
|
||||
);
|
||||
|
||||
$body = json_decode((string) $response->getContent(), true);
|
||||
|
||||
self::assertIsArray($body);
|
||||
self::assertSame('ok', $body['status'] ?? null);
|
||||
self::assertSame('ok', $body['db'] ?? null);
|
||||
}
|
||||
|
||||
public function testItReportsUnhealthyWhenTheDatabaseIsUnreachable(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
// Swap the DBAL connection for a stub that fails the ping, simulating an
|
||||
// unreachable database without touching the real one. Overriding the
|
||||
// bare Connection::class id is what the controller actually receives:
|
||||
// autowiring keys by FQCN, so the `Connection $connection` parameter
|
||||
// resolves to this same service.
|
||||
$connection = $this->createStub(Connection::class);
|
||||
$connection->method('executeQuery')
|
||||
->willThrowException(new \RuntimeException('connection refused'));
|
||||
static::getContainer()->set(Connection::class, $connection);
|
||||
|
||||
$client->request('GET', '/api/health');
|
||||
|
||||
$response = $client->getResponse();
|
||||
|
||||
self::assertSame(503, $response->getStatusCode());
|
||||
|
||||
$body = json_decode((string) $response->getContent(), true);
|
||||
|
||||
self::assertIsArray($body);
|
||||
self::assertSame('error', $body['status'] ?? null);
|
||||
self::assertSame('unreachable', $body['db'] ?? null);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue