diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 6befcd555..bcb60b86b 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -75,7 +75,10 @@ jobs: uses: codecov/codecov-action@v7 with: token: ${{ secrets.CODECOV_TOKEN }} + files: backend/coverage.xml + disable_search: true fail_ci_if_error: true + verbose: true - name: Check coverage percentage run: | diff --git a/backend/Dockerfile b/backend/Dockerfile index c0ba3e285..f34b22b36 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -48,7 +48,7 @@ COPY alembic.ini /app/alembic.ini EXPOSE 80 -CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "80", "--workers", "4"] +CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "80", "--workers", "4", "--timeout-keep-alive", "180"] # command for Celery worker # CMD ["uv", "run", "celery", "-A", "app.celery.celery_app", "worker", "--loglevel=info"] diff --git a/backend/app/core/security.py b/backend/app/core/security.py index 96f7251ac..e6d068dfd 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -31,7 +31,6 @@ logger = logging.getLogger(__name__) -# Password hashing configuration pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") # JWT configuration @@ -299,7 +298,9 @@ class APIKeyManager: KEY_LENGTH = 65 # Total length: 22 (prefix) + 43 (secret) HASH_ALGORITHM = "bcrypt" - pwd_context = CryptContext(schemes=[HASH_ALGORITHM], deprecated="auto") + pwd_context = CryptContext( + schemes=[HASH_ALGORITHM], deprecated="auto" + ) # module-level context, shares the rounds config @classmethod def generate(cls) -> tuple[str, str, str]: diff --git a/backend/app/tests/conftest.py b/backend/app/tests/conftest.py index 5039493c5..dfa6fa18c 100644 --- a/backend/app/tests/conftest.py +++ b/backend/app/tests/conftest.py @@ -5,6 +5,7 @@ os.environ["ENVIRONMENT"] = "testing" from fastapi.testclient import TestClient +from filelock import FileLock from sqlmodel import Session from sqlalchemy import event from typing import Any, Generator @@ -46,7 +47,9 @@ def restart_savepoint(sess, trans): @pytest.fixture(scope="session", autouse=True) -def seed_baseline() -> Generator[None, None, None]: +def seed_baseline( + tmp_path_factory: pytest.TempPathFactory, worker_id: str +) -> Generator[None, None, None]: """ Seeds the database with baseline test data including credentials. @@ -55,10 +58,24 @@ def seed_baseline() -> Generator[None, None, None]: - OpenAI credentials are created for all test projects - Langfuse credentials are created for all test projects - All test fixtures can rely on credentials existing + + Under pytest-xdist, seed_database wipes and re-inserts shared rows, so + exactly one worker may run it; the others block on the lock and skip. """ - with Session(engine) as session: - seed_database(session) # deterministic baseline with credentials + if worker_id == "master": # not running under xdist + with Session(engine) as session: + seed_database(session) yield + return + + root_tmp = tmp_path_factory.getbasetemp().parent + with FileLock(root_tmp / "seed.lock"): + seeded_flag = root_tmp / "seeded" + if not seeded_flag.exists(): + with Session(engine) as session: + seed_database(session) + seeded_flag.touch() + yield @pytest.fixture(scope="function") diff --git a/backend/app/tests/seed_data/seed_data.py b/backend/app/tests/seed_data/seed_data.py index 01b7e56cd..f49e9bf65 100644 --- a/backend/app/tests/seed_data/seed_data.py +++ b/backend/app/tests/seed_data/seed_data.py @@ -1,7 +1,6 @@ import json import logging from pathlib import Path -from passlib.context import CryptContext from typing import Optional, Any from pydantic import BaseModel, EmailStr @@ -10,7 +9,7 @@ from app.core.db import engine from app.core import settings -from app.core.security import get_password_hash, encrypt_credentials +from app.core.security import APIKeyManager, get_password_hash, encrypt_credentials from app.models import ( APIKey, Organization, @@ -192,8 +191,7 @@ def create_api_key(session: Session, api_key_data_raw: dict[str, Any]) -> APIKey key_prefix = key_portion[:12] - pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") - key_hash = pwd_context.hash(key_portion[12:]) + key_hash = APIKeyManager.pwd_context.hash(key_portion[12:]) api_key = APIKey( organization_id=organization.id, diff --git a/backend/app/tests/test_conftest_seed_baseline.py b/backend/app/tests/test_conftest_seed_baseline.py new file mode 100644 index 000000000..8dd35c058 --- /dev/null +++ b/backend/app/tests/test_conftest_seed_baseline.py @@ -0,0 +1,66 @@ +from contextlib import suppress +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from app.tests import conftest + +# the fixture decorator hides the generator function; drive the raw one +seed_baseline_fn = conftest.seed_baseline.__wrapped__ + + +def _tmp_path_factory(root: Path) -> MagicMock: + # fixture reads getbasetemp().parent, so hand back a child of root + factory = MagicMock(spec=pytest.TempPathFactory) + factory.getbasetemp.return_value = root / "popen-gw0" + return factory + + +class TestSeedBaselineMaster: + def test_seeds_once_without_filelock(self, tmp_path: Path) -> None: + with ( + patch.object(conftest, "Session"), + patch.object(conftest, "seed_database") as seed_database, + patch.object(conftest, "FileLock") as file_lock, + ): + gen = seed_baseline_fn(_tmp_path_factory(tmp_path), "master") + next(gen) + with suppress(StopIteration): + next(gen) + + assert seed_database.call_count == 1 + file_lock.assert_not_called() + + +class TestSeedBaselineXdistWorker: + def test_seeds_and_touches_flag_when_absent(self, tmp_path: Path) -> None: + with ( + patch.object(conftest, "Session"), + patch.object(conftest, "seed_database") as seed_database, + patch.object(conftest, "FileLock") as file_lock, + ): + gen = seed_baseline_fn(_tmp_path_factory(tmp_path), "gw0") + next(gen) + with suppress(StopIteration): + next(gen) + + assert seed_database.call_count == 1 + assert (tmp_path / "seeded").exists() + file_lock.assert_called_once_with(tmp_path / "seed.lock") + + def test_skips_seeding_when_flag_exists(self, tmp_path: Path) -> None: + (tmp_path / "seeded").touch() + + with ( + patch.object(conftest, "Session"), + patch.object(conftest, "seed_database") as seed_database, + patch.object(conftest, "FileLock") as file_lock, + ): + gen = seed_baseline_fn(_tmp_path_factory(tmp_path), "gw1") + next(gen) + with suppress(StopIteration): + next(gen) + + seed_database.assert_not_called() + file_lock.assert_called_once_with(tmp_path / "seed.lock") diff --git a/backend/pyproject.toml b/backend/pyproject.toml index eb062cb0e..973d3ede7 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -76,6 +76,9 @@ dev-dependencies = [ "types-passlib<2.0.0.0,>=1.7.7.20240106", "coverage<8.0.0,>=7.4.3", "pytest-asyncio>=1.0.0", + "pytest-xdist>=3.8.0", + "filelock>=3.25.2", + "pytest-cov>=7.1.0", ] [build-system] @@ -85,6 +88,11 @@ build-backend = "hatchling.build" [project.scripts] ai-cli = "app.cli.main:cli" +# Keeps coverage.xml paths as "app/..." (relative to backend/) so Codecov +# can map them to repo files for the per-file view. +[tool.coverage.run] +relative_files = true + [tool.mypy] strict = true exclude = ["venv", ".venv", "alembic"] diff --git a/backend/scripts/test.sh b/backend/scripts/test.sh index 5ed7a9f31..b3364aa81 100755 --- a/backend/scripts/test.sh +++ b/backend/scripts/test.sh @@ -2,14 +2,9 @@ set -e set -x -# Run tests with coverage tracking -coverage run --source=app -m pytest - -# Generate a human-readable coverage report in the terminal -coverage report --show-missing - -# Generate an HTML report for local viewing -coverage html --title "${@-coverage}" - -# Generate the XML report for Codecov -coverage xml +# Parallel run with coverage; pytest-cov merges coverage across xdist workers +pytest -n auto -v \ + --cov=app \ + --cov-report=term-missing \ + --cov-report="html:htmlcov" \ + --cov-report=xml diff --git a/backend/uv.lock b/backend/uv.lock index cd9c09c86..675933ab5 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.12, <4.0" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", @@ -310,10 +310,13 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "coverage" }, + { name = "filelock" }, { name = "mypy" }, { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-xdist" }, { name = "ruff" }, { name = "types-passlib" }, ] @@ -379,10 +382,13 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "coverage", specifier = ">=7.4.3,<8.0.0" }, + { name = "filelock", specifier = ">=3.25.2" }, { name = "mypy", specifier = ">=1.8.0,<2.0.0" }, { name = "pre-commit", specifier = ">=3.6.2,<4.0.0" }, { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-asyncio", specifier = ">=1.0.0" }, + { name = "pytest-cov", specifier = ">=7.1.0" }, + { name = "pytest-xdist", specifier = ">=3.8.0" }, { name = "ruff", specifier = ">=0.2.2,<1.0.0" }, { name = "types-passlib", specifier = ">=1.7.7.20240106,<2.0.0.0" }, ] @@ -994,6 +1000,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, ] +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + [[package]] name = "fastapi" version = "0.135.1" @@ -3542,6 +3557,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, ] +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 000000000..e2f38017c --- /dev/null +++ b/codecov.yml @@ -0,0 +1,2 @@ +fixes: + - "app/::backend/app/" diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index f9e3b3e1c..09ae08b59 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -18,7 +18,7 @@ services: timeout: 5s retries: 5 command: > - uv run uvicorn app.main:app --host 0.0.0.0 --port 80 --reload + uv run uvicorn app.main:app --host 0.0.0.0 --port 80 --reload --timeout-keep-alive 180 develop: watch: # Sync backend source code into container immediately on change diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml index bbcece6bb..e7e8c149b 100644 --- a/docker-compose.staging.yml +++ b/docker-compose.staging.yml @@ -20,7 +20,7 @@ services: timeout: 5s retries: 5 command: > - uv run uvicorn app.main:app --host 0.0.0.0 --port 80 --reload + uv run uvicorn app.main:app --host 0.0.0.0 --port 80 --reload --timeout-keep-alive 180 develop: watch: # Sync backend source code into container immediately on change diff --git a/docker-compose.yml b/docker-compose.yml index 5d50e205b..83c1dba2a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -119,7 +119,7 @@ services: timeout: 5s retries: 5 command: > - uv run uvicorn app.main:app --host 0.0.0.0 --port 80 --reload + uv run uvicorn app.main:app --host 0.0.0.0 --port 80 --reload --timeout-keep-alive 180 develop: watch: # Sync backend source code into container immediately on change