**Phase 01 final verification pass — all criteria green**
- Verified (nothing left to implement; all 4 task files already in `complete/`): toolchain lock, typed `Settings`+`.env.example`, debugpy bootstrap, app factory, CLI (`--host/--port/--reload/--levels-dir`, `python -m undercooked`), static shell + `/static`, security headers, no-CDN/doc guards, `validate.sh`.
- `uv sync --extra dev` → exit 0; `uv.lock` present (untracked → harness commits it with the phase).
- `uv run undercooked --host 127.0.0.1 --port 8000` → `curl /healthz` = 200 `{"status":"ok","version":"0.1.0","rooms":0}` (superset of the criterion's body — now documented in README); `curl /` = 200 shell HTML; static css/js 200; CSP/nosniff/no-referrer/DENY on 200, 404 and 405 too.
- `DEBUGPY=1`: port 5678 is held on this machine by the user's own podman dev-container debugpy adapter — **no live change made/killed**; verified via `DEBUGPY=1 DEBUGPY_PORT=5679` → listener opens, `/healthz` still 200; the occupied-port case degraded gracefully as designed (warning, server serves). Bad config → one line + exit 2 ✓.
- `bash .agents/validate.sh` (root **and** `.agents/`) → exit 0: `ruff check .` 0 errors, `pyright` 0 errors, levels-CLI notice-skip (matches README prose), **284 passed** full suite incl. 10 Playwright e2e, coverage **100.00%** (floor 90), repo hygiene clean. `uv run pytest -m e2e` → 10 passed.
- Docs fixed: README Status → phase 01 complete + next pending 02 (was pointing at `todo/01…`), documented real `/healthz` body, precedence now "CLI flags > env > .env > default", added the PLAN §13-promised **How to play** section (honest about shell-only today), Layout rows marked what exists vs phases 02/07. AGENTS.md: recorded the `data:`-URL exemption in the no-CDN rule (test allows it) and matched the session-token rule to the browser's WebCrypto/base64url implementation. Also corrected a stale "session-wide chromium" docstring in `tests/e2e/conftest.py` (fixtures are function-scoped).
- Deviations: no container build run (needs an image pull; not a phase criterion — `Containerfile`/`compose.yaml` reviewed and consistent with `docs/DEPLOYMENT.md`); `HEAD /` is 405 (routes are GET-only by design; health checks use GET). No git index/history commands run.
- Next pending phase: `02_level_content_and_validation`.
258 lines
8.5 KiB
Python
258 lines
8.5 KiB
Python
"""Unit tests for the `undercooked` CLI (task 03): precedence, exit codes, wiring.
|
|
|
|
`_resolve` and `_uvicorn_log_config` are pure, so precedence is testable without
|
|
a socket. `main()` is tested with uvicorn's `run` replaced by a recorder — the
|
|
real server is started by the integration/phase criteria, not by a unit test.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
import pytest
|
|
import uvicorn
|
|
import uvicorn.config
|
|
|
|
from undercooked.__main__ import (
|
|
APP_IMPORT_STRING,
|
|
EXIT_CONFIG_ERROR,
|
|
EXIT_OK,
|
|
RunOptions,
|
|
_cli_overrides,
|
|
_resolve,
|
|
_uvicorn_log_config,
|
|
build_parser,
|
|
main,
|
|
)
|
|
from undercooked.config import (
|
|
ENV_KEYS,
|
|
ConfigError,
|
|
Settings,
|
|
apply_cli_overrides,
|
|
get_settings,
|
|
reset_settings_cache,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Iterator
|
|
|
|
pytestmark = pytest.mark.unit
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
# A string in an assertion, never a socket bind: the CLI flag is what is under test.
|
|
ALL_INTERFACES = "0.0.0.0" # noqa: S104 - a test assertion, not a socket bind
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def pristine_env() -> Iterator[None]:
|
|
"""`main()` publishes CLI flags into `os.environ`; nothing may leak out."""
|
|
saved = dict(os.environ)
|
|
reset_settings_cache()
|
|
yield
|
|
os.environ.clear()
|
|
os.environ.update(saved)
|
|
reset_settings_cache()
|
|
|
|
|
|
@pytest.fixture
|
|
def uvicorn_calls(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]:
|
|
"""Replace uvicorn's `run` with a recorder; returns the recorded invocations."""
|
|
calls: list[dict[str, Any]] = []
|
|
|
|
def fake_run(target: str, **kwargs: Any) -> None:
|
|
calls.append({"target": target, **kwargs})
|
|
|
|
monkeypatch.setattr(uvicorn, "run", fake_run)
|
|
return calls
|
|
|
|
|
|
@pytest.fixture
|
|
def bare_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""No `UNDERCOOKED_*`/`DEBUGPY*` in the environment: defaults are observable."""
|
|
for key in list(os.environ):
|
|
if key.startswith(("UNDERCOOKED_", "DEBUGPY")):
|
|
monkeypatch.delenv(key, raising=False)
|
|
monkeypatch.setenv("PYTHON_DOTENV_DISABLED", "true")
|
|
|
|
|
|
def parse(argv: list[str]) -> Any:
|
|
"""Parse ``argv`` the way `main` would, without serving anything."""
|
|
return build_parser().parse_args(argv)
|
|
|
|
|
|
# ----------------------------------------------------------------------------- parser
|
|
|
|
|
|
def test_every_flag_defaults_to_unset() -> None:
|
|
args = parse([])
|
|
assert (args.host, args.port, args.reload, args.levels_dir) == (None, None, False, None)
|
|
|
|
|
|
def test_flags_are_parsed() -> None:
|
|
args = parse(
|
|
["--host", ALL_INTERFACES, "--port", "9000", "--reload", "--levels-dir", "kitchens"]
|
|
)
|
|
assert (args.host, args.port, args.reload) == (ALL_INTERFACES, 9000, True)
|
|
assert args.levels_dir == Path("kitchens")
|
|
|
|
|
|
def test_help_names_the_env_fallback() -> None:
|
|
text = build_parser().format_help()
|
|
assert "--levels-dir" in text
|
|
assert "UNDERCOOKED_PORT" in text
|
|
|
|
|
|
# ----------------------------------------------------------------------------- precedence
|
|
|
|
|
|
def test_cli_beats_environment_and_defaults() -> None:
|
|
settings = Settings(host="10.0.0.1", port=8123, levels_dir=Path("env-levels"))
|
|
options = _resolve(
|
|
parse(["--host", "127.0.0.1", "--port", "9999", "--levels-dir", "cli-levels"]), settings
|
|
)
|
|
assert options == RunOptions(
|
|
host="127.0.0.1", port=9999, reload=False, levels_dir=Path("cli-levels")
|
|
)
|
|
|
|
|
|
def test_settings_supply_everything_the_cli_left_unset() -> None:
|
|
settings = Settings(host=ALL_INTERFACES, port=8123, levels_dir=Path("env-levels"))
|
|
options = _resolve(parse([]), settings)
|
|
assert options == RunOptions(
|
|
host=ALL_INTERFACES, port=8123, reload=False, levels_dir=Path("env-levels")
|
|
)
|
|
|
|
|
|
def test_settings_default_to_the_documented_defaults_in_a_bare_environment(
|
|
bare_env: None,
|
|
) -> None:
|
|
options = _resolve(parse([]), get_settings())
|
|
assert options == RunOptions(
|
|
host="127.0.0.1", port=8000, reload=False, levels_dir=Path("levels")
|
|
)
|
|
|
|
|
|
def test_environment_beats_the_builtin_default(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setenv("UNDERCOOKED_PORT", "8123")
|
|
monkeypatch.setenv("UNDERCOOKED_HOST", ALL_INTERFACES)
|
|
reset_settings_cache()
|
|
options = _resolve(parse([]), get_settings())
|
|
assert (options.host, options.port) == (ALL_INTERFACES, 8123)
|
|
|
|
|
|
def test_reload_is_a_flag_not_an_environment_variable() -> None:
|
|
assert _resolve(parse(["--reload"]), Settings()).reload is True
|
|
assert _resolve(parse([]), Settings()).reload is False
|
|
|
|
|
|
def test_cli_overrides_only_report_flags_that_were_given() -> None:
|
|
assert _cli_overrides(parse([])) == {}
|
|
assert _cli_overrides(parse(["--port", "9000", "--levels-dir", "x"])) == {
|
|
"port": 9000,
|
|
"levels_dir": Path("x"),
|
|
}
|
|
|
|
|
|
# ----------------------------------------------------------------------------- main()
|
|
|
|
|
|
def test_main_serves_the_app_string_with_resolved_options(
|
|
uvicorn_calls: list[dict[str, Any]], bare_env: None
|
|
) -> None:
|
|
assert main([]) == EXIT_OK
|
|
assert len(uvicorn_calls) == 1
|
|
call = uvicorn_calls[0]
|
|
assert call["target"] == APP_IMPORT_STRING # reload-capable "module:attribute" form
|
|
assert (call["host"], call["port"], call["reload"]) == ("127.0.0.1", 8000, False)
|
|
assert "undercooked" in call["log_config"]["loggers"]
|
|
|
|
|
|
def test_main_passes_cli_flags_to_uvicorn(
|
|
uvicorn_calls: list[dict[str, Any]], bare_env: None
|
|
) -> None:
|
|
assert main(["--host", ALL_INTERFACES, "--port", "9123", "--reload"]) == EXIT_OK
|
|
call = uvicorn_calls[0]
|
|
assert (call["host"], call["port"], call["reload"]) == (ALL_INTERFACES, 9123, True)
|
|
|
|
|
|
def test_main_publishes_cli_flags_to_the_environment(
|
|
uvicorn_calls: list[dict[str, Any]], bare_env: None
|
|
) -> None:
|
|
"""A uvicorn --reload child re-reads the environment, so flags must live there."""
|
|
assert main(["--levels-dir", "kitchens", "--port", "9124"]) == EXIT_OK
|
|
assert os.environ[ENV_KEYS["levels_dir"]] == "kitchens"
|
|
assert os.environ[ENV_KEYS["port"]] == "9124"
|
|
assert get_settings().levels_dir == Path("kitchens")
|
|
assert get_settings().port == 9124
|
|
|
|
|
|
def test_main_validates_cli_values_like_environment_values(
|
|
uvicorn_calls: list[dict[str, Any]], capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
assert main(["--port", "0"]) == EXIT_CONFIG_ERROR
|
|
assert uvicorn_calls == []
|
|
err = capsys.readouterr().err
|
|
assert err.strip().startswith("undercooked:")
|
|
assert "UNDERCOOKED_PORT" in err
|
|
assert err.count("\n") == 1 # a one-line message, not a traceback
|
|
|
|
|
|
def test_main_reports_an_invalid_environment(
|
|
uvicorn_calls: list[dict[str, Any]],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
capsys: pytest.CaptureFixture[str],
|
|
) -> None:
|
|
monkeypatch.setenv("UNDERCOOKED_TICK_HZ", "99")
|
|
reset_settings_cache()
|
|
assert main([]) == EXIT_CONFIG_ERROR
|
|
assert uvicorn_calls == []
|
|
assert "UNDERCOOKED_TICK_HZ=99" in capsys.readouterr().err
|
|
|
|
|
|
def test_unknown_flag_is_an_argument_error() -> None:
|
|
with pytest.raises(SystemExit) as excinfo:
|
|
main(["--nope"])
|
|
assert excinfo.value.code == EXIT_CONFIG_ERROR
|
|
|
|
|
|
def test_an_unknown_settings_field_is_a_config_error() -> None:
|
|
with pytest.raises(ConfigError, match="not a configuration field"):
|
|
apply_cli_overrides(**{"nonsense": 1})
|
|
|
|
|
|
# ----------------------------------------------------------------------------- logging
|
|
|
|
|
|
def test_uvicorn_log_config_adds_our_loggers_without_touching_uvicorns() -> None:
|
|
base = copy.deepcopy(uvicorn.config.LOGGING_CONFIG)
|
|
config = _uvicorn_log_config(base)
|
|
assert config is not base
|
|
assert set(base["loggers"]) == {"uvicorn", "uvicorn.error", "uvicorn.access"}
|
|
ours = config["loggers"]["undercooked"]
|
|
assert ours["handlers"] == ["default"]
|
|
assert ours["level"] == "INFO"
|
|
assert config["loggers"]["uvicorn"] is base["loggers"]["uvicorn"]
|
|
assert set(config["formatters"]) == set(base["formatters"]) # the handler exists
|
|
|
|
|
|
# ----------------------------------------------------------------------------- the console script
|
|
|
|
|
|
def test_python_m_undercooked_prints_usage_and_exits_zero() -> None:
|
|
result = subprocess.run( # fixed argv, the repo's own interpreter
|
|
[sys.executable, "-m", "undercooked", "--help"],
|
|
cwd=REPO_ROOT,
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
timeout=120,
|
|
)
|
|
assert result.returncode == EXIT_OK, result.stderr
|
|
assert "usage: undercooked" in result.stdout
|
|
assert "--reload" in result.stdout
|