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`.
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user