Files
undercooked/tests/unit/test_main.py
T
ducoterra 6fd83446ec phase: 01_runnable_service_scaffold
**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`.
2026-10-06 01:51:20 -04:00

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