"""Static deployment-readiness checks (these do NOT deploy anything to Heroku).""" import json import re from pathlib import Path ROOT = Path(__file__).resolve().parent.parent # Top-level keys documented for app.json (https://devcenter.heroku.com/articles/app-json-schema) APP_JSON_KEYS = { "name", "description", "keywords", "website", "repository", "logo", "success_url", "scripts", "env", "formation", "image", "addons", "buildpacks", "environments", "stack", } ENV_KEYS = {"description", "value", "required", "generator"} def _config_vars() -> set[str]: source = (ROOT / "config.py").read_text() return set(re.findall(r'r\.\w+\(\s*"([A-Z0-9_]+)"', source)) | set(re.findall(r'env\.get\("([A-Z0-9_]+)"\)', source)) def _app_json() -> dict: return json.loads((ROOT / "app.json").read_text()) def test_app_json_structure(): manifest = _app_json() assert set(manifest) <= APP_JSON_KEYS assert len(manifest["name"]) <= 30 and manifest["description"] assert manifest["buildpacks"] == [{"url": "heroku/python"}] assert manifest["formation"] == {"worker": {"quantity": 1, "size": "basic"}} assert manifest["addons"][0]["plan"].startswith("heroku-postgresql:") for name, spec in manifest["env"].items(): assert set(spec) <= ENV_KEYS, name assert spec["description"], name assert isinstance(spec.get("required", False), bool), name if "value" in spec: assert isinstance(spec["value"], str), name def test_app_json_only_lists_supported_variables(): supported = _config_vars() unsupported = set(_app_json()["env"]) - supported assert not unsupported, f"app.json lists variables the app does not read: {unsupported}" def test_env_example_documents_every_variable(): documented = set(re.findall(r"^([A-Z0-9_]+)=", (ROOT / ".env.example").read_text(), re.MULTILINE)) supported = _config_vars() - {"DYNO"} # DYNO is set by Heroku itself assert supported - documented == set() assert documented - supported == set() def test_procfile_matches_entry_point(): procfile = (ROOT / "Procfile").read_text().strip().splitlines() assert procfile == ["worker: python main.py run"] assert set(_app_json()["formation"]) == {line.split(":")[0] for line in procfile} main_source = (ROOT / "main.py").read_text() assert 'sub.add_parser("run"' in main_source and "signal.SIGTERM" in main_source def test_runtime_and_requirements(): assert (ROOT / ".python-version").read_text().strip() == "3.12" assert not (ROOT / "runtime.txt").exists() # deprecated in favour of .python-version reqs = (ROOT / "requirements.txt").read_text() for package in ("Telethon", "google-api-python-client", "google-auth", "google-auth-oauthlib", "psycopg[binary]", "python-dotenv"): assert re.search(rf"^{re.escape(package)}==", reqs, re.MULTILINE), package def test_gitignore_protects_secrets_and_state(): ignored = (ROOT / ".gitignore").read_text().splitlines() for pattern in (".env", "*.session", "credentials/", "token.json", "session_string.txt", "data/", "downloads/", "*.db"): assert pattern in ignored, pattern def test_readme_deploy_button(): readme = (ROOT / "README.md").read_text() assert "https://www.herokucdn.com/deploy/button.svg" in readme assert re.search(r"\]\(https://heroku\.com/deploy\?template=https://github\.com/[^)\s]+\)", readme)