Files
MeBox/scripts/smoke-test.sh
T
Kiro 77e3fdd1f6 test: add E2E smoke test script + CI gate
After deploying the binary against a fresh data dir and exercising every
major REST endpoint by hand, codify the verification as a re-runnable
script.

scripts/smoke-test.sh:
  - Spins up bin/mediastation-go against a tempdir.
  - Generates 3 sample clips (movie, tv, anime) + an external SRT.
  - Walks 26 assertions: health → auth → libraries → scan → ffprobe →
    season parser → search → range stream → HLS playlist → external
    subtitle → history → favourites → playlists → RBAC (alice 403) →
    NFO export → recycle bin → SPA fallback → graceful shutdown.
  - Tears the server down via SIGTERM and reports pass/fail counts.
  - Exits non-zero on any failure so CI can use it as a deployment gate.

Local verification on this branch: PASS=26  FAIL=0.

Wiring:
  - Makefile: new 'smoke' target, depends on 'build'.
  - .github/workflows/ci.yml: third job 'smoke' that depends on backend
    + frontend, installs ffmpeg, builds both halves, then runs the
    script. The CI pipeline now blocks merges that break end-to-end
    deployability.
2026-05-14 16:36:34 +00:00

242 lines
11 KiB
Bash
Executable File

#!/usr/bin/env bash
# =============================================================================
# scripts/smoke-test.sh
#
# End-to-end deployment smoke test. Spins up a fresh MediaStationGo binary
# against a temporary data dir, exercises every major REST surface, then
# tears it all down. Exits non-zero on the first failure so it can run in
# CI as a deployment gate.
#
# Requirements:
# - bin/mediastation-go (run `make build-server` first)
# - web/dist (run `make build-web` first)
# - ffmpeg + ffprobe on PATH (the script will skip transcode tests if missing)
# - python3 (used for JSON pretty-printing only)
# - curl
#
# Usage:
# ./scripts/smoke-test.sh # default port 18080
# PORT=19000 ./scripts/smoke-test.sh # override
# =============================================================================
set -euo pipefail
PORT="${PORT:-18080}"
ROOT="$(mktemp -d -t msgo-smoke.XXXXXX)"
DATA="$ROOT/data"
CACHE="$ROOT/cache"
MEDIA="$ROOT/media"
LOG="$ROOT/server.log"
PASS=0
FAIL=0
cleanup() {
if [ -n "${PID:-}" ]; then
kill -TERM "$PID" 2>/dev/null || true
wait "$PID" 2>/dev/null || true
fi
rm -rf "$ROOT"
}
trap cleanup EXIT
# --- Helpers ----------------------------------------------------------------
ok() { printf " \033[32m✓\033[0m %s\n" "$1"; PASS=$((PASS+1)); }
fail() { printf " \033[31m✗\033[0m %s\n" "$1"; FAIL=$((FAIL+1)); }
hdr() { printf "\n\033[1;36m==> %s\033[0m\n" "$1"; }
require() {
command -v "$1" >/dev/null || { echo "missing dependency: $1"; exit 2; }
}
require curl
require python3
HAVE_FFMPEG=1
command -v ffmpeg >/dev/null && command -v ffprobe >/dev/null || HAVE_FFMPEG=0
# --- 1. Prepare workspace ---------------------------------------------------
hdr "Preparing $ROOT"
mkdir -p "$DATA" "$CACHE" "$MEDIA/movies" "$MEDIA/tv/Show/Season 01" "$MEDIA/anime"
if [ "$HAVE_FFMPEG" = 1 ]; then
ffmpeg -y -loglevel error -f lavfi -i "color=c=blue:s=320x240:d=2" \
-f lavfi -i "sine=frequency=1000:duration=2" \
-c:v libx264 -preset ultrafast -c:a aac \
"$MEDIA/movies/Inception.2010.1080p.BluRay.x264.mp4"
ffmpeg -y -loglevel error -f lavfi -i "color=c=red:s=320x240:d=2" \
-f lavfi -i "sine=frequency=440:duration=2" \
-c:v libx264 -preset ultrafast -c:a aac \
"$MEDIA/tv/Show/Season 01/Show.S01E01.mkv"
ffmpeg -y -loglevel error -f lavfi -i "color=c=cyan:s=320x240:d=2" \
-f lavfi -i "sine=frequency=500:duration=2" \
-c:v libx264 -preset ultrafast -c:a aac \
"$MEDIA/anime/[Erai-raws] One Piece - 1100 [1080p].mkv"
cat > "$MEDIA/movies/Inception.2010.1080p.BluRay.x264.zh.srt" <<'SRT'
1
00:00:00,500 --> 00:00:01,500
Hello
SRT
ok "ffmpeg sample media generated"
else
fail "ffmpeg/ffprobe not on PATH — transcode + ffprobe tests will be skipped"
fi
# --- 2. Start the server ----------------------------------------------------
hdr "Starting MediaStationGo on :$PORT"
BIN="${BIN:-./bin/mediastation-go}"
if [ ! -x "$BIN" ]; then
echo "Binary $BIN not found. Run 'make build-server' first."
exit 2
fi
ADMIN_INITIAL_PASSWORD=smoketest12345 \
MEDIASTATION_APP_PORT="$PORT" \
MEDIASTATION_APP_DATA_DIR="$DATA" \
MEDIASTATION_APP_WEB_DIR="${WEB_DIR:-./web/dist}" \
MEDIASTATION_DATABASE_DB_PATH="$DATA/test.db" \
MEDIASTATION_CACHE_CACHE_DIR="$CACHE" \
"$BIN" >"$LOG" 2>&1 &
PID=$!
# Wait until /api/health responds.
for _ in $(seq 1 20); do
curl -s -o /dev/null "http://127.0.0.1:$PORT/api/health" && break
sleep 0.5
done
curl -s "http://127.0.0.1:$PORT/api/health" | grep -q '"ok"' && ok "/api/health" || fail "/api/health"
# --- 3. Auth ----------------------------------------------------------------
hdr "Auth"
TOKEN=$(curl -s -X POST -H 'Content-Type: application/json' \
-d '{"username":"admin","password":"smoketest12345"}' \
"http://127.0.0.1:$PORT/api/auth/login" | python3 -c 'import json,sys;print(json.load(sys.stdin)["token"])')
[ -n "$TOKEN" ] && ok "login as admin" || fail "login as admin"
H="Authorization: Bearer $TOKEN"
curl -s -o /dev/null -w "%{http_code}" -H "$H" "http://127.0.0.1:$PORT/api/me" | grep -q 200 \
&& ok "/api/me with bearer" || fail "/api/me with bearer"
curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:$PORT/api/me" | grep -q 401 \
&& ok "/api/me without bearer → 401" || fail "/api/me without bearer"
# --- 4. Library + scan ------------------------------------------------------
hdr "Library + scan + search"
MOVIE=$(curl -s -X POST -H "$H" -H 'Content-Type: application/json' \
-d "{\"name\":\"movies\",\"path\":\"$MEDIA/movies\",\"type\":\"movie\"}" \
"http://127.0.0.1:$PORT/api/libraries" | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])')
[ -n "$MOVIE" ] && ok "create movie library" || fail "create movie library"
TV=$(curl -s -X POST -H "$H" -H 'Content-Type: application/json' \
-d "{\"name\":\"tv\",\"path\":\"$MEDIA/tv\",\"type\":\"tv\"}" \
"http://127.0.0.1:$PORT/api/libraries" | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])')
[ -n "$TV" ] && ok "create tv library" || fail "create tv library"
if [ "$HAVE_FFMPEG" = 1 ]; then
RES=$(curl -s -X POST -H "$H" "http://127.0.0.1:$PORT/api/libraries/$MOVIE/scan")
ADDED=$(echo "$RES" | python3 -c 'import json,sys;print(json.load(sys.stdin)["added"])')
[ "$ADDED" = "1" ] && ok "scan: 1 movie added" || fail "scan movie added=$ADDED"
RES=$(curl -s -X POST -H "$H" "http://127.0.0.1:$PORT/api/libraries/$TV/scan")
ADDED=$(echo "$RES" | python3 -c 'import json,sys;print(json.load(sys.stdin)["added"])')
[ "$ADDED" = "1" ] && ok "scan: 1 tv episode added" || fail "scan tv added=$ADDED"
# SxxExx parser
SE=$(curl -s -H "$H" "http://127.0.0.1:$PORT/api/libraries/$TV/seasons" \
| python3 -c 'import json,sys; ss=json.load(sys.stdin)["seasons"]; e=ss[0]["episodes"][0]; print("%dx%d" % (e["season_num"], e["episode_num"]))')
[ "$SE" = "1x1" ] && ok "season parser → S01E01" || fail "season parser → $SE"
# ffprobe wrote width/height/codec
W=$(curl -s -H "$H" "http://127.0.0.1:$PORT/api/libraries/$MOVIE/media" \
| python3 -c 'import json,sys;print(json.load(sys.stdin)["items"][0]["width"])')
[ "$W" = "320" ] && ok "ffprobe metadata extracted (width=320)" || fail "ffprobe width=$W"
fi
curl -s -H "$H" "http://127.0.0.1:$PORT/api/media?q=inception" \
| python3 -c 'import json,sys; assert json.load(sys.stdin)["items"], "empty"' \
&& ok "search returns rows" || fail "search returns rows"
# --- 5. Streaming -----------------------------------------------------------
if [ "$HAVE_FFMPEG" = 1 ]; then
hdr "Streaming + subtitles"
ID=$(curl -s -H "$H" "http://127.0.0.1:$PORT/api/libraries/$MOVIE/media" \
| python3 -c 'import json,sys;print(json.load(sys.stdin)["items"][0]["id"])')
curl -s -o /dev/null -w "%{http_code}" -H "$H" -H "Range: bytes=0-1023" \
"http://127.0.0.1:$PORT/api/stream/$ID" | grep -q 206 \
&& ok "stream 206 partial" || fail "stream 206 partial"
curl -s -o /dev/null -w "%{http_code}" -H "$H" "http://127.0.0.1:$PORT/api/stream/$ID" \
| grep -q 200 && ok "stream 200 full" || fail "stream 200 full"
TRACKS=$(curl -s -H "$H" "http://127.0.0.1:$PORT/api/media/$ID/subtitles" \
| python3 -c 'import json,sys;print(len(json.load(sys.stdin)["tracks"]))')
[ "$TRACKS" = "1" ] && ok "external SRT discovered" || fail "external SRT discovered=$TRACKS"
curl -s -H "$H" "http://127.0.0.1:$PORT/api/hls/$ID/index.m3u8" | grep -q EXTM3U \
&& ok "HLS playlist (transcode triggered)" || fail "HLS playlist"
curl -s -X DELETE -H "$H" "http://127.0.0.1:$PORT/api/hls/$ID" -o /dev/null
fi
# --- 6. Playback bookkeeping -----------------------------------------------
hdr "History / favourites / playlists"
curl -s -X POST -H "$H" -H 'Content-Type: application/json' \
-d "{\"media_id\":\"$ID\",\"position_ms\":500,\"duration_ms\":2000}" \
-o /dev/null "http://127.0.0.1:$PORT/api/history"
HIST=$(curl -s -H "$H" "http://127.0.0.1:$PORT/api/history" \
| python3 -c 'import json,sys;print(len(json.load(sys.stdin)["items"]))')
[ "$HIST" -ge 1 ] && ok "history persisted" || fail "history persisted=$HIST"
curl -s -X POST -H "$H" "http://127.0.0.1:$PORT/api/favourites/$ID" \
| grep -q '"favourite":true' && ok "favourite toggled on" || fail "favourite toggle"
PL=$(curl -s -X POST -H "$H" -H 'Content-Type: application/json' \
-d '{"name":"smoke"}' "http://127.0.0.1:$PORT/api/playlists" \
| python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])')
curl -s -X POST -H "$H" -H 'Content-Type: application/json' \
-d "{\"media_id\":\"$ID\"}" -o /dev/null \
"http://127.0.0.1:$PORT/api/playlists/$PL/items"
N=$(curl -s -H "$H" "http://127.0.0.1:$PORT/api/playlists/$PL" \
| python3 -c 'import json,sys;print(len(json.load(sys.stdin)["items"]))')
[ "$N" -ge 1 ] && ok "playlist has 1 item" || fail "playlist items=$N"
# --- 7. Admin operations ---------------------------------------------------
hdr "Admin / RBAC / persistence"
curl -s -X POST -H 'Content-Type: application/json' \
-d '{"username":"alice","password":"alice12345"}' \
-o /dev/null "http://127.0.0.1:$PORT/api/auth/register"
ATOK=$(curl -s -X POST -H 'Content-Type: application/json' \
-d '{"username":"alice","password":"alice12345"}' \
"http://127.0.0.1:$PORT/api/auth/login" \
| python3 -c 'import json,sys;print(json.load(sys.stdin)["token"])')
curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer $ATOK" \
-X POST -H 'Content-Type: application/json' \
-d "{\"name\":\"x\",\"path\":\"$MEDIA\",\"type\":\"movie\"}" \
"http://127.0.0.1:$PORT/api/libraries" | grep -q 403 \
&& ok "regular user cannot create library (403)" || fail "regular user RBAC"
# --- 8. NFO + recycle bin --------------------------------------------------
if [ "$HAVE_FFMPEG" = 1 ]; then
curl -s -X POST -H "$H" "http://127.0.0.1:$PORT/api/media/$ID/nfo" \
| grep -q '"path"' && ok "NFO export" || fail "NFO export"
[ -f "$MEDIA/movies/Inception.2010.1080p.BluRay.x264.nfo" ] \
&& ok "NFO file written next to media" || fail "NFO file missing"
curl -s -X DELETE -H "$H" -o /dev/null "http://127.0.0.1:$PORT/api/media/$ID"
R=$(curl -s -H "$H" "http://127.0.0.1:$PORT/api/recycle" \
| python3 -c 'import json,sys;print(len(json.load(sys.stdin)["items"]))')
[ "$R" -ge 1 ] && ok "recycle bin has the soft-deleted row" || fail "recycle bin=$R"
curl -s -X POST -H "$H" -o /dev/null "http://127.0.0.1:$PORT/api/media/$ID/restore"
fi
# --- 9. SPA + assets -------------------------------------------------------
hdr "SPA"
curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:$PORT/" | grep -q 200 \
&& ok "SPA / served" || fail "SPA /"
curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:$PORT/login" | grep -q 200 \
&& ok "SPA /login fallback" || fail "SPA /login"
# --- 10. Graceful shutdown -------------------------------------------------
hdr "Shutdown"
kill -TERM "$PID"
wait "$PID" 2>/dev/null || true
unset PID
grep -q "MediaStationGo stopped" "$LOG" && ok "graceful shutdown logged" || fail "no shutdown log"
# --- Summary ---------------------------------------------------------------
hdr "Summary"
printf "\033[32m PASS=%d\033[0m \033[31mFAIL=%d\033[0m\n" "$PASS" "$FAIL"
exit $((FAIL > 0 ? 1 : 0))