Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions tests/test_smoke_install.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
"""Fresh-install smoke tests (Phase 1): the floor must be green.

Covers the cheapest release-confidence gate:
1. Python version requirement (3.11+).
2. Core dependency imports (fastapi, uvicorn, sqlalchemy, bcrypt, httpx, dotenv).
3. .env.example parses as KEY=VALUE lines (setup.py copies it verbatim).
4. internal_api_base() honors APP_PORT (never hardcode :7000).
5. Full app boots via TestClient and /api/health is healthy even with
optional services (Chroma/SearXNG/Ollama) unreachable.

Pattern followed: tests/test_internal_api_base.py (env-scoped base helper +
no-hardcoded-loopback guard) and setup.py::check_deps (same module list).
"""
import pathlib
import sys

import pytest

REPO = pathlib.Path(__file__).resolve().parent.parent
CORE_DEPS = ["fastapi", "uvicorn", "sqlalchemy", "bcrypt", "httpx", "dotenv"]


def test_python_is_311_plus():
assert sys.version_info >= (3, 11), f"need 3.11+, got {sys.version}"


@pytest.mark.parametrize("mod", CORE_DEPS)
def test_core_dependency_imports(mod):
__import__(mod)


def test_env_example_parses():
example = REPO / ".env.example"
assert example.exists(), ".env.example must exist (setup.py copies it)"
bad = []
for n, line in enumerate(example.read_text(encoding="utf-8").splitlines(), 1):
s = line.strip()
if not s or s.startswith("#"):
continue
if "=" not in s:
bad.append((n, line))
assert not bad, f".env.example has non KEY=VALUE lines: {bad[:5]}"


def test_internal_api_base_honors_app_port(monkeypatch):
import core.constants as cc

for k in ("ODYSSEUS_INTERNAL_BASE", "APP_PORT"):
monkeypatch.delenv(k, raising=False)
assert cc.internal_api_base() == "http://127.0.0.1:7000"
monkeypatch.setenv("APP_PORT", "7001")
assert cc.internal_api_base() == "http://127.0.0.1:7001"


def test_app_boots_and_health_is_healthy(monkeypatch):
"""Full app boots; health is healthy with optional services unreachable."""
import os

# Point every optional-service probe at dead ports so the test proves
# graceful degradation instead of depending on the dev machine.
monkeypatch.setenv("DATABASE_URL", "sqlite:///:memory:")
monkeypatch.setenv("CHROMADB_HOST", "127.0.0.1")
monkeypatch.setenv("CHROMADB_PORT", "9") # discard port: nothing listens
monkeypatch.setenv("SEARXNG_INSTANCE", "http://127.0.0.1:9")
monkeypatch.setenv("OLLAMA_BASE_URL", "http://127.0.0.1:9/v1")
# Keep the embedding stack local-only (no HF download in CI).
monkeypatch.setenv("EMBEDDING_URL", "")
os.environ.pop("EMBEDDING_MODEL", None)

from fastapi.testclient import TestClient
from app import app

client = TestClient(app, raise_server_exceptions=False)
resp = client.get("/api/health")
assert resp.status_code == 200, resp.text[:500]
assert resp.json().get("status") == "healthy"


def test_root_serves_login_shell_html(monkeypatch):
"""Fresh install must serve the UI shell, not just a healthy API.

Smoke-contract item from #3968: "confirm the UI can become available
when applicable". Without users configured, the app's first-run path
redirects browser navigation to /login; assert that the shell HTML
actually comes back (status OK, HTML document, viewport meta) rather
than an error page or an empty body.
"""
import os

monkeypatch.setenv("DATABASE_URL", "sqlite:///:memory:")
monkeypatch.setenv("CHROMADB_HOST", "127.0.0.1")
monkeypatch.setenv("CHROMADB_PORT", "9")
monkeypatch.setenv("SEARXNG_INSTANCE", "http://127.0.0.1:9")
monkeypatch.setenv("OLLAMA_BASE_URL", "http://127.0.0.1:9/v1")
monkeypatch.setenv("EMBEDDING_URL", "")
os.environ.pop("EMBEDDING_MODEL", None)

from fastapi.testclient import TestClient
from app import app

client = TestClient(app, raise_server_exceptions=False)
resp = client.get("/", follow_redirects=False)
# Either the login shell directly (200) or the first-run redirect to
# /login — both mean the UI layer is mounted and answering. What must
# NOT happen is a 5xx or an unexpected scheme.
assert resp.status_code in (200, 302, 307), (
f"unexpected status {resp.status_code}: {resp.text[:300]}"
)
if resp.status_code in (302, 307):
location = resp.headers.get("location", "")
assert "/login" in location, f"redirect did not target login: {location}"
resp = client.get(location or "/login")
body = resp.text
assert resp.status_code == 200, resp.text[:300]
assert "<html" in body.lower(), "login shell did not return HTML"
assert 'name="viewport"' in body, "login shell missing responsive viewport meta"