| import asyncio |
| import os |
| import sys |
| import types |
| import uuid |
| from pathlib import Path |
|
|
| import pytest |
| import psutil |
| from fastapi import FastAPI |
| from fastapi.middleware.cors import CORSMiddleware |
| from fastapi.responses import JSONResponse |
| from fastapi.testclient import TestClient |
| from sqlalchemy import text |
| from sqlalchemy.ext.asyncio import create_async_engine |
| from sqlalchemy.pool import NullPool |
|
|
| PROJECT_ROOT = Path(__file__).resolve().parents[1] |
| if str(PROJECT_ROOT) not in sys.path: |
| sys.path.insert(0, str(PROJECT_ROOT)) |
|
|
| os.environ.setdefault("JWT_SECRET_KEY", "test-secret-key") |
| os.environ.setdefault("JWT_ALGORITHM", "HS256") |
| os.environ.setdefault( |
| "ENCRYPTION_KEY", "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA=" |
| ) |
|
|
| fake_supabase_module = types.ModuleType("src.core.storage.supabase") |
|
|
|
|
| class _FakeSupabaseStorage: |
| @classmethod |
| def from_env(cls): |
| return cls() |
|
|
| async def upload_pdf(self, file, user_id, prefix): |
| return f"{prefix}/{user_id}/test.pdf" |
|
|
| def get_file_url(self, key): |
| return f"https://storage.example/{key}" |
|
|
|
|
| fake_supabase_module.SupabaseStorage = _FakeSupabaseStorage |
| sys.modules.setdefault("src.core.storage.supabase", fake_supabase_module) |
|
|
| fake_ingestion_module = types.ModuleType("src.core.ingest_engine.ingestion") |
|
|
|
|
| class _FakeIngestionEngine: |
| @staticmethod |
| async def delete_paper_using_paper_ids(arxiv_ids): |
| return None |
|
|
|
|
| fake_ingestion_module.IngestionEngine = _FakeIngestionEngine |
| sys.modules.setdefault("src.core.ingest_engine.ingestion", fake_ingestion_module) |
|
|
| from src.database.db import Base, session_pool |
| from src.lib.auth import create_access_token, create_refresh_token, hash_password |
| from src.lib.enum import PaperSourceEnum, ServiceType |
| from src.errors import DatabaseConnectionError |
| from src.model import ( |
| LoginSession, |
| Paper, |
| Profile, |
| ResourceCatalog, |
| ServiceCatalog, |
| Usability, |
| User, |
| UserSettings, |
| ) |
| from src.router.auth import router as auth_router |
| from src.router.paper import router as paper_router |
| from src.router.summary import router as summary_router |
| from src.router.user_settings import router as user_settings_router |
|
|
|
|
| def run_async(awaitable): |
| return asyncio.run(awaitable) |
|
|
|
|
| def _quote(identifier: str) -> str: |
| return f'"{identifier}"' |
|
|
|
|
| @pytest.fixture(scope="session") |
| def test_database_url(): |
| database_url = os.getenv("TEST_DATABASE_URL") or os.getenv("DATABASE_URL") |
| if not database_url: |
| pytest.skip("Set TEST_DATABASE_URL or DATABASE_URL before running backend tests.") |
| return database_url.strip().strip('"') |
|
|
|
|
| @pytest.fixture(scope="session") |
| def test_schema_name(): |
| return f"pytest_{uuid.uuid4().hex}" |
|
|
|
|
| @pytest.fixture(scope="session") |
| def test_engine(test_database_url, test_schema_name): |
| async def _setup(): |
| admin_engine = create_async_engine( |
| test_database_url, |
| poolclass=NullPool, |
| pool_pre_ping=True, |
| ) |
| async with admin_engine.begin() as conn: |
| await conn.execute( |
| text(f"CREATE SCHEMA IF NOT EXISTS {_quote(test_schema_name)}") |
| ) |
|
|
| engine = create_async_engine( |
| test_database_url, |
| poolclass=NullPool, |
| pool_pre_ping=True, |
| connect_args={"server_settings": {"search_path": test_schema_name}}, |
| ) |
| async with engine.begin() as conn: |
| await conn.run_sync(Base.metadata.create_all) |
|
|
| return admin_engine, engine |
|
|
| original_bind = session_pool.kw.get("bind") |
| admin_engine, engine = run_async(_setup()) |
| session_pool.configure(bind=engine) |
|
|
| yield engine |
|
|
| async def _teardown(): |
| await engine.dispose() |
| async with admin_engine.begin() as conn: |
| await conn.execute( |
| text(f"DROP SCHEMA IF EXISTS {_quote(test_schema_name)} CASCADE") |
| ) |
| await admin_engine.dispose() |
|
|
| session_pool.configure(bind=original_bind) |
| run_async(_teardown()) |
|
|
|
|
| @pytest.fixture(autouse=True) |
| def reset_database(test_engine): |
| async def _reset(): |
| async with test_engine.begin() as conn: |
| for table in reversed(Base.metadata.sorted_tables): |
| await conn.execute( |
| text( |
| f"TRUNCATE TABLE {_quote(table.name)} RESTART IDENTITY CASCADE" |
| ) |
| ) |
|
|
| run_async(_reset()) |
|
|
|
|
| @pytest.fixture() |
| def app(): |
| app = FastAPI() |
| app.state.logger = None |
|
|
| app.add_middleware( |
| CORSMiddleware, |
| allow_origins=["*"], |
| allow_credentials=True, |
| allow_methods=["*"], |
| allow_headers=["*"], |
| ) |
|
|
| app.include_router(auth_router, prefix="/api/v1/auth", tags=["Authentication"]) |
| app.include_router(paper_router, prefix="/api/v1/papers", tags=["Papers"]) |
| app.include_router(summary_router, prefix="/api/v1/summary", tags=["Summary"]) |
| app.include_router( |
| user_settings_router, prefix="/api/v1/settings", tags=["User Settings"] |
| ) |
|
|
| @app.get("/health") |
| def health_check(): |
| return { |
| "health": "ok", |
| "cpu_usage": psutil.cpu_percent(interval=None), |
| "memory_usage": psutil.virtual_memory().percent, |
| "num_threads": psutil.cpu_count(), |
| } |
|
|
| @app.get("/") |
| def root(): |
| return {"message": "Welcome to The Arxplorer Backend API"} |
|
|
| @app.exception_handler(DatabaseConnectionError) |
| async def db_connection_exception_handler(request, exc: DatabaseConnectionError): |
| return JSONResponse( |
| status_code=503, |
| content={ |
| "detail": "Database connection error. Please try again later.", |
| "error": str(exc), |
| }, |
| ) |
|
|
| return app |
|
|
|
|
| @pytest.fixture() |
| def client(app): |
| test_client = TestClient(app) |
| try: |
| yield test_client |
| finally: |
| test_client.close() |
|
|
|
|
| @pytest.fixture() |
| def external_service_spies(monkeypatch): |
| calls = { |
| "thumbnails": [], |
| "background_index": [], |
| "cleanup": [], |
| "summaries": [], |
| "usability": [], |
| } |
|
|
| async def fake_thumbnail(pdf_url: str, user_id: int, target_width: int = 400): |
| calls["thumbnails"].append( |
| {"pdf_url": pdf_url, "user_id": user_id, "target_width": target_width} |
| ) |
| return f"https://thumbs.example/{user_id}.png" |
|
|
| async def fake_cleanup(arxiv_ids: list[str]): |
| calls["cleanup"].append(list(arxiv_ids)) |
|
|
| async def fake_summary(arxiv_id=None, pdf_url=None, request=None): |
| calls["summaries"].append({"arxiv_id": arxiv_id, "pdf_url": pdf_url}) |
| target = arxiv_id or pdf_url |
| return f"summary for {target}" |
|
|
| async def fake_usability(arxiv_id=None, pdf_url=None, request=None): |
| calls["usability"].append({"arxiv_id": arxiv_id, "pdf_url": pdf_url}) |
| return { |
| "domain_applicability": {"Technology": 0.9}, |
| "reproducibility_score": {"Reproducible": 0.8}, |
| "new_tech_applicability": {"Agentic AI": 0.7}, |
| "impact_score": 0.6, |
| } |
|
|
| def fake_index_background(paper_id: int, title: str, abstract: str): |
| calls["background_index"].append( |
| {"paper_id": paper_id, "title": title, "abstract": abstract} |
| ) |
|
|
| monkeypatch.setattr( |
| "src.controller.paper.generate_first_page_thumbnail", fake_thumbnail |
| ) |
| monkeypatch.setattr( |
| "src.controller.paper.IngestionEngine.delete_paper_using_paper_ids", |
| fake_cleanup, |
| ) |
| monkeypatch.setattr("src.controller.paper.index_paper_background", fake_index_background) |
| monkeypatch.setattr( |
| "src.controller.summary.SummaryEngine.generate_paper_summary", fake_summary |
| ) |
| monkeypatch.setattr( |
| "src.controller.summary.UsabilityEngine.generate_paper_summary", |
| fake_usability, |
| ) |
|
|
| return calls |
|
|
|
|
| @pytest.fixture() |
| def create_user(): |
| def factory( |
| *, |
| email: str | None = None, |
| username: str | None = None, |
| password: str = "Password123!", |
| full_name: str = "Test User", |
| with_profile: bool = False, |
| with_settings: bool = False, |
| ): |
| async def _create(): |
| unique_suffix = uuid.uuid4().hex[:8] |
| user = User( |
| username=username or f"user_{unique_suffix}", |
| full_name=full_name, |
| email=email or f"{unique_suffix}@example.com", |
| password=hash_password(password), |
| ) |
| async with session_pool() as session: |
| session.add(user) |
| await session.commit() |
| await session.refresh(user) |
|
|
| if with_profile: |
| session.add(Profile(user_id=user.id)) |
| if with_settings: |
| session.add(UserSettings(user_id=user.id)) |
| if with_profile or with_settings: |
| await session.commit() |
|
|
| return { |
| "id": user.id, |
| "email": user.email, |
| "username": user.username, |
| "password": password, |
| "full_name": user.full_name, |
| } |
|
|
| return run_async(_create()) |
|
|
| return factory |
|
|
|
|
| @pytest.fixture() |
| def create_authenticated_user(create_user): |
| def factory( |
| *, |
| email: str | None = None, |
| username: str | None = None, |
| password: str = "Password123!", |
| full_name: str = "Authenticated User", |
| with_profile: bool = False, |
| with_settings: bool = False, |
| ): |
| user = create_user( |
| email=email, |
| username=username, |
| password=password, |
| full_name=full_name, |
| with_profile=with_profile, |
| with_settings=with_settings, |
| ) |
|
|
| async def _create_session(): |
| access_token, access_expires_at = create_access_token( |
| data={"sub": str(user["id"])} |
| ) |
| refresh_token, _ = create_refresh_token(subject=str(user["id"])) |
| login_session = LoginSession( |
| user_id=user["id"], |
| login_method="password", |
| is_active=True, |
| access_token=access_token, |
| refresh_token=refresh_token, |
| token_expires_at=access_expires_at, |
| device_info="pytest", |
| ip_address="127.0.0.1", |
| user_agent="pytest", |
| ) |
|
|
| async with session_pool() as session: |
| session.add(login_session) |
| await session.commit() |
| await session.refresh(login_session) |
|
|
| return { |
| **user, |
| "access_token": access_token, |
| "refresh_token": refresh_token, |
| "session_id": login_session.id, |
| "headers": {"Authorization": f"Bearer {access_token}"}, |
| } |
|
|
| return run_async(_create_session()) |
|
|
| return factory |
|
|
|
|
| @pytest.fixture() |
| def create_paper_record(): |
| def factory( |
| *, |
| user_id: int, |
| title: str = "Paper Title", |
| abstract: str = "Paper abstract", |
| authors: str = "Author One", |
| arxiv_id: str | None = None, |
| paper_summary: str | None = "", |
| pdf_url: str | None = "https://example.com/paper.pdf", |
| ): |
| async def _create(): |
| paper = Paper( |
| user_id=user_id, |
| title=title, |
| abstract=abstract, |
| authors=authors, |
| arxiv_id=arxiv_id or f"{uuid.uuid4().hex[:6]}.1234", |
| pdf_url=pdf_url, |
| paper_summary=paper_summary, |
| paper_source=PaperSourceEnum.ARXIV.value, |
| ) |
| async with session_pool() as session: |
| session.add(paper) |
| await session.commit() |
| await session.refresh(paper) |
| return { |
| "id": paper.id, |
| "arxiv_id": paper.arxiv_id, |
| "title": paper.title, |
| "paper_summary": paper.paper_summary, |
| } |
|
|
| return run_async(_create()) |
|
|
| return factory |
|
|
|
|
| @pytest.fixture() |
| def create_usability_record(): |
| def factory( |
| *, |
| user_id: int, |
| paper_id: int, |
| domain_applicability: dict | None = None, |
| reproducibility_score: dict | None = None, |
| new_tech_applicability: dict | None = None, |
| impact_score: float = 0.5, |
| ): |
| async def _create(): |
| usability = Usability( |
| user_id=user_id, |
| paper_id=paper_id, |
| domain_applicability=domain_applicability or {"Technology": 0.8}, |
| reproducibility_score=reproducibility_score |
| or {"Reproducible": 0.7}, |
| new_tech_applicability=new_tech_applicability |
| or {"Agentic AI": 0.6}, |
| impact_score=impact_score, |
| ) |
| async with session_pool() as session: |
| session.add(usability) |
| await session.commit() |
| await session.refresh(usability) |
| return {"id": usability.id, "impact_score": usability.impact_score} |
|
|
| return run_async(_create()) |
|
|
| return factory |
|
|
|
|
| @pytest.fixture() |
| def seed_catalog(): |
| def factory(): |
| async def _seed(): |
| service_active = ServiceCatalog( |
| name="OpenAI", |
| slug="openai", |
| service_type=ServiceType.LLM, |
| description="Primary LLM provider", |
| is_active=True, |
| ) |
| service_inactive = ServiceCatalog( |
| name="Disabled Service", |
| slug="disabled-service", |
| service_type=ServiceType.WEB_SEARCH, |
| description="Inactive service", |
| is_active=False, |
| ) |
|
|
| async with session_pool() as session: |
| session.add_all([service_active, service_inactive]) |
| await session.commit() |
| await session.refresh(service_active) |
| await session.refresh(service_inactive) |
|
|
| resource_active = ResourceCatalog( |
| name="GPT-4.1", |
| slug="gpt-4.1", |
| service_id=service_active.id, |
| description="Active model", |
| is_active=True, |
| ) |
| resource_inactive = ResourceCatalog( |
| name="Inactive Model", |
| slug="inactive-model", |
| service_id=service_active.id, |
| description="Inactive model", |
| is_active=False, |
| ) |
| session.add_all([resource_active, resource_inactive]) |
| await session.commit() |
|
|
| return { |
| "service_id": service_active.id, |
| "resource_slug": resource_active.slug, |
| } |
|
|
| return run_async(_seed()) |
|
|
| return factory |
|
|