diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1b865cd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,20 @@ +name: CI + +on: + pull_request: + branches: + - main + push: + branches: + - main + +jobs: + backend-tests: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Run backend tests in Docker + run: docker compose run --rm --no-deps --build bench-processor python -m pytest -q tests diff --git a/README.md b/README.md index fe643d3..047367f 100644 --- a/README.md +++ b/README.md @@ -39,4 +39,18 @@ docker compose exec bench-processor python -m alembic -c alembic.ini revision -- Check alembic/versions newly created migration, then apply migration: ``` docker compose exec bench-processor python -m alembic -c alembic.ini upgrade head -``` \ No newline at end of file +``` + +## Tests + +Run backend tests inside the Docker container: + +```bash +docker compose run --rm --no-deps bench-processor python -m pytest -q tests +``` + +If you want to run tests against the latest image rebuild: + +```bash +docker compose run --rm --no-deps --build bench-processor python -m pytest -q tests +``` diff --git a/bench_processor/requirements.txt b/bench_processor/requirements.txt index 25663d2..586d3cf 100644 --- a/bench_processor/requirements.txt +++ b/bench_processor/requirements.txt @@ -2,4 +2,6 @@ fastapi==0.115.0 uvicorn[standard]==0.30.6 sqlalchemy==2.0.32 psycopg[binary]==3.2.2 -alembic==1.13.2 \ No newline at end of file +alembic==1.13.2 +pytest==8.4.2 +httpx==0.27.2 diff --git a/bench_processor/tests/conftest.py b/bench_processor/tests/conftest.py new file mode 100644 index 0000000..05d4d09 --- /dev/null +++ b/bench_processor/tests/conftest.py @@ -0,0 +1,45 @@ +import sys +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import StaticPool + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from app.db import Base, get_db +from app.main import app + + +@pytest.fixture(scope="module") +def test_db_session() -> Session: + engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + testing_session_local = sessionmaker(bind=engine, autoflush=False, autocommit=False) + Base.metadata.create_all(bind=engine) + + db = testing_session_local() + try: + yield db + finally: + db.close() + Base.metadata.drop_all(bind=engine) + engine.dispose() + + +@pytest.fixture(scope="module") +def client(test_db_session: Session): + def override_get_db(): + yield test_db_session + + app.dependency_overrides[get_db] = override_get_db + with TestClient(app) as test_client: + yield test_client + app.dependency_overrides.clear() diff --git a/bench_processor/tests/test_health_check.py b/bench_processor/tests/test_health_check.py new file mode 100644 index 0000000..40823c2 --- /dev/null +++ b/bench_processor/tests/test_health_check.py @@ -0,0 +1,5 @@ +def test_health_returns_ok(client) -> None: + response = client.get("/api/health") + + assert response.status_code == 200 + assert response.json() == {"status": "ok"} diff --git a/bench_processor/tests/test_log_stool.py b/bench_processor/tests/test_log_stool.py new file mode 100644 index 0000000..d73e995 --- /dev/null +++ b/bench_processor/tests/test_log_stool.py @@ -0,0 +1,40 @@ +import pytest +from sqlalchemy.orm import Session + +from app.db.models import StoolRecord, User + + +@pytest.fixture(scope="module", autouse=True) +def seed_default_user(test_db_session: Session) -> None: + test_db_session.add(User(user_id=1, name="Test User", email="test@example.com")) + test_db_session.commit() + + +def test_insert_stool_record_writes_row(client, test_db_session: Session) -> None: + payload = {"wetness": 0.67, "experience": 1.0} + response = client.post("/api/1/stool", json=payload) + + assert response.status_code == 200 + assert response.json() == "created" + + rows = test_db_session.query(StoolRecord).all() + assert len(rows) == 1 + assert rows[0].user_id == 1 + assert rows[0].wetness_rating == 0.67 + assert rows[0].experience_rating == 1.0 + + +def test_insert_stool_record_returns_validation_error_when_user_missing(client) -> None: + payload = {"wetness": 0.67, "experience": 1.0} + response = client.post("/api/2/stool", json=payload) + + assert response.status_code == 400 + assert response.json() == {"message": "validation error: user_id 2 not found"} + + +def test_insert_stool_record_rejects_out_of_range_values(client) -> None: + payload = {"wetness": 1.2, "experience": -0.1} + response = client.post("/api/1/stool", json=payload) + + assert response.status_code == 422 + assert response.json()["message"].startswith("validation error:")