diff --git a/.env.example b/.env.example index abea3f771..b5ca0ba9b 100644 --- a/.env.example +++ b/.env.example @@ -44,14 +44,3 @@ AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= AWS_DEFAULT_REGION=ap-south-1 AWS_S3_BUCKET_PREFIX="bucket-prefix-name" - -# OpenAI - -OPENAI_API_KEY="this_is_not_a_secret" -LANGFUSE_PUBLIC_KEY="this_is_not_a_secret" -LANGFUSE_SECRET_KEY="this_is_not_a_secret" -LANGFUSE_HOST="this_is_not_a_secret" - -# Misc - -CI="" diff --git a/.env.test.example b/.env.test.example new file mode 100644 index 000000000..79bc08147 --- /dev/null +++ b/.env.test.example @@ -0,0 +1,25 @@ +ENVIRONMENT=local + +PROJECT_NAME="AI Platform" +STACK_NAME=ai-platform + +#Backend +SECRET_KEY=changethis +FIRST_SUPERUSER=superuser@example.com +FIRST_SUPERUSER_PASSWORD=changethis +EMAIL_TEST_USER="test@example.com" + +# Postgres + +POSTGRES_SERVER=localhost +POSTGRES_PORT=5432 +POSTGRES_DB=ai_platform_test +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres + +# AWS + +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_DEFAULT_REGION=ap-south-1 +AWS_S3_BUCKET_PREFIX="bucket-prefix-name" diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 917203f2c..daccfd6e6 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -18,10 +18,6 @@ jobs: count: [100] env: - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - LANGFUSE_PUBLIC_KEY: ${{ secrets.LANGFUSE_PUBLIC_KEY }} - LANGFUSE_SECRET_KEY: ${{ secrets.LANGFUSE_SECRET_KEY }} - LANGFUSE_HOST: ${{ secrets.LANGFUSE_HOST }} LOCAL_CREDENTIALS_ORG_OPENAI_API_KEY: ${{ secrets.LOCAL_CREDENTIALS_ORG_OPENAI_API_KEY }} LOCAL_CREDENTIALS_API_KEY: ${{ secrets.LOCAL_CREDENTIALS_API_KEY }} diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml index 5b40cdd3e..b617c59e2 100644 --- a/.github/workflows/continuous_integration.yml +++ b/.github/workflows/continuous_integration.yml @@ -15,7 +15,7 @@ jobs: env: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres - POSTGRES_DB: ai_platform + POSTGRES_DB: ai_platform_test ports: - 5432:5432 options: --health-cmd "pg_isready -U postgres" --health-interval 10s --health-timeout 5s --health-retries 5 @@ -34,7 +34,9 @@ jobs: python-version: ${{ matrix.python-version }} - name: Making env file - run: cp .env.example .env + run: | + cp .env.test.example .env + cp .env.test.example .env.test - name: Install uv uses: astral-sh/setup-uv@v6 diff --git a/.gitignore b/.gitignore index 0d8e46df9..ad2127f45 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,7 @@ node_modules/ /playwright/.cache/ # Environments -.env +.env* .venv env/ venv/ diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 24779bf33..0c829ed26 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -30,10 +30,6 @@ class Settings(BaseSettings): env_ignore_empty=True, extra="ignore", ) - LANGFUSE_PUBLIC_KEY: str - LANGFUSE_SECRET_KEY: str - LANGFUSE_HOST: str # 🇪🇺 EU region - OPENAI_API_KEY: str API_V1_STR: str = "/api/v1" SECRET_KEY: str = secrets.token_urlsafe(32) # 60 minutes * 24 hours * 1 days = 1 days diff --git a/backend/app/core/db.py b/backend/app/core/db.py index ba991fb36..c923b0b47 100644 --- a/backend/app/core/db.py +++ b/backend/app/core/db.py @@ -6,6 +6,19 @@ engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI)) +# Configure connection pool settings +# For testing, we need more connections since tests run in parallel +pool_size = 20 if settings.ENVIRONMENT == "local" else 5 +max_overflow = 30 if settings.ENVIRONMENT == "local" else 10 + +engine = create_engine( + str(settings.SQLALCHEMY_DATABASE_URI), + pool_size=pool_size, + max_overflow=max_overflow, + pool_pre_ping=True, + pool_recycle=300, # Recycle connections after 5 minutes +) + # make sure all SQLModel models are imported (app.models) before initializing DB # otherwise, SQLModel might fail to initialize relationships properly diff --git a/backend/app/tests/api/routes/documents/test_route_document_upload.py b/backend/app/tests/api/routes/documents/test_route_document_upload.py index 4c6abaaa9..17c37766e 100644 --- a/backend/app/tests/api/routes/documents/test_route_document_upload.py +++ b/backend/app/tests/api/routes/documents/test_route_document_upload.py @@ -12,11 +12,7 @@ from app.core.cloud import AmazonCloudStorageClient from app.core.config import settings from app.models import Document -from app.tests.utils.document import ( - Route, - WebCrawler, - httpx_to_standard, -) +from app.tests.utils.document import Route, WebCrawler, httpx_to_standard class WebUploader(WebCrawler): diff --git a/backend/app/tests/api/routes/test_onboarding.py b/backend/app/tests/api/routes/test_onboarding.py index 6f0327003..3a7ce037e 100644 --- a/backend/app/tests/api/routes/test_onboarding.py +++ b/backend/app/tests/api/routes/test_onboarding.py @@ -1,11 +1,7 @@ -import pytest from fastapi.testclient import TestClient from app.main import app # Assuming your FastAPI app is in app/main.py from app.models import Organization, Project, User, APIKey -from app.crud import create_organization, create_project, create_user, create_api_key -from app.api.deps import SessionDep -from sqlalchemy import create_engine -from sqlmodel import Session, SQLModel +from sqlmodel import Session from app.core.config import settings from app.tests.utils.utils import random_email, random_lower_string from app.core.security import decrypt_api_key diff --git a/backend/app/tests/conftest.py b/backend/app/tests/conftest.py index 027738714..939f3f303 100644 --- a/backend/app/tests/conftest.py +++ b/backend/app/tests/conftest.py @@ -1,27 +1,60 @@ +import logging +from collections.abc import Generator + import pytest +from dotenv import find_dotenv from fastapi.testclient import TestClient -from sqlmodel import Session from sqlalchemy import event -from collections.abc import Generator +from sqlmodel import Session -from app.core.config import settings -from app.core.db import engine from app.api.deps import get_db +from app.core.config import settings from app.main import app from app.models import APIKeyPublic -from app.tests.utils.user import authentication_token_from_email -from app.tests.utils.utils import get_superuser_token_headers, get_api_key_by_email from app.seed_data.seed_data import seed_database +from app.tests.utils.user import authentication_token_from_email +from app.tests.utils.utils import ( + get_superuser_token_headers, + get_api_key_by_email, + load_environment, +) + +logger = logging.getLogger(__name__) + + +@pytest.fixture(scope="session", autouse=True) +def seed_baseline(): + # Load test environment to ensure correct bucket name + path = find_dotenv(".env.test") + load_environment(path) + + # Import engine after environment is loaded + from app.core.db import engine + + with Session(engine) as session: + logger.info("Seeding baseline data...") + seed_database(session) # deterministic baseline + yield + + +@pytest.fixture(scope="function", autouse=True) +def cleanup_sessions(): + """Clean up any lingering sessions after each test.""" + yield + # Force cleanup of any remaining connections in the pool + from app.core.db import engine + + engine.dispose() @pytest.fixture(scope="function") def db() -> Generator[Session, None, None]: + from app.core.db import engine + connection = engine.connect() transaction = connection.begin() session = Session(bind=connection) - nested = session.begin_nested() - @event.listens_for(session, "after_transaction_end") def restart_savepoint(sess, trans): if trans.nested and not trans._parent.nested: @@ -32,14 +65,7 @@ def restart_savepoint(sess, trans): finally: session.close() transaction.rollback() - connection.close() - - -@pytest.fixture(scope="session", autouse=True) -def seed_baseline(): - with Session(engine) as session: - seed_database(session) # deterministic baseline - yield + connection.close() # Explicitly close the connection @pytest.fixture(scope="function") diff --git a/backend/app/tests/crud/test_thread_result.py b/backend/app/tests/crud/test_thread_result.py index 00c581dea..b82a5a8d5 100644 --- a/backend/app/tests/crud/test_thread_result.py +++ b/backend/app/tests/crud/test_thread_result.py @@ -1,7 +1,6 @@ -import pytest -from sqlmodel import SQLModel, Session, create_engine +from sqlmodel import Session -from app.models import OpenAI_Thread, OpenAIThreadCreate +from app.models import OpenAIThreadCreate from app.crud import upsert_thread_result, get_thread_result diff --git a/backend/app/tests/utils/utils.py b/backend/app/tests/utils/utils.py index 9fb5311fa..b66ef9fe0 100644 --- a/backend/app/tests/utils/utils.py +++ b/backend/app/tests/utils/utils.py @@ -1,36 +1,44 @@ +import importlib +import logging +import os import random import string +from typing import TypeVar from uuid import UUID -from typing import Type, TypeVar - -import pytest -from pydantic import EmailStr +from dotenv import load_dotenv from fastapi.testclient import TestClient -from sqlmodel import Session, select +from pydantic import EmailStr +from sqlmodel import Session, create_engine, select +from app.core import config from app.core.config import settings +from app.crud.api_key import get_api_key_by_user_id, get_api_key_by_value from app.crud.user import get_user_by_email -from app.crud.api_key import get_api_key_by_value, get_api_key_by_user_id -from app.models import APIKeyPublic, Project, Assistant, Organization +from app.models import APIKeyPublic, Assistant, Organization, Project +logger = logging.getLogger(__name__) T = TypeVar("T") def random_lower_string() -> str: + """Generate a random lowercase string of 32 characters.""" return "".join(random.choices(string.ascii_lowercase, k=32)) -def generate_random_string(length=10): +def generate_random_string(length: int = 10) -> str: + """Generate a random string of specified length.""" return "".join(random.choices(string.ascii_letters + string.digits, k=length)) def random_email() -> str: + """Generate a random email address.""" return f"{random_lower_string()}@{random_lower_string()}.com" def get_superuser_token_headers(client: TestClient) -> dict[str, str]: + """Get authentication headers for superuser.""" login_data = { "username": settings.FIRST_SUPERUSER, "password": settings.FIRST_SUPERUSER_PASSWORD, @@ -43,18 +51,20 @@ def get_superuser_token_headers(client: TestClient) -> dict[str, str]: def get_api_key_by_email(db: Session, email: EmailStr) -> APIKeyPublic: + """Get API key for a user by their email address.""" user = get_user_by_email(session=db, email=email) api_key = get_api_key_by_user_id(db, user_id=user.id) - return api_key def get_user_id_by_email(db: Session) -> int: + """Get user ID for the test user email.""" user = get_user_by_email(session=db, email=settings.EMAIL_TEST_USER) return user.id def get_user_from_api_key(db: Session, api_key_headers: dict[str, str]) -> APIKeyPublic: + """Get API key object from API key headers.""" key_value = api_key_headers["X-API-KEY"] api_key = get_api_key_by_value(db, api_key_value=key_value) if api_key is None: @@ -62,7 +72,8 @@ def get_user_from_api_key(db: Session, api_key_headers: dict[str, str]) -> APIKe return api_key -def get_non_existent_id(session: Session, model: Type[T]) -> int: +def get_non_existent_id(session: Session, model: type[T]) -> int: + """Get an ID that doesn't exist in the database for the given model.""" result = session.exec(select(model.id).order_by(model.id.desc())).first() return (result or 0) + 1 @@ -89,11 +100,81 @@ def get_project(session: Session, name: str | None = None) -> Project: return project +def load_environment(env_test_path: str) -> None: + """ + Load test environment variables from the specified file. + 1. Application starts + 2. .env file (loaded by Settings class) + 3. for testcases only .env.test file (loaded by load_environment_file function) + + Args: + env_test_path: Path to the test environment file. + + Raises: + ValueError: If required PostgreSQL credentials are missing. + RuntimeError: If the database name doesn't contain 'test'. + """ + if os.path.exists(env_test_path): + load_dotenv(env_test_path, override=True) + + required_vars = [ + "POSTGRES_USER", + "POSTGRES_PASSWORD", + "POSTGRES_SERVER", + "POSTGRES_PORT", + "POSTGRES_DB", + ] + + missing_vars = [var for var in required_vars if not os.getenv(var)] + + if missing_vars: + raise ValueError( + f"Missing the following PostgreSQL credentials in {env_test_path}: " + f"{', '.join(missing_vars)}" + ) + + db_name = os.getenv("POSTGRES_DB", "").lower() + if "test" not in db_name: + raise RuntimeError( + f"Connected to database '{db_name}', which doesn't appear to be a " + f"test database" + ) + + # Reload settings to reflect the new environment variables + importlib.reload(config) + + # Recreate the engine with the updated settings + # need import here to avoid circular imports & ensures proper timing of engine creation + from app.core.db import engine + + # Dispose of the old engine + engine.dispose() + + # Create a new engine with updated settings + new_engine = create_engine( + str(config.settings.SQLALCHEMY_DATABASE_URI), + pool_size=20 if config.settings.ENVIRONMENT == "local" else 5, + max_overflow=30 if config.settings.ENVIRONMENT == "local" else 10, + pool_pre_ping=True, + pool_recycle=300, + ) + + # Replace the engine in the db module + import app.core.db + + app.core.db.engine = new_engine + + else: + logger.warning( + f"{env_test_path} not found. Using default environment settings." + ) + + def get_assistant(session: Session, name: str | None = None) -> Assistant: """ Retrieve an active assistant from the database. - If a assistant name is provided, fetch the active assistant with that name. + If an assistant name is provided, fetch the active assistant with that name. If no name is provided, fetch any random assistant. """ if name: @@ -138,16 +219,22 @@ def get_organization(session: Session, name: str | None = None) -> Organization: class SequentialUuidGenerator: - def __init__(self, start=0): + """Generate sequential UUIDs for testing purposes.""" + + def __init__(self, start: int = 0) -> None: + """Initialize the generator with a starting value.""" self.start = start - def __iter__(self): + def __iter__(self) -> "SequentialUuidGenerator": + """Return self as an iterator.""" return self def __next__(self) -> UUID: + """Generate the next UUID in sequence.""" uu_id = UUID(int=self.start) self.start += 1 return uu_id def peek(self) -> UUID: + """Peek at the next UUID without advancing the sequence.""" return UUID(int=self.start) diff --git a/backend/app/tests_pre_start.py b/backend/app/tests_pre_start.py index 0ce604563..2fc5a2188 100644 --- a/backend/app/tests_pre_start.py +++ b/backend/app/tests_pre_start.py @@ -1,9 +1,17 @@ import logging +from dotenv import find_dotenv from sqlalchemy import Engine from sqlmodel import Session, select from tenacity import after_log, before_log, retry, stop_after_attempt, wait_fixed +from app.tests.utils.utils import load_environment + +# Load test environment before importing engine +path = find_dotenv(".env.test") +load_environment(path) + +# Import engine after environment is loaded from app.core.db import engine logging.basicConfig(level=logging.INFO) @@ -21,7 +29,6 @@ ) def init(db_engine: Engine) -> None: try: - # Try to create session to check if DB is awake with Session(db_engine) as session: session.exec(select(1)) except Exception as e: @@ -33,6 +40,9 @@ def main() -> None: logger.info("Initializing service") init(engine) logger.info("Service finished initializing") + logger.info( + f"Using database URL: {engine.url.render_as_string(hide_password=True)}" + ) if __name__ == "__main__": diff --git a/docker-compose.yml b/docker-compose.yml index e8e1330dc..78a4af528 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -72,10 +72,6 @@ services: - POSTGRES_USER=${POSTGRES_USER?Variable not set} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set} - SENTRY_DSN=${SENTRY_DSN} - - OPENAI_API_KEY=${OPENAI_API_KEY} - - LANGFUSE_PUBLIC_KEY=${LANGFUSE_PUBLIC_KEY} - - LANGFUSE_SECRET_KEY=${LANGFUSE_SECRET_KEY} - - LANGFUSE_HOST=${LANGFUSE_HOST} - LOCAL_CREDENTIALS_ORG_OPENAI_API_KEY=${LOCAL_CREDENTIALS_ORG_OPENAI_API_KEY} - LOCAL_CREDENTIALS_API_KEY=${LOCAL_CREDENTIALS_API_KEY} - EMAIL_TEST_USER=${EMAIL_TEST_USER} @@ -112,10 +108,6 @@ services: - POSTGRES_USER=${POSTGRES_USER?Variable not set} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set} - SENTRY_DSN=${SENTRY_DSN} - - OPENAI_API_KEY=${OPENAI_API_KEY} - - LANGFUSE_PUBLIC_KEY=${LANGFUSE_PUBLIC_KEY} - - LANGFUSE_SECRET_KEY=${LANGFUSE_SECRET_KEY} - - LANGFUSE_HOST=${LANGFUSE_HOST} - LOCAL_CREDENTIALS_ORG_OPENAI_API_KEY=${LOCAL_CREDENTIALS_ORG_OPENAI_API_KEY} - LOCAL_CREDENTIALS_API_KEY=${LOCAL_CREDENTIALS_API_KEY} - EMAIL_TEST_USER=${EMAIL_TEST_USER}