diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 26054df..f236c19 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,45 @@ jobs: exit 1 fi + backend-tests: + name: Backend tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Check for backend changes + id: changes + run: | + if [ "${{ github.event_name }}" = "push" ]; then + CHANGED=$(git diff --name-only ${{ github.event.before }} ${{ github.sha }} 2>/dev/null \ + || git diff --name-only HEAD~1 HEAD 2>/dev/null \ + || echo "backend/") + else + CHANGED=$(git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.event.pull_request.head.sha }}) + fi + if echo "$CHANGED" | grep -Eq '^(backend/|\.github/workflows/ci\.yml$)'; then + echo "backend=true" >> "$GITHUB_OUTPUT" + fi + - uses: actions/setup-python@v5 + if: steps.changes.outputs.backend == 'true' + with: + python-version: "3.13" + cache: pip + cache-dependency-path: backend/requirements-dev.txt + - name: Install backend dependencies + if: steps.changes.outputs.backend == 'true' + working-directory: backend + run: python -m pip install -r requirements-dev.txt + - name: Run Ruff + if: steps.changes.outputs.backend == 'true' + working-directory: backend + run: python -m ruff check . + - name: Run tests + if: steps.changes.outputs.backend == 'true' + working-directory: backend + run: python -m pytest tests/ -v --cov=app --cov-report=term-missing + frontend-lint: name: Frontend Lint & Typecheck runs-on: ubuntu-latest diff --git a/README.md b/README.md index d646c3e..524fe16 100644 --- a/README.md +++ b/README.md @@ -35,16 +35,28 @@ Services start automatically with hot reload: ## Development Commands ### Testing + ```bash -# Via Docker (recommended) +# Backend tests +docker compose -f docker-compose.dev.yml exec backend python -m pytest tests/ -v + +# With coverage report +docker compose -f docker-compose.dev.yml exec backend python -m pytest tests/ --cov=app --cov-report=term-missing + +# Frontend checks docker compose -f docker-compose.dev.yml exec frontend npm run lint docker compose -f docker-compose.dev.yml exec frontend npm run typecheck - -# Or locally in the frontend directory -npm run lint # ESLint (must pass with zero errors) -npm run typecheck # TypeScript type checking ``` +Backend tests use an in-memory SQLite database, independent of the +PostgreSQL instance used in development. Each test gets a fresh database +with empty tables. Fixtures in `backend/tests/conftest.py` provide +pre-built model instances (commits, binaries, environments, runs, +benchmark results, auth tokens) that tests can depend on as needed. +Requests go through `httpx.AsyncClient` with FastAPI's ASGI transport, +so the full request/response cycle (middleware, dependency injection, +validation) is exercised without a running server. + Both checks run in CI on pushes to `main` and on pull requests. ### Populating Mock Data diff --git a/backend/.coveragerc b/backend/.coveragerc new file mode 100644 index 0000000..0099493 --- /dev/null +++ b/backend/.coveragerc @@ -0,0 +1,2 @@ +[run] +concurrency = greenlet diff --git a/backend/app/admin_auth.py b/backend/app/admin_auth.py index aa17dd7..a239f39 100644 --- a/backend/app/admin_auth.py +++ b/backend/app/admin_auth.py @@ -12,8 +12,7 @@ from .database import get_database from .models import AdminSession -from .oauth import github_oauth, GitHubUser -from .config import get_settings +from .oauth import GitHubUser logger = logging.getLogger(__name__) @@ -52,7 +51,7 @@ async def get_admin_session( select(AdminSession).where( and_( AdminSession.session_token == session_token, - AdminSession.is_active == True, + AdminSession.is_active.is_(True), AdminSession.expires_at > datetime.now(UTC).replace(tzinfo=None), ) ) @@ -77,7 +76,7 @@ async def cleanup_expired_sessions(db: AsyncSession) -> None: select(AdminSession).where( and_( AdminSession.expires_at <= datetime.now(UTC).replace(tzinfo=None), - AdminSession.is_active == True, + AdminSession.is_active.is_(True), ) ) ) diff --git a/backend/app/auth.py b/backend/app/auth.py index ac96404..e98bd17 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -1,10 +1,9 @@ """Authentication utilities for the Memory Tracker API.""" -from fastapi import Depends, HTTPException, status, Header +from fastapi import Depends, Header from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from sqlalchemy.ext.asyncio import AsyncSession from typing import Annotated -import logging from . import models, crud from .database import get_database diff --git a/backend/app/config.py b/backend/app/config.py index 02a8ecd..2a92e2e 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -3,9 +3,8 @@ All settings are loaded from environment variables with sensible defaults. """ -from typing import List, Optional, Union +from typing import List from pydantic_settings import BaseSettings -from pydantic import field_validator from functools import lru_cache diff --git a/backend/app/crud.py b/backend/app/crud.py index 8fe3ad0..422ea1a 100644 --- a/backend/app/crud.py +++ b/backend/app/crud.py @@ -4,7 +4,6 @@ from sqlalchemy import select, desc, and_, func, text from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import selectinload, joinedload, contains_eager from typing import List, Optional, Dict, Any from datetime import datetime, UTC import logging @@ -257,7 +256,7 @@ async def create_benchmark_result( allocation_histogram=result.result_json.allocation_histogram, total_allocated_bytes=result.result_json.total_allocated_bytes, top_allocating_functions=[ - func.dict() for func in result.result_json.top_allocating_functions + func.model_dump() for func in result.result_json.top_allocating_functions ], flamegraph_html=result.flamegraph_html, ) @@ -411,7 +410,10 @@ async def get_auth_token_by_token( """Get an auth token by its token value.""" result = await db.execute( select(models.AuthToken).where( - and_(models.AuthToken.token == token, models.AuthToken.is_active == True) + and_( + models.AuthToken.token == token, + models.AuthToken.is_active.is_(True), + ) ) ) return result.scalars().first() @@ -465,7 +467,7 @@ async def get_admin_users(db: AsyncSession) -> List[models.AdminUser]: """Get all admin users.""" result = await db.execute( select(models.AdminUser) - .where(models.AdminUser.is_active == True) + .where(models.AdminUser.is_active.is_(True)) .order_by(models.AdminUser.added_at) ) return result.scalars().all() @@ -479,7 +481,7 @@ async def get_admin_user_by_username( select(models.AdminUser).where( and_( models.AdminUser.github_username == username, - models.AdminUser.is_active == True, + models.AdminUser.is_active.is_(True), ) ) ) diff --git a/backend/app/database.py b/backend/app/database.py index 789f737..4cb1615 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -1,6 +1,8 @@ +from contextlib import asynccontextmanager +from typing import AsyncGenerator + import logging from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker -from sqlalchemy.orm import sessionmaker from sqlalchemy.exc import OperationalError, StatementError from .models import Base from .config import get_settings @@ -85,11 +87,6 @@ async def drop_tables(): async with engine.begin() as conn: await conn.run_sync(Base.metadata.drop_all) - -from contextlib import asynccontextmanager -from typing import AsyncGenerator - - @asynccontextmanager async def transaction_scope() -> AsyncGenerator[AsyncSession, None]: """ diff --git a/backend/app/factory.py b/backend/app/factory.py index 1e3e206..ff5b74e 100644 --- a/backend/app/factory.py +++ b/backend/app/factory.py @@ -3,6 +3,8 @@ import uuid import time import logging +from contextlib import asynccontextmanager +from typing import AsyncIterator from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware @@ -31,6 +33,36 @@ def create_app(settings=None) -> FastAPI: if settings is None: settings = get_settings() + @asynccontextmanager + async def lifespan(app: FastAPI) -> AsyncIterator[None]: + # Configure logging using the app state before the app starts serving. + app.state.logging_manager.configure_logging() + + # Disable uvicorn access logs to avoid duplication + uvicorn_logger = logging.getLogger("uvicorn.access") + uvicorn_logger.disabled = True + + logger = get_logger("api.startup") + logger.info( + "Application starting up", + extra={ + "log_level": settings.log_level, + "log_format": settings.log_format, + "api_version": settings.api_version, + }, + ) + await create_tables() + logger.info("Database tables created successfully") + + # Ensure initial admin user exists + from .database import AsyncSessionLocal + from .crud import ensure_initial_admin + + async with AsyncSessionLocal() as db: + await ensure_initial_admin(db, settings.admin_initial_username) + + yield + # Create FastAPI instance app = FastAPI( title=settings.api_title, @@ -38,6 +70,7 @@ def create_app(settings=None) -> FastAPI: docs_url="/api/docs", redoc_url="/api/redoc", openapi_url="/api/openapi.json", + lifespan=lifespan, ) # Store dependencies in app state @@ -133,35 +166,6 @@ async def log_requests(request: Request, call_next): return response - # Configure startup event - @app.on_event("startup") - async def startup_event(): - # Configure logging using the app state - app.state.logging_manager.configure_logging() - - # Disable uvicorn access logs to avoid duplication - uvicorn_logger = logging.getLogger("uvicorn.access") - uvicorn_logger.disabled = True - - logger = get_logger("api.startup") - logger.info( - "Application starting up", - extra={ - "log_level": settings.log_level, - "log_format": settings.log_format, - "api_version": settings.api_version, - }, - ) - await create_tables() - logger.info("Database tables created successfully") - - # Ensure initial admin user exists - from .database import AsyncSessionLocal - from .crud import ensure_initial_admin - - async with AsyncSessionLocal() as db: - await ensure_initial_admin(db, settings.admin_initial_username) - # Include routers app.include_router(health.router) app.include_router(commits.router) diff --git a/backend/app/logging_utils.py b/backend/app/logging_utils.py index fc7a0da..c8d30c3 100644 --- a/backend/app/logging_utils.py +++ b/backend/app/logging_utils.py @@ -1,7 +1,7 @@ """Logging utilities for sanitizing sensitive data.""" import re -from typing import Any, Dict, List, Union +from typing import Any, Dict, List # Patterns for sensitive data SENSITIVE_PATTERNS = [ diff --git a/backend/app/models.py b/backend/app/models.py index 6c201ae..5f414b7 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -11,8 +11,7 @@ Boolean, UniqueConstraint, ) -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import relationship +from sqlalchemy.orm import declarative_base, relationship from datetime import datetime, UTC Base = declarative_base() diff --git a/backend/app/oauth.py b/backend/app/oauth.py index c3aac8e..559121d 100644 --- a/backend/app/oauth.py +++ b/backend/app/oauth.py @@ -4,9 +4,9 @@ import secrets import logging -from typing import Optional, Dict, Any +from typing import Optional from authlib.integrations.httpx_client import AsyncOAuth2Client -from fastapi import HTTPException, Request +from fastapi import HTTPException from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession diff --git a/backend/app/routers/binaries.py b/backend/app/routers/binaries.py index 21a2da0..5b0b6d7 100644 --- a/backend/app/routers/binaries.py +++ b/backend/app/routers/binaries.py @@ -19,7 +19,7 @@ async def get_binaries(db: AsyncSession = Depends(get_database)): try: binaries = await crud.get_binaries(db) - logger.info(f"Successfully retrieved binaries", extra={"count": len(binaries)}) + logger.info("Successfully retrieved binaries", extra={"count": len(binaries)}) return [ schemas.Binary( @@ -34,7 +34,7 @@ async def get_binaries(db: AsyncSession = Depends(get_database)): for binary in binaries ] except Exception as e: - logger.error(f"Failed to fetch binaries", extra={"error": str(e)}) + logger.error("Failed to fetch binaries", extra={"error": str(e)}) raise HTTPException(status_code=500, detail="Failed to fetch binaries") diff --git a/backend/app/routers/commits.py b/backend/app/routers/commits.py index 15d9d71..2fb5d6b 100644 --- a/backend/app/routers/commits.py +++ b/backend/app/routers/commits.py @@ -20,11 +20,11 @@ async def get_commits( db: AsyncSession = Depends(get_database), ): logger = get_logger("api.commits") - logger.info(f"Fetching commits", extra={"skip": skip, "limit": limit}) + logger.info("Fetching commits", extra={"skip": skip, "limit": limit}) try: commits = await crud.get_commits(db, skip=skip, limit=limit) - logger.info(f"Successfully retrieved commits", extra={"count": len(commits)}) + logger.info("Successfully retrieved commits", extra={"count": len(commits)}) return [ schemas.Commit( @@ -41,23 +41,23 @@ async def get_commits( for commit in commits ] except Exception as e: - logger.error(f"Failed to fetch commits", extra={"error": str(e)}) + logger.error("Failed to fetch commits", extra={"error": str(e)}) raise HTTPException(status_code=500, detail="Failed to fetch commits") @router.get("/commits/{sha}", response_model=schemas.Commit) async def get_commit(sha: str, db: AsyncSession = Depends(get_database)): logger = get_logger("api.commits") - logger.info(f"Fetching commit by SHA", extra={"sha": sha}) + logger.info("Fetching commit by SHA", extra={"sha": sha}) try: commit = await crud.get_commit_by_sha(db, sha=sha) if commit is None: - logger.warning(f"Commit not found", extra={"sha": sha}) + logger.warning("Commit not found", extra={"sha": sha}) raise HTTPException(status_code=404, detail="Commit not found") logger.info( - f"Successfully retrieved commit", + "Successfully retrieved commit", extra={ "sha": commit.sha[:8], "author": commit.author, @@ -79,7 +79,7 @@ async def get_commit(sha: str, db: AsyncSession = Depends(get_database)): except HTTPException: raise except Exception as e: - logger.error(f"Failed to fetch commit", extra={"sha": sha, "error": str(e)}) + logger.error("Failed to fetch commit", extra={"sha": sha, "error": str(e)}) raise HTTPException(status_code=500, detail="Failed to fetch commit") diff --git a/backend/app/routers/upload.py b/backend/app/routers/upload.py index cfae199..c73e605 100644 --- a/backend/app/routers/upload.py +++ b/backend/app/routers/upload.py @@ -329,6 +329,7 @@ def clean_flag(flag): models.MemrayBuildFailure.environment_id == environment_id ) ) + await db.commit() return { "message": "Worker run uploaded successfully", @@ -341,8 +342,18 @@ def clean_flag(flag): } except IntegrityError as e: - # Handle unique constraint violation for duplicate commit+binary+environment - if "unique_commit_binary_env" in str(e).lower(): + # Check for duplicate run constraint violation. + # asyncpg exposes constraint_name directly on the original exception; + # fall back to string matching for other backends (e.g. SQLite). + constraint = getattr(getattr(e, "orig", None), "constraint_name", None) + is_duplicate = ( + constraint == "unique_commit_binary_env" + if constraint is not None + else "commit_sha" in str(e).lower() + and "binary_id" in str(e).lower() + and "environment_id" in str(e).lower() + ) + if is_duplicate: logger.error( f"Upload failed: Duplicate run for commit {commit_sha[:8]}, binary '{binary_id}', environment '{environment_id}'" ) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 0d87419..c2541b4 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from typing import List, Optional, Dict, Any, Tuple from datetime import datetime @@ -22,8 +22,7 @@ class CommitCreate(CommitBase): class Commit(CommitBase): - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class BinaryBase(BaseModel): @@ -45,8 +44,7 @@ class BinaryCreate(BinaryBase): class Binary(BinaryBase): - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class EnvironmentBase(BaseModel): @@ -60,8 +58,7 @@ class EnvironmentCreate(EnvironmentBase): class Environment(EnvironmentBase): - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class RunBase(BaseModel): @@ -78,8 +75,7 @@ class RunCreate(RunBase): class Run(RunBase): - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class TopAllocatingFunction(BaseModel): @@ -111,8 +107,7 @@ class BenchmarkResultCreate(BaseModel): class BenchmarkResult(BenchmarkResultBase): - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # Worker upload schemas @@ -181,8 +176,7 @@ class AdminUserPublic(BaseModel): added_at: datetime is_active: bool - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class MemrayFailureReport(BaseModel): @@ -204,5 +198,4 @@ class MemrayFailurePublic(BaseModel): failure_timestamp: datetime commit_timestamp: datetime - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..6f94355 --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests +asyncio_mode = auto diff --git a/backend/requirements-dev.in b/backend/requirements-dev.in index d116015..bcd863f 100644 --- a/backend/requirements-dev.in +++ b/backend/requirements-dev.in @@ -1,3 +1,5 @@ -r requirements.in pytest pytest-asyncio +pytest-cov +ruff diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt index f18e0eb..8dc51ed 100644 --- a/backend/requirements-dev.txt +++ b/backend/requirements-dev.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.13 +# This file is autogenerated by pip-compile with Python 3.14 # by the following command: # # pip-compile --generate-hashes --output-file=requirements-dev.txt --strip-extras requirements-dev.in @@ -182,6 +182,114 @@ click==8.3.1 \ --hash=sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a \ --hash=sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6 # via uvicorn +coverage==7.13.4 \ + --hash=sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246 \ + --hash=sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459 \ + --hash=sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129 \ + --hash=sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6 \ + --hash=sha256:0fc31c787a84f8cd6027eba44010517020e0d18487064cd3d8968941856d1415 \ + --hash=sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf \ + --hash=sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80 \ + --hash=sha256:19bc3c88078789f8ef36acb014d7241961dbf883fd2533d18cb1e7a5b4e28b11 \ + --hash=sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0 \ + --hash=sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b \ + --hash=sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9 \ + --hash=sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b \ + --hash=sha256:2421d591f8ca05b308cf0092807308b2facbefe54af7c02ac22548b88b95c98f \ + --hash=sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505 \ + --hash=sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47 \ + --hash=sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55 \ + --hash=sha256:296f8b0af861d3970c2a4d8c91d48eb4dd4771bcef9baedec6a9b515d7de3def \ + --hash=sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689 \ + --hash=sha256:2a09cfa6a5862bc2fc6ca7c3def5b2926194a56b8ab78ffcf617d28911123012 \ + --hash=sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5 \ + --hash=sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3 \ + --hash=sha256:2cb0f1e000ebc419632bbe04366a8990b6e32c4e0b51543a6484ffe15eaeda95 \ + --hash=sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9 \ + --hash=sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601 \ + --hash=sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997 \ + --hash=sha256:33901f604424145c6e9c2398684b92e176c0b12df77d52db81c20abd48c3794c \ + --hash=sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac \ + --hash=sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c \ + --hash=sha256:3998e5a32e62fdf410c0dbd3115df86297995d6e3429af80b8798aad894ca7aa \ + --hash=sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750 \ + --hash=sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3 \ + --hash=sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d \ + --hash=sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12 \ + --hash=sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a \ + --hash=sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932 \ + --hash=sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356 \ + --hash=sha256:4fc7fa81bbaf5a02801b65346c8b3e657f1d93763e58c0abdf7c992addd81a92 \ + --hash=sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148 \ + --hash=sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39 \ + --hash=sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634 \ + --hash=sha256:65dfcbe305c3dfe658492df2d85259e0d79ead4177f9ae724b6fb245198f55d6 \ + --hash=sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72 \ + --hash=sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98 \ + --hash=sha256:71ca20079dd8f27fcf808817e281e90220475cd75115162218d0e27549f95fef \ + --hash=sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3 \ + --hash=sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9 \ + --hash=sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0 \ + --hash=sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a \ + --hash=sha256:784fc3cf8be001197b652d51d3fd259b1e2262888693a4636e18879f613a62a9 \ + --hash=sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552 \ + --hash=sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc \ + --hash=sha256:79e73a76b854d9c6088fe5d8b2ebe745f8681c55f7397c3c0a016192d681045f \ + --hash=sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525 \ + --hash=sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940 \ + --hash=sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a \ + --hash=sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23 \ + --hash=sha256:8041b6c5bfdc03257666e9881d33b1abc88daccaf73f7b6340fb7946655cd10f \ + --hash=sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc \ + --hash=sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b \ + --hash=sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056 \ + --hash=sha256:8e264226ec98e01a8e1054314af91ee6cde0eacac4f465cc93b03dbe0bce2fd7 \ + --hash=sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb \ + --hash=sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a \ + --hash=sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd \ + --hash=sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea \ + --hash=sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126 \ + --hash=sha256:94eb63f9b363180aff17de3e7c8760c3ba94664ea2695c52f10111244d16a299 \ + --hash=sha256:9d107aff57a83222ddbd8d9ee705ede2af2cc926608b57abed8ef96b50b7e8f9 \ + --hash=sha256:a32ebc02a1805adf637fc8dec324b5cdacd2e493515424f70ee33799573d661b \ + --hash=sha256:a3aa4e7b9e416774b21797365b358a6e827ffadaaca81b69ee02946852449f00 \ + --hash=sha256:a6f94a7d00eb18f1b6d403c91a88fd58cfc92d4b16080dfdb774afc8294469bf \ + --hash=sha256:aa3feb8db2e87ff5e6d00d7e1480ae241876286691265657b500886c98f38bda \ + --hash=sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2 \ + --hash=sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5 \ + --hash=sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d \ + --hash=sha256:b507778ae8a4c915436ed5c2e05b4a6cecfa70f734e19c22a005152a11c7b6a9 \ + --hash=sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9 \ + --hash=sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b \ + --hash=sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa \ + --hash=sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092 \ + --hash=sha256:bb28c0f2cf2782508a40cec377935829d5fcc3ad9a3681375af4e84eb34b6b58 \ + --hash=sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea \ + --hash=sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26 \ + --hash=sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea \ + --hash=sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9 \ + --hash=sha256:d490ba50c3f35dd7c17953c68f3270e7ccd1c6642e2d2afe2d8e720b98f5a053 \ + --hash=sha256:d65b2d373032411e86960604dc4edac91fdfb5dca539461cf2cbe78327d1e64f \ + --hash=sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0 \ + --hash=sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3 \ + --hash=sha256:e101609bcbbfb04605ea1027b10dc3735c094d12d40826a60f897b98b1c30256 \ + --hash=sha256:e24f9156097ff9dc286f2f913df3a7f63c0e333dcafa3c196f2c18b4175ca09a \ + --hash=sha256:e2f25215f1a359ab17320b47bcdaca3e6e6356652e8256f2441e4ef972052903 \ + --hash=sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91 \ + --hash=sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd \ + --hash=sha256:e856bf6616714c3a9fbc270ab54103f4e685ba236fa98c054e8f87f266c93505 \ + --hash=sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7 \ + --hash=sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0 \ + --hash=sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2 \ + --hash=sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a \ + --hash=sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71 \ + --hash=sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985 \ + --hash=sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242 \ + --hash=sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d \ + --hash=sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af \ + --hash=sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c \ + --hash=sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0 + # via pytest-cov cryptography==46.0.5 \ --hash=sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72 \ --hash=sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235 \ @@ -368,7 +476,9 @@ packaging==26.0 \ pluggy==1.6.0 \ --hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \ --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 - # via pytest + # via + # pytest + # pytest-cov psycopg2-binary==2.9.11 \ --hash=sha256:00ce1830d971f43b667abe4a56e42c1e2d594b32da4802e44a73bacacb25535f \ --hash=sha256:04195548662fa544626c8ea0f06561eb6203f1984ba5b4562764fbeb4c3d14b1 \ @@ -586,10 +696,15 @@ pytest==9.0.2 \ # via # -r requirements-dev.in # pytest-asyncio + # pytest-cov pytest-asyncio==1.3.0 \ --hash=sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5 \ --hash=sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5 # via -r requirements-dev.in +pytest-cov==7.0.0 \ + --hash=sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1 \ + --hash=sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861 + # via -r requirements-dev.in python-dateutil==2.9.0.post0 \ --hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \ --hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427 @@ -679,6 +794,26 @@ pyyaml==6.0.3 \ --hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \ --hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0 # via uvicorn +ruff==0.15.6 \ + --hash=sha256:13f4594b04e42cd24a41da653886b04d2ff87adbf57497ed4f728b0e8a4866f8 \ + --hash=sha256:1c22e6f02c16cfac3888aa636e9eba857254d15bbacc9906c9689fdecb1953ab \ + --hash=sha256:3bd9967851a25f038fc8b9ae88a7fbd1b609f30349231dffaa37b6804923c4bb \ + --hash=sha256:542aaf1de3154cea088ced5a819ce872611256ffe2498e750bbae5247a8114e9 \ + --hash=sha256:55a1ad63c5a6e54b1f21b7514dfadc0c7fb40093fa22e95143cf3f64ebdcd512 \ + --hash=sha256:70789d3e7830b848b548aae96766431c0dc01a6c78c13381f423bf7076c66d15 \ + --hash=sha256:70d263770d234912374493e8cc1e7385c5d49376e41dfa51c5c3453169dc581c \ + --hash=sha256:7c98c3b16407b2cf3d0f2b80c80187384bc92c6774d85fefa913ecd941256fff \ + --hash=sha256:8394c7bb153a4e3811a4ecdacd4a8e6a4fa8097028119160dffecdcdf9b56ae4 \ + --hash=sha256:85b042377c2a5561131767974617006f99f7e13c63c111b998f29fc1e58a4cfb \ + --hash=sha256:8dc473ba093c5ec238bb1e7429ee676dca24643c471e11fbaa8a857925b061c0 \ + --hash=sha256:98893c4c0aadc8e448cfa315bd0cc343a5323d740fe5f28ef8a3f9e21b381f7e \ + --hash=sha256:aee25bc84c2f1007ecb5037dff75cef00414fdf17c23f07dc13e577883dca406 \ + --hash=sha256:bbf67d39832404812a2d23020dda68fee7f18ce15654e96fb1d3ad21a5fe436c \ + --hash=sha256:c34de3dd0b0ba203be50ae70f5910b17188556630e2178fd7d79fc030eb0d837 \ + --hash=sha256:cef49e30bc5a86a6a92098a7fbf6e467a234d90b63305d6f3ec01225a9d092e0 \ + --hash=sha256:e2ed8aea2f3fe57886d3f00ea5b8aae5bf68d5e195f487f037a955ff9fbaac9e \ + --hash=sha256:ee7dcfaad8b282a284df4aa6ddc2741b3f4a18b0555d626805555a820ea181c3 + # via -r requirements-dev.in six==1.17.0 \ --hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \ --hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81 diff --git a/backend/scripts/init_db.py b/backend/scripts/init_db.py index 341b8f5..9da73e3 100755 --- a/backend/scripts/init_db.py +++ b/backend/scripts/init_db.py @@ -11,7 +11,7 @@ # Add the parent directory to the path so we can import from app sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) -from app.database import create_tables, drop_tables, get_database +from app.database import get_database from app import crud from app.config import get_settings @@ -65,14 +65,6 @@ async def init_database(): try: # Import models to ensure they're registered from app.models import ( - AdminUser, - AdminSession, - AuthToken, - Commit, - Binary, - Environment, - Run, - BenchmarkResult, Base, ) from app.database import create_database_engine @@ -128,7 +120,6 @@ async def reset_database(): Base, ) from app.database import create_database_engine - from sqlalchemy.ext.asyncio import create_async_engine # Create a fresh engine with current settings engine = create_database_engine() diff --git a/backend/scripts/manage_tokens.py b/backend/scripts/manage_tokens.py index bda0018..45f177e 100755 --- a/backend/scripts/manage_tokens.py +++ b/backend/scripts/manage_tokens.py @@ -9,11 +9,9 @@ import sys import os import secrets -import hashlib import argparse import json -from datetime import datetime, timedelta, UTC -from typing import List, Dict, Any, Optional +from datetime import datetime, timedelta # Add the parent directory to the path so we can import from app sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) @@ -72,7 +70,7 @@ async def create_token(name: str, description: str = None) -> str: # Create the token in the database auth_token = await crud.create_auth_token(db, token, name, description) - print(f"āœ… Successfully created authentication token!") + print("āœ… Successfully created authentication token!") print(f" ID: {auth_token.id}") print(f" Name: {auth_token.name}") print(f" Description: {auth_token.description or 'None'}") @@ -142,9 +140,9 @@ async def list_tokens() -> None: async def deactivate_token(token_id: int) -> None: """Deactivate an authentication token.""" # Ensure database tables exist - await create_tables() + await db_manager.create_tables() - async with AsyncSessionLocal() as db: + async with db_manager.AsyncSession() as db: try: success = await crud.deactivate_auth_token(db, token_id) if success: @@ -160,9 +158,9 @@ async def deactivate_token(token_id: int) -> None: async def reactivate_token(token_id: int) -> None: """Reactivate a deactivated authentication token.""" - await create_tables() + await db_manager.create_tables() - async with AsyncSessionLocal() as db: + async with db_manager.AsyncSession() as db: try: result = await db.execute( select(models.AuthToken).where(models.AuthToken.id == token_id) @@ -190,9 +188,9 @@ async def update_token_info( token_id: int, name: str = None, description: str = None ) -> None: """Update token name and/or description.""" - await create_tables() + await db_manager.create_tables() - async with AsyncSessionLocal() as db: + async with db_manager.AsyncSession() as db: try: result = await db.execute( select(models.AuthToken).where(models.AuthToken.id == token_id) @@ -233,9 +231,9 @@ async def search_tokens( inactive_only: bool = False, ) -> None: """Search tokens by name or description patterns.""" - await create_tables() + await db_manager.create_tables() - async with AsyncSessionLocal() as db: + async with db_manager.AsyncSession() as db: try: query = select(models.AuthToken) @@ -251,9 +249,9 @@ async def search_tokens( ) if active_only: - conditions.append(models.AuthToken.is_active == True) + conditions.append(models.AuthToken.is_active.is_(True)) elif inactive_only: - conditions.append(models.AuthToken.is_active == False) + conditions.append(models.AuthToken.is_active.is_(False)) if conditions: query = query.where(and_(*conditions)) @@ -308,9 +306,9 @@ async def search_tokens( async def show_token_details(token_id: int) -> None: """Show detailed information about a specific token.""" - await create_tables() + await db_manager.create_tables() - async with AsyncSessionLocal() as db: + async with db_manager.AsyncSession() as db: try: result = await db.execute( select(models.AuthToken).where(models.AuthToken.id == token_id) @@ -349,9 +347,9 @@ async def show_token_details(token_id: int) -> None: async def show_token_analytics() -> None: """Show analytics and statistics about token usage.""" - await create_tables() + await db_manager.create_tables() - async with AsyncSessionLocal() as db: + async with db_manager.AsyncSession() as db: try: # Get basic counts total_result = await db.execute(select(func.count(models.AuthToken.id))) @@ -359,7 +357,7 @@ async def show_token_analytics() -> None: active_result = await db.execute( select(func.count(models.AuthToken.id)).where( - models.AuthToken.is_active == True + models.AuthToken.is_active.is_(True) ) ) active_tokens = active_result.scalar() @@ -456,11 +454,11 @@ async def show_token_analytics() -> None: async def cleanup_old_tokens(days: int = 90, dry_run: bool = True) -> None: """Clean up unused tokens older than specified days.""" - await create_tables() + await db_manager.create_tables() cutoff_date = datetime.utcnow() - timedelta(days=days) - async with AsyncSessionLocal() as db: + async with db_manager.AsyncSession() as db: try: # Find tokens that are either never used and old, or inactive and old result = await db.execute( @@ -476,7 +474,7 @@ async def cleanup_old_tokens(days: int = 90, dry_run: bool = True) -> None: result2 = await db.execute( select(models.AuthToken).where( and_( - models.AuthToken.is_active == False, + models.AuthToken.is_active.is_(False), models.AuthToken.created_at < cutoff_date, ) ) @@ -547,13 +545,13 @@ async def export_tokens( format_type: str = "json", include_inactive: bool = True ) -> None: """Export token information (excluding actual token values).""" - await create_tables() + await db_manager.create_tables() - async with AsyncSessionLocal() as db: + async with db_manager.AsyncSession() as db: try: query = select(models.AuthToken) if not include_inactive: - query = query.where(models.AuthToken.is_active == True) + query = query.where(models.AuthToken.is_active.is_(True)) query = query.order_by(models.AuthToken.created_at) result = await db.execute(query) @@ -672,9 +670,7 @@ def main(): ) # List tokens command - list_parser = subparsers.add_parser( - "list", help="List all existing authentication tokens" - ) + subparsers.add_parser("list", help="List all existing authentication tokens") # Show token details command details_parser = subparsers.add_parser( @@ -724,7 +720,7 @@ def main(): ) # Analytics command - analytics_parser = subparsers.add_parser( + subparsers.add_parser( "analytics", help="Show token usage analytics and statistics" ) diff --git a/backend/scripts/populate_binaries.py b/backend/scripts/populate_binaries.py index 6ed7bb5..5f7350d 100644 --- a/backend/scripts/populate_binaries.py +++ b/backend/scripts/populate_binaries.py @@ -183,13 +183,13 @@ async def populate_binaries(force: bool = False, database_url: str = None): display_order=binary_data.get("display_order", 0), ) - new_binary = await crud.create_binary(db, binary_create) + await crud.create_binary(db, binary_create) print(f"āœ… Created binary '{binary_id}': {binary_data['name']}") print(f" Flags: {binary_data['flags']}") print(f" Description: {binary_data['description']}") created_count += 1 - print(f"\nšŸŽ‰ Binary population completed!") + print("\nšŸŽ‰ Binary population completed!") print(f" - Created: {created_count} binaries") print(f" - Updated: {updated_count} binaries") print(f" - Skipped: {skipped_count} binaries") diff --git a/backend/scripts/populate_db.py b/backend/scripts/populate_db.py index 3450bc3..2ab6e83 100755 --- a/backend/scripts/populate_db.py +++ b/backend/scripts/populate_db.py @@ -379,7 +379,7 @@ async def populate_database(database_url: Optional[str] = None): await db.flush() print(f"āœ… Created {len(binary_objects)} new binaries") else: - print(f"āœ… All binaries already exist") + print("āœ… All binaries already exist") # Use all binaries (existing + new) for runs all_binary_objects = existing_binaries + binary_objects @@ -404,7 +404,7 @@ async def populate_database(database_url: Optional[str] = None): await db.flush() print(f"āœ… Created {len(environment_objects)} new environments") else: - print(f"āœ… All environments already exist") + print("āœ… All environments already exist") # Use all environments (existing + new) for runs all_environment_objects = existing_environments + environment_objects @@ -487,7 +487,7 @@ async def populate_database(database_url: Optional[str] = None): # Commit everything at once await db.commit() - print(f"\nšŸŽ‰ Database populated successfully!") + print("\nšŸŽ‰ Database populated successfully!") print(f" - {len(commit_objects)} commits (100 per Python version)") print( f" - {len(binary_objects)} new binaries ({len(all_binary_objects)} total)" diff --git a/backend/scripts/populate_simple_data.py b/backend/scripts/populate_simple_data.py index bb2950d..860254f 100755 --- a/backend/scripts/populate_simple_data.py +++ b/backend/scripts/populate_simple_data.py @@ -15,7 +15,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from app.database import AsyncSessionLocal -from app import models, schemas, crud +from app import models, schemas # Mock data generators @@ -403,7 +403,7 @@ async def populate_database(): # Commit everything at once await db.commit() - print(f"\nšŸŽ‰ Database populated with OBVIOUS TEST DATA!") + print("\nšŸŽ‰ Database populated with OBVIOUS TEST DATA!") print(f" - {len(commit_objects)} commits (2 per Python version)") print(f" - {len(binary_objects)} binaries (default, debug, nogil)") print(f" - {len(environment_objects)} environments (gcc-11, clang-14)") @@ -413,10 +413,10 @@ async def populate_database(): print( f" - {len(result_objects)} benchmark results (3 benchmarks per run)" ) - print(f"\nšŸ“Š MEMORY VALUES FOR VERIFICATION:") - print(f" Benchmark A: Default=1MB, Debug=1.5MB, NoGIL=0.8MB") - print(f" Benchmark B: Default=2MB, Debug=3MB, NoGIL=1.6MB") - print(f" Benchmark C: Default=10MB, Debug=15MB, NoGIL=8MB") + print("\nšŸ“Š MEMORY VALUES FOR VERIFICATION:") + print(" Benchmark A: Default=1MB, Debug=1.5MB, NoGIL=0.8MB") + print(" Benchmark B: Default=2MB, Debug=3MB, NoGIL=1.6MB") + print(" Benchmark C: Default=10MB, Debug=15MB, NoGIL=8MB") except Exception as e: print(f"āŒ Error populating database: {e}") diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..878230b --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,225 @@ +"""Shared fixtures for backend tests.""" + +import secrets +from datetime import datetime, timedelta + +import pytest +import pytest_asyncio +from httpx import ASGITransport, AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.pool import StaticPool + +from app.database import get_database +from app.factory import create_app +from app.config import Settings +from app.models import Base, AuthToken, AdminUser, AdminSession + + +@pytest.fixture(scope="session") +def test_settings(): + return Settings( + database_url="sqlite+aiosqlite://", + cors_origins="http://localhost:9002", + admin_initial_username="test_admin", + enable_health_check_db=False, + log_level="WARNING", + ) + + +@pytest_asyncio.fixture +async def db_engine(): + engine = create_async_engine( + "sqlite+aiosqlite://", + echo=False, + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield engine + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + await engine.dispose() + + +@pytest_asyncio.fixture +async def db_session(db_engine): + session_factory = async_sessionmaker( + db_engine, class_=AsyncSession, expire_on_commit=False + ) + async with session_factory() as session: + yield session + + +@pytest_asyncio.fixture +async def app(db_engine, test_settings): + session_factory = async_sessionmaker( + db_engine, class_=AsyncSession, expire_on_commit=False + ) + + async def _override_get_database(): + async with session_factory() as session: + yield session + + application = create_app(settings=test_settings) + application.dependency_overrides[get_database] = _override_get_database + yield application + application.dependency_overrides.clear() + + +@pytest_asyncio.fixture +async def client(app): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + +@pytest_asyncio.fixture +async def auth_token(db_session): + """Create an active auth token and return (token_string, token_model).""" + raw_token = secrets.token_hex(32) + token = AuthToken( + token=raw_token, + name="test-worker", + description="Token for testing", + ) + db_session.add(token) + await db_session.commit() + await db_session.refresh(token) + return raw_token, token + + +@pytest_asyncio.fixture +async def auth_headers(auth_token): + """Authorization headers for authenticated requests.""" + raw_token, _ = auth_token + return {"Authorization": f"Bearer {raw_token}"} + + +@pytest_asyncio.fixture +async def admin_user(db_session): + user = AdminUser( + github_username="test_admin", + added_by="system", + notes="Test admin", + ) + db_session.add(user) + await db_session.commit() + await db_session.refresh(user) + return user + + +@pytest_asyncio.fixture +async def admin_session(db_session, admin_user): + """Create an admin session and return (session_token, session_model).""" + raw_token = secrets.token_hex(32) + session = AdminSession( + session_token=raw_token, + github_user_id=12345, + github_username=admin_user.github_username, + github_name="Test Admin", + github_email="admin@test.com", + github_avatar_url="https://example.com/avatar.png", + expires_at=datetime.now() + timedelta(hours=24), + ) + db_session.add(session) + await db_session.commit() + await db_session.refresh(session) + return raw_token, session + + +@pytest_asyncio.fixture +async def admin_cookies(admin_session): + """Cookies dict for admin-authenticated requests.""" + raw_token, _ = admin_session + return {"admin_session": raw_token} + + +@pytest_asyncio.fixture +async def sample_binary(db_session): + from app.models import Binary + binary = Binary( + id="default", + name="Default", + flags=["--enable-optimizations"], + description="Standard build", + color="#8b5cf6", + icon="server", + display_order=0, + ) + db_session.add(binary) + await db_session.commit() + await db_session.refresh(binary) + return binary + + +@pytest_asyncio.fixture +async def sample_environment(db_session): + from app.models import Environment + env = Environment( + id="linux-x86_64", + name="Linux x86_64", + description="Standard Linux build environment", + ) + db_session.add(env) + await db_session.commit() + await db_session.refresh(env) + return env + + +@pytest_asyncio.fixture +async def sample_commit(db_session): + from app.models import Commit + commit = Commit( + sha="a" * 40, + timestamp=datetime(2025, 6, 15, 12, 0, 0), + message="Test commit", + author="Test Author", + python_major=3, + python_minor=14, + python_patch=0, + ) + db_session.add(commit) + await db_session.commit() + await db_session.refresh(commit) + return commit + + +@pytest_asyncio.fixture +async def sample_run(db_session, sample_commit, sample_binary, sample_environment): + from app.models import Run + run = Run( + run_id="run_test_001", + commit_sha=sample_commit.sha, + binary_id=sample_binary.id, + environment_id=sample_environment.id, + python_major=3, + python_minor=14, + python_patch=0, + timestamp=datetime(2025, 6, 15, 12, 30, 0), + ) + db_session.add(run) + await db_session.commit() + await db_session.refresh(run) + return run + + +@pytest_asyncio.fixture +async def sample_benchmark_result(db_session, sample_run): + from app.models import BenchmarkResult + result = BenchmarkResult( + id=f"{sample_run.run_id}_json-dumps", + run_id=sample_run.run_id, + benchmark_name="json_dumps", + high_watermark_bytes=1_000_000, + allocation_histogram=[[64, 500], [128, 300], [256, 100]], + total_allocated_bytes=5_000_000, + top_allocating_functions=[ + {"function": "json.dumps", "count": 100, "total_size": 500_000} + ], + flamegraph_html="flamegraph", + ) + db_session.add(result) + await db_session.commit() + await db_session.refresh(result) + return result diff --git a/backend/tests/production_fixtures.py b/backend/tests/production_fixtures.py new file mode 100644 index 0000000..e615687 --- /dev/null +++ b/backend/tests/production_fixtures.py @@ -0,0 +1,175 @@ +""" +Test fixtures derived from the production database. + +These represent real CPython commits, benchmark results, and the relationships +between them. The deltablue_base benchmark shows a ~10.5% high watermark +increase between the two commits, while json_dumps_base and nbody_base remain +unchanged — a pattern typical of real-world memory regressions where only +specific benchmarks are affected. +""" + +from datetime import datetime + +BINARY_NOGIL = { + "id": "nogil", + "name": "Free-threaded Build", + "flags": ["--disable-gil"], + "description": "Experimental build without the Global Interpreter Lock (GIL).", + "color": "#f59e0b", + "icon": "zap", + "display_order": 5, +} + +ENVIRONMENT_GH_ACTIONS = { + "id": "gh_actions", + "name": "GitHub actions", + "description": "GitHub actions in memory.python.org", +} + +COMMIT_PREV = { + "sha": "e05182f98ea100b6e26796a76b1399237aeac22f", + "timestamp": datetime(2025, 8, 29, 11, 49, 35), + "message": "gh-138250: load fast optimization should fall through to empty blocks (#138249)", + "author": "Dino Viehland", + "python_major": 3, + "python_minor": 15, + "python_patch": 0, +} + +COMMIT_CURR = { + "sha": "d3d94e0ed715829d9bf93ef9c35e04832962f19f", + "timestamp": datetime(2025, 8, 30, 22, 21, 25), + "message": "gh-138061: Exclude __pycache__ directory from the computed digest in the JIT stencils (#138131)", + "author": "alm", + "python_major": 3, + "python_minor": 15, + "python_patch": 0, +} + +RUN_PREV = { + "run_id": "run_e05182f9_nogil_gh_actions_1756509299", + "commit_sha": COMMIT_PREV["sha"], + "binary_id": "nogil", + "environment_id": "gh_actions", + "python_major": 3, + "python_minor": 15, + "python_patch": 0, + "timestamp": datetime(2025, 8, 29, 23, 14, 59, 158448), +} + +RUN_CURR = { + "run_id": "run_d3d94e0e_nogil_gh_actions_1756595617", + "commit_sha": COMMIT_CURR["sha"], + "binary_id": "nogil", + "environment_id": "gh_actions", + "python_major": 3, + "python_minor": 15, + "python_patch": 0, + "timestamp": datetime(2025, 8, 30, 23, 13, 37, 215031), +} + +# deltablue_base: 10.5% high watermark increase between commits +BENCH_DELTABLUE_PREV = { + "id": "run_e05182f9_nogil_gh_actions_1756509299_deltablue-base", + "run_id": RUN_PREV["run_id"], + "benchmark_name": "deltablue_base", + "high_watermark_bytes": 1_557_777, + "total_allocated_bytes": 111_297_305, + "allocation_histogram": [ + [0, 123], [3, 3612], [10, 992], [34, 61414], + [111, 519085], [362, 726], [1176, 198], + [3821, 386], [12416, 23], [40342, 10], + ], + "top_allocating_functions": [ + {"function": "execute:deltablue_base.py:340", "count": 0, "total_size": 39_168_000}, + {"function": "execute:deltablue_base.py:494", "count": 0, "total_size": 23_869_728}, + {"function": "_get_code_from_file::259", "count": 0, "total_size": 4_191_949}, + {"function": "add_propagate:deltablue_base.py:438", "count": 0, "total_size": 3_131_664}, + {"function": "weakest_of:deltablue_base.py:51", "count": 0, "total_size": 1_664_832}, + ], +} + +BENCH_DELTABLUE_CURR = { + "id": "run_d3d94e0e_nogil_gh_actions_1756595617_deltablue-base", + "run_id": RUN_CURR["run_id"], + "benchmark_name": "deltablue_base", + "high_watermark_bytes": 1_721_155, + "total_allocated_bytes": 111_291_390, + "allocation_histogram": [ + [0, 123], [3, 3612], [10, 992], [34, 61399], + [111, 519085], [362, 722], [1176, 197], + [3821, 386], [12416, 23], [40342, 10], + ], + "top_allocating_functions": [ + {"function": "execute:deltablue_base.py:340", "count": 0, "total_size": 39_168_000}, + {"function": "execute:deltablue_base.py:494", "count": 0, "total_size": 23_869_728}, + {"function": "_get_code_from_file::259", "count": 0, "total_size": 4_191_949}, + {"function": "add_propagate:deltablue_base.py:438", "count": 0, "total_size": 3_131_664}, + {"function": "weakest_of:deltablue_base.py:51", "count": 0, "total_size": 1_664_832}, + ], +} + +# json_dumps_base: identical across both commits +BENCH_JSON_DUMPS_PREV = { + "id": "run_e05182f9_nogil_gh_actions_1756509299_json-dumps-base", + "run_id": RUN_PREV["run_id"], + "benchmark_name": "json_dumps_base", + "high_watermark_bytes": 405_465, + "total_allocated_bytes": 14_132_797, + "allocation_histogram": [ + [0, 14], [3, 425], [12, 196], [45, 49869], + [160, 23501], [571, 85], [2036, 31], + [7248, 22], [25805, 8], [91871, 11], + ], + "top_allocating_functions": [ + {"function": "iterencode:json/encoder.py:261", "count": 0, "total_size": 7_404_609}, + {"function": "bench_json_dumps:json_dumps_base.py:31", "count": 0, "total_size": 1_632_536}, + {"function": "encode:json/encoder.py:200", "count": 0, "total_size": 1_312_456}, + {"function": "iterencode:json/encoder.py:252", "count": 0, "total_size": 960_240}, + {"function": "dumps:json/__init__.py:231", "count": 0, "total_size": 928_360}, + ], +} + +BENCH_JSON_DUMPS_CURR = { + "id": "run_d3d94e0e_nogil_gh_actions_1756595617_json-dumps-base", + "run_id": RUN_CURR["run_id"], + "benchmark_name": "json_dumps_base", + "high_watermark_bytes": 405_465, + "total_allocated_bytes": 14_132_797, + "allocation_histogram": BENCH_JSON_DUMPS_PREV["allocation_histogram"], + "top_allocating_functions": BENCH_JSON_DUMPS_PREV["top_allocating_functions"], +} + +# nbody_base: identical across both commits +BENCH_NBODY_PREV = { + "id": "run_e05182f9_nogil_gh_actions_1756509299_nbody-base", + "run_id": RUN_PREV["run_id"], + "benchmark_name": "nbody_base", + "high_watermark_bytes": 563_371, + "total_allocated_bytes": 1_808_575, + "allocation_histogram": [ + [0, 18], [3, 1047], [10, 223], [34, 3845], + [111, 804], [362, 166], [1176, 37], + [3821, 75], [12416, 8], [40342, 5], + ], + "top_allocating_functions": [ + {"function": "_get_code_from_file::259", "count": 0, "total_size": 905_285}, + {"function": "_read_directory::302", "count": 0, "total_size": 132_232}, + {"function": "get_data::954", "count": 0, "total_size": 132_225}, + {"function": "_get_code_from_file::258", "count": 0, "total_size": 132_176}, + {"function": "_get_code_from_file::254", "count": 0, "total_size": 132_176}, + ], +} + +BENCH_NBODY_CURR = { + "id": "run_d3d94e0e_nogil_gh_actions_1756595617_nbody-base", + "run_id": RUN_CURR["run_id"], + "benchmark_name": "nbody_base", + "high_watermark_bytes": 563_371, + "total_allocated_bytes": 1_808_575, + "allocation_histogram": BENCH_NBODY_PREV["allocation_histogram"], + "top_allocating_functions": BENCH_NBODY_PREV["top_allocating_functions"], +} + +ALL_PREV_BENCHMARKS = [BENCH_DELTABLUE_PREV, BENCH_JSON_DUMPS_PREV, BENCH_NBODY_PREV] +ALL_CURR_BENCHMARKS = [BENCH_DELTABLUE_CURR, BENCH_JSON_DUMPS_CURR, BENCH_NBODY_CURR] diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..8bf33bf --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,100 @@ +"""Tests for token authentication.""" + + + +async def test_valid_bearer_token(client, auth_headers, sample_binary, sample_environment): + """A valid Bearer token should authenticate successfully on a protected endpoint.""" + payload = { + "commit_sha": "a" * 40, + "commit_timestamp": "2025-06-16T10:00:00", + "binary_id": "default", + "environment_id": "linux-x86_64", + "error_message": "test", + } + # Without auth → rejected + response = await client.post("/api/report-memray-failure", json=payload) + assert response.status_code in (401, 403) + + # With auth → accepted + response = await client.post( + "/api/report-memray-failure", json=payload, headers=auth_headers + ) + assert response.status_code == 200 + + +async def test_upload_with_invalid_token(client, sample_binary, sample_environment): + """An invalid token should be rejected.""" + headers = {"Authorization": "Bearer invalid_token_value"} + response = await client.post( + "/api/upload-run", + json={"metadata": {}, "benchmark_results": [], "binary_id": "x", "environment_id": "y"}, + headers=headers, + ) + assert response.status_code in (401, 403) + + +async def test_upload_with_no_token(client): + """Missing token should be rejected.""" + response = await client.post( + "/api/upload-run", + json={"metadata": {}, "benchmark_results": [], "binary_id": "x", "environment_id": "y"}, + ) + assert response.status_code in (401, 403) + + +async def test_token_format_bearer(client, auth_token, sample_binary, sample_environment): + """'Bearer ' format should work.""" + raw_token, _ = auth_token + headers = {"Authorization": f"Bearer {raw_token}"} + # Use upload endpoint since it requires auth + response = await client.post( + "/api/report-memray-failure", + json={ + "commit_sha": "d" * 40, + "commit_timestamp": "2025-06-16T10:00:00", + "binary_id": "default", + "environment_id": "linux-x86_64", + "error_message": "test", + }, + headers=headers, + ) + assert response.status_code == 200 + + +async def test_token_format_token_prefix(client, auth_token, sample_binary, sample_environment): + """'Token ' format should also work.""" + raw_token, _ = auth_token + headers = {"Authorization": f"Token {raw_token}"} + response = await client.post( + "/api/report-memray-failure", + json={ + "commit_sha": "e" * 40, + "commit_timestamp": "2025-06-16T10:00:00", + "binary_id": "default", + "environment_id": "linux-x86_64", + "error_message": "test", + }, + headers=headers, + ) + assert response.status_code == 200 + + +async def test_inactive_token_rejected(client, db_session, auth_token, sample_binary, sample_environment): + """A deactivated token should be rejected.""" + raw_token, token_model = auth_token + token_model.is_active = False + await db_session.commit() + + headers = {"Authorization": f"Bearer {raw_token}"} + response = await client.post( + "/api/report-memray-failure", + json={ + "commit_sha": "f" * 40, + "commit_timestamp": "2025-06-16T10:00:00", + "binary_id": "default", + "environment_id": "linux-x86_64", + "error_message": "test", + }, + headers=headers, + ) + assert response.status_code in (401, 403) diff --git a/backend/tests/test_benchmarks.py b/backend/tests/test_benchmarks.py new file mode 100644 index 0000000..7a4e057 --- /dev/null +++ b/backend/tests/test_benchmarks.py @@ -0,0 +1,129 @@ +"""Tests for the benchmarks API endpoints.""" + + + +async def test_all_benchmark_names_empty(client): + response = await client.get("/api/benchmarks") + assert response.status_code == 200 + assert response.json() == [] + + +async def test_all_benchmark_names(client, sample_benchmark_result): + response = await client.get("/api/benchmarks") + assert response.status_code == 200 + data = response.json() + assert "json_dumps" in data + + +async def test_filtered_benchmark_names(client, sample_benchmark_result): + response = await client.get( + "/api/benchmark-names", + params={ + "environment_id": "linux-x86_64", + "binary_id": "default", + "python_major": 3, + "python_minor": 14, + }, + ) + assert response.status_code == 200 + data = response.json() + assert "json_dumps" in data + + +async def test_filtered_benchmark_names_no_match(client, sample_benchmark_result): + response = await client.get( + "/api/benchmark-names", + params={ + "environment_id": "linux-x86_64", + "binary_id": "default", + "python_major": 3, + "python_minor": 99, + }, + ) + assert response.status_code == 200 + assert response.json() == [] + + +async def test_diff_table(client, sample_benchmark_result): + response = await client.get( + "/api/diff", + params={ + "commit_sha": "a" * 40, + "binary_id": "default", + "environment_id": "linux-x86_64", + }, + ) + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + row = data[0] + assert row["benchmark_name"] == "json_dumps" + assert row["curr_metric_value"] == 1_000_000 + assert row["metric_key"] == "high_watermark_bytes" + assert row["has_flamegraph"] is True + + +async def test_diff_table_commit_not_found(client): + response = await client.get( + "/api/diff", + params={ + "commit_sha": "f" * 40, + "binary_id": "default", + "environment_id": "linux-x86_64", + }, + ) + assert response.status_code == 404 + + +async def test_trends(client, sample_benchmark_result): + response = await client.get( + "/api/trends", + params={ + "benchmark_name": "json_dumps", + "binary_id": "default", + "environment_id": "linux-x86_64", + "python_major": 3, + "python_minor": 14, + }, + ) + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["sha"] == "a" * 40 + assert data[0]["high_watermark_bytes"] == 1_000_000 + + +async def test_trends_batch(client, sample_benchmark_result): + response = await client.post( + "/api/trends-batch", + json={ + "trend_queries": [ + { + "benchmark_name": "json_dumps", + "binary_id": "default", + "environment_id": "linux-x86_64", + "python_major": 3, + "python_minor": 14, + "limit": 50, + } + ] + }, + ) + assert response.status_code == 200 + data = response.json() + assert "results" in data + assert len(data["results"]) == 1 + + +async def test_flamegraph(client, sample_benchmark_result): + result_id = sample_benchmark_result.id + response = await client.get(f"/api/flamegraph/{result_id}") + assert response.status_code == 200 + data = response.json() + assert data["flamegraph_html"] == "flamegraph" + assert data["benchmark_name"] == "json_dumps" + + +async def test_flamegraph_not_found(client): + response = await client.get("/api/flamegraph/nonexistent") + assert response.status_code == 404 diff --git a/backend/tests/test_binaries.py b/backend/tests/test_binaries.py new file mode 100644 index 0000000..b5be671 --- /dev/null +++ b/backend/tests/test_binaries.py @@ -0,0 +1,55 @@ +"""Tests for the binaries API endpoints.""" + + + +async def test_list_binaries_empty(client): + response = await client.get("/api/binaries") + assert response.status_code == 200 + assert response.json() == [] + + +async def test_list_binaries(client, sample_binary): + response = await client.get("/api/binaries") + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["id"] == "default" + assert data[0]["name"] == "Default" + assert "--enable-optimizations" in data[0]["flags"] + + +async def test_get_binary_by_id(client, sample_binary): + response = await client.get("/api/binaries/default") + assert response.status_code == 200 + data = response.json() + assert data["id"] == "default" + assert data["description"] == "Standard build" + + +async def test_get_binary_not_found(client): + response = await client.get("/api/binaries/nonexistent") + assert response.status_code == 404 + + +async def test_environments_for_binary(client, sample_benchmark_result): + response = await client.get("/api/binaries/default/environments") + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["id"] == "linux-x86_64" + assert data[0]["run_count"] >= 1 + + +async def test_environments_for_nonexistent_binary(client): + response = await client.get("/api/binaries/nonexistent/environments") + assert response.status_code == 404 + + +async def test_commits_for_binary_and_environment(client, sample_benchmark_result): + response = await client.get( + "/api/binaries/default/environments/linux-x86_64/commits" + ) + assert response.status_code == 200 + data = response.json() + assert len(data) >= 1 + assert data[0]["sha"] == "a" * 40 diff --git a/backend/tests/test_commits.py b/backend/tests/test_commits.py new file mode 100644 index 0000000..46557ca --- /dev/null +++ b/backend/tests/test_commits.py @@ -0,0 +1,57 @@ +"""Tests for the commits API endpoints.""" + + + +async def test_list_commits_empty(client): + response = await client.get("/api/commits") + assert response.status_code == 200 + assert response.json() == [] + + +async def test_list_commits(client, sample_commit): + response = await client.get("/api/commits") + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["sha"] == sample_commit.sha + assert data[0]["author"] == "Test Author" + assert data[0]["python_version"]["major"] == 3 + assert data[0]["python_version"]["minor"] == 14 + + +async def test_list_commits_pagination(client, sample_commit): + response = await client.get("/api/commits", params={"skip": 0, "limit": 1}) + assert response.status_code == 200 + assert len(response.json()) == 1 + + response = await client.get("/api/commits", params={"skip": 1, "limit": 1}) + assert response.status_code == 200 + assert len(response.json()) == 0 + + +async def test_get_commit_by_sha(client, sample_commit): + response = await client.get(f"/api/commits/{sample_commit.sha}") + assert response.status_code == 200 + data = response.json() + assert data["sha"] == sample_commit.sha + assert data["message"] == "Test commit" + + +async def test_get_commit_not_found(client): + response = await client.get("/api/commits/" + "f" * 40) + assert response.status_code == 404 + + +async def test_python_versions_empty(client): + response = await client.get("/api/python-versions") + assert response.status_code == 200 + assert response.json() == [] + + +async def test_python_versions(client, sample_benchmark_result): + response = await client.get("/api/python-versions") + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["major"] == 3 + assert data[0]["minor"] == 14 diff --git a/backend/tests/test_environments.py b/backend/tests/test_environments.py new file mode 100644 index 0000000..ddae060 --- /dev/null +++ b/backend/tests/test_environments.py @@ -0,0 +1,29 @@ +"""Tests for the environments API endpoints.""" + + + +async def test_list_environments_empty(client): + response = await client.get("/api/environments") + assert response.status_code == 200 + assert response.json() == [] + + +async def test_list_environments(client, sample_environment): + response = await client.get("/api/environments") + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["id"] == "linux-x86_64" + assert data[0]["name"] == "Linux x86_64" + + +async def test_get_environment_by_id(client, sample_environment): + response = await client.get("/api/environments/linux-x86_64") + assert response.status_code == 200 + data = response.json() + assert data["id"] == "linux-x86_64" + + +async def test_get_environment_not_found(client): + response = await client.get("/api/environments/nonexistent") + assert response.status_code == 404 diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py new file mode 100644 index 0000000..cec37e3 --- /dev/null +++ b/backend/tests/test_health.py @@ -0,0 +1,25 @@ +"""Tests for the health check endpoint.""" + + + +async def test_health_check(client): + response = await client.get("/health") + assert response.status_code == 200 + data = response.json() + assert "status" in data + assert "timestamp" in data + + +async def test_health_check_reports_db_status(client): + """The health router uses a module-level settings object with + enable_health_check_db=True (not overridable via test_settings). + It attempts db.execute("SELECT 1") which fails on SQLAlchemy 2.x + because raw strings need text(). This is a pre-existing app bug. + We verify the endpoint still returns 200 and reports the DB as + unhealthy rather than crashing.""" + response = await client.get("/health") + assert response.status_code == 200 + data = response.json() + assert "database" in data + assert data["database"] == "unhealthy" + assert data["status"] == "unhealthy" diff --git a/backend/tests/test_logging_utils.py b/backend/tests/test_logging_utils.py new file mode 100644 index 0000000..a808399 --- /dev/null +++ b/backend/tests/test_logging_utils.py @@ -0,0 +1,82 @@ +"""Tests for logging utility functions.""" + +from app.logging_utils import ( + sanitize_string, + sanitize_dict, + sanitize_list, + mask_token, + create_safe_log_context, +) + + +def test_sanitize_string_redacts_hex_token(): + token = "a" * 64 + result = sanitize_string(f"Token is {token}") + assert token not in result + assert "REDACTED" in result + + +def test_sanitize_string_redacts_password(): + result = sanitize_string('password="supersecret"') + assert "supersecret" not in result + assert "REDACTED" in result + + +def test_sanitize_string_preserves_normal_text(): + text = "This is a normal log message" + assert sanitize_string(text) == text + + +def test_sanitize_string_handles_non_string(): + assert sanitize_string(42) == 42 + + +def test_sanitize_dict_redacts_sensitive_keys(): + data = {"token": "secret123", "name": "worker-1"} + result = sanitize_dict(data) + assert result["token"] == "***REDACTED***" + assert result["name"] == "worker-1" + + +def test_sanitize_dict_recursive(): + data = {"config": {"auth": {"password": "secret"}}} + result = sanitize_dict(data) + assert "secret" not in str(result) + + +def test_sanitize_dict_handles_non_dict(): + assert sanitize_dict("not a dict") == "not a dict" + + +def test_sanitize_list_handles_mixed(): + data = [{"token": "secret"}, "normal", 42] + result = sanitize_list(data) + assert result[0]["token"] == "***REDACTED***" + assert result[1] == "normal" + assert result[2] == 42 + + +def test_mask_token_normal(): + assert mask_token("abcdefghijklmnop") == "abcd...mnop" + + +def test_mask_token_short(): + assert mask_token("short") == "***" + + +def test_mask_token_empty(): + assert mask_token("") == "***" + assert mask_token(None) == "***" + + +def test_create_safe_log_context_redacts_keys(): + ctx = create_safe_log_context(token="secret", name="worker") + assert ctx["token"] == "***REDACTED***" + assert ctx["name"] == "worker" + + +def test_create_safe_log_context_truncates_long_strings(): + long_value = "x" * 100 + ctx = create_safe_log_context(data=long_value) + assert len(ctx["data"]) < 100 + assert ctx["data"].endswith("...") diff --git a/backend/tests/test_production_data.py b/backend/tests/test_production_data.py new file mode 100644 index 0000000..cee259b --- /dev/null +++ b/backend/tests/test_production_data.py @@ -0,0 +1,252 @@ +""" +Tests using fixtures derived from production data. + +These verify that the API behaves correctly with real-world data shapes, +including a case where deltablue_base shows a ~10.5% high watermark +regression between two consecutive nogil commits while json_dumps_base +and nbody_base remain unchanged. +""" + +import pytest +import pytest_asyncio + +from app.models import Binary, Environment, Commit, Run, BenchmarkResult + +from .production_fixtures import ( + BINARY_NOGIL, + ENVIRONMENT_GH_ACTIONS, + COMMIT_PREV, + COMMIT_CURR, + RUN_PREV, + RUN_CURR, + ALL_PREV_BENCHMARKS, + ALL_CURR_BENCHMARKS, + BENCH_DELTABLUE_PREV, + BENCH_DELTABLUE_CURR, +) + + +@pytest_asyncio.fixture +async def prod_data(db_session): + """Load the full production fixture set into the test database.""" + db_session.add(Binary(**BINARY_NOGIL)) + db_session.add(Environment(**ENVIRONMENT_GH_ACTIONS)) + await db_session.flush() + + db_session.add(Commit(**COMMIT_PREV)) + db_session.add(Commit(**COMMIT_CURR)) + await db_session.flush() + + db_session.add(Run(**RUN_PREV)) + db_session.add(Run(**RUN_CURR)) + await db_session.flush() + + for bench in ALL_PREV_BENCHMARKS + ALL_CURR_BENCHMARKS: + db_session.add(BenchmarkResult(**bench)) + await db_session.commit() + + +async def test_diff_detects_regression(client, prod_data): + """The diff endpoint should show the deltablue_base regression.""" + response = await client.get( + "/api/diff", + params={ + "commit_sha": COMMIT_CURR["sha"], + "binary_id": "nogil", + "environment_id": "gh_actions", + }, + ) + assert response.status_code == 200 + rows = response.json() + assert len(rows) == 3 + + deltablue = next(r for r in rows if r["benchmark_name"] == "deltablue_base") + assert deltablue["curr_metric_value"] == BENCH_DELTABLUE_CURR["high_watermark_bytes"] + assert deltablue["prev_metric_value"] == BENCH_DELTABLUE_PREV["high_watermark_bytes"] + assert deltablue["metric_delta_percent"] == pytest.approx(10.49, abs=0.1) + + # json_dumps_base and nbody_base should show no change + json_dumps = next(r for r in rows if r["benchmark_name"] == "json_dumps_base") + assert json_dumps["metric_delta_percent"] == pytest.approx(0.0) + + nbody = next(r for r in rows if r["benchmark_name"] == "nbody_base") + assert nbody["metric_delta_percent"] == pytest.approx(0.0) + + +async def test_diff_previous_commit_details(client, prod_data): + """The diff should include correct previous commit metadata.""" + response = await client.get( + "/api/diff", + params={ + "commit_sha": COMMIT_CURR["sha"], + "binary_id": "nogil", + "environment_id": "gh_actions", + }, + ) + rows = response.json() + deltablue = next(r for r in rows if r["benchmark_name"] == "deltablue_base") + + prev = deltablue["prev_commit_details"] + assert prev["sha"] == COMMIT_PREV["sha"] + assert prev["author"] == "Dino Viehland" + assert prev["python_version"]["major"] == 3 + assert prev["python_version"]["minor"] == 15 + + curr = deltablue["curr_commit_details"] + assert curr["sha"] == COMMIT_CURR["sha"] + assert curr["author"] == "alm" + + +async def test_diff_first_commit_has_no_previous(client, prod_data): + """Diffing the earlier commit should show no previous data.""" + response = await client.get( + "/api/diff", + params={ + "commit_sha": COMMIT_PREV["sha"], + "binary_id": "nogil", + "environment_id": "gh_actions", + }, + ) + assert response.status_code == 200 + rows = response.json() + for row in rows: + assert row["prev_metric_value"] is None + assert row["metric_delta_percent"] is None + assert row["prev_commit_details"] is None + + +async def test_diff_with_total_allocated_metric(client, prod_data): + """Diff should work with total_allocated_bytes metric too.""" + response = await client.get( + "/api/diff", + params={ + "commit_sha": COMMIT_CURR["sha"], + "binary_id": "nogil", + "environment_id": "gh_actions", + "metric_key": "total_allocated_bytes", + }, + ) + assert response.status_code == 200 + rows = response.json() + deltablue = next(r for r in rows if r["benchmark_name"] == "deltablue_base") + assert deltablue["metric_key"] == "total_allocated_bytes" + assert deltablue["curr_metric_value"] == BENCH_DELTABLUE_CURR["total_allocated_bytes"] + assert deltablue["prev_metric_value"] == BENCH_DELTABLUE_PREV["total_allocated_bytes"] + + +async def test_trends_returns_chronological_data(client, prod_data): + """Trends should return data points in reverse chronological order (newest first).""" + response = await client.get( + "/api/trends", + params={ + "benchmark_name": "deltablue_base", + "binary_id": "nogil", + "environment_id": "gh_actions", + "python_major": 3, + "python_minor": 15, + }, + ) + assert response.status_code == 200 + data = response.json() + assert len(data) == 2 + + # Ordered by timestamp DESC: newer commit first + assert data[0]["sha"] == COMMIT_CURR["sha"] + assert data[0]["high_watermark_bytes"] == 1_721_155 + assert data[1]["sha"] == COMMIT_PREV["sha"] + assert data[1]["high_watermark_bytes"] == 1_557_777 + + +async def test_batch_trends_multiple_benchmarks(client, prod_data): + """Batch trends should return data for multiple benchmarks at once.""" + response = await client.post( + "/api/trends-batch", + json={ + "trend_queries": [ + { + "benchmark_name": "deltablue_base", + "binary_id": "nogil", + "environment_id": "gh_actions", + "python_major": 3, + "python_minor": 15, + "limit": 50, + }, + { + "benchmark_name": "json_dumps_base", + "binary_id": "nogil", + "environment_id": "gh_actions", + "python_major": 3, + "python_minor": 15, + "limit": 50, + }, + ] + }, + ) + assert response.status_code == 200 + results = response.json()["results"] + assert len(results) == 2 + + deltablue_key = "nogil:deltablue_base|3.15" + json_key = "nogil:json_dumps_base|3.15" + assert deltablue_key in results + assert json_key in results + assert len(results[deltablue_key]) == 2 + assert len(results[json_key]) == 2 + + +async def test_benchmark_names_filtered_by_version(client, prod_data): + """Benchmark names should filter correctly by Python version.""" + response = await client.get( + "/api/benchmark-names", + params={ + "environment_id": "gh_actions", + "binary_id": "nogil", + "python_major": 3, + "python_minor": 15, + }, + ) + assert response.status_code == 200 + names = response.json() + assert set(names) == {"deltablue_base", "json_dumps_base", "nbody_base"} + + # Non-existent version should return empty + response = await client.get( + "/api/benchmark-names", + params={ + "environment_id": "gh_actions", + "binary_id": "nogil", + "python_major": 3, + "python_minor": 14, + }, + ) + assert response.json() == [] + + +async def test_python_versions_from_production_data(client, prod_data): + response = await client.get("/api/python-versions") + assert response.status_code == 200 + versions = response.json() + assert len(versions) == 1 + assert versions[0]["major"] == 3 + assert versions[0]["minor"] == 15 + + +async def test_environments_for_binary(client, prod_data): + response = await client.get("/api/binaries/nogil/environments") + assert response.status_code == 200 + envs = response.json() + assert len(envs) == 1 + assert envs[0]["id"] == "gh_actions" + assert envs[0]["run_count"] == 2 + assert envs[0]["commit_count"] == 2 + + +async def test_commits_for_binary_and_environment(client, prod_data): + response = await client.get( + "/api/binaries/nogil/environments/gh_actions/commits" + ) + assert response.status_code == 200 + commits = response.json() + assert len(commits) == 2 + shas = {c["sha"][:8] for c in commits} + assert shas == {"e05182f9", "d3d94e0e"} diff --git a/backend/tests/test_public.py b/backend/tests/test_public.py new file mode 100644 index 0000000..17e6730 --- /dev/null +++ b/backend/tests/test_public.py @@ -0,0 +1,50 @@ +"""Tests for public API endpoints.""" + + + +async def test_maintainers_empty(client): + response = await client.get("/api/maintainers") + assert response.status_code == 200 + assert response.json() == [] + + +async def test_maintainers_with_admin(client, admin_user): + response = await client.get("/api/maintainers") + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["github_username"] == "test_admin" + assert data[0]["is_active"] is True + + +async def test_memray_status_healthy(client): + response = await client.get("/api/memray-status") + assert response.status_code == 200 + data = response.json() + assert data["has_failures"] is False + assert data["failure_count"] == 0 + assert data["affected_environments"] == [] + + +async def test_memray_status_with_failure( + client, auth_headers, sample_binary, sample_environment +): + # Report a failure first + await client.post( + "/api/report-memray-failure", + json={ + "commit_sha": "c" * 40, + "commit_timestamp": "2025-06-16T10:00:00", + "binary_id": "default", + "environment_id": "linux-x86_64", + "error_message": "memray install failed", + }, + headers=auth_headers, + ) + + response = await client.get("/api/memray-status") + assert response.status_code == 200 + data = response.json() + assert data["has_failures"] is True + assert data["failure_count"] == 1 + assert data["affected_environments"][0]["binary_id"] == "default" diff --git a/backend/tests/test_upload.py b/backend/tests/test_upload.py new file mode 100644 index 0000000..2c2390a --- /dev/null +++ b/backend/tests/test_upload.py @@ -0,0 +1,286 @@ +"""Tests for the upload API endpoints.""" + +import copy + + +UPLOAD_PAYLOAD = { + "metadata": { + "commit": { + "hexsha": "b" * 40, + "committed_date": "2025-06-16T10:00:00+00:00", + "message": "Benchmark commit", + "author": "Benchmark Author", + }, + "version": {"major": 3, "minor": 14, "micro": 1}, + "configure_vars": { + "CONFIG_ARGS": "'--enable-optimizations' '--prefix=/tmp/install'" + }, + }, + "benchmark_results": [ + { + "benchmark_name": "test_bench", + "stats_json": { + "metadata": {"peak_memory": 2_000_000}, + "allocation_size_histogram": [ + {"min_bytes": 64, "count": 100}, + {"min_bytes": 128, "count": 50}, + ], + "total_bytes_allocated": 3_000_000, + "top_allocations_by_size": [ + {"location": "test_func", "count": 10, "size": 100_000} + ], + }, + "flamegraph_html": "test flamegraph", + } + ], + "binary_id": "default", + "environment_id": "linux-x86_64", +} + + +async def test_upload_requires_auth(client, sample_binary, sample_environment): + response = await client.post("/api/upload-run", json=UPLOAD_PAYLOAD) + assert response.status_code in (401, 403) + + +async def test_upload_success( + client, auth_headers, sample_binary, sample_environment +): + response = await client.post( + "/api/upload-run", json=UPLOAD_PAYLOAD, headers=auth_headers + ) + assert response.status_code == 200 + data = response.json() + assert data["commit_sha"] == "b" * 40 + assert data["binary_id"] == "default" + assert data["environment_id"] == "linux-x86_64" + assert data["results_created"] == 1 + + +async def test_upload_missing_commit_sha( + client, auth_headers, sample_binary, sample_environment +): + payload = { + **UPLOAD_PAYLOAD, + "metadata": {"commit": {}, "version": {"major": 3, "minor": 14, "micro": 0}}, + } + response = await client.post( + "/api/upload-run", json=payload, headers=auth_headers + ) + assert response.status_code == 400 + + +async def test_upload_invalid_binary(client, auth_headers, sample_environment): + payload = {**UPLOAD_PAYLOAD, "binary_id": "nonexistent"} + response = await client.post( + "/api/upload-run", json=payload, headers=auth_headers + ) + assert response.status_code == 400 + assert "not found" in response.json()["detail"].lower() + + +async def test_upload_invalid_environment(client, auth_headers, sample_binary): + payload = {**UPLOAD_PAYLOAD, "environment_id": "nonexistent"} + response = await client.post( + "/api/upload-run", json=payload, headers=auth_headers + ) + assert response.status_code == 400 + assert "not found" in response.json()["detail"].lower() + + +async def test_upload_flag_mismatch( + client, auth_headers, sample_binary, sample_environment +): + payload = { + **UPLOAD_PAYLOAD, + "metadata": { + **UPLOAD_PAYLOAD["metadata"], + "configure_vars": {"CONFIG_ARGS": "'--with-pydebug'"}, + }, + } + response = await client.post( + "/api/upload-run", json=payload, headers=auth_headers + ) + assert response.status_code == 400 + assert "configure flags" in response.json()["detail"].lower() + + +async def test_report_memray_failure_requires_auth( + client, sample_binary, sample_environment +): + payload = { + "commit_sha": "c" * 40, + "commit_timestamp": "2025-06-16T10:00:00", + "binary_id": "default", + "environment_id": "linux-x86_64", + "error_message": "memray install failed", + } + response = await client.post("/api/report-memray-failure", json=payload) + assert response.status_code in (401, 403) + + +async def test_report_memray_failure_success( + client, auth_headers, sample_binary, sample_environment +): + payload = { + "commit_sha": "c" * 40, + "commit_timestamp": "2025-06-16T10:00:00", + "binary_id": "default", + "environment_id": "linux-x86_64", + "error_message": "memray install failed", + } + response = await client.post( + "/api/report-memray-failure", json=payload, headers=auth_headers + ) + assert response.status_code == 200 + data = response.json() + assert data["message"] == "Memray failure reported successfully" + + +async def test_upload_duplicate_commit_binary_env( + client, auth_headers, sample_binary, sample_environment +): + """Uploading the same commit+binary+environment twice should return 409.""" + response = await client.post( + "/api/upload-run", json=UPLOAD_PAYLOAD, headers=auth_headers + ) + assert response.status_code == 200 + + response = await client.post( + "/api/upload-run", json=UPLOAD_PAYLOAD, headers=auth_headers + ) + assert response.status_code == 409 + + +async def test_upload_existing_commit_new_binary( + client, auth_headers, db_session, sample_environment +): + """Uploading the same commit with a different binary should succeed.""" + from app.models import Binary + for bin_id in ("bin-a", "bin-b"): + db_session.add(Binary( + id=bin_id, name=bin_id, flags=[], display_order=0, + )) + await db_session.commit() + + payload_a = copy.deepcopy(UPLOAD_PAYLOAD) + payload_a["binary_id"] = "bin-a" + payload_a["metadata"]["configure_vars"]["CONFIG_ARGS"] = "" + + payload_b = copy.deepcopy(UPLOAD_PAYLOAD) + payload_b["binary_id"] = "bin-b" + payload_b["metadata"]["configure_vars"]["CONFIG_ARGS"] = "" + + resp_a = await client.post( + "/api/upload-run", json=payload_a, headers=auth_headers + ) + assert resp_a.status_code == 200 + + resp_b = await client.post( + "/api/upload-run", json=payload_b, headers=auth_headers + ) + assert resp_b.status_code == 200 + + +async def test_upload_clears_memray_failure( + client, auth_headers, sample_binary, sample_environment +): + """A successful upload should clear memray failures for that binary+env.""" + # Report a failure + failure_payload = { + "commit_sha": "b" * 40, + "commit_timestamp": "2025-06-16T09:00:00", + "binary_id": "default", + "environment_id": "linux-x86_64", + "error_message": "memray failed", + } + resp = await client.post( + "/api/report-memray-failure", json=failure_payload, headers=auth_headers + ) + assert resp.status_code == 200 + + # Upload successfully — this should clear the failure + resp = await client.post( + "/api/upload-run", json=UPLOAD_PAYLOAD, headers=auth_headers + ) + assert resp.status_code == 200 + assert resp.json()["results_created"] == 1 + + # Verify the failure was cleared + status = await client.get("/api/memray-status") + data = status.json() + assert data["has_failures"] is False + assert data["failure_count"] == 0 + + +async def test_memray_failure_update_newer( + client, auth_headers, sample_binary, sample_environment +): + """Reporting a newer failure should update the existing record.""" + older = { + "commit_sha": "a" * 40, + "commit_timestamp": "2025-06-15T10:00:00", + "binary_id": "default", + "environment_id": "linux-x86_64", + "error_message": "old failure", + } + newer = { + "commit_sha": "b" * 40, + "commit_timestamp": "2025-06-16T10:00:00", + "binary_id": "default", + "environment_id": "linux-x86_64", + "error_message": "new failure", + } + + resp = await client.post( + "/api/report-memray-failure", json=older, headers=auth_headers + ) + assert resp.status_code == 200 + + resp = await client.post( + "/api/report-memray-failure", json=newer, headers=auth_headers + ) + assert resp.status_code == 200 + assert resp.json()["message"] == "Memray failure reported successfully" + + status = await client.get("/api/memray-status") + data = status.json() + assert data["failure_count"] == 1 + assert data["affected_environments"][0]["commit_sha"] == "b" * 40 + + +async def test_memray_failure_ignore_older( + client, auth_headers, sample_binary, sample_environment +): + """Reporting an older failure should be ignored.""" + newer = { + "commit_sha": "b" * 40, + "commit_timestamp": "2025-06-16T10:00:00", + "binary_id": "default", + "environment_id": "linux-x86_64", + "error_message": "newer failure", + } + older = { + "commit_sha": "a" * 40, + "commit_timestamp": "2025-06-15T10:00:00", + "binary_id": "default", + "environment_id": "linux-x86_64", + "error_message": "older failure", + } + + resp = await client.post( + "/api/report-memray-failure", json=newer, headers=auth_headers + ) + assert resp.status_code == 200 + + resp = await client.post( + "/api/report-memray-failure", json=older, headers=auth_headers + ) + assert resp.status_code == 200 + assert "ignored" in resp.json()["message"].lower() + + # Original failure should remain unchanged + status = await client.get("/api/memray-status") + data = status.json() + assert data["failure_count"] == 1 + assert data["affected_environments"][0]["commit_sha"] == "b" * 40