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:
@@ -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
1
examples/todo/tests/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
var/
|
||||||
160
examples/todo/tests/integration.sh
Executable file
160
examples/todo/tests/integration.sh
Executable 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."
|
||||||
Reference in New Issue
Block a user