Phase 3 sub-commit 4: bridge-integration test (HTTP/SSE round-trip + crash-recover)

examples/todo/tests/integration.sh boots FrankenPHP against the example
app on an isolated port (8767) and database (tests/var/integration.sqlite),
then asserts:

  - POST /api/todos creates a row, GET returns it.
  - SSE stream on app://model/todo carries the §4 envelope with op,
    correlationKey echoed from Idempotency-Key, and the JSON payload.
  - The backend log shows ≥2 "Update published" lines per change
    (collection topic + entity topic — dual publish per ModelPublisher).
  - Killing FrankenPHP makes /healthz unreachable; restarting it
    restores GET access without losing data.

Wired into make quality alongside the existing PHPStan / cs-fixer /
PHPUnit / qmllint checks. The script is self-contained — runs against
the example without disturbing a developer's `make dev` instance.

qmltestrunner integration deferred: out-of-the-box runner can't see
PhpQml.Bridge because the framework module is statically linked into
the host binary. A proper QML test target would need a custom CMake
executable that links the module + uses QtQuickTest's quick_test_main.
Phase 3.x or Phase 5 polish.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-02 15:50:03 +02:00
parent 15f9aa032e
commit 1288a960d4
3 changed files with 168 additions and 2 deletions

View File

@@ -36,7 +36,12 @@ doctor-connect: ## Run bridge:doctor with backend connectivity probe
clean: ## Remove build artefacts clean: ## Remove build artefacts
rm -rf $(BUILD_DIR) rm -rf $(BUILD_DIR)
.PHONY: integration
integration: ## Run the bridge-integration test (FrankenPHP boot + HTTP/SSE round-trip + crash-recover)
./tests/integration.sh
.PHONY: quality .PHONY: quality
quality: build ## Run PHPStan, php-cs-fixer (check), PHPUnit, qmllint quality: build ## Run PHPStan, php-cs-fixer (check), PHPUnit, qmllint, integration
cd ../php && composer quality cd ../../framework/php && composer quality
cmake --build $(BUILD_DIR) --target all_qmllint cmake --build $(BUILD_DIR) --target all_qmllint
./tests/integration.sh

1
examples/todo/tests/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
var/

View File

@@ -0,0 +1,160 @@
#!/usr/bin/env bash
# Bridge-integration test for the todo example. Drives FrankenPHP
# against an isolated SQLite database, exercises the HTTP + SSE
# round-trip, asserts the §4 envelope shape (correlationKey echo,
# dual publish), then kills + restarts the backend to verify
# crash-and-recover behaviour described in PLAN.md §13 Phase 3.
#
# Designed to be safe against an existing dev instance: uses port
# 8767 (one above dev) and a temporary database under tests/var/.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
APP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
SYMFONY_DIR="$APP_DIR/symfony"
TEST_VAR="$SCRIPT_DIR/var"
PORT=8767
TOKEN=integration-token
JWT="dev_php_qml_bridge_jwt_secret_at_least_256_bits_long_for_lcobucci"
: "${FRANKENPHP:=frankenphp}"
if ! command -v "$FRANKENPHP" >/dev/null 2>&1; then
echo "frankenphp not on PATH (set FRANKENPHP env var)" >&2
exit 1
fi
mkdir -p "$TEST_VAR"
DB_FILE="$TEST_VAR/integration.sqlite"
rm -f "$DB_FILE"
# Generate a per-test Caddyfile bound to a non-conflicting port.
CADDYFILE="$TEST_VAR/Caddyfile"
cat > "$CADDYFILE" <<EOF
{
auto_https off
admin off
frankenphp
order php_server before respond
order mercure after encode
}
http://127.0.0.1:$PORT {
root * $SYMFONY_DIR/public/
encode gzip
mercure {
transport local
publisher_jwt $JWT
subscriber_jwt $JWT
anonymous
publish_origins *
cors_origins *
}
php_server
log {
output stderr
format console
}
}
EOF
start_backend() {
DATABASE_URL="sqlite:///$DB_FILE" \
BRIDGE_TOKEN="$TOKEN" \
MERCURE_URL="http://127.0.0.1:$PORT/.well-known/mercure" \
MERCURE_PUBLIC_URL="http://127.0.0.1:$PORT/.well-known/mercure" \
MERCURE_JWT_SECRET="$JWT" \
MERCURE_PUBLISHER_JWT_KEY="$JWT" \
MERCURE_SUBSCRIBER_JWT_KEY="$JWT" \
APP_ENV=dev APP_DEBUG=0 APP_SECRET=integration-secret \
"$FRANKENPHP" run --config "$CADDYFILE" \
> "$TEST_VAR/frankenphp.log" 2>&1 &
BACKEND_PID=$!
# Wait for /healthz
for _ in $(seq 1 50); do
curl -fsS -m 1 "http://127.0.0.1:$PORT/healthz" >/dev/null 2>&1 && return 0
kill -0 "$BACKEND_PID" 2>/dev/null || { echo "frankenphp died early" >&2; cat "$TEST_VAR/frankenphp.log" >&2; exit 1; }
sleep 0.2
done
echo "frankenphp didn't respond after 10s" >&2
cat "$TEST_VAR/frankenphp.log" >&2
exit 1
}
stop_backend() {
[ -z "${BACKEND_PID:-}" ] && return 0
kill -TERM "$BACKEND_PID" 2>/dev/null || true
for _ in 1 2 3 4 5 6 7 8 9 10; do
kill -0 "$BACKEND_PID" 2>/dev/null || break
sleep 0.2
done
kill -KILL "$BACKEND_PID" 2>/dev/null || true
wait "$BACKEND_PID" 2>/dev/null || true
BACKEND_PID=""
}
cleanup() {
trap - EXIT INT TERM
stop_backend
}
trap cleanup EXIT INT TERM
step() { echo "$*"; }
fail() { echo "✗ FAIL: $*" >&2; exit 1; }
# ── Setup ──────────────────────────────────────────────────────────────
step "Migrate schema into $DB_FILE"
( cd "$SYMFONY_DIR" && DATABASE_URL="sqlite:///$DB_FILE" APP_ENV=dev APP_DEBUG=0 APP_SECRET=int \
bin/console doctrine:migrations:migrate -n >/dev/null )
step "Boot FrankenPHP on :$PORT"
start_backend
# ── HTTP round-trip ────────────────────────────────────────────────────
step "POST /api/todos"
RESP="$(curl -fsS -m 3 -X POST "http://127.0.0.1:$PORT/api/todos" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: it-key-1' \
-d '{"title":"first","done":false}')"
echo "$RESP" | grep -q '"title":"first"' || fail "POST response missing title"
ID="$(echo "$RESP" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')"
[ -n "$ID" ] || fail "POST response missing id"
step "GET /api/todos returns the row"
LIST="$(curl -fsS -m 3 "http://127.0.0.1:$PORT/api/todos" -H "Authorization: Bearer $TOKEN")"
echo "$LIST" | grep -q "$ID" || fail "GET didn't include $ID"
# ── Mercure dual-publish + correlationKey echo ─────────────────────────
step "Open SSE, POST during it, verify envelope"
SSE_OUT="$TEST_VAR/sse.txt"
( curl -sSN -m 4 "http://127.0.0.1:$PORT/.well-known/mercure?topic=app%3A%2F%2Fmodel%2Ftodo" > "$SSE_OUT" 2>/dev/null & SP=$!
sleep 0.5
curl -fsS -m 3 -X POST "http://127.0.0.1:$PORT/api/todos" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-H 'Idempotency-Key: it-key-2' \
-d '{"title":"second","done":false}' > /dev/null
wait $SP 2>/dev/null || true
)
grep -q '"op":"upsert"' "$SSE_OUT" || fail "SSE missing op:upsert"
grep -q '"correlationKey":"it-key-2"' "$SSE_OUT" || fail "SSE missing correlationKey echo"
grep -q '"title":"second"' "$SSE_OUT" || fail "SSE missing payload"
step "Both topics receive the envelope"
PUB_COUNT=$(grep -c 'Update published' "$TEST_VAR/frankenphp.log" || true)
[ "$PUB_COUNT" -ge 2 ] || fail "expected ≥2 Update published entries (collection + entity), got $PUB_COUNT"
# ── Crash and recover ──────────────────────────────────────────────────
step "Kill the backend"
stop_backend
sleep 0.3
curl -fsS -m 1 "http://127.0.0.1:$PORT/healthz" >/dev/null 2>&1 \
&& fail "backend should be down but /healthz still answered"
step "Restart and verify GET still works"
start_backend
RESP2="$(curl -fsS -m 3 "http://127.0.0.1:$PORT/api/todos" -H "Authorization: Bearer $TOKEN")"
echo "$RESP2" | grep -q "$ID" || fail "after restart GET missing original todo"
echo "$RESP2" | grep -q '"second"' || fail "after restart GET missing second todo"
step "All bridge-integration assertions passed."