Skip to content

Commit e67d313

Browse files
ambvclaudepablogsal
authored
Add backend tests (#12)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Pablo Galindo Salgado <pablogsal@gmail.com>
1 parent 28a926d commit e67d313

37 files changed

+1784
-136
lines changed

.github/workflows/ci.yml

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,45 @@ jobs:
4141
exit 1
4242
fi
4343
44+
backend-tests:
45+
name: Backend tests
46+
runs-on: ubuntu-latest
47+
steps:
48+
- uses: actions/checkout@v4
49+
with:
50+
fetch-depth: 0
51+
- name: Check for backend changes
52+
id: changes
53+
run: |
54+
if [ "${{ github.event_name }}" = "push" ]; then
55+
CHANGED=$(git diff --name-only ${{ github.event.before }} ${{ github.sha }} 2>/dev/null \
56+
|| git diff --name-only HEAD~1 HEAD 2>/dev/null \
57+
|| echo "backend/")
58+
else
59+
CHANGED=$(git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.event.pull_request.head.sha }})
60+
fi
61+
if echo "$CHANGED" | grep -Eq '^(backend/|\.github/workflows/ci\.yml$)'; then
62+
echo "backend=true" >> "$GITHUB_OUTPUT"
63+
fi
64+
- uses: actions/setup-python@v5
65+
if: steps.changes.outputs.backend == 'true'
66+
with:
67+
python-version: "3.13"
68+
cache: pip
69+
cache-dependency-path: backend/requirements-dev.txt
70+
- name: Install backend dependencies
71+
if: steps.changes.outputs.backend == 'true'
72+
working-directory: backend
73+
run: python -m pip install -r requirements-dev.txt
74+
- name: Run Ruff
75+
if: steps.changes.outputs.backend == 'true'
76+
working-directory: backend
77+
run: python -m ruff check .
78+
- name: Run tests
79+
if: steps.changes.outputs.backend == 'true'
80+
working-directory: backend
81+
run: python -m pytest tests/ -v --cov=app --cov-report=term-missing
82+
4483
frontend-lint:
4584
name: Frontend Lint & Typecheck
4685
runs-on: ubuntu-latest

README.md

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,16 +35,28 @@ Services start automatically with hot reload:
3535
## Development Commands
3636

3737
### Testing
38+
3839
```bash
39-
# Via Docker (recommended)
40+
# Backend tests
41+
docker compose -f docker-compose.dev.yml exec backend python -m pytest tests/ -v
42+
43+
# With coverage report
44+
docker compose -f docker-compose.dev.yml exec backend python -m pytest tests/ --cov=app --cov-report=term-missing
45+
46+
# Frontend checks
4047
docker compose -f docker-compose.dev.yml exec frontend npm run lint
4148
docker compose -f docker-compose.dev.yml exec frontend npm run typecheck
42-
43-
# Or locally in the frontend directory
44-
npm run lint # ESLint (must pass with zero errors)
45-
npm run typecheck # TypeScript type checking
4649
```
4750

51+
Backend tests use an in-memory SQLite database, independent of the
52+
PostgreSQL instance used in development. Each test gets a fresh database
53+
with empty tables. Fixtures in `backend/tests/conftest.py` provide
54+
pre-built model instances (commits, binaries, environments, runs,
55+
benchmark results, auth tokens) that tests can depend on as needed.
56+
Requests go through `httpx.AsyncClient` with FastAPI's ASGI transport,
57+
so the full request/response cycle (middleware, dependency injection,
58+
validation) is exercised without a running server.
59+
4860
Both checks run in CI on pushes to `main` and on pull requests.
4961

5062
### Populating Mock Data

backend/.coveragerc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[run]
2+
concurrency = greenlet

backend/app/admin_auth.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,7 @@
1212

1313
from .database import get_database
1414
from .models import AdminSession
15-
from .oauth import github_oauth, GitHubUser
16-
from .config import get_settings
15+
from .oauth import GitHubUser
1716

1817
logger = logging.getLogger(__name__)
1918

@@ -52,7 +51,7 @@ async def get_admin_session(
5251
select(AdminSession).where(
5352
and_(
5453
AdminSession.session_token == session_token,
55-
AdminSession.is_active == True,
54+
AdminSession.is_active.is_(True),
5655
AdminSession.expires_at > datetime.now(UTC).replace(tzinfo=None),
5756
)
5857
)
@@ -77,7 +76,7 @@ async def cleanup_expired_sessions(db: AsyncSession) -> None:
7776
select(AdminSession).where(
7877
and_(
7978
AdminSession.expires_at <= datetime.now(UTC).replace(tzinfo=None),
80-
AdminSession.is_active == True,
79+
AdminSession.is_active.is_(True),
8180
)
8281
)
8382
)

backend/app/auth.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
"""Authentication utilities for the Memory Tracker API."""
22

3-
from fastapi import Depends, HTTPException, status, Header
3+
from fastapi import Depends, Header
44
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
55
from sqlalchemy.ext.asyncio import AsyncSession
66
from typing import Annotated
7-
import logging
87

98
from . import models, crud
109
from .database import get_database

backend/app/config.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,8 @@
33
All settings are loaded from environment variables with sensible defaults.
44
"""
55

6-
from typing import List, Optional, Union
6+
from typing import List
77
from pydantic_settings import BaseSettings
8-
from pydantic import field_validator
98
from functools import lru_cache
109

1110

backend/app/crud.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44

55
from sqlalchemy import select, desc, and_, func, text
66
from sqlalchemy.ext.asyncio import AsyncSession
7-
from sqlalchemy.orm import selectinload, joinedload, contains_eager
87
from typing import List, Optional, Dict, Any
98
from datetime import datetime, UTC
109
import logging
@@ -257,7 +256,7 @@ async def create_benchmark_result(
257256
allocation_histogram=result.result_json.allocation_histogram,
258257
total_allocated_bytes=result.result_json.total_allocated_bytes,
259258
top_allocating_functions=[
260-
func.dict() for func in result.result_json.top_allocating_functions
259+
func.model_dump() for func in result.result_json.top_allocating_functions
261260
],
262261
flamegraph_html=result.flamegraph_html,
263262
)
@@ -411,7 +410,10 @@ async def get_auth_token_by_token(
411410
"""Get an auth token by its token value."""
412411
result = await db.execute(
413412
select(models.AuthToken).where(
414-
and_(models.AuthToken.token == token, models.AuthToken.is_active == True)
413+
and_(
414+
models.AuthToken.token == token,
415+
models.AuthToken.is_active.is_(True),
416+
)
415417
)
416418
)
417419
return result.scalars().first()
@@ -465,7 +467,7 @@ async def get_admin_users(db: AsyncSession) -> List[models.AdminUser]:
465467
"""Get all admin users."""
466468
result = await db.execute(
467469
select(models.AdminUser)
468-
.where(models.AdminUser.is_active == True)
470+
.where(models.AdminUser.is_active.is_(True))
469471
.order_by(models.AdminUser.added_at)
470472
)
471473
return result.scalars().all()
@@ -479,7 +481,7 @@ async def get_admin_user_by_username(
479481
select(models.AdminUser).where(
480482
and_(
481483
models.AdminUser.github_username == username,
482-
models.AdminUser.is_active == True,
484+
models.AdminUser.is_active.is_(True),
483485
)
484486
)
485487
)

backend/app/database.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
from contextlib import asynccontextmanager
2+
from typing import AsyncGenerator
3+
14
import logging
25
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
3-
from sqlalchemy.orm import sessionmaker
46
from sqlalchemy.exc import OperationalError, StatementError
57
from .models import Base
68
from .config import get_settings
@@ -85,11 +87,6 @@ async def drop_tables():
8587
async with engine.begin() as conn:
8688
await conn.run_sync(Base.metadata.drop_all)
8789

88-
89-
from contextlib import asynccontextmanager
90-
from typing import AsyncGenerator
91-
92-
9390
@asynccontextmanager
9491
async def transaction_scope() -> AsyncGenerator[AsyncSession, None]:
9592
"""

backend/app/factory.py

Lines changed: 33 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import uuid
44
import time
55
import logging
6+
from contextlib import asynccontextmanager
7+
from typing import AsyncIterator
68
from fastapi import FastAPI, Request
79
from fastapi.middleware.cors import CORSMiddleware
810

@@ -31,13 +33,44 @@ def create_app(settings=None) -> FastAPI:
3133
if settings is None:
3234
settings = get_settings()
3335

36+
@asynccontextmanager
37+
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
38+
# Configure logging using the app state before the app starts serving.
39+
app.state.logging_manager.configure_logging()
40+
41+
# Disable uvicorn access logs to avoid duplication
42+
uvicorn_logger = logging.getLogger("uvicorn.access")
43+
uvicorn_logger.disabled = True
44+
45+
logger = get_logger("api.startup")
46+
logger.info(
47+
"Application starting up",
48+
extra={
49+
"log_level": settings.log_level,
50+
"log_format": settings.log_format,
51+
"api_version": settings.api_version,
52+
},
53+
)
54+
await create_tables()
55+
logger.info("Database tables created successfully")
56+
57+
# Ensure initial admin user exists
58+
from .database import AsyncSessionLocal
59+
from .crud import ensure_initial_admin
60+
61+
async with AsyncSessionLocal() as db:
62+
await ensure_initial_admin(db, settings.admin_initial_username)
63+
64+
yield
65+
3466
# Create FastAPI instance
3567
app = FastAPI(
3668
title=settings.api_title,
3769
version=settings.api_version,
3870
docs_url="/api/docs",
3971
redoc_url="/api/redoc",
4072
openapi_url="/api/openapi.json",
73+
lifespan=lifespan,
4174
)
4275

4376
# Store dependencies in app state
@@ -133,35 +166,6 @@ async def log_requests(request: Request, call_next):
133166

134167
return response
135168

136-
# Configure startup event
137-
@app.on_event("startup")
138-
async def startup_event():
139-
# Configure logging using the app state
140-
app.state.logging_manager.configure_logging()
141-
142-
# Disable uvicorn access logs to avoid duplication
143-
uvicorn_logger = logging.getLogger("uvicorn.access")
144-
uvicorn_logger.disabled = True
145-
146-
logger = get_logger("api.startup")
147-
logger.info(
148-
"Application starting up",
149-
extra={
150-
"log_level": settings.log_level,
151-
"log_format": settings.log_format,
152-
"api_version": settings.api_version,
153-
},
154-
)
155-
await create_tables()
156-
logger.info("Database tables created successfully")
157-
158-
# Ensure initial admin user exists
159-
from .database import AsyncSessionLocal
160-
from .crud import ensure_initial_admin
161-
162-
async with AsyncSessionLocal() as db:
163-
await ensure_initial_admin(db, settings.admin_initial_username)
164-
165169
# Include routers
166170
app.include_router(health.router)
167171
app.include_router(commits.router)

backend/app/logging_utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Logging utilities for sanitizing sensitive data."""
22

33
import re
4-
from typing import Any, Dict, List, Union
4+
from typing import Any, Dict, List
55

66
# Patterns for sensitive data
77
SENSITIVE_PATTERNS = [

0 commit comments

Comments
 (0)