diff --git a/.gitignore b/.gitignore index 8e802a0..7d02e46 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ dist/ build/ .pytest_cache/ .mypy_cache/ +.ruff_cache .coverage htmlcov/ diff --git a/backend/app/core/exceptions.py b/backend/app/core/exceptions.py index aedde1d..7e4ccb9 100644 --- a/backend/app/core/exceptions.py +++ b/backend/app/core/exceptions.py @@ -15,7 +15,7 @@ def __init__(self, job_id: str, proposal_index: int) -> None: super().__init__(f"Proposal not found: job={job_id}, index={proposal_index}") -class K8sServiceError(Exception): +class K8sClientError(Exception): """Raised when a Kubernetes operation fails.""" diff --git a/backend/app/core/middleware.py b/backend/app/core/middleware.py index 2643ae0..8e293e2 100644 --- a/backend/app/core/middleware.py +++ b/backend/app/core/middleware.py @@ -10,7 +10,7 @@ ArtifactNotFoundError, InvalidJobStateError, JobNotFoundError, - K8sServiceError, + K8sClientError, ProposalNotFoundError, ) @@ -32,7 +32,7 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) - return JSONResponse(status_code=404, content={"detail": str(e)}) except InvalidJobStateError as e: return JSONResponse(status_code=400, content={"detail": str(e)}) - except K8sServiceError as e: + except K8sClientError as e: logger.error("K8s service error: %s", e) return JSONResponse( status_code=503, diff --git a/backend/app/di/container.py b/backend/app/di/container.py index 1a71db8..5271768 100644 --- a/backend/app/di/container.py +++ b/backend/app/di/container.py @@ -2,15 +2,15 @@ from sqlalchemy.orm import Session +from app.infra.log_stream_client import LogStreamClient from app.repository.database import SessionLocal from app.repository.setting_repository import SettingRepository -from app.service.log_stream_service import LogStreamService class DIContainer: """依存性注入コンテナ""" - _log_stream_service: LogStreamService | None = None + _log_stream_client: LogStreamClient | None = None @staticmethod def get_db() -> Generator[Session]: @@ -26,7 +26,7 @@ def get_setting_repository(db: Session) -> SettingRepository: return SettingRepository(db) @classmethod - def get_log_stream_service(cls) -> LogStreamService: - if cls._log_stream_service is None: - cls._log_stream_service = LogStreamService() - return cls._log_stream_service + def get_log_stream_client(cls) -> LogStreamClient: + if cls._log_stream_client is None: + cls._log_stream_client = LogStreamClient() + return cls._log_stream_client diff --git a/backend/app/di/dependencies.py b/backend/app/di/dependencies.py index 2e838dc..9f71a64 100644 --- a/backend/app/di/dependencies.py +++ b/backend/app/di/dependencies.py @@ -4,9 +4,9 @@ from sqlalchemy.orm import Session from app.di.container import DIContainer +from app.infra.log_stream_client import LogStreamClient +from app.infra.s3_client import S3Client from app.repository.setting_repository import SettingRepository -from app.service.log_stream_service import LogStreamService -from app.service.s3_service import S3Service def get_db() -> Generator[Session]: @@ -18,9 +18,9 @@ def get_setting_repository(db: Session = Depends(get_db)) -> SettingRepository: return DIContainer.get_setting_repository(db) -def get_s3_service() -> S3Service: - return S3Service() +def get_s3_client() -> S3Client: + return S3Client() -def get_log_stream_service() -> LogStreamService: - return DIContainer.get_log_stream_service() +def get_log_stream_client() -> LogStreamClient: + return DIContainer.get_log_stream_client() diff --git a/backend/app/infra/__init__.py b/backend/app/infra/__init__.py new file mode 100644 index 0000000..33f7569 --- /dev/null +++ b/backend/app/infra/__init__.py @@ -0,0 +1,3 @@ +from .k8s_client import K8sClient + +__all__ = ["K8sClient"] diff --git a/backend/app/service/k8s_service.py b/backend/app/infra/k8s_client.py similarity index 99% rename from backend/app/service/k8s_service.py rename to backend/app/infra/k8s_client.py index ed805ba..90585dc 100644 --- a/backend/app/service/k8s_service.py +++ b/backend/app/infra/k8s_client.py @@ -10,8 +10,8 @@ logger = logging.getLogger(__name__) -class K8sService: - """Kubernetes Job management service.""" +class K8sClient: + """Kubernetes Job management client.""" def __init__(self) -> None: settings = get_settings() diff --git a/backend/app/service/log_stream_service.py b/backend/app/infra/log_stream_client.py similarity index 98% rename from backend/app/service/log_stream_service.py rename to backend/app/infra/log_stream_client.py index b520a43..fe5d661 100644 --- a/backend/app/service/log_stream_service.py +++ b/backend/app/infra/log_stream_client.py @@ -4,7 +4,7 @@ from collections.abc import AsyncIterator from dataclasses import dataclass, field -from app.service.k8s_service import K8sService +from app.infra.k8s_client import K8sClient logger = logging.getLogger(__name__) @@ -38,7 +38,7 @@ def parse_log_line(line: str) -> dict | None: return None -class LogStreamService: +class LogStreamClient: """Manages log streaming for sessions. Singleton per backend process.""" def __init__(self) -> None: @@ -83,7 +83,7 @@ async def stream_session_logs( since_seconds: If set, only stream logs newer than this many seconds. Used on reconnect to avoid replaying old logs. """ - k8s = K8sService() + k8s = K8sClient() queue: asyncio.Queue[dict | None] = asyncio.Queue() tracked_jobs: set[str] = set() tasks: list[asyncio.Task[None]] = [] diff --git a/backend/app/service/s3_service.py b/backend/app/infra/s3_client.py similarity index 99% rename from backend/app/service/s3_service.py rename to backend/app/infra/s3_client.py index c10ead9..c87212e 100644 --- a/backend/app/service/s3_service.py +++ b/backend/app/infra/s3_client.py @@ -11,7 +11,7 @@ logger = logging.getLogger(__name__) -class S3Service: +class S3Client: """S3-compatible artifact storage (works with AWS S3 and MinIO).""" def __init__(self) -> None: diff --git a/backend/app/repository/__init__.py b/backend/app/repository/__init__.py index 97c74b0..8c31572 100644 --- a/backend/app/repository/__init__.py +++ b/backend/app/repository/__init__.py @@ -15,6 +15,12 @@ from .database import SessionLocal from .iteration_repository import IterationRepository from .proposal_repository import ProposalRepository +from .protocols import ( + IterationRepositoryProtocol, + ProposalRepositoryProtocol, + SessionRepositoryProtocol, + SettingRepositoryProtocol, +) from .session_repository import SessionRepository from .setting_repository import SettingRepository @@ -24,4 +30,8 @@ "IterationRepository", "ProposalRepository", "SettingRepository", + "SessionRepositoryProtocol", + "IterationRepositoryProtocol", + "ProposalRepositoryProtocol", + "SettingRepositoryProtocol", ] diff --git a/backend/app/repository/mock/__init__.py b/backend/app/repository/mock/__init__.py new file mode 100644 index 0000000..79a881f --- /dev/null +++ b/backend/app/repository/mock/__init__.py @@ -0,0 +1,9 @@ +from .mock_iteration_repository import MockIterationRepository +from .mock_proposal_repository import MockProposalRepository +from .mock_session_repository import MockSessionRepository + +__all__ = [ + "MockSessionRepository", + "MockIterationRepository", + "MockProposalRepository", +] diff --git a/backend/app/repository/mock/mock_iteration_repository.py b/backend/app/repository/mock/mock_iteration_repository.py new file mode 100644 index 0000000..735e0b6 --- /dev/null +++ b/backend/app/repository/mock/mock_iteration_repository.py @@ -0,0 +1,56 @@ +from uuid import UUID + +from app.model.session import Iteration, IterationStatus + + +class MockIterationRepository: + def __init__(self) -> None: + self._store: dict[UUID, Iteration] = {} + + def create(self, iteration: Iteration) -> Iteration: + self._store[iteration.id] = iteration + return iteration + + def get_by_id(self, iteration_id: UUID) -> Iteration | None: + return self._store.get(iteration_id) + + def get_by_session_and_index(self, session_id: UUID, iteration_index: int) -> Iteration | None: + for it in self._store.values(): + if it.session_id == session_id and it.iteration_index == iteration_index: + return it + return None + + def get_latest_for_session(self, session_id: UUID) -> Iteration | None: + iters = [it for it in self._store.values() if it.session_id == session_id] + if not iters: + return None + return max(iters, key=lambda it: it.iteration_index) + + def get_all_for_session(self, session_id: UUID) -> list[Iteration]: + return sorted( + [it for it in self._store.values() if it.session_id == session_id], + key=lambda it: it.iteration_index, + ) + + def update_status_optimistic( + self, + iteration_id: UUID, + expected_version: int, + status: IterationStatus, + **kwargs: object, + ) -> Iteration | None: + iteration = self._store.get(iteration_id) + if not iteration or iteration.version != expected_version: + return None + iteration.status = status + iteration.version += 1 + for k, v in kwargs.items(): + if hasattr(iteration, k): + setattr(iteration, k, v) + return iteration + + def update_selected_proposal(self, iteration_id: UUID, proposal_index: int) -> Iteration | None: + iteration = self._store.get(iteration_id) + if iteration: + iteration.selected_proposal_index = proposal_index + return iteration diff --git a/backend/app/repository/mock/mock_proposal_repository.py b/backend/app/repository/mock/mock_proposal_repository.py new file mode 100644 index 0000000..def7d5b --- /dev/null +++ b/backend/app/repository/mock/mock_proposal_repository.py @@ -0,0 +1,49 @@ +from uuid import UUID + +from app.model.session import Proposal, ProposalStatus + + +class MockProposalRepository: + def __init__(self) -> None: + self._store: dict[UUID, Proposal] = {} + + def create(self, proposal: Proposal) -> Proposal: + self._store[proposal.id] = proposal + return proposal + + def get_by_id(self, proposal_id: UUID) -> Proposal | None: + return self._store.get(proposal_id) + + def get_by_iteration_and_index( + self, iteration_id: UUID, proposal_index: int + ) -> Proposal | None: + for p in self._store.values(): + if p.iteration_id == iteration_id and p.proposal_index == proposal_index: + return p + return None + + def get_all_by_status(self, status: ProposalStatus) -> list[Proposal]: + return [p for p in self._store.values() if p.status == status] + + def get_all_for_iteration(self, iteration_id: UUID) -> list[Proposal]: + return sorted( + [p for p in self._store.values() if p.iteration_id == iteration_id], + key=lambda p: p.proposal_index, + ) + + def update_status_optimistic( + self, + proposal_id: UUID, + expected_version: int, + status: ProposalStatus, + **kwargs: object, + ) -> Proposal | None: + proposal = self._store.get(proposal_id) + if not proposal or proposal.version != expected_version: + return None + proposal.status = status + proposal.version += 1 + for k, v in kwargs.items(): + if hasattr(proposal, k): + setattr(proposal, k, v) + return proposal diff --git a/backend/app/repository/mock/mock_session_repository.py b/backend/app/repository/mock/mock_session_repository.py new file mode 100644 index 0000000..7a2827a --- /dev/null +++ b/backend/app/repository/mock/mock_session_repository.py @@ -0,0 +1,24 @@ +from uuid import UUID + +from app.model.session import Session, SessionStatus + + +class MockSessionRepository: + def __init__(self) -> None: + self._store: dict[UUID, Session] = {} + + def create(self, session: Session) -> Session: + self._store[session.id] = session + return session + + def get_by_id(self, session_id: UUID) -> Session | None: + return self._store.get(session_id) + + def update_status(self, session_id: UUID, status: SessionStatus) -> Session | None: + session = self._store.get(session_id) + if session: + session.status = status + return session + + def list_all(self) -> list[Session]: + return list(self._store.values()) diff --git a/backend/app/repository/protocols.py b/backend/app/repository/protocols.py new file mode 100644 index 0000000..ced53c4 --- /dev/null +++ b/backend/app/repository/protocols.py @@ -0,0 +1,63 @@ +from typing import Protocol +from uuid import UUID + +from app.model.session import ( + Iteration, + IterationStatus, + Proposal, + ProposalStatus, + Session, + SessionStatus, + Setting, +) + + +class SessionRepositoryProtocol(Protocol): + def create(self, session: Session) -> Session: ... + def get_by_id(self, session_id: UUID) -> Session | None: ... + def update_status(self, session_id: UUID, status: SessionStatus) -> Session | None: ... + def list_all(self) -> list[Session]: ... + + +class IterationRepositoryProtocol(Protocol): + def create(self, iteration: Iteration) -> Iteration: ... + def get_by_id(self, iteration_id: UUID) -> Iteration | None: ... + def get_by_session_and_index( + self, session_id: UUID, iteration_index: int + ) -> Iteration | None: ... + def get_latest_for_session(self, session_id: UUID) -> Iteration | None: ... + def get_all_for_session(self, session_id: UUID) -> list[Iteration]: ... + def update_status_optimistic( + self, + iteration_id: UUID, + expected_version: int, + status: IterationStatus, + **kwargs: object, + ) -> Iteration | None: ... + def update_selected_proposal( + self, iteration_id: UUID, proposal_index: int + ) -> Iteration | None: ... + + +class ProposalRepositoryProtocol(Protocol): + def create(self, proposal: Proposal) -> Proposal: ... + def get_by_id(self, proposal_id: UUID) -> Proposal | None: ... + def get_by_iteration_and_index( + self, iteration_id: UUID, proposal_index: int + ) -> Proposal | None: ... + def get_all_by_status(self, status: ProposalStatus) -> list[Proposal]: ... + def get_all_for_iteration(self, iteration_id: UUID) -> list[Proposal]: ... + def update_status_optimistic( + self, + proposal_id: UUID, + expected_version: int, + status: ProposalStatus, + **kwargs: object, + ) -> Proposal | None: ... + + +class SettingRepositoryProtocol(Protocol): + def get_by_key(self, key: str) -> Setting | None: ... + def upsert(self, key: str, value: str) -> Setting: ... + def list_all(self) -> list[Setting]: ... + def delete(self, key: str) -> bool: ... diff --git a/backend/app/router/sessions.py b/backend/app/router/sessions.py index 71531f0..c2e5718 100644 --- a/backend/app/router/sessions.py +++ b/backend/app/router/sessions.py @@ -9,7 +9,9 @@ from sqlalchemy.orm import Session from sse_starlette.sse import EventSourceResponse -from app.di.dependencies import get_db, get_log_stream_service, get_s3_service +from app.di.dependencies import get_db, get_log_stream_client, get_s3_client +from app.infra.log_stream_client import LogStreamClient +from app.infra.s3_client import S3Client from app.model.session import Iteration, Proposal from app.model.session import Session as SessionModel from app.schema.session_schema import ( @@ -20,8 +22,6 @@ ProposalResponse, SessionResponse, ) -from app.service.log_stream_service import LogStreamService -from app.service.s3_service import S3Service from app.usecase.session_usecase import ( CreateSessionPRUseCase, CreateSessionUseCase, @@ -174,7 +174,7 @@ async def create_pr( async def get_before_screenshot( session_id: UUID, iter_index: int, - s3: S3Service = Depends(get_s3_service), + s3: S3Client = Depends(get_s3_client), ) -> StreamingResponse: data = s3.get_before_screenshot(str(session_id), iter_index) if not data: @@ -187,7 +187,7 @@ async def get_after_screenshot( session_id: UUID, iter_index: int, prop_index: int, - s3: S3Service = Depends(get_s3_service), + s3: S3Client = Depends(get_s3_client), ) -> StreamingResponse: data = s3.get_after_screenshot(str(session_id), iter_index, prop_index) if not data: @@ -200,7 +200,7 @@ async def get_diff( session_id: UUID, iter_index: int, prop_index: int, - s3: S3Service = Depends(get_s3_service), + s3: S3Client = Depends(get_s3_client), ) -> dict: diff = s3.get_diff(str(session_id), iter_index, prop_index) if not diff: @@ -212,11 +212,11 @@ async def get_diff( async def stream_session_logs( session_id: UUID, since_seconds: int | None = None, - log_service: LogStreamService = Depends(get_log_stream_service), + log_client: LogStreamClient = Depends(get_log_stream_client), ) -> EventSourceResponse: async def event_generator(): # type: ignore[no-untyped-def] try: - async for event in log_service.stream_session_logs( + async for event in log_client.stream_session_logs( str(session_id), since_seconds=since_seconds ): yield { diff --git a/backend/app/service/__init__.py b/backend/app/service/__init__.py deleted file mode 100644 index 8ed6f7b..0000000 --- a/backend/app/service/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .artifact_service import ArtifactService -from .k8s_service import K8sService - -__all__ = ["ArtifactService", "K8sService"] diff --git a/backend/app/service/artifact_service.py b/backend/app/service/artifact_service.py deleted file mode 100644 index 330f7b4..0000000 --- a/backend/app/service/artifact_service.py +++ /dev/null @@ -1,155 +0,0 @@ -import base64 -import json -import logging -import re -from pathlib import Path -from typing import Any - -from app.core.config import get_settings - -logger = logging.getLogger(__name__) - -# Marker pattern used by workers to embed artifacts in stdout -_ARTIFACT_PATTERN = re.compile( - r"===ARTIFACT:(?P[^:]+):START===\n(?P.*?)===ARTIFACT:(?P=name):END===", - re.DOTALL, -) - - -class ArtifactService: - """Manages artifacts produced by worker pods. - - Artifacts are extracted from pod logs (stdout markers) and cached - to the local filesystem. This approach is portable across any K8s - environment without requiring shared volumes or cloud storage. - """ - - def __init__(self) -> None: - self.base_dir = Path(get_settings().ARTIFACTS_DIR) - - def extract_artifacts_from_logs(self, job_id: str, logs: str) -> dict[str, str]: - """Extract artifacts embedded in pod logs and save them locally. - - Returns a dict of artifact_name -> local_path. - """ - job_dir = self.base_dir / job_id - job_dir.mkdir(parents=True, exist_ok=True) - saved = {} - - for match in _ARTIFACT_PATTERN.finditer(logs): - name = match.group("name") - content = match.group("content").strip() - - if name.endswith(".png"): - # Binary file encoded as base64 - try: - data = base64.b64decode(content) - path = job_dir / name - path.write_bytes(data) - saved[name] = str(path) - logger.info("Extracted binary artifact %s for job %s", name, job_id) - except Exception as e: - logger.error("Failed to decode %s for job %s: %s", name, job_id, e) - else: - # Text file - path = job_dir / name - path.write_text(content) - saved[name] = str(path) - logger.info("Extracted text artifact %s for job %s", name, job_id) - - return saved - - def extract_impl_artifacts_from_logs( - self, job_id: str, proposal_index: int, logs: str - ) -> dict[str, str]: - """Extract implementation artifacts and save under proposals/{index}/.""" - proposal_dir = self.base_dir / job_id / "proposals" / str(proposal_index) - proposal_dir.mkdir(parents=True, exist_ok=True) - saved = {} - - for match in _ARTIFACT_PATTERN.finditer(logs): - name = match.group("name") - content = match.group("content").strip() - - if name.endswith(".png"): - try: - data = base64.b64decode(content) - path = proposal_dir / name - path.write_bytes(data) - saved[name] = str(path) - logger.info( - "Extracted binary artifact %s for job %s proposal %d", - name, - job_id, - proposal_index, - ) - except Exception as e: - logger.error("Failed to decode %s: %s", name, e) - else: - path = proposal_dir / name - path.write_text(content) - saved[name] = str(path) - logger.info( - "Extracted text artifact %s for job %s proposal %d", - name, - job_id, - proposal_index, - ) - - return saved - - def get_before_screenshot_path(self, job_id: str) -> Path | None: - path = self.base_dir / job_id / "before.png" - return path if path.exists() else None - - def get_proposals_json(self, job_id: str) -> list[dict[str, Any]] | None: - """Read proposals.json from local cache.""" - path = self.base_dir / job_id / "proposals.json" - if not path.exists(): - logger.warning("Proposals file not found for job %s", job_id) - return None - - try: - data = json.loads(path.read_text()) - if isinstance(data, dict) and "proposals" in data: - proposals: list[dict[str, Any]] = data["proposals"] - return proposals - if isinstance(data, list): - return data - logger.warning("Unexpected proposals format for job %s", job_id) - return None - except (json.JSONDecodeError, OSError) as e: - logger.error("Failed to parse proposals for job %s: %s", job_id, e) - return None - - def get_after_screenshot_path(self, job_id: str, proposal_index: int) -> Path | None: - path = self.base_dir / job_id / "proposals" / str(proposal_index) / "after.png" - return path if path.exists() else None - - def get_diff(self, job_id: str, proposal_index: int) -> str | None: - path = self.base_dir / job_id / "proposals" / str(proposal_index) / "changes.diff" - if not path.exists(): - return None - try: - return path.read_text() - except OSError as e: - logger.error("Failed to read diff: %s", e) - return None - - def get_pr_url(self, job_id: str, proposal_index: int) -> str | None: - """Read the PR URL artifact for a proposal.""" - path = self.base_dir / job_id / "proposals" / str(proposal_index) / "pr_url.txt" - if not path.exists(): - return None - try: - return path.read_text().strip() - except OSError as e: - logger.error("Failed to read PR URL: %s", e) - return None - - def write_proposal_plan(self, job_id: str, proposal_index: int, plan_text: str) -> Path: - """Write proposal plan to a file (used for local reference).""" - path = self.base_dir / job_id / f"proposal_{proposal_index}_plan.txt" - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(plan_text) - return path diff --git a/backend/app/usecase/session_usecase.py b/backend/app/usecase/session_usecase.py index cb5aae8..15d64c0 100644 --- a/backend/app/usecase/session_usecase.py +++ b/backend/app/usecase/session_usecase.py @@ -7,6 +7,7 @@ from sqlalchemy.orm import Session as DbSession from app.core.config import get_settings +from app.infra.s3_client import S3Client from app.model.session import ( Iteration, IterationStatus, @@ -18,8 +19,12 @@ from app.repository.database import SessionLocal from app.repository.iteration_repository import IterationRepository from app.repository.proposal_repository import ProposalRepository +from app.repository.protocols import ( + IterationRepositoryProtocol, + ProposalRepositoryProtocol, + SessionRepositoryProtocol, +) from app.repository.session_repository import SessionRepository -from app.service.s3_service import S3Service from app.workflow.session_analyzer_graph import build_session_analyzer_graph from app.workflow.session_create_pr_graph import build_session_create_pr_graph from app.workflow.session_implementation_graph import build_session_implementation_graph @@ -62,10 +67,17 @@ def _update_iteration_status_with_retry( class CreateSessionUseCase: """Create a new session and trigger the first analysis.""" - def __init__(self, db: DbSession) -> None: + def __init__( + self, + db: DbSession, + session_repo: SessionRepositoryProtocol | None = None, + iteration_repo: IterationRepositoryProtocol | None = None, + s3_client: S3Client | None = None, + ) -> None: self.db = db - self.session_repo = SessionRepository(db) - self.iteration_repo = IterationRepository(db) + self.session_repo = session_repo or SessionRepository(db) + self.iteration_repo = iteration_repo or IterationRepository(db) + self.s3_client = s3_client async def execute(self, repo_url: str, branch: str, instruction: str) -> Session: # Create session @@ -87,7 +99,7 @@ async def execute(self, repo_url: str, branch: str, instruction: str) -> Session iteration = self.iteration_repo.create(iteration) # Ensure S3 bucket exists - s3 = S3Service() + s3 = self.s3_client or S3Client() s3._ensure_bucket() # Run analysis in background @@ -109,11 +121,19 @@ async def execute(self, repo_url: str, branch: str, instruction: str) -> Session class IterateUseCase: """Create the next iteration in a session.""" - def __init__(self, db: DbSession) -> None: + def __init__( + self, + db: DbSession, + session_repo: SessionRepositoryProtocol | None = None, + iteration_repo: IterationRepositoryProtocol | None = None, + proposal_repo: ProposalRepositoryProtocol | None = None, + s3_client: S3Client | None = None, + ) -> None: self.db = db - self.session_repo = SessionRepository(db) - self.iteration_repo = IterationRepository(db) - self.proposal_repo = ProposalRepository(db) + self.session_repo = session_repo or SessionRepository(db) + self.iteration_repo = iteration_repo or IterationRepository(db) + self.proposal_repo = proposal_repo or ProposalRepository(db) + self.s3_client = s3_client async def execute( self, session_id: UUID, selected_proposal_index: int, instruction: str @@ -139,7 +159,7 @@ async def execute( raise ValueError(f"Proposal is not completed: {proposal.status}") # Verify the patch exists in S3 - s3 = S3Service() + s3 = self.s3_client or S3Client() diff_k = s3.diff_key(str(session_id), latest.iteration_index, selected_proposal_index) if not s3.exists(diff_k): raise ValueError("No patch file found for the selected proposal") @@ -190,11 +210,17 @@ async def execute( class CreateSessionPRUseCase: """Create a PR from a specific proposal in a session.""" - def __init__(self, db: DbSession) -> None: + def __init__( + self, + db: DbSession, + session_repo: SessionRepositoryProtocol | None = None, + iteration_repo: IterationRepositoryProtocol | None = None, + proposal_repo: ProposalRepositoryProtocol | None = None, + ) -> None: self.db = db - self.session_repo = SessionRepository(db) - self.iteration_repo = IterationRepository(db) - self.proposal_repo = ProposalRepository(db) + self.session_repo = session_repo or SessionRepository(db) + self.iteration_repo = iteration_repo or IterationRepository(db) + self.proposal_repo = proposal_repo or ProposalRepository(db) async def execute( self, session_id: UUID, iteration_index: int, proposal_index: int @@ -530,7 +556,7 @@ async def recover_stuck_proposals() -> None: try: proposal_repo = ProposalRepository(db) iter_repo = IterationRepository(db) - s3 = S3Service() + s3 = S3Client() stuck = proposal_repo.get_all_by_status(ProposalStatus.IMPLEMENTING) if not stuck: diff --git a/backend/app/usecase/test_session_usecase.py b/backend/app/usecase/test_session_usecase.py new file mode 100644 index 0000000..d88a877 --- /dev/null +++ b/backend/app/usecase/test_session_usecase.py @@ -0,0 +1,428 @@ +"""Unit tests for session usecases using mock repositories (no DB required).""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch +from uuid import UUID, uuid4 + +import pytest + +from app.model.session import ( + Iteration, + IterationStatus, + Proposal, + ProposalStatus, + Session, + SessionStatus, +) +from app.repository.mock import ( + MockIterationRepository, + MockProposalRepository, + MockSessionRepository, +) +from app.usecase.session_usecase import ( + CreateSessionPRUseCase, + CreateSessionUseCase, + IterateUseCase, +) + +# ── Helpers ── + + +def _make_session( + status: SessionStatus = SessionStatus.ACTIVE, +) -> Session: + return Session( + id=uuid4(), + repo_url="https://github.com/test/repo", + base_branch="main", + status=status, + ) + + +def _make_iteration( + session_id: UUID, + *, + index: int = 0, + status: IterationStatus = IterationStatus.COMPLETED, + version: int = 1, +) -> Iteration: + return Iteration( + id=uuid4(), + session_id=session_id, + iteration_index=index, + instruction="test instruction", + status=status, + version=version, + ) + + +def _make_proposal( + iteration_id: UUID, + *, + index: int = 0, + status: ProposalStatus = ProposalStatus.COMPLETED, + version: int = 1, + pr_status: str | None = None, +) -> Proposal: + return Proposal( + id=uuid4(), + iteration_id=iteration_id, + proposal_index=index, + title=f"Proposal {index}", + concept="test concept", + plan="[]", + files="[]", + complexity="medium", + status=status, + pr_status=pr_status, + version=version, + ) + + +# ── IterateUseCase tests ── + + +class TestIterateUseCase: + def _build( + self, + session_repo: MockSessionRepository | None = None, + iteration_repo: MockIterationRepository | None = None, + proposal_repo: MockProposalRepository | None = None, + s3_client: MagicMock | None = None, + ) -> IterateUseCase: + return IterateUseCase( + db=MagicMock(), + session_repo=session_repo or MockSessionRepository(), + iteration_repo=iteration_repo or MockIterationRepository(), + proposal_repo=proposal_repo or MockProposalRepository(), + s3_client=s3_client, + ) + + @pytest.mark.asyncio + async def test_session_not_found(self) -> None: + uc = self._build() + with pytest.raises(ValueError, match="Session not found"): + await uc.execute(uuid4(), 0, "instruction") + + @pytest.mark.asyncio + async def test_session_not_active(self) -> None: + sr = MockSessionRepository() + session = _make_session(status=SessionStatus.COMPLETED) + sr.create(session) + + uc = self._build(session_repo=sr) + with pytest.raises(ValueError, match="Session is not active"): + await uc.execute(session.id, 0, "instruction") + + @pytest.mark.asyncio + async def test_no_iterations_found(self) -> None: + sr = MockSessionRepository() + session = _make_session() + sr.create(session) + + uc = self._build(session_repo=sr) + with pytest.raises(ValueError, match="No iterations found"): + await uc.execute(session.id, 0, "instruction") + + @pytest.mark.asyncio + async def test_previous_iteration_not_completed(self) -> None: + sr = MockSessionRepository() + ir = MockIterationRepository() + + session = _make_session() + sr.create(session) + iteration = _make_iteration(session.id, status=IterationStatus.ANALYZING) + ir.create(iteration) + + uc = self._build(session_repo=sr, iteration_repo=ir) + with pytest.raises(ValueError, match="Previous iteration is not completed"): + await uc.execute(session.id, 0, "instruction") + + @pytest.mark.asyncio + async def test_proposal_not_found(self) -> None: + sr = MockSessionRepository() + ir = MockIterationRepository() + + session = _make_session() + sr.create(session) + iteration = _make_iteration(session.id) + ir.create(iteration) + + uc = self._build(session_repo=sr, iteration_repo=ir) + with pytest.raises(ValueError, match="Proposal 0 not found"): + await uc.execute(session.id, 0, "instruction") + + @pytest.mark.asyncio + async def test_proposal_not_completed(self) -> None: + sr = MockSessionRepository() + ir = MockIterationRepository() + pr = MockProposalRepository() + + session = _make_session() + sr.create(session) + iteration = _make_iteration(session.id) + ir.create(iteration) + proposal = _make_proposal(iteration.id, status=ProposalStatus.IMPLEMENTING) + pr.create(proposal) + + uc = self._build(session_repo=sr, iteration_repo=ir, proposal_repo=pr) + with pytest.raises(ValueError, match="Proposal is not completed"): + await uc.execute(session.id, 0, "instruction") + + @pytest.mark.asyncio + async def test_no_patch_in_s3(self) -> None: + sr = MockSessionRepository() + ir = MockIterationRepository() + pr = MockProposalRepository() + + session = _make_session() + sr.create(session) + iteration = _make_iteration(session.id) + ir.create(iteration) + proposal = _make_proposal(iteration.id) + pr.create(proposal) + + s3 = MagicMock() + s3.exists.return_value = False + + uc = self._build(session_repo=sr, iteration_repo=ir, proposal_repo=pr, s3_client=s3) + with pytest.raises(ValueError, match="No patch file found"): + await uc.execute(session.id, 0, "instruction") + + @pytest.mark.asyncio + @patch("app.usecase.session_usecase.asyncio.create_task") + async def test_happy_path(self, mock_create_task: MagicMock) -> None: + sr = MockSessionRepository() + ir = MockIterationRepository() + pr = MockProposalRepository() + + session = _make_session() + sr.create(session) + iteration = _make_iteration(session.id, index=0) + ir.create(iteration) + proposal = _make_proposal(iteration.id, index=0) + pr.create(proposal) + + s3 = MagicMock() + s3.exists.return_value = True + + uc = self._build(session_repo=sr, iteration_repo=ir, proposal_repo=pr, s3_client=s3) + result = await uc.execute(session.id, 0, "next instruction") + + assert result.id == session.id + # selected_proposal set on previous iteration + assert iteration.selected_proposal_index == 0 + # new iteration created + new_iter = ir.get_by_session_and_index(session.id, 1) + assert new_iter is not None + assert new_iter.status == IterationStatus.PENDING + assert new_iter.instruction == "next instruction" + # background task launched + mock_create_task.assert_called_once() + + +# ── CreateSessionPRUseCase tests ── + + +class TestCreateSessionPRUseCase: + def _build( + self, + session_repo: MockSessionRepository | None = None, + iteration_repo: MockIterationRepository | None = None, + proposal_repo: MockProposalRepository | None = None, + ) -> CreateSessionPRUseCase: + return CreateSessionPRUseCase( + db=MagicMock(), + session_repo=session_repo or MockSessionRepository(), + iteration_repo=iteration_repo or MockIterationRepository(), + proposal_repo=proposal_repo or MockProposalRepository(), + ) + + @pytest.mark.asyncio + async def test_session_not_found(self) -> None: + uc = self._build() + with pytest.raises(ValueError, match="Session not found"): + await uc.execute(uuid4(), 0, 0) + + @pytest.mark.asyncio + async def test_iteration_not_found(self) -> None: + sr = MockSessionRepository() + session = _make_session() + sr.create(session) + + uc = self._build(session_repo=sr) + with pytest.raises(ValueError, match="Iteration 0 not found"): + await uc.execute(session.id, 0, 0) + + @pytest.mark.asyncio + async def test_proposal_not_found(self) -> None: + sr = MockSessionRepository() + ir = MockIterationRepository() + + session = _make_session() + sr.create(session) + iteration = _make_iteration(session.id, index=0) + ir.create(iteration) + + uc = self._build(session_repo=sr, iteration_repo=ir) + with pytest.raises(ValueError, match="Proposal 0 not found"): + await uc.execute(session.id, 0, 0) + + @pytest.mark.asyncio + async def test_proposal_not_completed(self) -> None: + sr = MockSessionRepository() + ir = MockIterationRepository() + pr = MockProposalRepository() + + session = _make_session() + sr.create(session) + iteration = _make_iteration(session.id, index=0) + ir.create(iteration) + proposal = _make_proposal(iteration.id, status=ProposalStatus.IMPLEMENTING) + pr.create(proposal) + + uc = self._build(session_repo=sr, iteration_repo=ir, proposal_repo=pr) + with pytest.raises(ValueError, match="Proposal is not completed"): + await uc.execute(session.id, 0, 0) + + @pytest.mark.asyncio + async def test_pr_already_created(self) -> None: + sr = MockSessionRepository() + ir = MockIterationRepository() + pr = MockProposalRepository() + + session = _make_session() + sr.create(session) + iteration = _make_iteration(session.id, index=0) + ir.create(iteration) + proposal = _make_proposal(iteration.id, pr_status="created") + pr.create(proposal) + + uc = self._build(session_repo=sr, iteration_repo=ir, proposal_repo=pr) + with pytest.raises(ValueError, match="PR already created"): + await uc.execute(session.id, 0, 0) + + @pytest.mark.asyncio + async def test_pr_creation_in_progress(self) -> None: + sr = MockSessionRepository() + ir = MockIterationRepository() + pr = MockProposalRepository() + + session = _make_session() + sr.create(session) + iteration = _make_iteration(session.id, index=0) + ir.create(iteration) + proposal = _make_proposal(iteration.id, pr_status="creating") + pr.create(proposal) + + uc = self._build(session_repo=sr, iteration_repo=ir, proposal_repo=pr) + with pytest.raises(ValueError, match="PR creation already in progress"): + await uc.execute(session.id, 0, 0) + + @pytest.mark.asyncio + async def test_concurrent_update_detected(self) -> None: + sr = MockSessionRepository() + ir = MockIterationRepository() + pr = MockProposalRepository() + + session = _make_session() + sr.create(session) + iteration = _make_iteration(session.id, index=0) + ir.create(iteration) + proposal = _make_proposal(iteration.id, version=1) + pr.create(proposal) + + # Wrap update_status_optimistic to always return None (simulating race) + pr.update_status_optimistic = lambda *a, **kw: None # type: ignore[method-assign] + + uc = self._build(session_repo=sr, iteration_repo=ir, proposal_repo=pr) + with pytest.raises(ValueError, match="Concurrent update detected"): + await uc.execute(session.id, 0, 0) + + @pytest.mark.asyncio + @patch("app.usecase.session_usecase.asyncio.create_task") + async def test_happy_path(self, mock_create_task: MagicMock) -> None: + sr = MockSessionRepository() + ir = MockIterationRepository() + pr = MockProposalRepository() + + session = _make_session() + sr.create(session) + iteration = _make_iteration(session.id, index=0) + ir.create(iteration) + proposal = _make_proposal(iteration.id, version=1) + pr.create(proposal) + + uc = self._build(session_repo=sr, iteration_repo=ir, proposal_repo=pr) + result = await uc.execute(session.id, 0, 0) + + # Optimistic lock succeeded → pr_status updated + assert result.pr_status == "creating" + assert result.version == 2 + # background task launched + mock_create_task.assert_called_once() + + +# ── CreateSessionUseCase tests ── + + +class TestCreateSessionUseCase: + @pytest.mark.asyncio + @patch("app.usecase.session_usecase.asyncio.create_task") + async def test_creates_session_and_iteration(self, mock_create_task: MagicMock) -> None: + sr = MockSessionRepository() + ir = MockIterationRepository() + s3 = MagicMock() + + uc = CreateSessionUseCase( + db=MagicMock(), + session_repo=sr, + iteration_repo=ir, + s3_client=s3, + ) + result = await uc.execute( + repo_url="https://github.com/test/repo", + branch="main", + instruction="do something", + ) + + # Session created + assert result.repo_url == "https://github.com/test/repo" + assert result.status == SessionStatus.ACTIVE + + # Iteration 0 created + iterations = ir.get_all_for_session(result.id) + assert len(iterations) == 1 + assert iterations[0].iteration_index == 0 + assert iterations[0].instruction == "do something" + + # S3 bucket ensured + s3._ensure_bucket.assert_called_once() + + # background task launched + mock_create_task.assert_called_once() + + @pytest.mark.asyncio + @patch("app.usecase.session_usecase.asyncio.create_task") + async def test_iteration_defaults(self, mock_create_task: MagicMock) -> None: + sr = MockSessionRepository() + ir = MockIterationRepository() + s3 = MagicMock() + + uc = CreateSessionUseCase( + db=MagicMock(), + session_repo=sr, + iteration_repo=ir, + s3_client=s3, + ) + result = await uc.execute( + repo_url="https://github.com/test/repo", + branch="main", + instruction="test", + ) + + iteration = ir.get_latest_for_session(result.id) + assert iteration is not None + assert iteration.status == IterationStatus.PENDING + assert iteration.iteration_index == 0 diff --git a/backend/app/workflow/session_analyzer_graph.py b/backend/app/workflow/session_analyzer_graph.py index a853396..ea416aa 100644 --- a/backend/app/workflow/session_analyzer_graph.py +++ b/backend/app/workflow/session_analyzer_graph.py @@ -3,8 +3,8 @@ from langgraph.graph import END, START, StateGraph -from app.service.k8s_service import K8sService -from app.service.s3_service import S3Service +from app.infra.k8s_client import K8sClient +from app.infra.s3_client import S3Client from app.workflow.state import SessionAnalyzerState logger = logging.getLogger(__name__) @@ -12,7 +12,7 @@ async def create_k8s_job(state: SessionAnalyzerState) -> dict: """Create the K8s Job for session-based analysis.""" - k8s = K8sService() + k8s = K8sClient() job_name = k8s.create_session_analyzer_job( session_id=state["session_id"], iteration_index=state["iteration_index"], @@ -25,7 +25,7 @@ async def create_k8s_job(state: SessionAnalyzerState) -> dict: from app.di.container import DIContainer - DIContainer.get_log_stream_service().register_job( + DIContainer.get_log_stream_client().register_job( session_id=state["session_id"], job_name=job_name, job_type="analyze", @@ -36,7 +36,7 @@ async def create_k8s_job(state: SessionAnalyzerState) -> dict: async def wait_for_job(state: SessionAnalyzerState) -> dict: """Poll K8s Job until completion.""" - k8s = K8sService() + k8s = K8sClient() k8s_job_name = state["k8s_job_name"] assert k8s_job_name is not None result = await k8s.wait_for_job(k8s_job_name) @@ -53,7 +53,7 @@ async def wait_for_job(state: SessionAnalyzerState) -> dict: async def extract_results(state: SessionAnalyzerState) -> dict: """Read analysis results from S3.""" - s3 = S3Service() + s3 = S3Client() session_id = state["session_id"] iteration_index = state["iteration_index"] diff --git a/backend/app/workflow/session_create_pr_graph.py b/backend/app/workflow/session_create_pr_graph.py index 5a705ac..fbc3c3d 100644 --- a/backend/app/workflow/session_create_pr_graph.py +++ b/backend/app/workflow/session_create_pr_graph.py @@ -3,8 +3,8 @@ from langgraph.graph import END, START, StateGraph -from app.service.k8s_service import K8sService -from app.service.s3_service import S3Service +from app.infra.k8s_client import K8sClient +from app.infra.s3_client import S3Client from app.workflow.state import SessionCreatePRState logger = logging.getLogger(__name__) @@ -12,7 +12,7 @@ async def create_k8s_job(state: SessionCreatePRState) -> dict: """Create the K8s Job for session-based PR creation.""" - k8s = K8sService() + k8s = K8sClient() job_name = k8s.create_session_pr_job( session_id=state["session_id"], iteration_index=state["iteration_index"], @@ -23,7 +23,7 @@ async def create_k8s_job(state: SessionCreatePRState) -> dict: from app.di.container import DIContainer - DIContainer.get_log_stream_service().register_job( + DIContainer.get_log_stream_client().register_job( session_id=state["session_id"], job_name=job_name, job_type="createpr", @@ -35,7 +35,7 @@ async def create_k8s_job(state: SessionCreatePRState) -> dict: async def wait_for_job(state: SessionCreatePRState) -> dict: """Poll K8s Job until completion.""" - k8s = K8sService() + k8s = K8sClient() k8s_job_name = state["k8s_job_name"] assert k8s_job_name is not None result = await k8s.wait_for_job(k8s_job_name) @@ -52,7 +52,7 @@ async def wait_for_job(state: SessionCreatePRState) -> dict: async def extract_results(state: SessionCreatePRState) -> dict: """Read PR URL from S3.""" - s3 = S3Service() + s3 = S3Client() pr_url_key = s3.pr_url_key( state["session_id"], state["iteration_index"], state["proposal_index"] ) diff --git a/backend/app/workflow/session_implementation_graph.py b/backend/app/workflow/session_implementation_graph.py index 3d08d3e..2cdcfdd 100644 --- a/backend/app/workflow/session_implementation_graph.py +++ b/backend/app/workflow/session_implementation_graph.py @@ -3,8 +3,8 @@ from langgraph.graph import END, START, StateGraph -from app.service.k8s_service import K8sService -from app.service.s3_service import S3Service +from app.infra.k8s_client import K8sClient +from app.infra.s3_client import S3Client from app.workflow.state import SessionImplementationState logger = logging.getLogger(__name__) @@ -12,8 +12,8 @@ async def create_k8s_job(state: SessionImplementationState) -> dict: """Create the K8s Job for session-based implementation.""" - k8s = K8sService() - s3 = S3Service() + k8s = K8sClient() + s3 = S3Client() # Write proposal plan to S3 for the worker to read plan_key = s3.plan_key(state["session_id"], state["iteration_index"], state["proposal_index"]) @@ -31,7 +31,7 @@ async def create_k8s_job(state: SessionImplementationState) -> dict: from app.di.container import DIContainer - DIContainer.get_log_stream_service().register_job( + DIContainer.get_log_stream_client().register_job( session_id=state["session_id"], job_name=job_name, job_type="implement", @@ -43,7 +43,7 @@ async def create_k8s_job(state: SessionImplementationState) -> dict: async def wait_for_job(state: SessionImplementationState) -> dict: """Poll K8s Job until completion.""" - k8s = K8sService() + k8s = K8sClient() k8s_job_name = state["k8s_job_name"] assert k8s_job_name is not None result = await k8s.wait_for_job(k8s_job_name) @@ -60,7 +60,7 @@ async def wait_for_job(state: SessionImplementationState) -> dict: async def extract_results(state: SessionImplementationState) -> dict: """Read implementation results from S3.""" - s3 = S3Service() + s3 = S3Client() session_id = state["session_id"] iteration_index = state["iteration_index"] proposal_index = state["proposal_index"] diff --git a/backend/pyproject.toml b/backend/pyproject.toml index cf60855..7daee71 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -30,6 +30,8 @@ dev = [ [tool.pytest.ini_options] asyncio_mode = "auto" +testpaths = ["app"] +python_files = "test_*.py" [tool.ruff] target-version = "py313" @@ -58,7 +60,7 @@ plugins = ["pydantic.mypy", "sqlalchemy.ext.mypy.plugin"] exclude = ["migration/"] [[tool.mypy.overrides]] -module = "tests.*" +module = "app.usecase.test_session_usecase" disallow_untyped_defs = false disallow_incomplete_defs = false disable_error_code = ["arg-type"] diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py deleted file mode 100644 index c325ae1..0000000 --- a/backend/tests/conftest.py +++ /dev/null @@ -1,47 +0,0 @@ -from collections.abc import Generator - -import pytest -from sqlalchemy import create_engine, event -from sqlalchemy.orm import Session, sessionmaker - -from app.model.base import Base -from app.model.session import Iteration, Proposal, Setting # noqa: F401 (register models) -from app.model.session import Session as SessionModel # noqa: F401 - - -@pytest.fixture(scope="session") -def engine(): - """Create an in-memory SQLite engine for testing. - - Uses render_as_literal for PostgreSQL-specific types (UUID, Enum) - so they work on SQLite. - """ - eng = create_engine( - "sqlite:///:memory:", - connect_args={"check_same_thread": False}, - ) - - # Enable foreign key support in SQLite - @event.listens_for(eng, "connect") - def set_sqlite_pragma(dbapi_connection, connection_record): - cursor = dbapi_connection.cursor() - cursor.execute("PRAGMA foreign_keys=ON") - cursor.close() - - Base.metadata.create_all(eng) - yield eng - eng.dispose() - - -@pytest.fixture() -def db(engine) -> Generator[Session]: - """Provide a transactional DB session that rolls back after each test.""" - connection = engine.connect() - transaction = connection.begin() - session = sessionmaker(bind=connection)() - - yield session - - session.close() - transaction.rollback() - connection.close() diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py deleted file mode 100644 index 2b17744..0000000 --- a/backend/tests/test_api.py +++ /dev/null @@ -1,180 +0,0 @@ -import uuid - -import pytest -from fastapi.testclient import TestClient - -from app.di.dependencies import get_db -from app.main import app -from app.model.session import ( - Iteration, - IterationStatus, - Proposal, - ProposalStatus, - Session, -) -from app.repository.iteration_repository import IterationRepository -from app.repository.proposal_repository import ProposalRepository -from app.repository.session_repository import SessionRepository - - -@pytest.fixture() -def client(db): - """Create a test client with overridden dependencies.""" - - def override_get_db(): - yield db - - app.dependency_overrides[get_db] = override_get_db - with TestClient(app) as c: - yield c - app.dependency_overrides.clear() - - -@pytest.fixture() -def sample_session(db) -> Session: - """Create a sample session with iteration 0.""" - session_repo = SessionRepository(db) - session = session_repo.create( - Session( - repo_url="https://github.com/test/repo", - base_branch="main", - ) - ) - iter_repo = IterationRepository(db) - iter_repo.create( - Iteration( - session_id=session.id, - iteration_index=0, - instruction="Make the header blue", - status=IterationStatus.PENDING, - ) - ) - db.refresh(session) - return session - - -@pytest.fixture() -def completed_session(db) -> Session: - """Create a session with completed iteration and proposals.""" - session_repo = SessionRepository(db) - session = session_repo.create( - Session( - repo_url="https://github.com/test/repo", - base_branch="main", - ) - ) - iter_repo = IterationRepository(db) - iteration = iter_repo.create( - Iteration( - session_id=session.id, - iteration_index=0, - instruction="Redesign the navbar", - status=IterationStatus.COMPLETED, - ) - ) - prop_repo = ProposalRepository(db) - for i in range(2): - prop_repo.create( - Proposal( - iteration_id=iteration.id, - proposal_index=i, - title=f"Proposal {i}", - concept=f"Concept {i}", - plan='["step 1"]', - files='[{"path": "src/App.tsx"}]', - complexity="medium", - status=ProposalStatus.COMPLETED, - ) - ) - db.refresh(session) - return session - - -class TestListSessions: - def test_empty_list(self, client): - response = client.get("/api/sessions/") - assert response.status_code == 200 - assert isinstance(response.json(), list) - - def test_list_with_sessions(self, client, sample_session): - response = client.get("/api/sessions/") - assert response.status_code == 200 - data = response.json() - assert len(data) >= 1 - assert data[0]["repo_url"] == "https://github.com/test/repo" - - -class TestGetSession: - def test_get_existing(self, client, sample_session): - response = client.get(f"/api/sessions/{sample_session.id}") - assert response.status_code == 200 - data = response.json() - assert data["id"] == str(sample_session.id) - assert data["status"] == "active" - assert len(data["iterations"]) == 1 - assert data["iterations"][0]["instruction"] == "Make the header blue" - - def test_get_nonexistent(self, client): - response = client.get(f"/api/sessions/{uuid.uuid4()}") - assert response.status_code == 404 - - def test_get_with_proposals(self, client, completed_session): - response = client.get(f"/api/sessions/{completed_session.id}") - assert response.status_code == 200 - data = response.json() - iteration = data["iterations"][0] - assert iteration["status"] == "completed" - assert len(iteration["proposals"]) == 2 - assert iteration["proposals"][0]["title"] == "Proposal 0" - assert iteration["proposals"][0]["plan"] == ["step 1"] - - -class TestCreateSession: - def test_create_success(self, client, monkeypatch): - import app.usecase.session_usecase as usecase_mod - - monkeypatch.setattr(usecase_mod.asyncio, "create_task", lambda coro: coro.close()) - monkeypatch.setattr("app.service.s3_service.S3Service._ensure_bucket", lambda self: None) - - response = client.post( - "/api/sessions/", - json={ - "repo_url": "https://github.com/new/repo", - "branch": "develop", - "instruction": "Add dark mode", - }, - ) - assert response.status_code == 201 - data = response.json() - assert data["repo_url"] == "https://github.com/new/repo" - assert data["base_branch"] == "develop" - assert data["status"] == "active" - - def test_create_missing_fields(self, client): - response = client.post("/api/sessions/", json={}) - assert response.status_code == 422 - - -class TestSettingsAPI: - def test_list_settings_empty(self, client): - response = client.get("/api/settings/") - assert response.status_code == 200 - assert isinstance(response.json(), list) - - def test_create_and_list_setting(self, client): - response = client.post("/api/settings/", json={"key": "max_proposals", "value": "5"}) - assert response.status_code == 200 - data = response.json() - assert data["key"] == "max_proposals" - assert data["value"] == "5" - - response = client.get("/api/settings/") - settings = response.json() - keys = [s["key"] for s in settings] - assert "max_proposals" in keys - - def test_update_setting(self, client): - client.post("/api/settings/", json={"key": "theme", "value": "dark"}) - response = client.post("/api/settings/", json={"key": "theme", "value": "light"}) - assert response.status_code == 200 - assert response.json()["value"] == "light" diff --git a/backend/tests/test_repository.py b/backend/tests/test_repository.py deleted file mode 100644 index 1c82ac7..0000000 --- a/backend/tests/test_repository.py +++ /dev/null @@ -1,218 +0,0 @@ -import uuid - -from app.model.session import ( - Iteration, - IterationStatus, - Proposal, - ProposalStatus, - Session, - SessionStatus, -) -from app.repository.iteration_repository import IterationRepository -from app.repository.proposal_repository import ProposalRepository -from app.repository.session_repository import SessionRepository -from app.repository.setting_repository import SettingRepository - - -class TestSessionRepository: - def test_create_and_get(self, db): - repo = SessionRepository(db) - session = Session( - repo_url="https://github.com/test/repo", - base_branch="main", - ) - created = repo.create(session) - - assert created.id is not None - assert created.status == SessionStatus.ACTIVE - - fetched = repo.get_by_id(created.id) - assert fetched is not None - assert fetched.repo_url == "https://github.com/test/repo" - - def test_get_by_id_not_found(self, db): - repo = SessionRepository(db) - result = repo.get_by_id(uuid.uuid4()) - assert result is None - - def test_list_all(self, db): - repo = SessionRepository(db) - repo.create(Session(repo_url="https://github.com/test/a", base_branch="main")) - repo.create(Session(repo_url="https://github.com/test/b", base_branch="main")) - - sessions = repo.list_all() - assert len(sessions) >= 2 - - -class TestIterationRepository: - def _create_session(self, db) -> Session: - repo = SessionRepository(db) - return repo.create(Session(repo_url="https://github.com/test/repo", base_branch="main")) - - def test_create_and_get(self, db): - session = self._create_session(db) - repo = IterationRepository(db) - iteration = repo.create( - Iteration( - session_id=session.id, - iteration_index=0, - instruction="Make button blue", - ) - ) - - assert iteration.id is not None - assert iteration.status == IterationStatus.PENDING - - fetched = repo.get_by_id(iteration.id) - assert fetched is not None - assert fetched.instruction == "Make button blue" - - def test_get_latest_for_session(self, db): - session = self._create_session(db) - repo = IterationRepository(db) - repo.create(Iteration(session_id=session.id, iteration_index=0, instruction="First")) - repo.create(Iteration(session_id=session.id, iteration_index=1, instruction="Second")) - - latest = repo.get_latest_for_session(session.id) - assert latest is not None - assert latest.iteration_index == 1 - assert latest.instruction == "Second" - - -class TestProposalRepository: - def _create_iteration(self, db) -> Iteration: - session_repo = SessionRepository(db) - session = session_repo.create( - Session(repo_url="https://github.com/test/repo", base_branch="main") - ) - iter_repo = IterationRepository(db) - return iter_repo.create( - Iteration(session_id=session.id, iteration_index=0, instruction="Test") - ) - - def test_create_and_get(self, db): - iteration = self._create_iteration(db) - repo = ProposalRepository(db) - proposal = repo.create( - Proposal( - iteration_id=iteration.id, - proposal_index=0, - title="Modern Layout", - concept="A clean modern layout", - plan='["step 1", "step 2"]', - ) - ) - - assert proposal.id is not None - assert proposal.status == ProposalStatus.PENDING - - fetched = repo.get_by_id(proposal.id) - assert fetched is not None - assert fetched.title == "Modern Layout" - - def test_get_by_iteration_and_index(self, db): - iteration = self._create_iteration(db) - repo = ProposalRepository(db) - repo.create( - Proposal( - iteration_id=iteration.id, - proposal_index=0, - title="Proposal A", - concept="A", - plan="[]", - ) - ) - repo.create( - Proposal( - iteration_id=iteration.id, - proposal_index=1, - title="Proposal B", - concept="B", - plan="[]", - ) - ) - - result = repo.get_by_iteration_and_index(iteration.id, 1) - assert result is not None - assert result.title == "Proposal B" - - result = repo.get_by_iteration_and_index(iteration.id, 99) - assert result is None - - def test_get_all_for_iteration(self, db): - iteration = self._create_iteration(db) - repo = ProposalRepository(db) - for i in range(3): - repo.create( - Proposal( - iteration_id=iteration.id, - proposal_index=i, - title=f"Proposal {i}", - concept=f"Concept {i}", - plan="[]", - ) - ) - - proposals = repo.get_all_for_iteration(iteration.id) - assert len(proposals) == 3 - assert proposals[0].proposal_index == 0 - assert proposals[2].proposal_index == 2 - - def test_update_status_optimistic(self, db): - iteration = self._create_iteration(db) - repo = ProposalRepository(db) - proposal = repo.create( - Proposal( - iteration_id=iteration.id, - proposal_index=0, - title="Test", - concept="Test", - plan="[]", - ) - ) - - updated = repo.update_status_optimistic( - proposal.id, proposal.version, ProposalStatus.COMPLETED - ) - assert updated is not None - assert updated.status == ProposalStatus.COMPLETED - assert updated.version == 2 - - -class TestSettingRepository: - def test_upsert_create(self, db): - repo = SettingRepository(db) - setting = repo.upsert("theme", "dark") - assert setting.key == "theme" - assert setting.value == "dark" - - def test_upsert_update(self, db): - repo = SettingRepository(db) - repo.upsert("theme", "dark") - updated = repo.upsert("theme", "light") - assert updated.value == "light" - - def test_get_by_key(self, db): - repo = SettingRepository(db) - repo.upsert("api_key", "test-key") - - setting = repo.get_by_key("api_key") - assert setting is not None - assert setting.value == "test-key" - - assert repo.get_by_key("nonexistent") is None - - def test_list_all(self, db): - repo = SettingRepository(db) - repo.upsert("a", "1") - repo.upsert("b", "2") - - settings = repo.list_all() - assert len(settings) >= 2 - - def test_delete(self, db): - repo = SettingRepository(db) - repo.upsert("to_delete", "value") - assert repo.delete("to_delete") is True - assert repo.get_by_key("to_delete") is None - assert repo.delete("nonexistent") is False