diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..78084f6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,76 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + +permissions: + contents: read + +jobs: + shellcheck: + name: ShellCheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install shellcheck + run: sudo apt-get update && sudo apt-get install -y shellcheck + # Gating: the actively-maintained installer must stay free of error-level + # findings. setup.sh + functions are error-clean today. + - name: ShellCheck core installer (gating) + run: shellcheck --severity=error setup.sh functions + # Informational: full report incl. migration scripts (which still carry + # error-level findings — see tests/README.md "Known issues"). Non-gating + # until those are triaged; ratchet up once they are fixed. + - name: ShellCheck full report (informational) + run: | + shellcheck --severity=style \ + setup.sh functions migration/migrate.sh migration/remote-migrate || true + + shfmt: + name: shfmt (informational) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install shfmt + run: sudo apt-get update && sudo apt-get install -y shfmt || true + - name: Show formatting diff + run: | + command -v shfmt >/dev/null 2>&1 || { echo "shfmt unavailable; skipping"; exit 0; } + shfmt -d -i 2 -ci setup.sh functions || true + + unit: + name: Unit tests (bats) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install bats + run: sudo apt-get update && sudo apt-get install -y bats + - name: Run unit tests + run: bats tests/unit + + integration: + name: Host prep (${{ matrix.image }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + image: + - ubuntu:22.04 # LTS + - ubuntu:24.04 # LTS + - ubuntu:26.04 # LTS — pinned: drove the host-PHP fixes, kept after rolling moves on + - ubuntu:rolling # latest stable release (floats forward) + - ubuntu:devel # next, in-development Ubuntu — earliest warning + - debian:12 + - debian:13 + container: + image: ${{ matrix.image }} + steps: + - name: Install checkout prerequisites + run: | + apt-get update + apt-get install -y --no-install-recommends ca-certificates git + - uses: actions/checkout@v4 + - name: Run host-prep assertions + run: bash tests/integration/host-prep.sh diff --git a/.shellcheckrc b/.shellcheckrc new file mode 100644 index 0000000..8c385ef --- /dev/null +++ b/.shellcheckrc @@ -0,0 +1,8 @@ +# ShellCheck configuration for the EasyEngine installer. + +# Follow sourced files where the path is statically known. +external-sources=true + +# The installer sources files whose paths are only known at runtime +# (e.g. "$TMP_WORK_DIR/helper-functions"); shellcheck cannot follow these. +disable=SC1090,SC1091 diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..6b0d752 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,106 @@ +# Tests + +Automated tests for the EasyEngine installer. Two layers: + +| Layer | Location | What it covers | Needs | +|-------|----------|----------------|-------| +| Unit | `tests/unit/*.bats` | Pure-ish logic in `functions`, with all external commands stubbed | [bats-core] | +| Integration | `tests/integration/host-prep.sh` | The host-prep stage run against a distro's **real** apt repos | Docker (or run inside a container) | + +CI wires both up plus ShellCheck — see `.github/workflows/ci.yml`. + +## Why integration stops before the phar + +`do_install()` ends with `pull_easyengine_images`, which runs `ee cli info` and +makes EasyEngine bootstrap its global Docker services (multi-GB image pulls, +a running Docker daemon, migrations). That is EasyEngine-**core** behaviour, not +the installer's, and is slow/flaky to reproduce in CI. So the integration test is +bounded **before** the phar download/pull and stubs `docker`, exercising only the +OS-version–dependent surface that has actually regressed in the past: + +``` +check_dependencies → setup_host_dependencies → setup_php → setup_php_extensions +``` + +PHP selection in particular can only be validated against real distro repos + +the real ondrej/sury PPA — a stub can't reproduce "ondrej has no build for this +codename yet", which is exactly the Ubuntu 26.04 class of bug. + +## Running + +### Unit tests + +```bash +# With bats installed locally: +bats tests/unit + +# Or via Docker, no local install: +docker run --rm -v "$PWD":/code -w /code bats/bats:latest tests/unit +``` + +### Integration tests + +```bash +# One distro: +docker run --rm -v "$PWD":/installer:ro -w /installer ubuntu:24.04 \ + bash tests/integration/host-prep.sh + +# Full local matrix (same images as CI): +tests/integration/run-matrix.sh +# ...or a subset: +tests/integration/run-matrix.sh ubuntu:24.04 debian:12 +``` + +### ShellCheck + +```bash +shellcheck setup.sh functions migration/migrate.sh migration/remote-migrate +``` + +## How the unit stubs work + +`tests/helpers/common.bash` prepends a temp dir to `PATH` and drops fake +executables into it (`make_stub apt-get <<'EOF' … EOF`), so calls to `apt-get`, +`curl`, `sleep`, etc. hit the stub instead of the host. The PHP-selection test +goes one step further and overrides the four `ee_php_*` helpers as shell +functions to drive the algorithm through every branch deterministically. + +## Coverage today + +- `parse_args` — every flag, including the `--remote-host` value-consuming case. +- `ee_apt_update` — succeeds first try / retries only on lock contention / aborts + on a non-lock error. +- `get_ondrej_php_ppa_release_status` — 200/404/curl-failure. +- `ee_select_and_install_php` — native vs PPA, version-first ordering, no-candidate. +- `download_and_install_easyengine` — checksum match / mismatch / empty phar / + empty checksum / download failure. +- Host-prep integration — php + the three required extensions install across + Ubuntu 22.04/24.04/26.04/rolling/devel and Debian 12/13. + +## Not yet covered (candidate follow-ups) + +- **`pull_easyengine_images` / full `setup.sh` end-to-end.** Would need a small + testability seam: an `EE_LOCAL_PHAR` override mirroring the existing + `EE_LOCAL_FUNCTIONS` hook so a fake `ee` can stand in for the real phar (also + useful for air-gapped/mirror installs). Not added here to keep this change + test-only with zero production edits. +- **Idempotency** — running the installer twice and asserting the second run is a + clean no-op. Needs the seam above. +- **`add_ssl_renew_cron`** — cron-entry creation (incl. the no-existing-crontab case). +- **Migration scripts** (`migration/*`) — only ShellCheck'd today; see below. + +## Known issues these tests / ShellCheck surface + +Found while building the suite; not fixed here (test-only change): + +- `--dry-run` is documented in `migration/README.md` and parsed by `parse_args` + (sets `EE_DRY_RUN`), but `EE_DRY_RUN` is read **nowhere** — `ee migrate --dry-run` + performs a real migration. +- `--trace` sets `EE_TRACE`, but the migration scripts check `$TRACE` → the flag + is a no-op. `--all` sets `EE_SITE_ALL`, also unused. +- `migration/migrate.sh:156` references `$new_site_name`, which is never assigned + (the site is created with an empty name). +- `migration/{migrate,remote-migrate}` reference undefined `$migrate`, `$Red`, + `$RCol`, and have 8 error-level `SC2068` unquoted array expansions. + +[bats-core]: https://github.com/bats-core/bats-core diff --git a/tests/helpers/common.bash b/tests/helpers/common.bash new file mode 100644 index 0000000..5edde1a --- /dev/null +++ b/tests/helpers/common.bash @@ -0,0 +1,37 @@ +# Shared helpers for the bats unit suite. +# +# These tests source the real `functions` file and replace external commands +# (apt-get, curl, sleep, …) with stubs on PATH, so the installer's logic can be +# exercised without touching the host or the network. + +# Absolute path to the repo root (this file lives in tests/helpers/). +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +export REPO_ROOT + +# Create a private dir at the front of PATH to hold command stubs. +stub_init() { + STUB_DIR="$(mktemp -d)" + PATH="$STUB_DIR:$PATH" + export STUB_DIR PATH +} + +stub_cleanup() { + [ -n "${STUB_DIR:-}" ] && rm -rf "$STUB_DIR" +} + +# make_stub NAME — body is read from stdin and becomes an executable on PATH. +# make_stub apt-get <<'EOF' +# #!/usr/bin/env bash +# exit 0 +# EOF +make_stub() { + local p="$STUB_DIR/$1" + cat > "$p" + chmod +x "$p" +} + +# Source the installer functions under test. +load_functions() { + # shellcheck source=/dev/null + source "$REPO_ROOT/functions" +} diff --git a/tests/integration/host-prep.sh b/tests/integration/host-prep.sh new file mode 100755 index 0000000..90cae88 --- /dev/null +++ b/tests/integration/host-prep.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# +# Integration test for the installer's host-preparation stage, run INSIDE a +# distro container against that distro's REAL package repositories. +# +# Scope is deliberately bounded *before* the phar download / image pull: those +# steps belong to EasyEngine-core CI, need a working Docker daemon, and pull +# multi-GB images. Here we only prove the part that is OS-version dependent and +# has historically broken (PHP selection across Ubuntu/Debian releases, the +# required PHP extensions, and the base dependencies). +# +# Docker is stubbed so setup_docker() short-circuits — we are not installing or +# running the Docker daemon here. + +set -uo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +fails=0 +pass() { printf 'ok - %s\n' "$1"; } +fail() { printf 'FAIL - %s\n' "$1"; fails=$((fails + 1)); } +check() { # check "description" cmd... + local desc="$1"; shift + if "$@" >/dev/null 2>&1; then pass "$desc"; else fail "$desc"; fi +} + +export DEBIAN_FRONTEND=noninteractive +export NEEDRESTART_MODE=a + +# Minimal bootstrap: the functions need lsb_release + an apt cache. +apt-get update -y >/dev/null 2>&1 || true +command -v lsb_release >/dev/null 2>&1 || \ + apt-get install -y --no-install-recommends lsb-release ca-certificates curl >/dev/null 2>&1 || true + +export EE_LINUX_DISTRO LOG_FILE EE_QUIET_OUTPUT +EE_LINUX_DISTRO="$(lsb_release -i 2>/dev/null | awk '{print $3}')" +LOG_FILE=/dev/null +EE_QUIET_OUTPUT="" + +# Stub docker/docker-compose so setup_docker()'s `command -v docker` guard skips +# the daemon install (out of scope for this test). +STUB_DIR="$(mktemp -d)" +for c in docker docker-compose; do + printf '#!/bin/sh\nexit 0\n' > "$STUB_DIR/$c" + chmod +x "$STUB_DIR/$c" +done +export PATH="$STUB_DIR:$PATH" +trap 'rm -rf "$STUB_DIR"' EXIT + +# shellcheck source=/dev/null +source "$REPO_ROOT/functions" + +echo "==> distro: $(lsb_release -ds 2>/dev/null || echo "$EE_LINUX_DISTRO")" +echo "==> EE_LINUX_DISTRO=$EE_LINUX_DISTRO" +echo + +ee_apt_update >/dev/null 2>&1 || true + +# --- the stage under test --- +setup_host_dependencies +setup_php +setup_php_extensions + +echo +echo "==> assertions" + +# The installer's actual promise after this stage: +check "php CLI is installed" command -v php +check "EE_INSTALLED_PHP_VERSION was set" test -n "${EE_INSTALLED_PHP_VERSION:-}" +check "php reports a runnable version" php -v + +# The three extensions EasyEngine requires. +for ext in curl sqlite3 zip; do + check "php extension loaded: $ext" bash -c "php -m | grep -qiE '^${ext}\$'" +done + +# Base host dependency installed earlier in check_dependencies(). +if ! command -v sqlite3 >/dev/null 2>&1; then + apt-get install -y sqlite3 >/dev/null 2>&1 || true +fi +check "sqlite3 CLI is installed" command -v sqlite3 + +echo +if [ "$fails" -ne 0 ]; then + echo "RESULT: $fails check(s) failed on ${EE_LINUX_DISTRO}" + exit 1 +fi +echo "RESULT: all host-prep checks passed on ${EE_LINUX_DISTRO} (PHP ${EE_INSTALLED_PHP_VERSION:-?})" diff --git a/tests/integration/run-matrix.sh b/tests/integration/run-matrix.sh new file mode 100755 index 0000000..c0f6959 --- /dev/null +++ b/tests/integration/run-matrix.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# +# Local convenience: run the host-prep integration test across a matrix of +# distro containers, the same way CI does. Requires Docker. +# +# tests/integration/run-matrix.sh # default image set +# tests/integration/run-matrix.sh ubuntu:24.04 # one or more specific images + +set -uo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +IMAGES=("$@") +if [ "${#IMAGES[@]}" -eq 0 ]; then + IMAGES=( + ubuntu:22.04 + ubuntu:24.04 + ubuntu:rolling + ubuntu:devel + debian:12 + debian:13 + ) +fi + +rc=0 +for img in "${IMAGES[@]}"; do + echo "==================================================================" + echo "==> $img" + echo "==================================================================" + if docker run --rm -v "$REPO_ROOT":/installer:ro -w /installer "$img" \ + bash tests/integration/host-prep.sh; then + echo "==> PASS: $img" + else + echo "==> FAIL: $img" + rc=1 + fi + echo +done + +exit "$rc" diff --git a/tests/unit/apt_update.bats b/tests/unit/apt_update.bats new file mode 100644 index 0000000..4e6a2b2 --- /dev/null +++ b/tests/unit/apt_update.bats @@ -0,0 +1,62 @@ +#!/usr/bin/env bats +# +# ee_apt_update() — retries `apt-get update` only while the failure is an apt/dpkg +# lock contention, and bails out immediately on any other error. + +setup() { + source "${BATS_TEST_DIRNAME}/../helpers/common.bash" + stub_init + export EE_QUIET_OUTPUT="" + # Never actually sleep between retries. + make_stub sleep <<'EOF' +#!/usr/bin/env bash +exit 0 +EOF + load_functions +} + +teardown() { + stub_cleanup +} + +@test "succeeds on the first try without retrying" { + make_stub apt-get <> "$STUB_DIR/calls" +echo "Reading package lists... Done" +exit 0 +EOF + run ee_apt_update + [ "$status" -eq 0 ] + [ "$(wc -l < "$STUB_DIR/calls")" -eq 1 ] +} + +@test "retries while the lock is held, then succeeds" { + make_stub apt-get </dev/null || echo 0); n=\$((n+1)); echo \$n > "$STUB_DIR/n" +if [ \$n -le 2 ]; then + echo "E: Could not get lock /var/lib/apt/lists/lock. It is held by another process" >&2 + exit 1 +fi +echo "Reading package lists... Done" +exit 0 +EOF + run ee_apt_update + [ "$status" -eq 0 ] + # 2 lock failures + 1 success. + [ "$(cat "$STUB_DIR/n")" -eq 3 ] +} + +@test "does NOT retry on a non-lock error" { + make_stub apt-get </dev/null || echo 0); n=\$((n+1)); echo \$n > "$STUB_DIR/n" +echo "E: Failed to fetch http://archive — 404 Not Found" >&2 +exit 1 +EOF + run ee_apt_update + [ "$status" -ne 0 ] + # Called exactly once: the loop must abort on a non-lock failure. + [ "$(cat "$STUB_DIR/n")" -eq 1 ] +} diff --git a/tests/unit/checksum.bats b/tests/unit/checksum.bats new file mode 100644 index 0000000..094096e --- /dev/null +++ b/tests/unit/checksum.bats @@ -0,0 +1,94 @@ +#!/usr/bin/env bats +# +# download_and_install_easyengine() — the security-critical path: download the +# phar, verify its SHA-512 against the published checksum, and only then install +# it. These tests stub `curl` so the phar/checksum bytes are fully controlled. + +setup() { + source "${BATS_TEST_DIRNAME}/../helpers/common.bash" + stub_init + export EE_QUIET_OUTPUT="" + # Install target lives in a throwaway path, not /usr/local/bin. + EE4_BINARY="$(mktemp -u "${BATS_TMPDIR:-/tmp}/ee.XXXXXX")" + export EE4_BINARY + load_functions + + # curl stub: writes controlled bytes to --output based on the URL suffix. + # CK_PHAR -> phar contents + # CK_CHECKSUM -> hex hash written as " easyengine.phar" + # (empty => an empty checksum file) + # CK_CURL_EXIT -> curl exit code (default 0) + make_stub curl <<'EOF' +#!/usr/bin/env bash +out=""; url="" +while [ $# -gt 0 ]; do + case "$1" in + -o|--output) out="$2"; shift 2 ;; + http://*|https://*) url="$1"; shift ;; + *) shift ;; + esac +done +case "$url" in + *.sha512) + if [ -n "${CK_CHECKSUM:-}" ]; then + printf '%s easyengine.phar\n' "$CK_CHECKSUM" > "$out" + else + : > "$out" + fi + ;; + *) + printf '%s' "${CK_PHAR:-}" > "$out" + ;; +esac +exit "${CK_CURL_EXIT:-0}" +EOF +} + +teardown() { + rm -f "$EE4_BINARY" + stub_cleanup +} + +@test "installs the phar when the checksum matches" { + export CK_PHAR="FAKE-PHAR-CONTENTS" + CK_CHECKSUM="$(printf '%s' "$CK_PHAR" | sha512sum | awk '{print $1}')" + export CK_CHECKSUM + run download_and_install_easyengine + [ "$status" -eq 0 ] + [ -f "$EE4_BINARY" ] + [ "$(cat "$EE4_BINARY")" = "FAKE-PHAR-CONTENTS" ] + [ -x "$EE4_BINARY" ] +} + +@test "aborts and installs nothing on a checksum mismatch" { + export CK_PHAR="TAMPERED" + export CK_CHECKSUM="deadbeef" + run download_and_install_easyengine + [ "$status" -ne 0 ] + [ ! -f "$EE4_BINARY" ] + [[ "$output" == *"checksum mismatch"* ]] +} + +@test "aborts when the downloaded phar is empty" { + export CK_PHAR="" + export CK_CHECKSUM="anything" + run download_and_install_easyengine + [ "$status" -ne 0 ] + [ ! -f "$EE4_BINARY" ] +} + +@test "aborts when the checksum file is empty" { + export CK_PHAR="SOME-PHAR" + export CK_CHECKSUM="" + run download_and_install_easyengine + [ "$status" -ne 0 ] + [ ! -f "$EE4_BINARY" ] +} + +@test "aborts when the phar download fails" { + export CK_PHAR="whatever" + export CK_CURL_EXIT=22 + run download_and_install_easyengine + [ "$status" -ne 0 ] + [ ! -f "$EE4_BINARY" ] +} diff --git a/tests/unit/logging.bats b/tests/unit/logging.bats new file mode 100644 index 0000000..1d7e354 --- /dev/null +++ b/tests/unit/logging.bats @@ -0,0 +1,52 @@ +#!/usr/bin/env bats +# +# Log formatters and the EE_QUIET_OUTPUT gating. + +setup() { + source "${BATS_TEST_DIRNAME}/../helpers/common.bash" + export EE_QUIET_OUTPUT="" + load_functions +} + +@test "ee_log_info1 prefixes the info1 arrow" { + run ee_log_info1 "hello" + [ "$status" -eq 0 ] + [ "$output" = "-----> hello" ] +} + +@test "ee_log_info2 prefixes the info2 arrow" { + run ee_log_info2 "world" + [ "$output" = "=====> world" ] +} + +@test "ee_log_quiet prints when EE_QUIET_OUTPUT is empty" { + export EE_QUIET_OUTPUT="" + run ee_log_quiet "shown" + [ "$output" = "shown" ] +} + +@test "ee_log_quiet is silent when EE_QUIET_OUTPUT is set" { + export EE_QUIET_OUTPUT=1 + run ee_log_quiet "hidden" + [ -z "$output" ] +} + +@test "ee_log_info1_quiet respects the quiet flag" { + export EE_QUIET_OUTPUT=1 + run ee_log_info1_quiet "hidden" + [ -z "$output" ] +} + +@test "ee_log_warn writes to stderr, not stdout" { + local out err + out="$(ee_log_warn 'uh oh' 2>/dev/null)" + err="$(ee_log_warn 'uh oh' 2>&1 1>/dev/null)" + [ -z "$out" ] + [ "$err" = " ! uh oh" ] +} + +@test "ee_log_fail prints and exits non-zero" { + run ee_log_fail "boom" + [ "$status" -eq 1 ] + [ "$output" = "boom" ] +} diff --git a/tests/unit/ondrej_ppa.bats b/tests/unit/ondrej_ppa.bats new file mode 100644 index 0000000..0e7f43b --- /dev/null +++ b/tests/unit/ondrej_ppa.bats @@ -0,0 +1,42 @@ +#!/usr/bin/env bats +# +# get_ondrej_php_ppa_release_status() — returns curl's HTTP status string for the +# ondrej/php PPA Release file of a given Ubuntu codename. + +setup() { + source "${BATS_TEST_DIRNAME}/../helpers/common.bash" + stub_init + load_functions +} + +teardown() { + stub_cleanup +} + +@test "returns the HTTP code curl reports (200)" { + make_stub curl <<'EOF' +#!/usr/bin/env bash +echo "200" +EOF + run get_ondrej_php_ppa_release_status noble + [ "$status" -eq 0 ] + [ "$output" = "200" ] +} + +@test "returns 404 for a codename with no PPA build" { + make_stub curl <<'EOF' +#!/usr/bin/env bash +echo "404" +EOF + run get_ondrej_php_ppa_release_status plucky + [ "$output" = "404" ] +} + +@test "tolerates curl failure without aborting (trailing || true)" { + make_stub curl <<'EOF' +#!/usr/bin/env bash +exit 7 +EOF + run get_ondrej_php_ppa_release_status noble + [ "$status" -eq 0 ] +} diff --git a/tests/unit/parse_args.bats b/tests/unit/parse_args.bats new file mode 100644 index 0000000..fa57bf2 --- /dev/null +++ b/tests/unit/parse_args.bats @@ -0,0 +1,54 @@ +#!/usr/bin/env bats +# +# parse_args() — the top-level CLI flag parser. Pure function, no I/O. + +setup() { + source "${BATS_TEST_DIRNAME}/../helpers/common.bash" + # Start each test from a clean slate so a leaked export can't mask a bug. + unset EE_QUIET_OUTPUT EE_TRACE EE_DRY_RUN EE_SITE_ALL REMOTE_HOST + load_functions +} + +@test "--quiet sets EE_QUIET_OUTPUT" { + parse_args --quiet + [ "$EE_QUIET_OUTPUT" = "1" ] +} + +@test "--dry-run sets EE_DRY_RUN" { + parse_args --dry-run + [ "$EE_DRY_RUN" = "1" ] +} + +@test "--all sets EE_SITE_ALL" { + parse_args --all + [ "$EE_SITE_ALL" = "1" ] +} + +@test "--trace sets EE_TRACE" { + parse_args --trace + [ "$EE_TRACE" = "1" ] +} + +@test "--remote-host consumes the following value" { + parse_args --remote-host example.com + [ "$REMOTE_HOST" = "example.com" ] +} + +@test "--remote-host followed by another flag does not swallow a real flag as the host" { + # Documents current behaviour: the token after --remote-host is taken verbatim + # as the host, even if it looks like a flag. + parse_args --remote-host --quiet + [ "$REMOTE_HOST" = "--quiet" ] +} + +@test "multiple flags are all applied" { + parse_args --quiet --dry-run --remote-host host.example + [ "$EE_QUIET_OUTPUT" = "1" ] + [ "$EE_DRY_RUN" = "1" ] + [ "$REMOTE_HOST" = "host.example" ] +} + +@test "unknown flags are ignored and return success" { + run parse_args --not-a-flag positional + [ "$status" -eq 0 ] +} diff --git a/tests/unit/php_select.bats b/tests/unit/php_select.bats new file mode 100644 index 0000000..a433be1 --- /dev/null +++ b/tests/unit/php_select.bats @@ -0,0 +1,89 @@ +#!/usr/bin/env bats +# +# ee_select_and_install_php() — the host-PHP selection algorithm. This is the most +# logic-heavy and most regression-prone part of the installer (it drove the +# Ubuntu 26.04 fixes), so it is tested in isolation by overriding the four helpers +# it depends on. The contract under test: +# * version-first: candidates are tried in the given order; a higher-priority +# version wins even if a lower one is natively available. +# * within a version, native is preferred and the probe PPA is dropped. +# * a version not available natively falls back to the third-party PPA. + +setup() { + source "${BATS_TEST_DIRNAME}/../helpers/common.bash" + load_functions + + export EE_QUIET_OUTPUT="" + EE_INSTALLED_PHP_VERSION="" + APT_LOG="$(mktemp)" + + # Mock state. NATIVE_OK / PPA_OK are space-separated version lists the test sets. + PPA_ADDED=0 + ADD_CALLS=0 + : "${NATIVE_OK:=}" "${PPA_OK:=}" "${PPA_AVAILABLE:=1}" + + # --- helper overrides (replace the real implementations) --- + ee_php_add_thirdparty_repo() { + ADD_CALLS=$((ADD_CALLS + 1)) + [ "$PPA_AVAILABLE" = "1" ] || return 1 + PPA_ADDED=1 + return 0 + } + ee_php_drop_thirdparty_repo() { PPA_ADDED=0; return 0; } + ee_php_cli_installable() { + local v="$1" + case " $NATIVE_OK " in *" $v "*) return 0 ;; esac + if [ "$PPA_ADDED" = "1" ]; then + case " $PPA_OK " in *" $v "*) return 0 ;; esac + fi + return 1 + } + # Record install commands instead of running apt. + apt-get() { echo "$*" >> "$APT_LOG"; return 0; } +} + +teardown() { + rm -f "$APT_LOG" +} + +@test "installs a natively-available version without touching the PPA" { + NATIVE_OK="8.3" + ee_select_and_install_php 8.3 + [ "$EE_INSTALLED_PHP_VERSION" = "8.3" ] + [ "$ADD_CALLS" -eq 0 ] + grep -q "install -y php8.3-cli" "$APT_LOG" +} + +@test "falls back to the third-party PPA when a version is not native" { + NATIVE_OK="" + PPA_OK="8.4" + ee_select_and_install_php 8.4 + [ "$EE_INSTALLED_PHP_VERSION" = "8.4" ] + [ "$ADD_CALLS" -ge 1 ] + grep -q "install -y php8.4-cli" "$APT_LOG" +} + +@test "version-first: skips an uninstallable higher version for a native lower one" { + NATIVE_OK="8.4" + PPA_OK="" + ee_select_and_install_php 8.5 8.4 + [ "$EE_INSTALLED_PHP_VERSION" = "8.4" ] + grep -q "install -y php8.4-cli" "$APT_LOG" + ! grep -q "php8.5-cli" "$APT_LOG" +} + +@test "version-first: a PPA-only higher version wins over a native lower one" { + NATIVE_OK="8.3" + PPA_OK="8.4" + ee_select_and_install_php 8.4 8.3 + [ "$EE_INSTALLED_PHP_VERSION" = "8.4" ] + grep -q "install -y php8.4-cli" "$APT_LOG" +} + +@test "returns non-zero and installs nothing when no candidate is installable" { + NATIVE_OK="" + PPA_OK="" + run ee_select_and_install_php 8.4 8.3 + [ "$status" -ne 0 ] + [ ! -s "$APT_LOG" ] +}