diff --git a/backend/app/alembic/versions/ecda6b144627_config_management_tables.py b/backend/app/alembic/versions/ecda6b144627_config_management_tables.py new file mode 100644 index 000000000..378d232c9 --- /dev/null +++ b/backend/app/alembic/versions/ecda6b144627_config_management_tables.py @@ -0,0 +1,101 @@ +"""Config management tables + +Revision ID: ecda6b144627 +Revises: 633e69806207 +Create Date: 2025-11-19 13:16:50.954576 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "ecda6b144627" +down_revision = "633e69806207" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "config", + sa.Column("name", sqlmodel.sql.sqltypes.AutoString(length=128), nullable=False), + sa.Column( + "description", sqlmodel.sql.sqltypes.AutoString(length=512), nullable=True + ), + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("project_id", sa.Integer(), nullable=False), + sa.Column("inserted_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.Column("deleted_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(["project_id"], ["project.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "idx_config_project_id_updated_at_active", + "config", + ["project_id", "updated_at"], + unique=False, + postgresql_where=sa.text("deleted_at IS NULL"), + ) + op.create_index( + "uq_config_project_id_name_active", + "config", + ["project_id", "name"], + unique=True, + postgresql_where=sa.text("deleted_at IS NULL"), + ) + op.create_table( + "config_version", + sa.Column( + "config_blob", postgresql.JSONB(astext_type=sa.Text()), nullable=False + ), + sa.Column( + "commit_message", + sqlmodel.sql.sqltypes.AutoString(length=512), + nullable=True, + ), + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("config_id", sa.Uuid(), nullable=False), + sa.Column("version", sa.Integer(), nullable=False), + sa.Column("inserted_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.Column("deleted_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(["config_id"], ["config.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "config_id", "version", name="uq_config_version_config_id_version" + ), + ) + op.create_index( + "idx_config_version_config_id_version_active", + "config_version", + ["config_id", "version"], + unique=False, + postgresql_where=sa.text("deleted_at IS NULL"), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index( + "idx_config_version_config_id_version_active", + table_name="config_version", + postgresql_where=sa.text("deleted_at IS NULL"), + ) + op.drop_table("config_version") + op.drop_index( + "uq_config_project_id_name_active", + table_name="config", + postgresql_where=sa.text("deleted_at IS NULL"), + ) + op.drop_index( + "idx_config_project_id_updated_at_active", + table_name="config", + postgresql_where=sa.text("deleted_at IS NULL"), + ) + op.drop_table("config") + # ### end Alembic commands ### diff --git a/backend/app/api/main.py b/backend/app/api/main.py index e2b473f92..ac6fd2863 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -4,6 +4,7 @@ api_keys, assistants, collections, + config, documents, doc_transformation_job, login, @@ -31,6 +32,7 @@ api_router.include_router(assistants.router) api_router.include_router(collections.router) api_router.include_router(collection_job.router) +api_router.include_router(config.router) api_router.include_router(credentials.router) api_router.include_router(cron.router) api_router.include_router(documents.router) diff --git a/backend/app/api/routes/config/__init__.py b/backend/app/api/routes/config/__init__.py new file mode 100644 index 000000000..39fbf9203 --- /dev/null +++ b/backend/app/api/routes/config/__init__.py @@ -0,0 +1,10 @@ +from fastapi import APIRouter + +from app.api.routes.config import config, version + +router = APIRouter(prefix="/configs", tags=["Config Management"]) + +router.include_router(config.router) +router.include_router(version.router) + +__all__ = ["router"] diff --git a/backend/app/api/routes/config/config.py b/backend/app/api/routes/config/config.py new file mode 100644 index 000000000..cdb71e981 --- /dev/null +++ b/backend/app/api/routes/config/config.py @@ -0,0 +1,133 @@ +from uuid import UUID +from fastapi import APIRouter, Depends, Query, HTTPException + +from app.api.deps import SessionDep, AuthContextDep +from app.crud.config import ConfigCrud +from app.models import ( + Config, + ConfigCreate, + ConfigUpdate, + ConfigPublic, + ConfigWithVersion, + ConfigVersion, + Message, +) +from app.utils import APIResponse +from app.api.permissions import Permission, require_permission + +router = APIRouter() + + +@router.post( + "/", + response_model=APIResponse[ConfigWithVersion], + status_code=201, + dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))], +) +def create_config( + config_create: ConfigCreate, + current_user: AuthContextDep, + session: SessionDep, +): + """ + create new config along with initial version + """ + config_crud = ConfigCrud(session=session, project_id=current_user.project.id) + config, version = config_crud.create_or_raise(config_create) + + response = ConfigWithVersion(**config.model_dump(), version=version) + + return APIResponse.success_response( + data=response, + ) + + +@router.get( + "/", + response_model=APIResponse[list[ConfigPublic]], + status_code=200, + dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))], +) +def list_configs( + current_user: AuthContextDep, + session: SessionDep, + skip: int = Query(0, ge=0, description="Number of records to skip"), + limit: int = Query(100, ge=1, le=100, description="Maximum records to return"), +): + """ + List all configurations for the current project. + Ordered by updated_at in descending order. + """ + config_crud = ConfigCrud(session=session, project_id=current_user.project.id) + configs = config_crud.read_all(skip=skip, limit=limit) + return APIResponse.success_response( + data=configs, + ) + + +@router.get( + "/{config_id}", + response_model=APIResponse[ConfigPublic], + status_code=200, + dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))], +) +def get_config( + config_id: UUID, + current_user: AuthContextDep, + session: SessionDep, +): + """ + Get a specific configuration by its ID. + """ + config_crud = ConfigCrud(session=session, project_id=current_user.project.id) + config = config_crud.exists_or_raise(config_id=config_id) + return APIResponse.success_response( + data=config, + ) + + +@router.patch( + "/{config_id}", + response_model=APIResponse[ConfigPublic], + status_code=200, + dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))], +) +def update_config( + config_id: UUID, + config_update: ConfigUpdate, + current_user: AuthContextDep, + session: SessionDep, +): + """ + Update a specific configuration. + """ + config_crud = ConfigCrud(session=session, project_id=current_user.project.id) + config = config_crud.update_or_raise( + config_id=config_id, config_update=config_update + ) + + return APIResponse.success_response( + data=config, + ) + + +@router.delete( + "/{config_id}", + response_model=APIResponse[Message], + status_code=200, + dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))], +) +def delete_config( + config_id: UUID, + current_user: AuthContextDep, + session: SessionDep, +): + """ + Delete a specific configuration. + """ + config_crud = ConfigCrud(session=session, project_id=current_user.project.id) + config_crud.delete_or_raise(config_id=config_id) + + return APIResponse.success_response( + data=Message(message="Config deleted successfully"), + ) diff --git a/backend/app/api/routes/config/version.py b/backend/app/api/routes/config/version.py new file mode 100644 index 000000000..aef49ae41 --- /dev/null +++ b/backend/app/api/routes/config/version.py @@ -0,0 +1,123 @@ +from uuid import UUID +from fastapi import APIRouter, Depends, Query, HTTPException, Path + +from app.api.deps import SessionDep, AuthContextDep +from app.crud.config import ConfigCrud, ConfigVersionCrud +from app.models import ( + ConfigVersionCreate, + ConfigVersionPublic, + Message, + ConfigVersionItems, +) +from app.utils import APIResponse +from app.api.permissions import Permission, require_permission + +router = APIRouter() + + +@router.post( + "/{config_id}/versions", + response_model=APIResponse[ConfigVersionPublic], + status_code=201, + dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))], +) +def create_version( + config_id: UUID, + version_create: ConfigVersionCreate, + current_user: AuthContextDep, + session: SessionDep, +): + """ + Create a new version for an existing configuration. + The version number is automatically incremented. + """ + version_crud = ConfigVersionCrud( + session=session, project_id=current_user.project.id, config_id=config_id + ) + version = version_crud.create_or_raise(version_create=version_create) + + return APIResponse.success_response( + data=ConfigVersionPublic(**version.model_dump()), + ) + + +@router.get( + "/{config_id}/versions", + response_model=APIResponse[list[ConfigVersionItems]], + status_code=200, + dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))], +) +def list_versions( + config_id: UUID, + current_user: AuthContextDep, + session: SessionDep, + skip: int = Query(0, ge=0, description="Number of records to skip"), + limit: int = Query(100, ge=1, le=100, description="Maximum records to return"), +): + """ + List all versions for a specific configuration. + Ordered by version number in descending order. + """ + version_crud = ConfigVersionCrud( + session=session, project_id=current_user.project.id, config_id=config_id + ) + versions = version_crud.read_all( + skip=skip, + limit=limit, + ) + return APIResponse.success_response( + data=versions, + ) + + +@router.get( + "/{config_id}/versions/{version_number}", + response_model=APIResponse[ConfigVersionPublic], + status_code=200, + dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))], +) +def get_version( + config_id: UUID, + current_user: AuthContextDep, + session: SessionDep, + version_number: int = Path( + ..., ge=1, description="The version number of the config" + ), +): + """ + Get a specific version of a config. + """ + version_crud = ConfigVersionCrud( + session=session, project_id=current_user.project.id, config_id=config_id + ) + version = version_crud.exists_or_raise(version_number=version_number) + return APIResponse.success_response( + data=version, + ) + + +@router.delete( + "/{config_id}/versions/{version_number}", + response_model=APIResponse[Message], + status_code=200, + dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))], +) +def delete_version( + config_id: UUID, + current_user: AuthContextDep, + session: SessionDep, + version_number: int = Path( + ..., ge=1, description="The version number of the config" + ), +): + """ + Delete a specific version of a config. + """ + version_crud = ConfigVersionCrud( + session=session, project_id=current_user.project.id, config_id=config_id + ) + version_crud.delete_or_raise(version_number=version_number) + + return APIResponse.success_response( + data=Message(message="Config Version deleted successfully"), + ) diff --git a/backend/app/crud/config/__init__.py b/backend/app/crud/config/__init__.py new file mode 100644 index 000000000..490a89d1d --- /dev/null +++ b/backend/app/crud/config/__init__.py @@ -0,0 +1,4 @@ +from app.crud.config.config import ConfigCrud +from app.crud.config.version import ConfigVersionCrud + +__all__ = ["ConfigCrud", "ConfigVersionCrud"] diff --git a/backend/app/crud/config/config.py b/backend/app/crud/config/config.py new file mode 100644 index 000000000..00ac3b927 --- /dev/null +++ b/backend/app/crud/config/config.py @@ -0,0 +1,159 @@ +import logging +from uuid import UUID +from typing import Tuple + +from sqlmodel import Session, select, and_ +from fastapi import HTTPException + +from app.models import ( + Config, + ConfigCreate, + ConfigUpdate, + ConfigVersion, +) +from app.core.util import now + +logger = logging.getLogger(__name__) + + +class ConfigCrud: + """ + CRUD operations for configurations scoped to a project. + """ + + def __init__(self, session: Session, project_id: int): + self.session = session + self.project_id = project_id + + def create_or_raise( + self, config_create: ConfigCreate + ) -> Tuple[Config, ConfigVersion]: + """ + Create a new configuration with an initial version. + """ + self._check_unique_name_or_raise(config_create.name) + + try: + config = Config( + name=config_create.name, + description=config_create.description, + project_id=self.project_id, + ) + + self.session.add(config) + self.session.flush() # Flush to get the config.id + + # Create the initial version + version = ConfigVersion( + config_id=config.id, + version=1, + config_blob=config_create.config_blob, + commit_message=config_create.commit_message, + ) + + self.session.add(version) + self.session.commit() + self.session.refresh(config) + self.session.refresh(version) + + logger.info( + f"[ConfigCrud.create] Configuration created successfully | " + f"{{'config_id': '{config.id}', 'config_version_id': '{version.id}', 'project_id': {self.project_id}}}" + ) + + return config, version + + except Exception as e: + self.session.rollback() + logger.error( + f"[ConfigCrud.create] Failed to create configuration | " + f"{{'name': '{config_create.name}', 'project_id': {self.project_id}, 'error': '{str(e)}'}}", + exc_info=True, + ) + raise HTTPException( + status_code=500, + detail=f"Unexpected error occurred: failed to create config", + ) + + def read_one(self, config_id: UUID) -> Config | None: + statement = select(Config).where( + and_( + Config.id == config_id, + Config.project_id == self.project_id, + Config.deleted_at.is_(None), + ) + ) + return self.session.exec(statement).one_or_none() + + def read_all(self, skip: int = 0, limit: int = 100) -> list[Config]: + statement = ( + select(Config) + .where( + and_( + Config.project_id == self.project_id, + Config.deleted_at.is_(None), + ) + ) + .order_by(Config.updated_at.desc()) + .offset(skip) + .limit(limit) + ) + return self.session.exec(statement).all() + + def update_or_raise(self, config_id: UUID, config_update: ConfigUpdate) -> Config: + config = self.exists_or_raise(config_id) + + config_update = config_update.model_dump(exclude_none=True) + + if config_update.get("name") and config_update["name"] != config.name: + self._check_unique_name_or_raise(config_update["name"]) + + for key, value in config_update.items(): + setattr(config, key, value) + + config.updated_at = now() + + self.session.add(config) + self.session.commit() + self.session.refresh(config) + + logger.info( + f"[ConfigCrud.update] Config updated successfully | " + f"{{'config_id': '{config.id}', 'project_id': {self.project_id}}}" + ) + return config + + def delete_or_raise(self, config_id: UUID) -> None: + config = self.exists_or_raise(config_id) + + config.deleted_at = now() + self.session.add(config) + self.session.commit() + self.session.refresh(config) + + def exists_or_raise(self, config_id: UUID) -> Config: + config = self.read_one(config_id) + if config is None: + raise HTTPException( + status_code=404, + detail=f"config with id '{config_id}' not found", + ) + + return config + + def _check_unique_name_or_raise(self, name: str) -> None: + if self._read_by_name(name): + raise HTTPException( + status_code=409, + detail=f"Config with name '{name}' already exists in this project", + ) + + def _read_by_name(self, name: str) -> Config | None: + statement = select(Config).where( + and_( + Config.name == name, + Config.project_id == self.project_id, + Config.deleted_at.is_(None), + ) + ) + return self.session.exec(statement).one_or_none() diff --git a/backend/app/crud/config/version.py b/backend/app/crud/config/version.py new file mode 100644 index 000000000..cf4a3ae20 --- /dev/null +++ b/backend/app/crud/config/version.py @@ -0,0 +1,142 @@ +import logging +from uuid import UUID + +from sqlmodel import Session, select, and_, func +from fastapi import HTTPException +from sqlalchemy.orm import defer + +from .config import ConfigCrud +from app.core.util import now +from app.models import Config, ConfigVersion, ConfigVersionCreate, ConfigVersionItems + +logger = logging.getLogger(__name__) + + +class ConfigVersionCrud: + """ + CRUD operations for configuration versions scoped to a project. + """ + + def __init__(self, session: Session, config_id: UUID, project_id: int): + self.session = session + self.project_id = project_id + self.config_id = config_id + + def create_or_raise(self, version_create: ConfigVersionCreate) -> ConfigVersion: + """ + Create a new version for an existing configuration. + Automatically increments the version number. + """ + self._config_exists_or_raise(self.config_id) + try: + next_version = self._get_next_version(self.config_id) + + version = ConfigVersion( + config_id=self.config_id, + version=next_version, + config_blob=version_create.config_blob, + commit_message=version_create.commit_message, + ) + + self.session.add(version) + self.session.commit() + self.session.refresh(version) + + logger.info( + f"[ConfigVersionCrud.create] Version created successfully | " + f"{{'config_id': '{self.config_id}', 'version_id': '{version.id}'}}" + ) + + return version + + except Exception as e: + self.session.rollback() + logger.error( + f"[ConfigVersionCrud.create] Failed to create version | " + f"{{'config_id': '{self.config_id}', 'error': '{str(e)}'}}", + exc_info=True, + ) + raise HTTPException( + status_code=500, + detail="Unexpected error occurred: failed to create version", + ) + + def read_one(self, version_number: int) -> ConfigVersion | None: + """ + Read a specific configuration version by its version number. + """ + self._config_exists_or_raise(self.config_id) + statement = select(ConfigVersion).where( + and_( + ConfigVersion.version == version_number, + ConfigVersion.config_id == self.config_id, + ConfigVersion.deleted_at.is_(None), + ) + ) + return self.session.exec(statement).one_or_none() + + def read_all(self, skip: int = 0, limit: int = 100) -> list[ConfigVersionItems]: + """ + Read all versions for a specific configuration with pagination. + """ + self._config_exists_or_raise(self.config_id) + + statement = ( + select(ConfigVersion) + .where( + and_( + ConfigVersion.config_id == self.config_id, + ConfigVersion.deleted_at.is_(None), + ) + ) + .options( + defer(ConfigVersion.config_blob), + ) + .order_by(ConfigVersion.version.desc()) + .offset(skip) + .limit(limit) + ) + results = self.session.exec(statement).all() + return [ConfigVersionItems.model_validate(item) for item in results] + + def delete_or_raise(self, version_number: int) -> None: + """ + Soft delete a configuration version by setting its deleted_at timestamp. + """ + version = self.exists_or_raise(version_number) + + version.deleted_at = now() + self.session.add(version) + self.session.commit() + self.session.refresh(version) + + def exists_or_raise(self, version_number: int) -> ConfigVersion: + """ + Check if a configuration version exists; raise 404 if not found. + """ + version = self.read_one(version_number=version_number) + if version is None: + raise HTTPException( + status_code=404, + detail=f"Version with number '{version_number}' not found for config '{self.config_id}'", + ) + return version + + def _get_next_version(self, config_id: UUID) -> int | None: + """Get the next version number for a config.""" + stmt = ( + select(ConfigVersion.version) + .where(ConfigVersion.config_id == config_id) + .order_by(ConfigVersion.version.desc()) + .limit(1) + ) + latest = self.session.exec(stmt).first() + if latest is None: + return 1 + + return latest + 1 + + def _config_exists_or_raise(self, config_id: UUID) -> Config: + """Check if a config exists in the project.""" + config_crud = ConfigCrud(session=self.session, project_id=self.project_id) + config_crud.exists_or_raise(config_id) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index b2f294025..06081464a 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -21,6 +21,19 @@ CollectionJobCreate, CollectionJobImmediatePublic, ) +from .config import ( + Config, + ConfigBase, + ConfigCreate, + ConfigUpdate, + ConfigPublic, + ConfigWithVersion, + ConfigVersion, + ConfigVersionBase, + ConfigVersionCreate, + ConfigVersionPublic, + ConfigVersionItems, +) from .credentials import ( Credential, CredsBase, diff --git a/backend/app/models/config/__init__.py b/backend/app/models/config/__init__.py new file mode 100644 index 000000000..fa34aa1d6 --- /dev/null +++ b/backend/app/models/config/__init__.py @@ -0,0 +1,29 @@ +from .config import ( + Config, + ConfigBase, + ConfigCreate, + ConfigPublic, + ConfigUpdate, + ConfigWithVersion, +) +from .version import ( + ConfigVersion, + ConfigVersionBase, + ConfigVersionCreate, + ConfigVersionPublic, + ConfigVersionItems, +) + +__all__ = [ + "Config", + "ConfigBase", + "ConfigCreate", + "ConfigPublic", + "ConfigUpdate", + "ConfigVersion", + "ConfigVersionBase", + "ConfigVersionCreate", + "ConfigVersionItems", + "ConfigVersionPublic", + "ConfigWithVersion", +] diff --git a/backend/app/models/config/config.py b/backend/app/models/config/config.py new file mode 100644 index 000000000..f13789802 --- /dev/null +++ b/backend/app/models/config/config.py @@ -0,0 +1,92 @@ +from uuid import UUID, uuid4 +from datetime import datetime +from typing import TYPE_CHECKING, Any + +from sqlmodel import Field, SQLModel, UniqueConstraint, Index, text +from pydantic import field_validator + +from app.core.util import now +from .version import ConfigVersionPublic + + +class ConfigBase(SQLModel): + """Base model for LLM configuration metadata""" + + name: str = Field(min_length=1, max_length=128, description="Config name") + description: str | None = Field( + default=None, max_length=512, description="Optional description" + ) + + +class Config(ConfigBase, table=True): + """Database model for LLM configuration storage""" + + __tablename__ = "config" + __table_args__ = ( + Index( + "uq_config_project_id_name_active", + "project_id", + "name", + unique=True, + postgresql_where=text("deleted_at IS NULL"), + ), + Index( + "idx_config_project_id_updated_at_active", + "project_id", + "updated_at", + postgresql_where=text("deleted_at IS NULL"), + ), + ) + + id: UUID = Field(default_factory=uuid4, primary_key=True) + + project_id: int = Field( + foreign_key="project.id", + nullable=False, + ondelete="CASCADE", + ) + + inserted_at: datetime = Field(default_factory=now, nullable=False) + updated_at: datetime = Field(default_factory=now, nullable=False) + + deleted_at: datetime | None = Field(default=None, nullable=True) + + +class ConfigCreate(ConfigBase): + """Create new configuration""" + + # Initial version data + config_blob: dict[str, Any] = Field(description="Provider-specific parameters") + commit_message: str | None = Field( + default=None, + max_length=512, + description="Optional message describing the changes in this version", + ) + + @field_validator("config_blob") + def validate_blob_not_empty(cls, value): + if not value: + raise ValueError("config_blob cannot be empty") + return value + + +class ConfigUpdate(SQLModel): + name: str | None = Field(default=None, min_length=1, max_length=128) + description: str | None = Field( + default=None, max_length=512, description="Optional description" + ) + + +class ConfigPublic(ConfigBase): + id: UUID + project_id: int + inserted_at: datetime + updated_at: datetime + + +class ConfigWithVersion(ConfigPublic): + version: ConfigVersionPublic + + +class ConfigWithVersions(ConfigPublic): + versions: list[ConfigVersionPublic] diff --git a/backend/app/models/config/version.py b/backend/app/models/config/version.py new file mode 100644 index 000000000..0169b048f --- /dev/null +++ b/backend/app/models/config/version.py @@ -0,0 +1,86 @@ +from datetime import datetime +from uuid import UUID, uuid4 +from typing import Any + +from pydantic import field_validator +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSONB +from sqlmodel import Field, SQLModel, UniqueConstraint, Index, text + +from app.core.util import now + + +class ConfigVersionBase(SQLModel): + config_blob: dict[str, Any] = Field( + sa_column=sa.Column(JSONB, nullable=False), + description="Provider-specific configuration parameters (temperature, max_tokens, etc.)", + ) + commit_message: str | None = Field( + default=None, + max_length=512, + description="Optional message describing the changes in this version", + ) + + @field_validator("config_blob") + def validate_blob_not_empty(cls, value): + if not value: + raise ValueError("config_blob cannot be empty") + return value + + +class ConfigVersion(ConfigVersionBase, table=True): + __tablename__ = "config_version" + __table_args__ = ( + UniqueConstraint( + "config_id", "version", name="uq_config_version_config_id_version" + ), + Index( + "idx_config_version_config_id_version_active", + "config_id", + "version", + postgresql_where=text("deleted_at IS NULL"), + ), + ) + + id: UUID = Field(default_factory=uuid4, primary_key=True) + + config_id: UUID = Field( + foreign_key="config.id", + nullable=False, + ondelete="CASCADE", + ) + version: int = Field( + nullable=False, description="Version number starting at 1", ge=1 + ) + + inserted_at: datetime = Field(default_factory=now, nullable=False) + updated_at: datetime = Field(default_factory=now, nullable=False) + + deleted_at: datetime | None = Field(default=None, nullable=True) + + +class ConfigVersionCreate(ConfigVersionBase): + pass + + +class ConfigVersionPublic(ConfigVersionBase): + id: UUID = Field(description="Unique id for the configuration version") + config_id: UUID = Field(description="Id of the parent configuration") + version: int = Field(nullable=False, description="Version number starting at 1") + inserted_at: datetime + updated_at: datetime + + +class ConfigVersionItems(SQLModel): + """Lightweight version for lists (without large config_blob)""" + + id: UUID = Field(description="Unique id for the configuration version") + version: int = Field(nullable=False, description="Version number starting at 1") + config_id: UUID = Field(description="Id of the parent configuration") + commit_message: str | None = Field( + default=None, + max_length=512, + description="Optional message describing the changes in this version", + ) + inserted_at: datetime + updated_at: datetime diff --git a/backend/app/tests/api/routes/configs/__init__.py b/backend/app/tests/api/routes/configs/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/app/tests/api/routes/configs/test_config.py b/backend/app/tests/api/routes/configs/test_config.py new file mode 100644 index 000000000..631f746e3 --- /dev/null +++ b/backend/app/tests/api/routes/configs/test_config.py @@ -0,0 +1,466 @@ +from uuid import uuid4 + +from fastapi.testclient import TestClient +from sqlmodel import Session + +from app.core.config import settings +from app.tests.utils.auth import TestAuthContext +from app.tests.utils.test_data import create_test_config, create_test_project + + +def test_create_config_success( + db: Session, + client: TestClient, + user_api_key: TestAuthContext, +) -> None: + """Test creating a config successfully with API key authentication.""" + config_data = { + "name": "test-llm-config", + "description": "A test LLM configuration", + "config_blob": { + "model": "gpt-4", + "temperature": 0.8, + "max_tokens": 2000, + }, + "commit_message": "Initial configuration", + } + + response = client.post( + f"{settings.API_V1_STR}/configs/", + headers={"X-API-KEY": user_api_key.key}, + json=config_data, + ) + assert response.status_code == 201 + data = response.json() + assert data["success"] is True + assert "data" in data + assert data["data"]["name"] == config_data["name"] + assert data["data"]["description"] == config_data["description"] + assert data["data"]["project_id"] == user_api_key.project_id + assert "id" in data["data"] + assert "version" in data["data"] + assert data["data"]["version"]["version"] == 1 + assert data["data"]["version"]["config_blob"] == config_data["config_blob"] + + +def test_create_config_empty_blob_fails( + db: Session, + client: TestClient, + user_api_key: TestAuthContext, +) -> None: + """Test that creating a config with empty config_blob fails validation.""" + config_data = { + "name": "test-config", + "description": "Test", + "config_blob": {}, + "commit_message": "Initial", + } + + response = client.post( + f"{settings.API_V1_STR}/configs/", + headers={"X-API-KEY": user_api_key.key}, + json=config_data, + ) + assert response.status_code == 422 + + +def test_create_config_duplicate_name_fails( + db: Session, + client: TestClient, + user_api_key: TestAuthContext, +) -> None: + """Test that creating a config with duplicate name in same project fails.""" + # Create first config + config = create_test_config( + db=db, + project_id=user_api_key.project_id, + name="duplicate-config", + ) + + # Try to create another with same name + config_data = { + "name": "duplicate-config", + "description": "Should fail", + "config_blob": {"model": "gpt-4"}, + "commit_message": "Initial", + } + + response = client.post( + f"{settings.API_V1_STR}/configs/", + headers={"X-API-KEY": user_api_key.key}, + json=config_data, + ) + assert response.status_code == 409 + response_data = response.json() + error = response_data.get("error", response_data.get("detail", "")) + assert "already exists" in error.lower() + + +def test_list_configs( + db: Session, + client: TestClient, + user_api_key: TestAuthContext, +) -> None: + """Test listing configs for a project.""" + created_configs = [] + for i in range(3): + config = create_test_config( + db=db, + project_id=user_api_key.project_id, + name=f"list-test-config-{i}", + ) + created_configs.append(config) + + response = client.get( + f"{settings.API_V1_STR}/configs/", + headers={"X-API-KEY": user_api_key.key}, + ) + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert isinstance(data["data"], list) + assert len(data["data"]) >= 3 + + config_names = [c["name"] for c in data["data"]] + for config in created_configs: + assert config.name in config_names + + +def test_list_configs_with_pagination( + db: Session, + client: TestClient, + user_api_key: TestAuthContext, +) -> None: + """Test listing configs with pagination parameters.""" + for i in range(5): + create_test_config( + db=db, + project_id=user_api_key.project_id, + name=f"pagination-test-{i}", + ) + + # Test with limit + response = client.get( + f"{settings.API_V1_STR}/configs/", + headers={"X-API-KEY": user_api_key.key}, + params={"skip": 0, "limit": 2}, + ) + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert len(data["data"]) == 2 + + # Test with skip + response = client.get( + f"{settings.API_V1_STR}/configs/", + headers={"X-API-KEY": user_api_key.key}, + params={"skip": 2, "limit": 2}, + ) + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert len(data["data"]) >= 2 + + +def test_get_config_by_id( + db: Session, + client: TestClient, + user_api_key: TestAuthContext, +) -> None: + """Test retrieving a specific config by ID.""" + config = create_test_config( + db=db, + project_id=user_api_key.project_id, + name="get-by-id-test", + description="Test config for retrieval", + ) + + response = client.get( + f"{settings.API_V1_STR}/configs/{config.id}", + headers={"X-API-KEY": user_api_key.key}, + ) + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["data"]["id"] == str(config.id) + assert data["data"]["name"] == config.name + assert data["data"]["description"] == config.description + assert data["data"]["project_id"] == user_api_key.project_id + + +def test_get_config_nonexistent( + db: Session, + client: TestClient, + user_api_key: TestAuthContext, +) -> None: + """Test retrieving a non-existent config returns 404.""" + fake_uuid = uuid4() + response = client.get( + f"{settings.API_V1_STR}/configs/{fake_uuid}", + headers={"X-API-KEY": user_api_key.key}, + ) + assert response.status_code == 404 + + +def test_get_config_from_different_project_fails( + db: Session, + client: TestClient, + user_api_key: TestAuthContext, +) -> None: + """Test that users cannot access configs from other projects.""" + # Create config in different project + other_project = create_test_project(db) + config = create_test_config( + db=db, + project_id=other_project.id, + name="other-project-config", + ) + + # Try to access it with user_api_key from different project + response = client.get( + f"{settings.API_V1_STR}/configs/{config.id}", + headers={"X-API-KEY": user_api_key.key}, + ) + assert response.status_code == 404 + + +def test_update_config_name( + db: Session, + client: TestClient, + user_api_key: TestAuthContext, +) -> None: + """Test updating a config's name.""" + config = create_test_config( + db=db, + project_id=user_api_key.project_id, + name="original-name", + ) + + update_data = { + "name": "updated-name", + } + + response = client.patch( + f"{settings.API_V1_STR}/configs/{config.id}", + headers={"X-API-KEY": user_api_key.key}, + json=update_data, + ) + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["data"]["name"] == "updated-name" + assert data["data"]["id"] == str(config.id) + + +def test_update_config_description( + db: Session, + client: TestClient, + user_api_key: TestAuthContext, +) -> None: + """Test updating a config's description.""" + config = create_test_config( + db=db, + project_id=user_api_key.project_id, + name="test-config", + description="Original description", + ) + + update_data = { + "description": "Updated description", + } + + response = client.patch( + f"{settings.API_V1_STR}/configs/{config.id}", + headers={"X-API-KEY": user_api_key.key}, + json=update_data, + ) + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["data"]["description"] == "Updated description" + + +def test_update_config_to_duplicate_name_fails( + db: Session, + client: TestClient, + user_api_key: TestAuthContext, +) -> None: + """Test that updating a config to a duplicate name fails.""" + # Create two configs + config1 = create_test_config( + db=db, + project_id=user_api_key.project_id, + name="config-one", + ) + config2 = create_test_config( + db=db, + project_id=user_api_key.project_id, + name="config-two", + ) + + # Try to update config2 to have the same name as config1 + update_data = { + "name": "config-one", + } + + response = client.patch( + f"{settings.API_V1_STR}/configs/{config2.id}", + headers={"X-API-KEY": user_api_key.key}, + json=update_data, + ) + assert response.status_code == 409 + response_data = response.json() + error = response_data.get("error", response_data.get("detail", "")) + assert "already exists" in error.lower() + + +def test_update_config_nonexistent( + db: Session, + client: TestClient, + user_api_key: TestAuthContext, +) -> None: + """Test updating a non-existent config returns 404.""" + fake_uuid = uuid4() + update_data = { + "name": "new-name", + } + + response = client.patch( + f"{settings.API_V1_STR}/configs/{fake_uuid}", + headers={"X-API-KEY": user_api_key.key}, + json=update_data, + ) + assert response.status_code == 404 + + +def test_delete_config( + db: Session, + client: TestClient, + user_api_key: TestAuthContext, +) -> None: + """Test deleting a config (soft delete).""" + config = create_test_config( + db=db, + project_id=user_api_key.project_id, + name="config-to-delete", + ) + + response = client.delete( + f"{settings.API_V1_STR}/configs/{config.id}", + headers={"X-API-KEY": user_api_key.key}, + ) + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert "deleted successfully" in data["data"]["message"].lower() + + # Verify the config is no longer accessible + get_response = client.get( + f"{settings.API_V1_STR}/configs/{config.id}", + headers={"X-API-KEY": user_api_key.key}, + ) + assert get_response.status_code == 404 + + +def test_delete_config_nonexistent( + db: Session, + client: TestClient, + user_api_key: TestAuthContext, +) -> None: + """Test deleting a non-existent config returns 404.""" + fake_uuid = uuid4() + response = client.delete( + f"{settings.API_V1_STR}/configs/{fake_uuid}", + headers={"X-API-KEY": user_api_key.key}, + ) + assert response.status_code == 404 + + +def test_delete_config_from_different_project_fails( + db: Session, + client: TestClient, + user_api_key: TestAuthContext, +) -> None: + """Test that users cannot delete configs from other projects.""" + # Create config in different project + other_project = create_test_project(db) + config = create_test_config( + db=db, + project_id=other_project.id, + name="other-project-config", + ) + + # Try to delete it with user_api_key from different project + response = client.delete( + f"{settings.API_V1_STR}/configs/{config.id}", + headers={"X-API-KEY": user_api_key.key}, + ) + assert response.status_code == 404 + + +def test_create_config_requires_authentication( + db: Session, + client: TestClient, +) -> None: + """Test that creating a config without authentication fails.""" + config_data = { + "name": "test-config", + "description": "Test", + "config_blob": {"model": "gpt-4"}, + "commit_message": "Initial", + } + + response = client.post( + f"{settings.API_V1_STR}/configs/", + json=config_data, + ) + assert response.status_code == 401 + + +def test_list_configs_requires_authentication( + db: Session, + client: TestClient, +) -> None: + """Test that listing configs without authentication fails.""" + response = client.get( + f"{settings.API_V1_STR}/configs/", + ) + assert response.status_code == 401 + + +def test_configs_isolated_by_project( + db: Session, + client: TestClient, + user_api_key: TestAuthContext, +) -> None: + """Test that configs are properly isolated between projects.""" + # Create configs in user's project + user_configs = [] + for i in range(2): + config = create_test_config( + db=db, + project_id=user_api_key.project_id, + name=f"user-config-{i}", + ) + user_configs.append(config) + + # Create configs in different project + other_project = create_test_project(db) + for i in range(3): + create_test_config( + db=db, + project_id=other_project.id, + name=f"other-config-{i}", + ) + + # User should only see their project's configs + response = client.get( + f"{settings.API_V1_STR}/configs/", + headers={"X-API-KEY": user_api_key.key}, + ) + assert response.status_code == 200 + data = response.json() + + # Verify we only get configs from user's project + for config_data in data["data"]: + assert config_data["project_id"] == user_api_key.project_id diff --git a/backend/app/tests/api/routes/configs/test_version.py b/backend/app/tests/api/routes/configs/test_version.py new file mode 100644 index 000000000..882e57fcf --- /dev/null +++ b/backend/app/tests/api/routes/configs/test_version.py @@ -0,0 +1,519 @@ +from uuid import uuid4 + +from fastapi.testclient import TestClient +from sqlmodel import Session + +from app.core.config import settings +from app.tests.utils.auth import TestAuthContext +from app.tests.utils.test_data import ( + create_test_config, + create_test_project, + create_test_version, +) + + +def test_create_version_success( + db: Session, + client: TestClient, + user_api_key: TestAuthContext, +) -> None: + """Test creating a new version for a config successfully.""" + config = create_test_config( + db=db, + project_id=user_api_key.project_id, + name="test-config", + ) + + version_data = { + "config_blob": { + "model": "gpt-4-turbo", + "temperature": 0.9, + "max_tokens": 3000, + }, + "commit_message": "Updated model to gpt-4-turbo", + } + + response = client.post( + f"{settings.API_V1_STR}/configs/{config.id}/versions", + headers={"X-API-KEY": user_api_key.key}, + json=version_data, + ) + assert response.status_code == 201 + data = response.json() + assert data["success"] is True + assert "data" in data + assert ( + data["data"]["version"] == 2 + ) # First version created with config, this is second + assert data["data"]["config_blob"] == version_data["config_blob"] + assert data["data"]["commit_message"] == version_data["commit_message"] + assert data["data"]["config_id"] == str(config.id) + + +def test_create_version_empty_blob_fails( + db: Session, + client: TestClient, + user_api_key: TestAuthContext, +) -> None: + """Test that creating a version with empty config_blob fails validation.""" + config = create_test_config( + db=db, + project_id=user_api_key.project_id, + name="test-config", + ) + + version_data = { + "config_blob": {}, + "commit_message": "Empty blob", + } + + response = client.post( + f"{settings.API_V1_STR}/configs/{config.id}/versions", + headers={"X-API-KEY": user_api_key.key}, + json=version_data, + ) + assert response.status_code == 422 + + +def test_create_version_nonexistent_config( + db: Session, + client: TestClient, + user_api_key: TestAuthContext, +) -> None: + """Test creating a version for a non-existent config returns 404.""" + fake_uuid = uuid4() + version_data = { + "config_blob": {"model": "gpt-4"}, + "commit_message": "Test", + } + + response = client.post( + f"{settings.API_V1_STR}/configs/{fake_uuid}/versions", + headers={"X-API-KEY": user_api_key.key}, + json=version_data, + ) + assert response.status_code == 404 + + +def test_create_version_different_project_fails( + db: Session, + client: TestClient, + user_api_key: TestAuthContext, +) -> None: + """Test that creating a version for a config in a different project fails.""" + other_project = create_test_project(db) + config = create_test_config( + db=db, + project_id=other_project.id, + name="other-project-config", + ) + + version_data = { + "config_blob": {"model": "gpt-4"}, + "commit_message": "Should fail", + } + + response = client.post( + f"{settings.API_V1_STR}/configs/{config.id}/versions", + headers={"X-API-KEY": user_api_key.key}, + json=version_data, + ) + assert response.status_code == 404 + + +def test_create_version_auto_increments( + db: Session, + client: TestClient, + user_api_key: TestAuthContext, +) -> None: + """Test that version numbers are automatically incremented.""" + config = create_test_config( + db=db, + project_id=user_api_key.project_id, + name="test-config", + ) + + # Create multiple versions and verify they increment + for i in range(2, 5): + version_data = { + "config_blob": {"model": f"gpt-4-version-{i}"}, + "commit_message": f"Version {i}", + } + + response = client.post( + f"{settings.API_V1_STR}/configs/{config.id}/versions", + headers={"X-API-KEY": user_api_key.key}, + json=version_data, + ) + assert response.status_code == 201 + data = response.json() + assert data["data"]["version"] == i + + +def test_list_versions( + db: Session, + client: TestClient, + user_api_key: TestAuthContext, +) -> None: + """Test listing all versions for a config.""" + config = create_test_config( + db=db, + project_id=user_api_key.project_id, + name="test-config", + ) + + # Create additional versions + for i in range(3): + create_test_version( + db=db, + config_id=config.id, + project_id=user_api_key.project_id, + commit_message=f"Version {i + 2}", + ) + + response = client.get( + f"{settings.API_V1_STR}/configs/{config.id}/versions", + headers={"X-API-KEY": user_api_key.key}, + ) + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert isinstance(data["data"], list) + assert len(data["data"]) == 4 # 1 initial + 3 created + + # Verify versions are ordered by version number descending + versions = data["data"] + for i in range(len(versions) - 1): + assert versions[i]["version"] > versions[i + 1]["version"] + + +def test_list_versions_with_pagination( + db: Session, + client: TestClient, + user_api_key: TestAuthContext, +) -> None: + """Test listing versions with pagination parameters.""" + config = create_test_config( + db=db, + project_id=user_api_key.project_id, + name="test-config", + ) + + # Create 5 additional versions (6 total including initial) + for i in range(5): + create_test_version( + db=db, + config_id=config.id, + project_id=user_api_key.project_id, + ) + + # Test with limit + response = client.get( + f"{settings.API_V1_STR}/configs/{config.id}/versions", + headers={"X-API-KEY": user_api_key.key}, + params={"skip": 0, "limit": 3}, + ) + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert len(data["data"]) == 3 + + # Test with skip + response = client.get( + f"{settings.API_V1_STR}/configs/{config.id}/versions", + headers={"X-API-KEY": user_api_key.key}, + params={"skip": 3, "limit": 3}, + ) + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert len(data["data"]) == 3 + + +def test_list_versions_nonexistent_config( + db: Session, + client: TestClient, + user_api_key: TestAuthContext, +) -> None: + """Test listing versions for a non-existent config returns 404.""" + fake_uuid = uuid4() + + response = client.get( + f"{settings.API_V1_STR}/configs/{fake_uuid}/versions", + headers={"X-API-KEY": user_api_key.key}, + ) + assert response.status_code == 404 + + +def test_list_versions_different_project_fails( + db: Session, + client: TestClient, + user_api_key: TestAuthContext, +) -> None: + """Test that listing versions for a config in a different project fails.""" + other_project = create_test_project(db) + config = create_test_config( + db=db, + project_id=other_project.id, + name="other-project-config", + ) + + response = client.get( + f"{settings.API_V1_STR}/configs/{config.id}/versions", + headers={"X-API-KEY": user_api_key.key}, + ) + assert response.status_code == 404 + + +def test_get_version_by_number( + db: Session, + client: TestClient, + user_api_key: TestAuthContext, +) -> None: + """Test retrieving a specific version by version number.""" + config = create_test_config( + db=db, + project_id=user_api_key.project_id, + name="test-config", + ) + + # Create additional version + version = create_test_version( + db=db, + config_id=config.id, + project_id=user_api_key.project_id, + config_blob={"model": "gpt-4-turbo", "temperature": 0.5}, + commit_message="Updated config", + ) + + response = client.get( + f"{settings.API_V1_STR}/configs/{config.id}/versions/{version.version}", + headers={"X-API-KEY": user_api_key.key}, + ) + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["data"]["version"] == version.version + assert data["data"]["config_blob"] == version.config_blob + assert data["data"]["commit_message"] == version.commit_message + assert data["data"]["config_id"] == str(config.id) + + +def test_get_version_nonexistent( + db: Session, + client: TestClient, + user_api_key: TestAuthContext, +) -> None: + """Test retrieving a non-existent version returns 404.""" + config = create_test_config( + db=db, + project_id=user_api_key.project_id, + name="test-config", + ) + + response = client.get( + f"{settings.API_V1_STR}/configs/{config.id}/versions/999", + headers={"X-API-KEY": user_api_key.key}, + ) + assert response.status_code == 404 + + +def test_get_version_from_different_project_fails( + db: Session, + client: TestClient, + user_api_key: TestAuthContext, +) -> None: + """Test that users cannot access versions from configs in other projects.""" + other_project = create_test_project(db) + config = create_test_config( + db=db, + project_id=other_project.id, + name="other-project-config", + ) + + # The config has version 1 by default + response = client.get( + f"{settings.API_V1_STR}/configs/{config.id}/versions/1", + headers={"X-API-KEY": user_api_key.key}, + ) + assert response.status_code == 404 + + +def test_delete_version( + db: Session, + client: TestClient, + user_api_key: TestAuthContext, +) -> None: + """Test deleting a version (soft delete).""" + config = create_test_config( + db=db, + project_id=user_api_key.project_id, + name="test-config", + ) + + # Create a version to delete + version = create_test_version( + db=db, + config_id=config.id, + project_id=user_api_key.project_id, + ) + + response = client.delete( + f"{settings.API_V1_STR}/configs/{config.id}/versions/{version.version}", + headers={"X-API-KEY": user_api_key.key}, + ) + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert "deleted successfully" in data["data"]["message"].lower() + + # Verify the version is no longer accessible + get_response = client.get( + f"{settings.API_V1_STR}/configs/{config.id}/versions/{version.version}", + headers={"X-API-KEY": user_api_key.key}, + ) + assert get_response.status_code == 404 + + +def test_delete_version_nonexistent( + db: Session, + client: TestClient, + user_api_key: TestAuthContext, +) -> None: + """Test deleting a non-existent version returns 404.""" + config = create_test_config( + db=db, + project_id=user_api_key.project_id, + name="test-config", + ) + + response = client.delete( + f"{settings.API_V1_STR}/configs/{config.id}/versions/999", + headers={"X-API-KEY": user_api_key.key}, + ) + assert response.status_code == 404 + + +def test_delete_version_from_different_project_fails( + db: Session, + client: TestClient, + user_api_key: TestAuthContext, +) -> None: + """Test that users cannot delete versions from configs in other projects.""" + other_project = create_test_project(db) + config = create_test_config( + db=db, + project_id=other_project.id, + name="other-project-config", + ) + + # Try to delete the initial version + response = client.delete( + f"{settings.API_V1_STR}/configs/{config.id}/versions/1", + headers={"X-API-KEY": user_api_key.key}, + ) + assert response.status_code == 404 + + +def test_create_version_requires_authentication( + db: Session, + client: TestClient, +) -> None: + """Test that creating a version without authentication fails.""" + version_data = { + "config_blob": {"model": "gpt-4"}, + "commit_message": "Test", + } + + fake_uuid = uuid4() + response = client.post( + f"{settings.API_V1_STR}/configs/{fake_uuid}/versions", + json=version_data, + ) + assert response.status_code == 401 + + +def test_list_versions_requires_authentication( + db: Session, + client: TestClient, +) -> None: + """Test that listing versions without authentication fails.""" + fake_uuid = uuid4() + response = client.get( + f"{settings.API_V1_STR}/configs/{fake_uuid}/versions", + ) + assert response.status_code == 401 + + +def test_get_version_requires_authentication( + db: Session, + client: TestClient, +) -> None: + """Test that getting a version without authentication fails.""" + fake_uuid = uuid4() + response = client.get( + f"{settings.API_V1_STR}/configs/{fake_uuid}/versions/1", + ) + assert response.status_code == 401 + + +def test_delete_version_requires_authentication( + db: Session, + client: TestClient, +) -> None: + """Test that deleting a version without authentication fails.""" + fake_uuid = uuid4() + response = client.delete( + f"{settings.API_V1_STR}/configs/{fake_uuid}/versions/1", + ) + assert response.status_code == 401 + + +def test_versions_isolated_by_project( + db: Session, + client: TestClient, + user_api_key: TestAuthContext, +) -> None: + """Test that versions are properly isolated between projects.""" + # Create config in user's project with additional versions + user_config = create_test_config( + db=db, + project_id=user_api_key.project_id, + name="user-config", + ) + for i in range(2): + create_test_version( + db=db, + config_id=user_config.id, + project_id=user_api_key.project_id, + ) + + # Create config in different project with versions + other_project = create_test_project(db) + other_config = create_test_config( + db=db, + project_id=other_project.id, + name="other-config", + ) + for i in range(3): + create_test_version( + db=db, + config_id=other_config.id, + project_id=other_project.id, + ) + + # User should only see versions from their project's config + response = client.get( + f"{settings.API_V1_STR}/configs/{user_config.id}/versions", + headers={"X-API-KEY": user_api_key.key}, + ) + assert response.status_code == 200 + data = response.json() + assert len(data["data"]) == 3 # 1 initial + 2 created + + # User should NOT be able to access other project's versions + response = client.get( + f"{settings.API_V1_STR}/configs/{other_config.id}/versions", + headers={"X-API-KEY": user_api_key.key}, + ) + assert response.status_code == 404 diff --git a/backend/app/tests/crud/config/__init__.py b/backend/app/tests/crud/config/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/app/tests/crud/config/test_config.py b/backend/app/tests/crud/config/test_config.py new file mode 100644 index 000000000..6b753e0ba --- /dev/null +++ b/backend/app/tests/crud/config/test_config.py @@ -0,0 +1,478 @@ +import pytest +from uuid import uuid4 +from sqlmodel import Session +from fastapi import HTTPException + +from app.models import ( + Config, + ConfigCreate, + ConfigUpdate, +) +from app.crud.config import ConfigCrud +from app.tests.utils.test_data import create_test_project, create_test_config +from app.tests.utils.utils import random_lower_string + + +def test_create_config(db: Session) -> None: + """Test creating a new configuration with initial version.""" + project = create_test_project(db) + config_crud = ConfigCrud(session=db, project_id=project.id) + + config_name = f"test-config-{random_lower_string()}" + config_blob = { + "model": "gpt-4", + "temperature": 0.7, + "max_tokens": 1000, + } + config_create = ConfigCreate( + name=config_name, + description="Test configuration", + config_blob=config_blob, + commit_message="Initial version", + ) + + config, version = config_crud.create_or_raise(config_create) + + assert config.id is not None + assert config.name == config_name + assert config.description == "Test configuration" + assert config.project_id == project.id + assert config.deleted_at is None + + # Verify initial version was created + assert version.id is not None + assert version.config_id == config.id + assert version.version == 1 + assert version.config_blob == config_blob + assert version.commit_message == "Initial version" + + +def test_create_config_duplicate_name(db: Session) -> None: + """Test creating a configuration with a duplicate name raises HTTPException.""" + project = create_test_project(db) + config_crud = ConfigCrud(session=db, project_id=project.id) + + config_name = f"test-config-{random_lower_string()}" + config_create = ConfigCreate( + name=config_name, + description="Test configuration", + config_blob={"model": "gpt-4"}, + commit_message="Initial version", + ) + + # Create first config + config_crud.create_or_raise(config_create) + + # Attempt to create second config with same name + with pytest.raises( + HTTPException, match=f"Config with name '{config_name}' already exists" + ): + config_crud.create_or_raise(config_create) + + +def test_create_config_different_projects_same_name(db: Session) -> None: + """Test creating configs with same name in different projects succeeds.""" + project1 = create_test_project(db) + project2 = create_test_project(db) + + config_name = f"test-config-{random_lower_string()}" + config_blob = {"model": "gpt-4"} + + # Create config in project1 + config_crud1 = ConfigCrud(session=db, project_id=project1.id) + config_create = ConfigCreate( + name=config_name, + description="Test configuration", + config_blob=config_blob, + commit_message="Initial version", + ) + config1, _ = config_crud1.create_or_raise(config_create) + + # Create config with same name in project2 + config_crud2 = ConfigCrud(session=db, project_id=project2.id) + config2, _ = config_crud2.create_or_raise(config_create) + + assert config1.id != config2.id + assert config1.name == config2.name == config_name + assert config1.project_id == project1.id + assert config2.project_id == project2.id + + +def test_read_one_config(db: Session) -> None: + """Test reading a single configuration by ID.""" + config = create_test_config(db) + config_crud = ConfigCrud(session=db, project_id=config.project_id) + + fetched_config = config_crud.read_one(config.id) + + assert fetched_config is not None + assert fetched_config.id == config.id + assert fetched_config.name == config.name + assert fetched_config.project_id == config.project_id + + +def test_read_one_config_not_found(db: Session) -> None: + """Test reading a non-existent configuration returns None.""" + project = create_test_project(db) + config_crud = ConfigCrud(session=db, project_id=project.id) + + non_existent_id = uuid4() + fetched_config = config_crud.read_one(non_existent_id) + + assert fetched_config is None + + +def test_read_one_config_different_project(db: Session) -> None: + """Test reading a config from a different project returns None.""" + # Create config in project1 + config = create_test_config(db) + + # Try to read from project2 + project2 = create_test_project(db) + config_crud = ConfigCrud(session=db, project_id=project2.id) + + fetched_config = config_crud.read_one(config.id) + + assert fetched_config is None + + +def test_read_one_deleted_config(db: Session) -> None: + """Test reading a deleted configuration returns None.""" + config = create_test_config(db) + config_crud = ConfigCrud(session=db, project_id=config.project_id) + + # Delete the config + config_crud.delete_or_raise(config.id) + + # Try to read deleted config + fetched_config = config_crud.read_one(config.id) + + assert fetched_config is None + + +def test_read_all_configs(db: Session) -> None: + """Test reading all configurations for a project.""" + project = create_test_project(db) + + # Create multiple configs + config1 = create_test_config(db, project_id=project.id, name="config-1") + config2 = create_test_config(db, project_id=project.id, name="config-2") + config3 = create_test_config(db, project_id=project.id, name="config-3") + + config_crud = ConfigCrud(session=db, project_id=project.id) + configs = config_crud.read_all() + + config_ids = [c.id for c in configs] + assert config1.id in config_ids + assert config2.id in config_ids + assert config3.id in config_ids + + +def test_read_all_configs_pagination(db: Session) -> None: + """Test reading configurations with pagination.""" + project = create_test_project(db) + + # Create 5 configs + for i in range(5): + create_test_config(db, project_id=project.id, name=f"config-{i}") + + config_crud = ConfigCrud(session=db, project_id=project.id) + + # Test skip and limit + configs_page1 = config_crud.read_all(skip=0, limit=2) + configs_page2 = config_crud.read_all(skip=2, limit=2) + + assert len(configs_page1) == 2 + assert len(configs_page2) == 2 + assert configs_page1[0].id != configs_page2[0].id + + +def test_read_all_configs_ordered_by_updated_at(db: Session) -> None: + """Test that configurations are ordered by updated_at in descending order.""" + project = create_test_project(db) + config_crud = ConfigCrud(session=db, project_id=project.id) + + # Create configs (they will have different updated_at timestamps) + config1 = create_test_config(db, project_id=project.id, name="config-1") + config2 = create_test_config(db, project_id=project.id, name="config-2") + config3 = create_test_config(db, project_id=project.id, name="config-3") + + # Update config1 to make it the most recently updated + config_crud.update_or_raise( + config1.id, ConfigUpdate(description="Updated description") + ) + + configs = config_crud.read_all() + + # config1 should be first because it was most recently updated + assert configs[0].id == config1.id + + +def test_read_all_configs_excludes_deleted(db: Session) -> None: + """Test that read_all excludes deleted configurations.""" + project = create_test_project(db) + + config1 = create_test_config(db, project_id=project.id, name="config-1") + config2 = create_test_config(db, project_id=project.id, name="config-2") + + config_crud = ConfigCrud(session=db, project_id=project.id) + + # Delete config1 + config_crud.delete_or_raise(config1.id) + + configs = config_crud.read_all() + + config_ids = [c.id for c in configs] + assert config1.id not in config_ids + assert config2.id in config_ids + + +def test_read_all_configs_different_projects(db: Session) -> None: + """Test that read_all only returns configs for the specific project.""" + project1 = create_test_project(db) + project2 = create_test_project(db) + + config1 = create_test_config(db, project_id=project1.id, name="config-1") + config2 = create_test_config(db, project_id=project2.id, name="config-2") + + config_crud = ConfigCrud(session=db, project_id=project1.id) + configs = config_crud.read_all() + + config_ids = [c.id for c in configs] + assert config1.id in config_ids + assert config2.id not in config_ids + + +def test_update_config_name(db: Session) -> None: + """Test updating a configuration's name.""" + config = create_test_config(db) + config_crud = ConfigCrud(session=db, project_id=config.project_id) + + new_name = f"updated-config-{random_lower_string()}" + config_update = ConfigUpdate(name=new_name) + + updated_config = config_crud.update_or_raise(config.id, config_update) + + assert updated_config.name == new_name + assert updated_config.id == config.id + + +def test_update_config_description(db: Session) -> None: + """Test updating a configuration's description.""" + config = create_test_config(db) + config_crud = ConfigCrud(session=db, project_id=config.project_id) + + new_description = "Updated description" + config_update = ConfigUpdate(description=new_description) + + updated_config = config_crud.update_or_raise(config.id, config_update) + + assert updated_config.description == new_description + assert updated_config.id == config.id + + +def test_update_config_multiple_fields(db: Session) -> None: + """Test updating multiple fields of a configuration.""" + config = create_test_config(db) + config_crud = ConfigCrud(session=db, project_id=config.project_id) + + new_name = f"updated-config-{random_lower_string()}" + new_description = "Updated description" + config_update = ConfigUpdate(name=new_name, description=new_description) + + updated_config = config_crud.update_or_raise(config.id, config_update) + + assert updated_config.name == new_name + assert updated_config.description == new_description + + +def test_update_config_duplicate_name(db: Session) -> None: + """Test updating a config to a duplicate name raises HTTPException.""" + project = create_test_project(db) + + config1 = create_test_config(db, project_id=project.id, name="config-1") + config2 = create_test_config(db, project_id=project.id, name="config-2") + + config_crud = ConfigCrud(session=db, project_id=project.id) + + # Try to update config2 to have config1's name + config_update = ConfigUpdate(name=config1.name) + + with pytest.raises( + HTTPException, match=f"Config with name '{config1.name}' already exists" + ): + config_crud.update_or_raise(config2.id, config_update) + + +def test_update_config_same_name(db: Session) -> None: + """Test updating a config to its own name succeeds.""" + config = create_test_config(db) + config_crud = ConfigCrud(session=db, project_id=config.project_id) + + # Update to same name should succeed + config_update = ConfigUpdate(name=config.name, description="Updated") + + updated_config = config_crud.update_or_raise(config.id, config_update) + + assert updated_config.name == config.name + assert updated_config.description == "Updated" + + +def test_update_config_not_found(db: Session) -> None: + """Test updating a non-existent configuration raises HTTPException.""" + project = create_test_project(db) + config_crud = ConfigCrud(session=db, project_id=project.id) + + non_existent_id = uuid4() + config_update = ConfigUpdate(name="new-name") + + with pytest.raises( + HTTPException, match=f"config with id '{non_existent_id}' not found" + ): + config_crud.update_or_raise(non_existent_id, config_update) + + +def test_update_config_updates_timestamp(db: Session) -> None: + """Test that updating a config updates the updated_at timestamp.""" + config = create_test_config(db) + config_crud = ConfigCrud(session=db, project_id=config.project_id) + + original_updated_at = config.updated_at + + config_update = ConfigUpdate(description="Updated description") + updated_config = config_crud.update_or_raise(config.id, config_update) + + assert updated_config.updated_at > original_updated_at + + +def test_delete_config(db: Session) -> None: + """Test soft deleting a configuration.""" + config = create_test_config(db) + config_crud = ConfigCrud(session=db, project_id=config.project_id) + + config_crud.delete_or_raise(config.id) + + # Verify soft delete (deleted_at is set) + db.refresh(config) + assert config.deleted_at is not None + + +def test_delete_config_not_found(db: Session) -> None: + """Test deleting a non-existent configuration raises HTTPException.""" + project = create_test_project(db) + config_crud = ConfigCrud(session=db, project_id=project.id) + + non_existent_id = uuid4() + + with pytest.raises( + HTTPException, match=f"config with id '{non_existent_id}' not found" + ): + config_crud.delete_or_raise(non_existent_id) + + +def test_delete_config_different_project(db: Session) -> None: + """Test deleting a config from a different project raises HTTPException.""" + # Create config in project1 + config = create_test_config(db) + + # Try to delete from project2 + project2 = create_test_project(db) + config_crud = ConfigCrud(session=db, project_id=project2.id) + + with pytest.raises(HTTPException, match=f"config with id '{config.id}' not found"): + config_crud.delete_or_raise(config.id) + + +def test_exists_config(db: Session) -> None: + """Test that exists returns the config when it exists.""" + config = create_test_config(db) + config_crud = ConfigCrud(session=db, project_id=config.project_id) + + existing_config = config_crud.exists_or_raise(config.id) + + assert existing_config.id == config.id + assert existing_config.name == config.name + + +def test_exists_config_not_found(db: Session) -> None: + """Test that exists raises HTTPException when config doesn't exist.""" + project = create_test_project(db) + config_crud = ConfigCrud(session=db, project_id=project.id) + + non_existent_id = uuid4() + + with pytest.raises( + HTTPException, match=f"config with id '{non_existent_id}' not found" + ): + config_crud.exists_or_raise(non_existent_id) + + +def test_exists_deleted_config(db: Session) -> None: + """Test that exists raises HTTPException for deleted configs.""" + config = create_test_config(db) + config_crud = ConfigCrud(session=db, project_id=config.project_id) + + # Delete the config + config_crud.delete_or_raise(config.id) + + # exists should raise HTTPException + with pytest.raises(HTTPException, match=f"config with id '{config.id}' not found"): + config_crud.exists_or_raise(config.id) + + +def test_check_unique_name_with_existing_name(db: Session) -> None: + """Test that _check_unique_name raises HTTPException for duplicate names.""" + config = create_test_config(db) + config_crud = ConfigCrud(session=db, project_id=config.project_id) + + with pytest.raises( + HTTPException, match=f"Config with name '{config.name}' already exists" + ): + config_crud._check_unique_name_or_raise(config.name) + + +def test_check_unique_name_with_new_name(db: Session) -> None: + """Test that _check_unique_name passes for unique names.""" + project = create_test_project(db) + config_crud = ConfigCrud(session=db, project_id=project.id) + + # Should not raise exception + unique_name = f"unique-name-{random_lower_string()}" + config_crud._check_unique_name_or_raise(unique_name) + + +def test_read_by_name(db: Session) -> None: + """Test reading a configuration by name.""" + config = create_test_config(db) + config_crud = ConfigCrud(session=db, project_id=config.project_id) + + fetched_config = config_crud._read_by_name(config.name) + + assert fetched_config is not None + assert fetched_config.id == config.id + assert fetched_config.name == config.name + + +def test_read_by_name_not_found(db: Session) -> None: + """Test that _read_by_name returns None for non-existent names.""" + project = create_test_project(db) + config_crud = ConfigCrud(session=db, project_id=project.id) + + non_existent_name = f"non-existent-{random_lower_string()}" + fetched_config = config_crud._read_by_name(non_existent_name) + + assert fetched_config is None + + +def test_read_by_name_deleted_config(db: Session) -> None: + """Test that _read_by_name returns None for deleted configs.""" + config = create_test_config(db) + config_crud = ConfigCrud(session=db, project_id=config.project_id) + + # Delete the config + config_crud.delete_or_raise(config.id) + + # Should return None + fetched_config = config_crud._read_by_name(config.name) + + assert fetched_config is None diff --git a/backend/app/tests/crud/config/test_version.py b/backend/app/tests/crud/config/test_version.py new file mode 100644 index 000000000..d62265d5e --- /dev/null +++ b/backend/app/tests/crud/config/test_version.py @@ -0,0 +1,406 @@ +import pytest +from uuid import uuid4 +from sqlmodel import Session +from fastapi import HTTPException + +from app.models import ConfigVersionCreate +from app.crud.config import ConfigVersionCrud +from app.tests.utils.test_data import ( + create_test_project, + create_test_config, + create_test_version, +) + + +def test_create_version(db: Session) -> None: + """Test creating a new version for an existing configuration.""" + config = create_test_config(db) + version_crud = ConfigVersionCrud( + session=db, project_id=config.project_id, config_id=config.id + ) + + config_blob = { + "model": "gpt-4-turbo", + "temperature": 0.8, + "max_tokens": 2000, + } + version_create = ConfigVersionCreate( + config_blob=config_blob, + commit_message="Updated model and parameters", + ) + + version = version_crud.create_or_raise(version_create) + + assert version.id is not None + assert version.config_id == config.id + assert version.version == 2 # Should be 2 since config creation creates version 1 + assert version.config_blob == config_blob + assert version.commit_message == "Updated model and parameters" + assert version.deleted_at is None + + +def test_create_version_auto_increment(db: Session) -> None: + """Test that version numbers auto-increment correctly.""" + config = create_test_config(db) + version_crud = ConfigVersionCrud( + session=db, project_id=config.project_id, config_id=config.id + ) + + # Create multiple versions + version2 = version_crud.create_or_raise( + ConfigVersionCreate(config_blob={"model": "gpt-4"}, commit_message="Version 2") + ) + version3 = version_crud.create_or_raise( + ConfigVersionCreate(config_blob={"model": "gpt-4"}, commit_message="Version 3") + ) + version4 = version_crud.create_or_raise( + ConfigVersionCreate(config_blob={"model": "gpt-4"}, commit_message="Version 4") + ) + + assert version2.version == 2 + assert version3.version == 3 + assert version4.version == 4 + + +def test_create_version_config_not_found(db: Session) -> None: + """Test creating a version for a non-existent config raises HTTPException.""" + project = create_test_project(db) + non_existent_config_id = uuid4() + + version_crud = ConfigVersionCrud( + session=db, project_id=project.id, config_id=non_existent_config_id + ) + + version_create = ConfigVersionCreate( + config_blob={"model": "gpt-4"}, commit_message="Test" + ) + + with pytest.raises( + HTTPException, match=f"config with id '{non_existent_config_id}' not found" + ): + version_crud.create_or_raise(version_create) + + +def test_read_one_version(db: Session) -> None: + """Test reading a specific version by its version number.""" + config = create_test_config(db) + version = create_test_version( + db, + config_id=config.id, + project_id=config.project_id, + config_blob={"model": "gpt-4-turbo"}, + commit_message="Test version", + ) + + version_crud = ConfigVersionCrud( + session=db, project_id=config.project_id, config_id=config.id + ) + + fetched_version = version_crud.read_one(version.version) + + assert fetched_version is not None + assert fetched_version.id == version.id + assert fetched_version.version == version.version + assert fetched_version.config_id == config.id + assert fetched_version.config_blob == {"model": "gpt-4-turbo"} + + +def test_read_one_version_not_found(db: Session) -> None: + """Test reading a non-existent version returns None.""" + config = create_test_config(db) + version_crud = ConfigVersionCrud( + session=db, project_id=config.project_id, config_id=config.id + ) + + non_existent_version = 999 + fetched_version = version_crud.read_one(non_existent_version) + + assert fetched_version is None + + +def test_read_one_version_deleted(db: Session) -> None: + """Test reading a deleted version returns None.""" + config = create_test_config(db) + version = create_test_version(db, config_id=config.id, project_id=config.project_id) + + version_crud = ConfigVersionCrud( + session=db, project_id=config.project_id, config_id=config.id + ) + + # Delete the version + version_crud.delete_or_raise(version.version) + + # Try to read deleted version + fetched_version = version_crud.read_one(version.version) + + assert fetched_version is None + + +def test_read_all_versions(db: Session) -> None: + """Test reading all versions for a configuration.""" + config = create_test_config(db) + + # Create additional versions (config already has version 1) + version2 = create_test_version( + db, + config_id=config.id, + project_id=config.project_id, + commit_message="Version 2", + ) + version3 = create_test_version( + db, + config_id=config.id, + project_id=config.project_id, + commit_message="Version 3", + ) + + version_crud = ConfigVersionCrud( + session=db, project_id=config.project_id, config_id=config.id + ) + versions = version_crud.read_all() + + assert len(versions) == 3 + version_numbers = [v.version for v in versions] + assert 1 in version_numbers + assert version2.version in version_numbers + assert version3.version in version_numbers + + +def test_read_all_versions_pagination(db: Session) -> None: + """Test reading versions with pagination.""" + config = create_test_config(db) + + # Create 4 additional versions + for i in range(4): + create_test_version( + db, + config_id=config.id, + project_id=config.project_id, + commit_message=f"Version {i + 2}", + ) + + version_crud = ConfigVersionCrud( + session=db, project_id=config.project_id, config_id=config.id + ) + + # Test skip and limit + versions_page1 = version_crud.read_all(skip=0, limit=2) + versions_page2 = version_crud.read_all(skip=2, limit=2) + + assert len(versions_page1) == 2 + assert len(versions_page2) == 2 + assert versions_page1[0].id != versions_page2[0].id + + +def test_read_all_versions_ordered_by_version_desc(db: Session) -> None: + """Test that versions are ordered by version number in descending order.""" + config = create_test_config(db) + + # Create additional versions + version2 = create_test_version( + db, + config_id=config.id, + project_id=config.project_id, + commit_message="Version 2", + ) + version3 = create_test_version( + db, + config_id=config.id, + project_id=config.project_id, + commit_message="Version 3", + ) + + version_crud = ConfigVersionCrud( + session=db, project_id=config.project_id, config_id=config.id + ) + versions = version_crud.read_all() + + # Versions should be in descending order (3, 2, 1) + assert versions[0].version == version3.version + assert versions[1].version == version2.version + assert versions[2].version == 1 + + +def test_read_all_versions_excludes_blob(db: Session) -> None: + """Test that read_all returns ConfigVersionItems without config_blob.""" + config = create_test_config(db) + create_test_version( + db, + config_id=config.id, + project_id=config.project_id, + config_blob={"model": "gpt-4-turbo"}, + ) + + version_crud = ConfigVersionCrud( + session=db, project_id=config.project_id, config_id=config.id + ) + versions = version_crud.read_all() + + # Verify versions are ConfigVersionItems (should not have config_blob field) + for version in versions: + assert hasattr(version, "id") + assert hasattr(version, "version") + assert hasattr(version, "commit_message") + # ConfigVersionItems should not include config_blob + assert not hasattr(version, "config_blob") + + +def test_read_all_versions_excludes_deleted(db: Session) -> None: + """Test that read_all excludes deleted versions.""" + config = create_test_config(db) + + version2 = create_test_version( + db, + config_id=config.id, + project_id=config.project_id, + commit_message="Version 2", + ) + version3 = create_test_version( + db, + config_id=config.id, + project_id=config.project_id, + commit_message="Version 3", + ) + + version_crud = ConfigVersionCrud( + session=db, project_id=config.project_id, config_id=config.id + ) + + # Delete version 2 + version_crud.delete_or_raise(version2.version) + + versions = version_crud.read_all() + + version_numbers = [v.version for v in versions] + assert version2.version not in version_numbers + assert 1 in version_numbers + assert version3.version in version_numbers + + +def test_delete_version(db: Session) -> None: + """Test soft deleting a version.""" + config = create_test_config(db) + version = create_test_version(db, config_id=config.id, project_id=config.project_id) + + version_crud = ConfigVersionCrud( + session=db, project_id=config.project_id, config_id=config.id + ) + + version_crud.delete_or_raise(version.version) + + # Verify soft delete (deleted_at is set) + db.refresh(version) + assert version.deleted_at is not None + + +def test_delete_version_not_found(db: Session) -> None: + """Test deleting a non-existent version raises HTTPException.""" + config = create_test_config(db) + version_crud = ConfigVersionCrud( + session=db, project_id=config.project_id, config_id=config.id + ) + + non_existent_version = 999 + + with pytest.raises( + HTTPException, + match=f"Version with number '{non_existent_version}' not found for config '{config.id}'", + ): + version_crud.delete_or_raise(non_existent_version) + + +def test_exists_version(db: Session) -> None: + """Test that exists returns the version when it exists.""" + config = create_test_config(db) + version = create_test_version(db, config_id=config.id, project_id=config.project_id) + + version_crud = ConfigVersionCrud( + session=db, project_id=config.project_id, config_id=config.id + ) + + existing_version = version_crud.exists_or_raise(version.version) + + assert existing_version.id == version.id + assert existing_version.version == version.version + + +def test_exists_version_not_found(db: Session) -> None: + """Test that exists raises HTTPException when version doesn't exist.""" + config = create_test_config(db) + version_crud = ConfigVersionCrud( + session=db, project_id=config.project_id, config_id=config.id + ) + + non_existent_version = 999 + + with pytest.raises( + HTTPException, + match=f"Version with number '{non_existent_version}' not found for config '{config.id}'", + ): + version_crud.exists_or_raise(non_existent_version) + + +def test_exists_version_deleted(db: Session) -> None: + """Test that exists raises HTTPException for deleted versions.""" + config = create_test_config(db) + version = create_test_version(db, config_id=config.id, project_id=config.project_id) + + version_crud = ConfigVersionCrud( + session=db, project_id=config.project_id, config_id=config.id + ) + + # Delete the version + version_crud.delete_or_raise(version.version) + + # exists should raise HTTPException + with pytest.raises( + HTTPException, + match=f"Version with number '{version.version}' not found for config '{config.id}'", + ): + version_crud.exists_or_raise(version.version) + + +def test_create_version_different_configs(db: Session) -> None: + """Test that version numbers are independent across different configs.""" + project = create_test_project(db) + + # Create two configs + config1 = create_test_config(db, project_id=project.id, name="config-1") + config2 = create_test_config(db, project_id=project.id, name="config-2") + + # Create versions for config1 + version_crud1 = ConfigVersionCrud( + session=db, project_id=project.id, config_id=config1.id + ) + version2_config1 = version_crud1.create_or_raise( + ConfigVersionCreate(config_blob={"model": "gpt-4"}, commit_message="V2") + ) + + # Create versions for config2 + version_crud2 = ConfigVersionCrud( + session=db, project_id=project.id, config_id=config2.id + ) + version2_config2 = version_crud2.create_or_raise( + ConfigVersionCreate(config_blob={"model": "gpt-4"}, commit_message="V2") + ) + + # Both should have version 2 (independent numbering) + assert version2_config1.version == 2 + assert version2_config2.version == 2 + assert version2_config1.config_id == config1.id + assert version2_config2.config_id == config2.id + + +def test_read_all_versions_config_not_found(db: Session) -> None: + """Test reading versions for a non-existent config raises HTTPException.""" + project = create_test_project(db) + non_existent_config_id = uuid4() + + version_crud = ConfigVersionCrud( + session=db, project_id=project.id, config_id=non_existent_config_id + ) + + with pytest.raises( + HTTPException, match=f"config with id '{non_existent_config_id}' not found" + ): + version_crud.read_all() diff --git a/backend/app/tests/utils/test_data.py b/backend/app/tests/utils/test_data.py index c560bbca3..abb62f542 100644 --- a/backend/app/tests/utils/test_data.py +++ b/backend/app/tests/utils/test_data.py @@ -14,6 +14,10 @@ ModelEvaluation, ModelEvaluationBase, ModelEvaluationStatus, + Config, + ConfigCreate, + ConfigVersion, + ConfigVersionCreate, ) from app.crud import ( create_organization, @@ -23,6 +27,7 @@ create_model_evaluation, APIKeyCrud, ) +from app.crud.config import ConfigCrud, ConfigVersionCrud from app.core.providers import Provider from app.tests.utils.user import create_random_user from app.tests.utils.utils import ( @@ -226,3 +231,74 @@ def create_test_model_evaluation(db) -> list[ModelEvaluation]: model_evaluations.append(model_eval) return model_evaluations + + +def create_test_config( + db: Session, + project_id: int | None = None, + name: str | None = None, + description: str | None = None, + config_blob: dict | None = None, +) -> Config: + """ + Creates and returns a test configuration with an initial version. + + Persists the config and version to the database. + """ + if project_id is None: + project = create_test_project(db) + project_id = project.id + + if name is None: + name = f"test-config-{random_lower_string()}" + + if config_blob is None: + config_blob = { + "model": "gpt-4", + "temperature": 0.7, + "max_tokens": 1000, + } + + config_create = ConfigCreate( + name=name, + description=description or "Test configuration description", + config_blob=config_blob, + commit_message="Initial version", + ) + + config_crud = ConfigCrud(session=db, project_id=project_id) + config, version = config_crud.create_or_raise(config_create) + + return config + + +def create_test_version( + db: Session, + config_id, + project_id: int, + config_blob: dict | None = None, + commit_message: str | None = None, +) -> ConfigVersion: + """ + Creates and returns a test version for an existing configuration. + + Persists the version to the database. + """ + if config_blob is None: + config_blob = { + "model": "gpt-4", + "temperature": 0.8, + "max_tokens": 1500, + } + + version_create = ConfigVersionCreate( + config_blob=config_blob, + commit_message=commit_message or "Test version commit", + ) + + version_crud = ConfigVersionCrud( + session=db, project_id=project_id, config_id=config_id + ) + version = version_crud.create_or_raise(version_create=version_create) + + return version diff --git a/backend/uv.lock b/backend/uv.lock index 45f551a1a..b1da74249 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -240,7 +240,7 @@ requires-dist = [ { name = "langfuse", specifier = "==2.60.3" }, { name = "moto", extras = ["s3"], specifier = ">=5.1.1" }, { name = "numpy", specifier = ">=1.24.0" }, - { name = "openai", specifier = ">=1.67.0" }, + { name = "openai", specifier = ">=1.100.1" }, { name = "openai-responses" }, { name = "pandas", specifier = ">=2.3.2" }, { name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4,<2.0.0" },