diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 78b40cc..8f4b6ce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,3 +29,63 @@ jobs: - name: Run pre-commit run: pre-commit run --all-files + + test: + runs-on: ubuntu-latest + + services: + mysql: + image: mysql:8.0 + env: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: drssed_test + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping -h 127.0.0.1 -uroot -proot" + --health-interval=10s + --health-timeout=5s + --health-retries=10 + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: >- + --health-cmd="redis-cli ping" + --health-interval=10s + --health-timeout=3s + --health-retries=5 + + env: + DATABASE_HOST: 127.0.0.1 + DATABASE_PORT: "3306" + DATABASE_USERNAME: root + DATABASE_PASSWORD: root + DATABASE_NAME: drssed_test + REDIS_URI: redis://127.0.0.1:6379 + SECRET_TOKEN_KEY: kkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkk + RATELIMITER_ENABLED: "False" + EMAIL_ENABLED: "False" + DISABLE_SCHEDULER: "true" + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + + - name: Install system libraries + run: | + sudo apt-get update + sudo apt-get install -y libjpeg-dev zlib1g-dev + + - name: Install dependencies + run: pip install -r requirements-dev.txt + + - name: Run migrations + run: sqlspec upgrade --no-prompt + + - name: Run tests + run: pytest -q diff --git a/.gitignore b/.gitignore index f97252b..00fb027 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,9 @@ app/static/* # Python cache __pycache__/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ *.pyc *.pyo *.pyd diff --git a/requirements-dev.txt b/requirements-dev.txt index 3535ccd..e8044a9 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,3 +2,4 @@ ruff==0.15.22 mypy==2.3.0 pre-commit==4.6.0 +pytest==8.4.2 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..a12e921 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,64 @@ +import os + +import pytest + +os.environ.setdefault("DATABASE_HOST", "127.0.0.1") +os.environ.setdefault("DATABASE_PORT", "3306") +os.environ.setdefault("DATABASE_USERNAME", "root") +os.environ.setdefault("DATABASE_PASSWORD", "root") +os.environ.setdefault("DATABASE_NAME", "drssed_test") +os.environ.setdefault("SECRET_TOKEN_KEY", "k" * 32) +os.environ.setdefault("RATELIMITER_ENABLED", "False") +os.environ.setdefault("EMAIL_ENABLED", "False") +os.environ.setdefault("DISABLE_SCHEDULER", "true") +os.environ.setdefault("FLASK_ENV", "testing") + + +def _database_reachable() -> bool: + try: + from app.persistence.queries import system as system_queries + + return system_queries.ping() + except Exception: + return False + + +needs_database = pytest.mark.skipif( + not _database_reachable(), reason="no database reachable" +) + + +@pytest.fixture(scope="session") +def api(): + import main + + return main.api + + +@pytest.fixture +def client(api): + return api.test_client() + + +@pytest.fixture +def user_ids(): + """Creates two throwaway users and removes them plus their rows afterwards.""" + from app.core.database import get_session + + owner, stranger = "test-owner", "test-stranger" + + with get_session() as session: + session.execute( + "DELETE FROM users WHERE user_id IN (:a, :b)", {"a": owner, "b": stranger} + ) + session.execute( + "INSERT INTO users (user_id, is_guest) VALUES (:a, 0), (:b, 0)", + {"a": owner, "b": stranger}, + ) + + yield owner, stranger + + with get_session() as session: + session.execute( + "DELETE FROM users WHERE user_id IN (:a, :b)", {"a": owner, "b": stranger} + ) diff --git a/tests/test_error_handling.py b/tests/test_error_handling.py new file mode 100644 index 0000000..cecaeec --- /dev/null +++ b/tests/test_error_handling.py @@ -0,0 +1,97 @@ +from time import time + +import pytest +from flask import Flask, jsonify, make_response +from flask_limiter import Limiter, RequestLimit +from flask_limiter.util import get_remote_address +from werkzeug.exceptions import HTTPException + + +def test_unknown_route_returns_404(client): + response = client.get("/does-not-exist") + + assert response.status_code == 404 + assert response.get_json() == {"error": "Resource not found"} + + +def test_wrong_method_returns_405(client): + response = client.get("/auth/login") + + assert response.status_code == 405 + + +def test_malformed_json_returns_400_not_500(client): + response = client.post( + "/auth/login", data="{broken", content_type="application/json" + ) + + assert response.status_code == 400 + + +def test_oversized_body_returns_413_not_500(client): + response = client.post( + "/auth/login", + data=b"x" * (6 * 1024 * 1024), + content_type="application/json", + ) + + assert response.status_code == 413 + + +def test_missing_token_returns_401(client): + response = client.get("/users/me") + + assert response.status_code == 401 + + +@pytest.fixture +def limited_app(): + """A minimal app wired like main.py, so the handler chain can be exercised.""" + + def on_breach(rate_limit: RequestLimit): + reset_in_seconds = rate_limit.reset_at - time() + return make_response( + jsonify( + { + "error": f"Rate limit exceeded and will reset in {reset_in_seconds:.0f} seconds." + } + ), + 429, + ) + + app = Flask("limited") + limiter = Limiter( + key_func=get_remote_address, + storage_uri="memory://", + on_breach=on_breach, + enabled=True, + ) + limiter.init_app(app) + + @app.errorhandler(HTTPException) + def http_error_handler(error): + if error.response is not None: + return error.response + return jsonify({"error": error.description}), error.code + + @app.errorhandler(Exception) + def internal_error_handler(error): + return jsonify({"error": "An unexpected error occurred"}), 500 + + @app.route("/limited") + @limiter.limit("1 per hour") + def limited(): + return jsonify({"ok": True}), 200 + + return app + + +def test_rate_limit_breach_returns_429_with_its_own_body(limited_app): + client = limited_app.test_client() + + assert client.get("/limited").status_code == 200 + + breached = client.get("/limited") + + assert breached.status_code == 429 + assert "Rate limit exceeded" in breached.get_json()["error"] diff --git a/tests/test_health.py b/tests/test_health.py new file mode 100644 index 0000000..a9457cc --- /dev/null +++ b/tests/test_health.py @@ -0,0 +1,14 @@ +def test_health_does_not_leak_internal_error_details(client, monkeypatch): + from app.routes import health as health_route + + def explode(): + raise RuntimeError("Can't connect to MySQL server on 'db-prod-01:3306'") + + monkeypatch.setattr(health_route.system_queries, "ping", explode) + + response = client.get("/health/mysql") + body = response.get_json() + + assert response.status_code == 503 + assert body == {"status": "error"} + assert "db-prod-01" not in response.get_data(as_text=True) diff --git a/tests/test_ownership.py b/tests/test_ownership.py new file mode 100644 index 0000000..b1f342e --- /dev/null +++ b/tests/test_ownership.py @@ -0,0 +1,70 @@ +import uuid + +from tests.conftest import needs_database + +pytestmark = needs_database + + +def _create_outfit(session, user_id: str) -> str: + outfit_id = str(uuid.uuid4()) + session.execute( + "INSERT INTO outfits (outfit_id, user_id, name) VALUES (:o, :u, 'Outfit')", + {"o": outfit_id, "u": user_id}, + ) + return outfit_id + + +def _create_clothing(session, user_id: str, deleted: bool = False) -> str: + clothing_id = str(uuid.uuid4()) + session.execute( + f""" + INSERT INTO clothing (clothing_id, user_id, name, category, sub_category, + image_id, warmth_level, color{", deleted_at" if deleted else ""}) + VALUES (:c, :u, 'Item', 'TOP', 'SWEATER', :i, 3, '#FFFFFF'{", NOW()" if deleted else ""}) + """, + {"c": clothing_id, "u": user_id, "i": str(uuid.uuid4())}, + ) + return clothing_id + + +def test_soft_delete_rejects_a_foreign_outfit(user_ids): + from app.core.database import get_session + from app.persistence.queries import outfit as outfit_queries + + owner, stranger = user_ids + + with get_session() as session: + outfit_id = _create_outfit(session, owner) + + with get_session() as session: + assert ( + outfit_queries.soft_delete_for_user(session, stranger, outfit_id) is False + ) + + with get_session() as session: + assert outfit_queries.soft_delete_for_user(session, owner, outfit_id) is True + + with get_session() as session: + assert outfit_queries.soft_delete_for_user(session, owner, outfit_id) is False + + +def test_scene_lookup_skips_foreign_and_deleted_items(user_ids): + from app.core.database import get_session + from app.persistence.queries import clothing as clothing_queries + + owner, stranger = user_ids + + with get_session() as session: + own = _create_clothing(session, owner) + gone = _create_clothing(session, owner, deleted=True) + foreign = _create_clothing(session, stranger) + + with get_session() as session: + found = { + row.clothing_id + for row in clothing_queries.get_active_image_ids_for_user( + session, owner, [own, gone, foreign] + ) + } + + assert found == {own} diff --git a/tests/test_persistence_contract.py b/tests/test_persistence_contract.py new file mode 100644 index 0000000..0578f30 --- /dev/null +++ b/tests/test_persistence_contract.py @@ -0,0 +1,96 @@ +import uuid + +import pytest + +from tests.conftest import needs_database + +pytestmark = needs_database + + +def test_seasons_round_trip_through_the_python_enum(user_ids): + from app.core.database import get_session + from app.models.season import Season + from app.persistence.queries import clothing as clothing_queries + + owner, _ = user_ids + clothing_id = str(uuid.uuid4()) + + with get_session() as session: + session.execute( + """ + INSERT INTO clothing (clothing_id, user_id, name, category, sub_category, + image_id, warmth_level, color) + VALUES (:c, :u, 'Item', 'TOP', 'SWEATER', :i, 3, '#FFFFFF') + """, + {"c": clothing_id, "u": owner, "i": str(uuid.uuid4())}, + ) + clothing_queries.add_seasons(session, clothing_id, ["SPRING", "WINTER"]) + + stored = { + row.season for row in clothing_queries.get_seasons_by_clothing_id(clothing_id) + } + + assert stored == {"SPRING", "WINTER"} + assert {Season[name] for name in stored} == {Season.SPRING, Season.WINTER} + + +def test_duplicate_username_is_rejected_by_the_database(): + from sqlspec.exceptions import UniqueViolationError + + from app.core.database import get_session + from app.persistence.queries import user as user_queries + from app.persistence.schemas.user import UserCreate + + username = f"dup{uuid.uuid4().hex[:8]}" + first, second = str(uuid.uuid4()), str(uuid.uuid4()) + + try: + user_queries.create( + UserCreate(user_id=first, is_guest=False, username=username) + ) + + with pytest.raises(UniqueViolationError): + user_queries.create( + UserCreate(user_id=second, is_guest=False, username=username) + ) + finally: + with get_session() as session: + session.execute( + "DELETE FROM users WHERE user_id IN (:a, :b)", {"a": first, "b": second} + ) + + +def test_new_users_get_a_last_active_timestamp(): + from app.core.database import get_session + from app.persistence.queries import user as user_queries + from app.persistence.schemas.user import UserCreate + + user_id = str(uuid.uuid4()) + + try: + user_queries.create(UserCreate(user_id=user_id, is_guest=True)) + profile = user_queries.get_profile_by_id(user_id) + + assert profile is not None + assert profile.last_active_at is not None + finally: + with get_session() as session: + session.execute("DELETE FROM users WHERE user_id = :u", {"u": user_id}) + + +def test_guest_without_email_is_not_found_instead_of_crashing(): + from app.core.database import get_session + from app.persistence.queries import user as user_queries + from app.persistence.schemas.user import UserCreate + + user_id = str(uuid.uuid4()) + + try: + user_queries.create(UserCreate(user_id=user_id, is_guest=True)) + status = user_queries.get_email_verification_status(user_id) + + assert status is not None + assert status.email is None + finally: + with get_session() as session: + session.execute("DELETE FROM users WHERE user_id = :u", {"u": user_id}) diff --git a/tests/test_validation.py b/tests/test_validation.py new file mode 100644 index 0000000..f593d2e --- /dev/null +++ b/tests/test_validation.py @@ -0,0 +1,54 @@ +import pytest + +from app.models.season import Season +from app.persistence.queries import clothing as clothing_queries +from app.services.recommendation import plausible_seasons, target_warmth +from app.services.wear import _parse_rating, _parse_temperature, _parse_worn_on +from app.utils.exceptions import WearRatingInvalidError, WearTemperatureInvalidError + + +@pytest.mark.parametrize("value", [123, None, ["SWEATER"], "BANANE"]) +def test_update_fields_rejects_invalid_sub_categories(value): + with pytest.raises(ValueError): + clothing_queries.update_fields(None, "some-id", {"sub_category": value}) + + +def test_update_fields_rejects_unknown_columns(): + with pytest.raises(ValueError): + clothing_queries.update_fields(None, "some-id", {"user_id": "somebody-else"}) + + +@pytest.mark.parametrize("value", [True, "20", 200.0, -200.0]) +def test_temperature_rejects_non_numeric_and_out_of_range(value): + with pytest.raises(WearTemperatureInvalidError): + _parse_temperature(value) + + +def test_temperature_treats_none_as_not_provided(): + assert _parse_temperature(None) is None + + +@pytest.mark.parametrize("value", [0, 6, True, "5"]) +def test_rating_rejects_values_outside_one_to_five(value): + with pytest.raises(WearRatingInvalidError): + _parse_rating(value) + + +def test_worn_on_rejects_a_date_too_far_in_the_future(): + from app.utils.exceptions import WearWornOnInFutureError + + with pytest.raises(WearWornOnInFutureError): + _parse_worn_on("2999-01-01") + + +def test_warmth_scale_is_monotonic_in_temperature(): + warmths = [target_warmth(t) for t in (25, 18, 10, 3, -10)] + + assert warmths == sorted(warmths) + assert set(warmths) <= {1, 2, 3, 4, 5} + + +@pytest.mark.parametrize("feels_like", [-20, 0, 8, 15, 25]) +def test_plausible_seasons_are_always_valid_season_names(feels_like): + for name in plausible_seasons(feels_like): + assert Season(name)