Skip to content

Commit e446782

Browse files
Prajna1999claude
andauthored
feat(test): Implement parallel test execution and uvicorn timeout increase to 180s (#1152)
Co-authored-by: Claude Sonnet 5 <[email protected]>
1 parent a261a9d commit e446782

13 files changed

Lines changed: 157 additions & 25 deletions

‎.github/workflows/continuous-integration.yml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,10 @@ jobs:
7575
uses: codecov/codecov-action@v7
7676
with:
7777
token: ${{ secrets.CODECOV_TOKEN }}
78+
files: backend/coverage.xml
79+
disable_search: true
7880
fail_ci_if_error: true
81+
verbose: true
7982

8083
- name: Check coverage percentage
8184
run: |

‎backend/Dockerfile‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ COPY alembic.ini /app/alembic.ini
4848
EXPOSE 80
4949

5050

51-
CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "80", "--workers", "4"]
51+
CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "80", "--workers", "4", "--timeout-keep-alive", "180"]
5252

5353
# command for Celery worker
5454
# CMD ["uv", "run", "celery", "-A", "app.celery.celery_app", "worker", "--loglevel=info"]

‎backend/app/core/security.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@
3131

3232
logger = logging.getLogger(__name__)
3333

34-
# Password hashing configuration
3534
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
3635

3736
# JWT configuration
@@ -299,7 +298,9 @@ class APIKeyManager:
299298
KEY_LENGTH = 65 # Total length: 22 (prefix) + 43 (secret)
300299
HASH_ALGORITHM = "bcrypt"
301300

302-
pwd_context = CryptContext(schemes=[HASH_ALGORITHM], deprecated="auto")
301+
pwd_context = CryptContext(
302+
schemes=[HASH_ALGORITHM], deprecated="auto"
303+
) # module-level context, shares the rounds config
303304

304305
@classmethod
305306
def generate(cls) -> tuple[str, str, str]:

‎backend/app/tests/conftest.py‎

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
os.environ["ENVIRONMENT"] = "testing"
66

77
from fastapi.testclient import TestClient
8+
from filelock import FileLock
89
from sqlmodel import Session
910
from sqlalchemy import event
1011
from typing import Any, Generator
@@ -46,7 +47,9 @@ def restart_savepoint(sess, trans):
4647

4748

4849
@pytest.fixture(scope="session", autouse=True)
49-
def seed_baseline() -> Generator[None, None, None]:
50+
def seed_baseline(
51+
tmp_path_factory: pytest.TempPathFactory, worker_id: str
52+
) -> Generator[None, None, None]:
5053
"""
5154
Seeds the database with baseline test data including credentials.
5255
@@ -55,10 +58,24 @@ def seed_baseline() -> Generator[None, None, None]:
5558
- OpenAI credentials are created for all test projects
5659
- Langfuse credentials are created for all test projects
5760
- All test fixtures can rely on credentials existing
61+
62+
Under pytest-xdist, seed_database wipes and re-inserts shared rows, so
63+
exactly one worker may run it; the others block on the lock and skip.
5864
"""
59-
with Session(engine) as session:
60-
seed_database(session) # deterministic baseline with credentials
65+
if worker_id == "master": # not running under xdist
66+
with Session(engine) as session:
67+
seed_database(session)
6168
yield
69+
return
70+
71+
root_tmp = tmp_path_factory.getbasetemp().parent
72+
with FileLock(root_tmp / "seed.lock"):
73+
seeded_flag = root_tmp / "seeded"
74+
if not seeded_flag.exists():
75+
with Session(engine) as session:
76+
seed_database(session)
77+
seeded_flag.touch()
78+
yield
6279

6380

6481
@pytest.fixture(scope="function")

‎backend/app/tests/seed_data/seed_data.py‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import json
22
import logging
33
from pathlib import Path
4-
from passlib.context import CryptContext
54
from typing import Optional, Any
65

76
from pydantic import BaseModel, EmailStr
@@ -10,7 +9,7 @@
109

1110
from app.core.db import engine
1211
from app.core import settings
13-
from app.core.security import get_password_hash, encrypt_credentials
12+
from app.core.security import APIKeyManager, get_password_hash, encrypt_credentials
1413
from app.models import (
1514
APIKey,
1615
Organization,
@@ -192,8 +191,7 @@ def create_api_key(session: Session, api_key_data_raw: dict[str, Any]) -> APIKey
192191

193192
key_prefix = key_portion[:12]
194193

195-
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
196-
key_hash = pwd_context.hash(key_portion[12:])
194+
key_hash = APIKeyManager.pwd_context.hash(key_portion[12:])
197195

198196
api_key = APIKey(
199197
organization_id=organization.id,
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
from contextlib import suppress
2+
from pathlib import Path
3+
from unittest.mock import MagicMock, patch
4+
5+
import pytest
6+
7+
from app.tests import conftest
8+
9+
# the fixture decorator hides the generator function; drive the raw one
10+
seed_baseline_fn = conftest.seed_baseline.__wrapped__
11+
12+
13+
def _tmp_path_factory(root: Path) -> MagicMock:
14+
# fixture reads getbasetemp().parent, so hand back a child of root
15+
factory = MagicMock(spec=pytest.TempPathFactory)
16+
factory.getbasetemp.return_value = root / "popen-gw0"
17+
return factory
18+
19+
20+
class TestSeedBaselineMaster:
21+
def test_seeds_once_without_filelock(self, tmp_path: Path) -> None:
22+
with (
23+
patch.object(conftest, "Session"),
24+
patch.object(conftest, "seed_database") as seed_database,
25+
patch.object(conftest, "FileLock") as file_lock,
26+
):
27+
gen = seed_baseline_fn(_tmp_path_factory(tmp_path), "master")
28+
next(gen)
29+
with suppress(StopIteration):
30+
next(gen)
31+
32+
assert seed_database.call_count == 1
33+
file_lock.assert_not_called()
34+
35+
36+
class TestSeedBaselineXdistWorker:
37+
def test_seeds_and_touches_flag_when_absent(self, tmp_path: Path) -> None:
38+
with (
39+
patch.object(conftest, "Session"),
40+
patch.object(conftest, "seed_database") as seed_database,
41+
patch.object(conftest, "FileLock") as file_lock,
42+
):
43+
gen = seed_baseline_fn(_tmp_path_factory(tmp_path), "gw0")
44+
next(gen)
45+
with suppress(StopIteration):
46+
next(gen)
47+
48+
assert seed_database.call_count == 1
49+
assert (tmp_path / "seeded").exists()
50+
file_lock.assert_called_once_with(tmp_path / "seed.lock")
51+
52+
def test_skips_seeding_when_flag_exists(self, tmp_path: Path) -> None:
53+
(tmp_path / "seeded").touch()
54+
55+
with (
56+
patch.object(conftest, "Session"),
57+
patch.object(conftest, "seed_database") as seed_database,
58+
patch.object(conftest, "FileLock") as file_lock,
59+
):
60+
gen = seed_baseline_fn(_tmp_path_factory(tmp_path), "gw1")
61+
next(gen)
62+
with suppress(StopIteration):
63+
next(gen)
64+
65+
seed_database.assert_not_called()
66+
file_lock.assert_called_once_with(tmp_path / "seed.lock")

‎backend/pyproject.toml‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,9 @@ dev-dependencies = [
7676
"types-passlib<2.0.0.0,>=1.7.7.20240106",
7777
"coverage<8.0.0,>=7.4.3",
7878
"pytest-asyncio>=1.0.0",
79+
"pytest-xdist>=3.8.0",
80+
"filelock>=3.25.2",
81+
"pytest-cov>=7.1.0",
7982
]
8083

8184
[build-system]
@@ -85,6 +88,11 @@ build-backend = "hatchling.build"
8588
[project.scripts]
8689
ai-cli = "app.cli.main:cli"
8790

91+
# Keeps coverage.xml paths as "app/..." (relative to backend/) so Codecov
92+
# can map them to repo files for the per-file view.
93+
[tool.coverage.run]
94+
relative_files = true
95+
8896
[tool.mypy]
8997
strict = true
9098
exclude = ["venv", ".venv", "alembic"]

‎backend/scripts/test.sh‎

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,9 @@
22
set -e
33
set -x
44

5-
# Run tests with coverage tracking
6-
coverage run --source=app -m pytest
7-
8-
# Generate a human-readable coverage report in the terminal
9-
coverage report --show-missing
10-
11-
# Generate an HTML report for local viewing
12-
coverage html --title "${@-coverage}"
13-
14-
# Generate the XML report for Codecov
15-
coverage xml
5+
# Parallel run with coverage; pytest-cov merges coverage across xdist workers
6+
pytest -n auto -v \
7+
--cov=app \
8+
--cov-report=term-missing \
9+
--cov-report="html:htmlcov" \
10+
--cov-report=xml

‎backend/uv.lock‎

Lines changed: 43 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎codecov.yml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
fixes:
2+
- "app/::backend/app/"

0 commit comments

Comments
 (0)