diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..a918073 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,28 @@ + + +# Pull Request + +## Summary +[SBP-XXX](https://biocloud.atlassian.net/browse/SBP-XXX) + +## Changes +- +- + +## How to Test + + +## Type of change +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to change) +- [ ] Documentation update + +## Checklist +- [ ] I have added tests that prove my fix is effective or that my feature works +- [ ] I have added or updated documentation where necessary +- [ ] I have run linting and unit tests locally +- [ ] The code follows the project's style guidelines diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..e7630e2 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,36 @@ +name: Lint + +on: + push: + branches: [main, workflows] + pull_request: + branches: [main, workflows] + +jobs: + ruff: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: "pip" + cache-dependency-path: requirements.txt + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + python -m pip install ruff black mypy + + - name: Run Ruff + run: ruff check app tests + + - name: Run Black + run: black --check app tests + + - name: Run MyPy + run: mypy app --ignore-missing-imports diff --git a/.github/workflows/test-coverage.yml b/.github/workflows/test-coverage.yml new file mode 100644 index 0000000..17d6261 --- /dev/null +++ b/.github/workflows/test-coverage.yml @@ -0,0 +1,69 @@ +name: Coverage + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + +jobs: + tests: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12"] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: "pip" + cache-dependency-path: requirements.txt + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + python -m pip install -r requirements-dev.txt + + - name: Run tests with coverage + env: + ALLOWED_ORIGINS: http://localhost + SEQERA_API_URL: https://example.com/api + SEQERA_ACCESS_TOKEN: test-token + WORK_SPACE: demo-workspace + COMPUTE_ID: compute-123 + WORK_DIR: /tmp/work + run: | + pytest --cov=app --cov-report=xml --cov-report=term-missing --cov-report=html -v + + - name: Check coverage threshold (90%) + run: | + coverage report --fail-under=90 + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + if: matrix.python-version == '3.11' + with: + file: ./coverage.xml + flags: unittests + name: codecov-umbrella + fail_ci_if_error: false + + - name: Coverage summary + uses: irongut/CodeCoverageSummary@v1.3.0 + if: matrix.python-version == '3.11' + with: + filename: coverage.xml + badge: true + fail_below_min: true + format: markdown + hide_branch_rate: false + hide_complexity: true + indicators: true + thresholds: "90 90" + output: both diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..883229b --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,32 @@ +# Pre-commit hooks configuration +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + - id: check-json + - id: check-toml + - id: check-merge-conflict + - id: debug-statements + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.1.15 + hooks: + - id: ruff + args: [--fix, --exit-non-zero-on-fix] + + - repo: https://github.com/psf/black + rev: 24.1.1 + hooks: + - id: black + language_version: python3.11 + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.8.0 + hooks: + - id: mypy + additional_dependencies: [types-httpx] + args: [--ignore-missing-imports] diff --git a/README.md b/README.md index 3986542..1aac502 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,14 @@ # SBP Portal Backend Server +![Lint](https://github.com/AustralianBioCommons/sbp-backend/actions/workflows/lint.yml/badge.svg) +![Coverage](https://github.com/AustralianBioCommons/sbp-backend/actions/workflows/test-coverage.yml/badge.svg) +[![codecov](https://codecov.io/gh/AustralianBioCommons/sbp-backend/branch/main/graph/badge.svg)](https://codecov.io/gh/AustralianBioCommons/sbp-backend) + FastAPI backend for handling Seqera Platform workflow launches. ## Prerequisites -- Python 3.9+ (matching the version used by your deployment target) +- Python 3.10+ (matching the version used by your deployment target) - [uvicorn](https://www.uvicorn.org/) and other dependencies listed in `requirements.txt` ## Setup @@ -22,7 +26,13 @@ FastAPI backend for handling Seqera Platform workflow launches. pip install -r requirements.txt ``` -3. Configure environment variables: +3. Install development dependencies (for testing and linting): + + ```bash + pip install -r requirements-dev.txt + ``` + +4. Configure environment variables: ```bash cp .env.example .env @@ -46,6 +56,52 @@ FastAPI backend for handling Seqera Platform workflow launches. - `GET /api/workflows/{runId}/details` — Placeholder details endpoint - `POST /api/workflows/datasets/upload` — Create a Seqera dataset and upload submitted form data as a CSV +## Testing + +Run the test suite with coverage: + +```bash +# Run all tests with coverage report +pytest --cov=app --cov-report=term-missing --cov-report=html + +# Run tests with verbose output +pytest -v + +# Run specific test file +pytest tests/test_main.py + +# Check coverage threshold (90%) +coverage report --fail-under=90 +``` + +View HTML coverage report: + +```bash +open htmlcov/index.html # macOS +xdg-open htmlcov/index.html # Linux +start htmlcov/index.html # Windows (Command Prompt / PowerShell) +``` + +## Linting and Code Quality + +```bash +# Run ruff linter +ruff check app tests + +# Run black formatter +black app tests + +# Run type checking with mypy +mypy app --ignore-missing-imports + +# Install pre-commit hooks +pip install pre-commit +pre-commit install + +# Run pre-commit on all files +pre-commit run --all-files +``` + ## Environment Variables Required entries in `.env`: diff --git a/app/main.py b/app/main.py index 00c9560..4ae2ef0 100644 --- a/app/main.py +++ b/app/main.py @@ -1,4 +1,5 @@ """FastAPI application entry point for the SBP Portal backend.""" + from __future__ import annotations import logging @@ -29,7 +30,9 @@ def create_app() -> FastAPI: if not allowed_origins_env: raise RuntimeError("ALLOWED_ORIGINS environment variable is required but not set") - allowed_origins = [origin.strip() for origin in allowed_origins_env.split(",") if origin.strip()] + allowed_origins = [ + origin.strip() for origin in allowed_origins_env.split(",") if origin.strip() + ] app.add_middleware( CORSMiddleware, diff --git a/app/routes/workflows.py b/app/routes/workflows.py index 0747832..95f0ebe 100644 --- a/app/routes/workflows.py +++ b/app/routes/workflows.py @@ -1,9 +1,9 @@ """Workflow-related HTTP routes.""" + from __future__ import annotations import asyncio from datetime import datetime, timezone -from typing import Optional from fastapi import APIRouter, HTTPException, Query, status @@ -36,26 +36,23 @@ async def launch_workflow(payload: WorkflowLaunchPayload) -> WorkflowLaunchRespo """Launch a workflow on the Seqera Platform.""" try: dataset_id = payload.datasetId - + # If formData is provided, create and upload dataset if payload.formData: dataset_result = await create_seqera_dataset( name=payload.launch.runName or "workflow-dataset" ) dataset_id = dataset_result.dataset_id - - await upload_dataset_to_seqera( - dataset_id=dataset_id, - form_data=payload.formData - ) - - result: SeqeraLaunchResult = await launch_seqera_workflow( - payload.launch, dataset_id - ) + + await upload_dataset_to_seqera(dataset_id=dataset_id, form_data=payload.formData) + + result: SeqeraLaunchResult = await launch_seqera_workflow(payload.launch, dataset_id) except SeqeraConfigurationError as exc: - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc) + ) from exc except SeqeraServiceError as exc: - raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc return WorkflowLaunchResponse( message="Workflow launched successfully", @@ -77,8 +74,8 @@ async def cancel_workflow(run_id: str) -> CancelWorkflowResponse: @router.get("/runs", response_model=ListRunsResponse) async def list_runs( - status_filter: Optional[str] = Query(None, alias="status"), - workspace: Optional[str] = Query(None), + status_filter: str | None = Query(None, alias="status"), + workspace: str | None = Query(None), limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), ) -> ListRunsResponse: @@ -143,9 +140,9 @@ async def upload_dataset(payload: DatasetUploadRequest) -> DatasetUploadResponse except SeqeraConfigurationError as exc: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc) - ) + ) from exc except SeqeraServiceError as exc: - raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc # Allow Seqera time to finish dataset initialization before uploading await asyncio.sleep(2) @@ -153,13 +150,13 @@ async def upload_dataset(payload: DatasetUploadRequest) -> DatasetUploadResponse try: upload_result = await upload_dataset_to_seqera(dataset.dataset_id, payload.formData) except ValueError as exc: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc except SeqeraConfigurationError as exc: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc) - ) + ) from exc except SeqeraServiceError as exc: - raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc return DatasetUploadResponse( message="Dataset created and uploaded successfully", diff --git a/app/schemas/workflows.py b/app/schemas/workflows.py index 8275fd0..79b09c8 100644 --- a/app/schemas/workflows.py +++ b/app/schemas/workflows.py @@ -1,8 +1,9 @@ """Pydantic models shared across workflow endpoints.""" + from __future__ import annotations from datetime import datetime -from typing import Any, Dict, List, Optional +from typing import Any from pydantic import BaseModel, ConfigDict, Field, field_validator @@ -11,18 +12,14 @@ class WorkflowLaunchForm(BaseModel): model_config = ConfigDict(extra="forbid") pipeline: str = Field(..., description="Workflow pipeline repository or URL") - revision: Optional[str] = Field( + revision: str | None = Field( default=None, description="Revision or branch of the pipeline to run" ) - configProfiles: List[str] = Field( + configProfiles: list[str] = Field( default_factory=list, description="Profiles that customize the workflow" ) - runName: Optional[str] = Field( - default=None, description="Human-readable workflow run name" - ) - paramsText: Optional[str] = Field( - default=None, description="YAML-style parameter overrides" - ) + runName: str | None = Field(default=None, description="Human-readable workflow run name") + paramsText: str | None = Field(default=None, description="YAML-style parameter overrides") @field_validator("pipeline") @classmethod @@ -36,11 +33,11 @@ class WorkflowLaunchPayload(BaseModel): model_config = ConfigDict(extra="forbid") launch: WorkflowLaunchForm - datasetId: Optional[str] = Field( + datasetId: str | None = Field( default=None, description="Optional Seqera dataset ID to attach to the workflow", ) - formData: Optional[Dict[str, Any]] = Field( + formData: dict[str, Any] | None = Field( default=None, description="Optional form data to convert to CSV and upload as a dataset", ) @@ -69,7 +66,7 @@ class RunInfo(BaseModel): class ListRunsResponse(BaseModel): - runs: List[RunInfo] + runs: list[RunInfo] total: int limit: int offset: int @@ -77,12 +74,12 @@ class ListRunsResponse(BaseModel): class LaunchLogs(BaseModel): truncated: bool - entries: List[str] + entries: list[str] rewindToken: str forwardToken: str pending: bool message: str - downloads: List[Dict[str, str]] = Field(default_factory=list) + downloads: list[dict[str, str]] = Field(default_factory=list) class LaunchDetails(BaseModel): @@ -108,20 +105,20 @@ class LaunchDetails(BaseModel): projectName: str scriptName: str launchId: str - configFiles: List[str] - params: Dict[str, str] + configFiles: list[str] + params: dict[str, str] class DatasetUploadRequest(BaseModel): model_config = ConfigDict(extra="forbid") - formData: Dict[str, Any] - datasetName: Optional[str] = Field(default=None) - datasetDescription: Optional[str] = Field(default=None) + formData: dict[str, Any] + datasetName: str | None = Field(default=None) + datasetDescription: str | None = Field(default=None) @field_validator("formData") @classmethod - def validate_form_data(cls, value: Dict[str, Any]) -> Dict[str, Any]: + def validate_form_data(cls, value: dict[str, Any]) -> dict[str, Any]: if not value: raise ValueError("formData cannot be empty") return value @@ -131,4 +128,4 @@ class DatasetUploadResponse(BaseModel): message: str datasetId: str success: bool - details: Optional[Dict[str, Any]] = None + details: dict[str, Any] | None = None diff --git a/app/services/datasets.py b/app/services/datasets.py index 5b8fc11..69589b7 100644 --- a/app/services/datasets.py +++ b/app/services/datasets.py @@ -1,4 +1,5 @@ """Dataset helpers for interacting with the Seqera Platform.""" + from __future__ import annotations import csv @@ -8,7 +9,7 @@ import os import time from dataclasses import dataclass -from typing import Any, Dict, Optional +from typing import Any import httpx @@ -20,9 +21,7 @@ def _get_required_env(key: str) -> str: value = os.getenv(key) if not value: - raise SeqeraConfigurationError( - f"Missing required environment variable: {key}" - ) + raise SeqeraConfigurationError(f"Missing required environment variable: {key}") return value @@ -36,7 +35,7 @@ def _stringify_field(value: Any) -> str: return str(value) -def convert_form_data_to_csv(form_data: Dict[str, Any]) -> str: +def convert_form_data_to_csv(form_data: dict[str, Any]) -> str: """Convert a record of form data into a single-row CSV string.""" if not form_data: raise ValueError("formData cannot be empty") @@ -54,7 +53,7 @@ def convert_form_data_to_csv(form_data: Dict[str, Any]) -> str: @dataclass class DatasetCreationResult: dataset_id: str - raw_response: Dict[str, Any] + raw_response: dict[str, Any] @dataclass @@ -62,11 +61,11 @@ class DatasetUploadResult: success: bool dataset_id: str message: str - raw_response: Optional[Dict[str, Any]] = None + raw_response: dict[str, Any] | None = None async def create_seqera_dataset( - name: Optional[str] = None, description: Optional[str] = None + name: str | None = None, description: str | None = None ) -> DatasetCreationResult: """Create a dataset on the Seqera Platform.""" seqera_api_url = _get_required_env("SEQERA_API_URL").rstrip("/") @@ -103,23 +102,19 @@ async def create_seqera_dataset( "body": body, }, ) - raise SeqeraServiceError( - f"Seqera dataset creation failed: {response.status_code} {body}" - ) + raise SeqeraServiceError(f"Seqera dataset creation failed: {response.status_code} {body}") data = response.json() dataset_id = data.get("dataset", {}).get("id") if not dataset_id: - raise SeqeraServiceError( - "Seqera dataset creation succeeded but response lacked dataset id" - ) + raise SeqeraServiceError("Seqera dataset creation succeeded but response lacked dataset id") logger.info("Seqera dataset created", extra={"datasetId": dataset_id}) return DatasetCreationResult(dataset_id=dataset_id, raw_response=data) async def upload_dataset_to_seqera( - dataset_id: str, form_data: Dict[str, Any] + dataset_id: str, form_data: dict[str, Any] ) -> DatasetUploadResult: """Upload CSV-encoded form data to an existing Seqera dataset.""" if not dataset_id: @@ -160,14 +155,10 @@ async def upload_dataset_to_seqera( "body": body, }, ) - raise SeqeraServiceError( - f"Seqera dataset upload failed: {response.status_code} {body}" - ) + raise SeqeraServiceError(f"Seqera dataset upload failed: {response.status_code} {body}") data = response.json() - returned_dataset_id = ( - data.get("version", {}).get("datasetId") or dataset_id - ) + returned_dataset_id = data.get("version", {}).get("datasetId") or dataset_id message = data.get("message") or "Upload successful" logger.info( diff --git a/app/services/seqera.py b/app/services/seqera.py index 99975ec..0cc928c 100644 --- a/app/services/seqera.py +++ b/app/services/seqera.py @@ -1,10 +1,11 @@ """Seqera Platform integration helpers.""" + from __future__ import annotations import logging import os from dataclasses import dataclass -from typing import Any, Dict, Optional +from typing import Any import httpx @@ -25,20 +26,18 @@ class SeqeraServiceError(RuntimeError): class SeqeraLaunchResult: workflow_id: str status: str - message: Optional[str] = None + message: str | None = None def _get_required_env(key: str) -> str: value = os.getenv(key) if not value: - raise SeqeraConfigurationError( - f"Missing required environment variable: {key}" - ) + raise SeqeraConfigurationError(f"Missing required environment variable: {key}") return value async def launch_seqera_workflow( - form: WorkflowLaunchForm, dataset_id: Optional[str] = None + form: WorkflowLaunchForm, dataset_id: str | None = None ) -> SeqeraLaunchResult: """Launch a workflow on the Seqera Platform.""" seqera_api_url = _get_required_env("SEQERA_API_URL").rstrip("/") @@ -68,20 +67,20 @@ async def launch_seqera_workflow( "batches: 1", "help: false", ] - + # Start with default parameters params_text = "\n".join(default_params) - + # Add custom paramsText from frontend if provided if form.paramsText and form.paramsText.strip(): params_text = f"{params_text}\n{form.paramsText.rstrip()}" - + # Add dataset input URL if dataset_id is provided if dataset_id: dataset_url = f"{seqera_api_url}/workspaces/{workspace_id}/datasets/{dataset_id}/v/1/n/samplesheet.csv" params_text = f"{params_text}\ninput: {dataset_url}" - launch_payload: Dict[str, Any] = { + launch_payload: dict[str, Any] = { "launch": { "computeEnvId": compute_env_id, "runName": form.runName or "hello-from-ui", @@ -100,18 +99,12 @@ async def launch_seqera_workflow( launch_payload["launch"]["datasetIds"] = [dataset_id] url = f"{seqera_api_url}/workflow/launch?workspaceId={workspace_id}" - + # Log the complete params being sent - logger.info( - "Launch payload paramsText", - extra={"paramsText": params_text} - ) - - logger.info( - "Full launch payload", - extra={"payload": launch_payload} - ) - + logger.info("Launch payload paramsText", extra={"paramsText": params_text}) + + logger.info("Full launch payload", extra={"payload": launch_payload}) + logger.info( "Launching workflow via Seqera API", extra={ @@ -142,18 +135,14 @@ async def launch_seqera_workflow( "body": body, }, ) - raise SeqeraServiceError( - f"Seqera workflow launch failed: {response.status_code} {body}" - ) + raise SeqeraServiceError(f"Seqera workflow launch failed: {response.status_code} {body}") data = response.json() workflow_id = data.get("workflowId") or data.get("data", {}).get("workflowId") status = data.get("status", "submitted") if not workflow_id: - raise SeqeraServiceError( - "Seqera workflow launch succeeded but did not return a workflowId" - ) + raise SeqeraServiceError("Seqera workflow launch succeeded but did not return a workflowId") return SeqeraLaunchResult( workflow_id=workflow_id, diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..3ef09d8 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,95 @@ +[build-system] +requires = ["setuptools>=45", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "sbp-backend" +version = "1.0.0" +description = "Structural Biology Platform Backend API" +requires-python = ">=3.10" + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = [ + "--strict-markers", + "--strict-config", + "--showlocals", +] + +[tool.coverage.run] +source = ["app"] +omit = [ + "*/tests/*", + "*/__pycache__/*", + "*/.venv/*", +] +branch = true + +[tool.coverage.report] +precision = 2 +show_missing = true +skip_covered = false +fail_under = 90 +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise AssertionError", + "raise NotImplementedError", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", + "@abstractmethod", +] + +[tool.coverage.html] +directory = "htmlcov" + +# Ruff configuration +[tool.ruff] +line-length = 100 +target-version = "py310" + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade +] +ignore = [ + "E501", # line too long (handled by black) + "B008", # do not perform function calls in argument defaults +] + +[tool.ruff.lint.per-file-ignores] +"__init__.py" = ["F401"] # imported but unused +"tests/*" = ["B018"] # useless expression + +# Black configuration +[tool.black] +line-length = 100 +target-version = ["py310", "py311", "py312"] +include = '\.pyi?$' +extend-exclude = ''' +/( + \.git + | \.venv + | __pycache__ + | build + | dist +)/ +''' + +# MyPy configuration +[tool.mypy] +python_version = "3.10" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = false +ignore_missing_imports = true diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..5adbc4b --- /dev/null +++ b/pytest.ini @@ -0,0 +1,15 @@ +[pytest] +asyncio_mode = auto +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +markers = + unit: Unit tests + integration: Integration tests + slow: Slow running tests +addopts = + --strict-markers + --strict-config + --showlocals + -v diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..8eb411b --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,7 @@ +# Development dependencies +pytest==8.3.3 +pytest-asyncio==0.23.5 +pytest-cov==4.1.0 +pytest-mock==3.12.0 +httpx +coverage[toml]==7.4.1 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..55302ad --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Test suite for SBP Backend.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..378c89d --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,116 @@ +"""Shared test fixtures and configuration.""" + +from __future__ import annotations + +import os +from collections.abc import AsyncGenerator, Generator +from unittest.mock import AsyncMock, MagicMock + +import pytest +from fastapi.testclient import TestClient +from httpx import AsyncClient + +# Set test environment variables before importing app +os.environ["ALLOWED_ORIGINS"] = "http://localhost:3000,http://localhost:4200" +os.environ["SEQERA_API_URL"] = "https://api.seqera.test" +os.environ["SEQERA_ACCESS_TOKEN"] = "test_token_12345" +os.environ["WORK_SPACE"] = "test_workspace_id" +os.environ["COMPUTE_ID"] = "test_compute_env_id" +os.environ["WORK_DIR"] = "/test/work/dir" + +from app.main import create_app + + +@pytest.fixture +def app(): + """Create a FastAPI app instance for testing.""" + return create_app() + + +@pytest.fixture +def client(app) -> Generator[TestClient, None, None]: + """Create a test client for the FastAPI app.""" + with TestClient(app) as test_client: + yield test_client + + +@pytest.fixture +async def async_client(app) -> AsyncGenerator[AsyncClient, None]: + """Create an async test client for the FastAPI app.""" + async with AsyncClient(app=app, base_url="http://test") as ac: + yield ac + + +@pytest.fixture +def mock_httpx_response(): + """Create a mock httpx Response.""" + + def _create_response( + status_code: int = 200, + json_data: dict | None = None, + text: str = "", + is_error: bool = False, + ): + response = MagicMock() + response.status_code = status_code + response.is_error = is_error + response.text = text + if json_data: + response.json.return_value = json_data + return response + + return _create_response + + +@pytest.fixture +def mock_async_client(mock_httpx_response): + """Create a mock async HTTP client.""" + mock_client = AsyncMock() + mock_client.post = AsyncMock() + mock_client.get = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock() + return mock_client + + +@pytest.fixture +def sample_workflow_launch_form(): + """Sample workflow launch form data.""" + return { + "pipeline": "https://github.com/nextflow-io/hello", + "revision": "main", + "configProfiles": ["singularity"], + "runName": "test-workflow-run", + "paramsText": "test_param: value", + } + + +@pytest.fixture +def sample_form_data(): + """Sample form data for dataset creation.""" + return { + "sample_name": "test_sample", + "input_file": "/path/to/file.txt", + "parameter1": "value1", + "parameter2": 42, + } + + +@pytest.fixture +def sample_seqera_dataset_response(): + """Sample Seqera dataset creation response.""" + return { + "id": "dataset_123abc", + "name": "test-dataset", + "description": "Test dataset", + "workspaceId": "test_workspace_id", + } + + +@pytest.fixture +def sample_seqera_launch_response(): + """Sample Seqera workflow launch response.""" + return { + "workflowId": "workflow_xyz789", + "status": "submitted", + } diff --git a/tests/test_additional_coverage.py b/tests/test_additional_coverage.py new file mode 100644 index 0000000..6f266a1 --- /dev/null +++ b/tests/test_additional_coverage.py @@ -0,0 +1,60 @@ +"""Additional tests to increase coverage.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +from app.routes.workflows import get_details, upload_dataset +from app.schemas.workflows import DatasetUploadRequest +from app.services.datasets import DatasetUploadResult + + +class TestUploadDataset: + """Tests for dataset upload endpoint.""" + + @patch("app.routes.workflows.upload_dataset_to_seqera") + @patch("app.routes.workflows.create_seqera_dataset") + async def test_upload_dataset_success(self, mock_create, mock_upload): + """Test successful dataset upload.""" + # Mock dataset creation + mock_create_result = AsyncMock() + mock_create_result.dataset_id = "dataset_123" + mock_create_result.raw_response = {"id": "dataset_123"} + mock_create.return_value = mock_create_result + + # Mock upload + mock_upload_result = DatasetUploadResult( + success=True, + dataset_id="dataset_123", + message="Uploaded", + ) + mock_upload.return_value = mock_upload_result + + # Create request + request = DatasetUploadRequest( + formData={"sample": "test"}, + datasetName="test-dataset", + ) + + # Execute + response = await upload_dataset(request) + + # Verify + assert response.success is True + assert response.datasetId == "dataset_123" + mock_create.assert_called_once() + mock_upload.assert_called_once() + + +class TestGetDetails: + """Tests for get details endpoint.""" + + async def test_get_details_returns_placeholder(self): + """Test that get_details returns proper placeholder data.""" + result = await get_details("run_abc123") + + assert result.id == "run_abc123" + assert result.status == "UNKNOWN" + assert result.runName == "" + assert isinstance(result.configFiles, list) + assert isinstance(result.params, dict) diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..0da91ec --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,91 @@ +"""Tests for the main FastAPI application.""" + +from __future__ import annotations + +import os +from unittest.mock import patch + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + + +def test_create_app_success(): + """Test that create_app creates a valid FastAPI instance.""" + from app.main import create_app + + app = create_app() + + assert isinstance(app, FastAPI) + assert app.title == "SBP Portal Backend" + assert app.version == "1.0.0" + + +def test_create_app_missing_allowed_origins(): + """Test that create_app raises error when ALLOWED_ORIGINS is missing.""" + from app.main import create_app + + with patch.dict(os.environ, {}, clear=True): + with pytest.raises(RuntimeError, match="ALLOWED_ORIGINS environment variable is required"): + create_app() + + +def test_health_endpoint(client: TestClient): + """Test the /health endpoint returns correct response.""" + response = client.get("/health") + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "ok" + assert "timestamp" in data + + +def test_cors_middleware_configured(app: FastAPI): + """Test that CORS middleware is properly configured.""" + # Check that middleware is added + middleware_found = False + for middleware in app.user_middleware: + if "CORSMiddleware" in str(middleware): + middleware_found = True + break + + assert middleware_found, "CORS middleware should be configured" + + +def test_workflow_router_included(app: FastAPI): + """Test that workflow router is included with correct prefix.""" + route_paths = [route.path for route in app.routes] + + assert "/api/workflows/launch" in route_paths + assert "/api/workflows/runs" in route_paths + + +def test_exception_handler(client: TestClient): + """Test that global exception handler works.""" + # Try to access a non-existent endpoint + response = client.get("/nonexistent") + + # Should return 404 but not crash + assert response.status_code == 404 + + +def test_cors_allowed_origins_parsing(): + """Test that ALLOWED_ORIGINS is correctly parsed from environment.""" + from app.main import create_app + + with patch.dict( + os.environ, {"ALLOWED_ORIGINS": "http://localhost:3000, http://localhost:4200"} + ): + app = create_app() + assert app is not None + + +def test_cors_allowed_origins_with_empty_values(): + """Test that empty values in ALLOWED_ORIGINS are filtered out.""" + from app.main import create_app + + with patch.dict( + os.environ, {"ALLOWED_ORIGINS": "http://localhost:3000,, , http://localhost:4200"} + ): + app = create_app() + assert app is not None diff --git a/tests/test_routes_workflows.py b/tests/test_routes_workflows.py new file mode 100644 index 0000000..81bd65a --- /dev/null +++ b/tests/test_routes_workflows.py @@ -0,0 +1,217 @@ +"""Tests for workflow routes.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +from fastapi.testclient import TestClient + +from app.services.seqera import ( + SeqeraConfigurationError, + SeqeraLaunchResult, + SeqeraServiceError, +) + + +class TestLaunchWorkflow: + """Tests for POST /api/workflows/launch endpoint.""" + + @patch("app.routes.workflows.launch_seqera_workflow") + async def test_launch_success_without_dataset(self, mock_launch, client: TestClient): + """Test successful workflow launch without dataset.""" + mock_launch.return_value = SeqeraLaunchResult( + workflow_id="wf_123", + status="submitted", + message="Success", + ) + + payload = { + "launch": { + "pipeline": "https://github.com/test/repo", + "runName": "test-run", + } + } + + response = client.post("/api/workflows/launch", json=payload) + + assert response.status_code == 201 + data = response.json() + assert data["runId"] == "wf_123" + assert data["status"] == "submitted" + assert "submitTime" in data + + @patch("app.routes.workflows.upload_dataset_to_seqera") + @patch("app.routes.workflows.create_seqera_dataset") + @patch("app.routes.workflows.launch_seqera_workflow") + async def test_launch_success_with_form_data( + self, mock_launch, mock_create_dataset, mock_upload, client: TestClient + ): + """Test successful workflow launch with form data.""" + # Mock dataset creation + mock_create_result = AsyncMock() + mock_create_result.dataset_id = "dataset_456" + mock_create_dataset.return_value = mock_create_result + + # Mock dataset upload + mock_upload.return_value = None + + # Mock workflow launch + mock_launch.return_value = SeqeraLaunchResult( + workflow_id="wf_789", + status="submitted", + ) + + payload = { + "launch": { + "pipeline": "https://github.com/test/repo", + "runName": "test-with-data", + }, + "formData": { + "sample": "test", + "input": "/path/file.txt", + }, + } + + response = client.post("/api/workflows/launch", json=payload) + + assert response.status_code == 201 + data = response.json() + assert data["runId"] == "wf_789" + + # Verify dataset creation was called + mock_create_dataset.assert_called_once() + mock_upload.assert_called_once() + + @patch("app.routes.workflows.launch_seqera_workflow") + async def test_launch_configuration_error(self, mock_launch, client: TestClient): + """Test launch with configuration error.""" + mock_launch.side_effect = SeqeraConfigurationError("Missing API token") + + payload = { + "launch": { + "pipeline": "https://github.com/test/repo", + } + } + + response = client.post("/api/workflows/launch", json=payload) + + assert response.status_code == 500 + assert "Missing API token" in response.json()["detail"] + + @patch("app.routes.workflows.launch_seqera_workflow") + async def test_launch_service_error(self, mock_launch, client: TestClient): + """Test launch with Seqera service error.""" + mock_launch.side_effect = SeqeraServiceError("API returned 502") + + payload = { + "launch": { + "pipeline": "https://github.com/test/repo", + } + } + + response = client.post("/api/workflows/launch", json=payload) + + assert response.status_code == 502 + assert "API returned 502" in response.json()["detail"] + + def test_launch_invalid_payload(self, client: TestClient): + """Test launch with invalid payload.""" + payload = { + "launch": { + "pipeline": "", # Empty pipeline + } + } + + response = client.post("/api/workflows/launch", json=payload) + + assert response.status_code == 422 # Validation error + + +class TestCancelWorkflow: + """Tests for POST /api/workflows/{run_id}/cancel endpoint.""" + + def test_cancel_workflow_success(self, client: TestClient): + """Test successful workflow cancellation.""" + response = client.post("/api/workflows/run_123/cancel") + + assert response.status_code == 200 + data = response.json() + assert data["runId"] == "run_123" + assert data["status"] == "cancelled" + assert "message" in data + + +class TestListRuns: + """Tests for GET /api/workflows/runs endpoint.""" + + def test_list_runs_default_params(self, client: TestClient): + """Test listing runs with default parameters.""" + response = client.get("/api/workflows/runs") + + assert response.status_code == 200 + data = response.json() + assert "runs" in data + assert data["limit"] == 50 + assert data["offset"] == 0 + assert data["total"] == 0 + + def test_list_runs_with_filters(self, client: TestClient): + """Test listing runs with filter parameters.""" + response = client.get( + "/api/workflows/runs", + params={ + "status": "running", + "workspace": "test_ws", + "limit": 10, + "offset": 5, + }, + ) + + assert response.status_code == 200 + data = response.json() + assert data["limit"] == 10 + assert data["offset"] == 5 + + def test_list_runs_limit_validation(self, client: TestClient): + """Test that limit must be between 1 and 200.""" + # Test limit too high + response = client.get("/api/workflows/runs", params={"limit": 300}) + assert response.status_code == 422 + + # Test limit too low + response = client.get("/api/workflows/runs", params={"limit": 0}) + assert response.status_code == 422 + + def test_list_runs_offset_validation(self, client: TestClient): + """Test that offset must be non-negative.""" + response = client.get("/api/workflows/runs", params={"offset": -1}) + assert response.status_code == 422 + + +class TestGetLogs: + """Tests for GET /api/workflows/{run_id}/logs endpoint.""" + + def test_get_logs_success(self, client: TestClient): + """Test successful log retrieval.""" + response = client.get("/api/workflows/run_123/logs") + + assert response.status_code == 200 + data = response.json() + assert "entries" in data + assert "truncated" in data + assert "pending" in data + assert isinstance(data["entries"], list) + + +class TestGetDetails: + """Tests for GET /api/workflows/{run_id}/details endpoint.""" + + def test_get_details_success(self, client: TestClient): + """Test successful details retrieval.""" + response = client.get("/api/workflows/run_123/details") + + assert response.status_code == 200 + data = response.json() + assert data["id"] == "run_123" + assert "status" in data + assert "runName" in data diff --git a/tests/test_schemas.py b/tests/test_schemas.py new file mode 100644 index 0000000..0310e64 --- /dev/null +++ b/tests/test_schemas.py @@ -0,0 +1,256 @@ +"""Tests for Pydantic schemas.""" + +from __future__ import annotations + +from datetime import datetime + +import pytest +from pydantic import ValidationError + +from app.schemas.workflows import ( + CancelWorkflowResponse, + LaunchDetails, + LaunchLogs, + ListRunsResponse, + RunInfo, + WorkflowLaunchForm, + WorkflowLaunchPayload, + WorkflowLaunchResponse, +) + + +class TestWorkflowLaunchForm: + """Tests for WorkflowLaunchForm schema.""" + + def test_valid_minimal_form(self): + """Test WorkflowLaunchForm with minimal valid data.""" + form = WorkflowLaunchForm(pipeline="https://github.com/test/repo") + + assert form.pipeline == "https://github.com/test/repo" + assert form.revision is None + assert form.configProfiles == [] + assert form.runName is None + assert form.paramsText is None + + def test_valid_complete_form(self): + """Test WorkflowLaunchForm with all fields.""" + form = WorkflowLaunchForm( + pipeline="https://github.com/test/repo", + revision="main", + configProfiles=["docker", "test"], + runName="my-test-run", + paramsText="param1: value1\nparam2: value2", + ) + + assert form.pipeline == "https://github.com/test/repo" + assert form.revision == "main" + assert form.configProfiles == ["docker", "test"] + assert form.runName == "my-test-run" + assert "param1" in form.paramsText + + def test_pipeline_required(self): + """Test that pipeline field is required.""" + with pytest.raises(ValidationError) as exc_info: + WorkflowLaunchForm() + + errors = exc_info.value.errors() + assert any(error["loc"] == ("pipeline",) for error in errors) + + def test_pipeline_cannot_be_empty(self): + """Test that pipeline cannot be empty string.""" + with pytest.raises(ValidationError, match="pipeline is required"): + WorkflowLaunchForm(pipeline="") + + def test_pipeline_whitespace_stripped(self): + """Test that pipeline whitespace is stripped.""" + form = WorkflowLaunchForm(pipeline=" https://github.com/test/repo ") + assert form.pipeline == "https://github.com/test/repo" + + def test_extra_fields_forbidden(self): + """Test that extra fields are not allowed.""" + with pytest.raises(ValidationError): + WorkflowLaunchForm(pipeline="https://github.com/test/repo", extraField="not allowed") + + +class TestWorkflowLaunchPayload: + """Tests for WorkflowLaunchPayload schema.""" + + def test_valid_payload_with_launch_only(self): + """Test payload with only launch data.""" + payload = WorkflowLaunchPayload(launch={"pipeline": "https://github.com/test/repo"}) + + assert payload.launch.pipeline == "https://github.com/test/repo" + assert payload.datasetId is None + assert payload.formData is None + + def test_valid_payload_with_dataset_id(self): + """Test payload with dataset ID.""" + payload = WorkflowLaunchPayload( + launch={"pipeline": "https://github.com/test/repo"}, + datasetId="dataset_123", + ) + + assert payload.datasetId == "dataset_123" + + def test_valid_payload_with_form_data(self): + """Test payload with form data.""" + form_data = { + "sample": "test", + "input": "/path/to/file", + "param": 42, + } + payload = WorkflowLaunchPayload( + launch={"pipeline": "https://github.com/test/repo"}, + formData=form_data, + ) + + assert payload.formData == form_data + + def test_extra_fields_forbidden(self): + """Test that extra fields are not allowed.""" + with pytest.raises(ValidationError): + WorkflowLaunchPayload( + launch={"pipeline": "https://github.com/test/repo"}, unknownField="value" + ) + + +class TestWorkflowLaunchResponse: + """Tests for WorkflowLaunchResponse schema.""" + + def test_valid_response(self): + """Test creating a valid launch response.""" + response = WorkflowLaunchResponse( + message="Workflow launched", + runId="run_123", + status="submitted", + submitTime=datetime(2024, 1, 1, 12, 0, 0), + ) + + assert response.message == "Workflow launched" + assert response.runId == "run_123" + assert response.status == "submitted" + assert response.submitTime.year == 2024 + + +class TestCancelWorkflowResponse: + """Tests for CancelWorkflowResponse schema.""" + + def test_valid_cancel_response(self): + """Test creating a valid cancel response.""" + response = CancelWorkflowResponse( + message="Cancelled", + runId="run_123", + status="cancelled", + ) + + assert response.message == "Cancelled" + assert response.runId == "run_123" + assert response.status == "cancelled" + + +class TestRunInfo: + """Tests for RunInfo schema.""" + + def test_valid_run_info(self): + """Test creating valid run info.""" + run_info = RunInfo( + id="run_123", + run="test-run", + workflow="test-workflow", + status="running", + date="2024-01-01", + cancel="false", + ) + + assert run_info.id == "run_123" + assert run_info.status == "running" + + +class TestListRunsResponse: + """Tests for ListRunsResponse schema.""" + + def test_empty_runs_list(self): + """Test response with empty runs list.""" + response = ListRunsResponse( + runs=[], + total=0, + limit=50, + offset=0, + ) + + assert response.runs == [] + assert response.total == 0 + + def test_runs_list_with_data(self): + """Test response with run data.""" + run_info = RunInfo( + id="run_123", + run="test", + workflow="wf", + status="done", + date="2024-01-01", + cancel="false", + ) + response = ListRunsResponse( + runs=[run_info], + total=1, + limit=50, + offset=0, + ) + + assert len(response.runs) == 1 + assert response.total == 1 + + +class TestLaunchLogs: + """Tests for LaunchLogs schema.""" + + def test_valid_logs(self): + """Test creating valid launch logs.""" + logs = LaunchLogs( + truncated=False, + entries=["log line 1", "log line 2"], + rewindToken="token1", + forwardToken="token2", + pending=False, + message="Logs retrieved", + ) + + assert len(logs.entries) == 2 + assert logs.truncated is False + + +class TestLaunchDetails: + """Tests for LaunchDetails schema.""" + + def test_valid_details(self): + """Test creating valid launch details.""" + details = LaunchDetails( + requiresAttention=False, + status="completed", + ownerId=123, + repository="https://github.com/test/repo", + id="launch_123", + submit="2024-01-01T12:00:00", + start="2024-01-01T12:01:00", + complete="2024-01-01T12:10:00", + dateCreated="2024-01-01T12:00:00", + lastUpdated="2024-01-01T12:10:00", + runName="test-run", + sessionId="session_123", + profile="standard", + workDir="/work", + commitId="abc123", + userName="testuser", + scriptId="script_123", + revision="main", + commandLine="nextflow run", + projectName="test-project", + scriptName="main.nf", + launchId="launch_123", + configFiles=["nextflow.config"], + params={"test": "value"}, + ) + + assert details.status == "completed" + assert details.ownerId == 123 diff --git a/tests/test_services_datasets.py b/tests/test_services_datasets.py new file mode 100644 index 0000000..d16df22 --- /dev/null +++ b/tests/test_services_datasets.py @@ -0,0 +1,328 @@ +"""Tests for dataset service.""" + +from __future__ import annotations + +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from app.services.datasets import ( + DatasetCreationResult, + DatasetUploadResult, + SeqeraServiceError, + _stringify_field, + convert_form_data_to_csv, + create_seqera_dataset, + upload_dataset_to_seqera, +) + + +class TestStringifyField: + """Tests for _stringify_field helper.""" + + def test_stringify_none(self): + """Test stringifying None returns empty string.""" + assert _stringify_field(None) == "" + + def test_stringify_string(self): + """Test stringifying a string.""" + assert _stringify_field("hello") == "hello" + + def test_stringify_number(self): + """Test stringifying a number.""" + assert _stringify_field(42) == "42" + assert _stringify_field(3.14) == "3.14" + + def test_stringify_list(self): + """Test stringifying a list.""" + assert _stringify_field(["a", "b", "c"]) == "a;b;c" + + def test_stringify_list_with_none(self): + """Test stringifying a list containing None.""" + assert _stringify_field(["a", None, "c"]) == "a;;c" + + def test_stringify_dict(self): + """Test stringifying a dict as JSON.""" + result = _stringify_field({"key": "value", "num": 42}) + parsed = json.loads(result) + assert parsed["key"] == "value" + assert parsed["num"] == 42 + + def test_stringify_boolean(self): + """Test stringifying boolean.""" + assert _stringify_field(True) == "True" + assert _stringify_field(False) == "False" + + +class TestConvertFormDataToCsv: + """Tests for convert_form_data_to_csv function.""" + + def test_convert_simple_data(self): + """Test converting simple form data to CSV.""" + form_data = { + "name": "test", + "value": "123", + "flag": "true", + } + + csv_output = convert_form_data_to_csv(form_data) + + lines = csv_output.strip().split("\n") + assert len(lines) == 2 # header + 1 data row + assert "name" in lines[0] + assert "value" in lines[0] + assert "flag" in lines[0] + assert "test" in lines[1] + assert "123" in lines[1] + + def test_convert_with_numbers(self): + """Test converting data with numeric values.""" + form_data = { + "sample_id": "sample_001", + "count": 42, + "ratio": 3.14, + } + + csv_output = convert_form_data_to_csv(form_data) + + assert "42" in csv_output + assert "3.14" in csv_output + + def test_convert_with_list(self): + """Test converting data with list values.""" + form_data = { + "sample": "test", + "files": ["file1.txt", "file2.txt"], + } + + csv_output = convert_form_data_to_csv(form_data) + + assert "file1.txt;file2.txt" in csv_output + + def test_convert_with_dict(self): + """Test converting data with dict values.""" + form_data = { + "sample": "test", + "metadata": {"type": "experiment", "id": 1}, + } + + csv_output = convert_form_data_to_csv(form_data) + + assert "metadata" in csv_output + assert "type" in csv_output or "experiment" in csv_output + + def test_convert_empty_data_raises_error(self): + """Test that empty form data raises ValueError.""" + with pytest.raises(ValueError, match="formData cannot be empty"): + convert_form_data_to_csv({}) + + def test_convert_with_none_values(self): + """Test converting data with None values.""" + form_data = { + "sample": "test", + "optional_field": None, + } + + csv_output = convert_form_data_to_csv(form_data) + + lines = csv_output.strip().split("\n") + assert len(lines) == 2 + + +class TestCreateSeqeraDataset: + """Tests for create_seqera_dataset function.""" + + @patch("app.services.datasets.httpx.AsyncClient") + async def test_create_dataset_success(self, mock_client_class): + """Test successful dataset creation.""" + mock_response = MagicMock() + mock_response.is_error = False + mock_response.json.return_value = { + "dataset": { + "id": "dataset_123", + "name": "test-dataset", + } + } + mock_response.status_code = 200 + mock_response.text = "" + + mock_client = AsyncMock() + mock_client.post = AsyncMock(return_value=mock_response) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + mock_client_class.return_value = mock_client + + result = await create_seqera_dataset(name="test-dataset", description="Test description") + + assert isinstance(result, DatasetCreationResult) + assert result.dataset_id == "dataset_123" + assert result.raw_response["dataset"]["name"] == "test-dataset" + + @patch("app.services.datasets.httpx.AsyncClient") + async def test_create_dataset_default_name(self, mock_client_class): + """Test dataset creation with auto-generated name.""" + mock_response = MagicMock() + mock_response.is_error = False + mock_response.json.return_value = { + "dataset": { + "id": "dataset_456", + "name": "dataset-1234567890", + } + } + mock_response.status_code = 200 + mock_response.text = "" + + mock_client = AsyncMock() + mock_client.post = AsyncMock(return_value=mock_response) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + mock_client_class.return_value = mock_client + + result = await create_seqera_dataset() + + assert result.dataset_id == "dataset_456" + # Verify a name was generated + mock_client.post.assert_called_once() + + @patch("app.services.datasets.httpx.AsyncClient") + async def test_create_dataset_api_error(self, mock_client_class): + """Test dataset creation with API error.""" + mock_response = AsyncMock() + mock_response.is_error = True + mock_response.status_code = 400 + mock_response.text = "Bad request" + + mock_client = AsyncMock() + mock_client.post = AsyncMock(return_value=mock_response) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + mock_client_class.return_value = mock_client + + with pytest.raises(SeqeraServiceError, match="400"): + await create_seqera_dataset(name="test") + + @patch("app.services.datasets.httpx.AsyncClient") + async def test_create_dataset_missing_id_in_response(self, mock_client_class): + """Test handling when response is missing dataset ID.""" + mock_response = MagicMock() + mock_response.is_error = False + mock_response.json.return_value = { + "dataset": { + "name": "test-dataset", + # Missing "id" field + } + } + mock_response.status_code = 200 + mock_response.text = "{}" + + mock_client = AsyncMock() + mock_client.post = AsyncMock(return_value=mock_response) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + mock_client_class.return_value = mock_client + + with pytest.raises(SeqeraServiceError, match="response lacked dataset id"): + await create_seqera_dataset(name="test") + + +class TestUploadDatasetToSeqera: + """Tests for upload_dataset_to_seqera function.""" + + @patch("app.services.datasets.httpx.AsyncClient") + async def test_upload_success(self, mock_client_class): + """Test successful dataset upload.""" + mock_response = MagicMock() + mock_response.is_error = False + mock_response.status_code = 200 + mock_response.text = "" + mock_response.json.return_value = { + "version": {"datasetId": "dataset_789"}, + "message": "Upload successful", + } + + mock_client = AsyncMock() + mock_client.post = AsyncMock(return_value=mock_response) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + mock_client_class.return_value = mock_client + + form_data = { + "sample": "test_sample", + "input": "/path/file.txt", + } + + result = await upload_dataset_to_seqera(dataset_id="dataset_789", form_data=form_data) + + assert isinstance(result, DatasetUploadResult) + assert result.success is True + assert result.dataset_id == "dataset_789" + + @patch("app.services.datasets.httpx.AsyncClient") + async def test_upload_creates_csv(self, mock_client_class): + """Test that upload creates proper CSV.""" + mock_response = MagicMock() + mock_response.is_error = False + mock_response.status_code = 200 + mock_response.text = "" + + mock_client = AsyncMock() + mock_client.post = AsyncMock(return_value=mock_response) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + mock_client_class.return_value = mock_client + + form_data = { + "col1": "value1", + "col2": "value2", + } + + await upload_dataset_to_seqera("dataset_123", form_data) + + # Verify POST was called with files + call_args = mock_client.post.call_args + assert "files" in call_args[1] + + @patch("app.services.datasets.httpx.AsyncClient") + async def test_upload_api_error(self, mock_client_class): + """Test upload with API error.""" + mock_response = AsyncMock() + mock_response.is_error = True + mock_response.status_code = 500 + mock_response.text = "Server error" + + mock_client = AsyncMock() + mock_client.post = AsyncMock(return_value=mock_response) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + mock_client_class.return_value = mock_client + + form_data = {"sample": "test"} + + with pytest.raises(SeqeraServiceError, match="500"): + await upload_dataset_to_seqera("dataset_123", form_data) + + @patch("app.services.datasets.httpx.AsyncClient") + async def test_upload_with_complex_data(self, mock_client_class): + """Test upload with complex form data.""" + mock_response = MagicMock() + mock_response.is_error = False + mock_response.status_code = 200 + mock_response.text = "" + + mock_client = AsyncMock() + mock_client.post = AsyncMock(return_value=mock_response) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + mock_client_class.return_value = mock_client + + form_data = { + "sample": "test", + "files": ["file1.txt", "file2.txt"], + "count": 42, + "metadata": {"type": "test"}, + } + + result = await upload_dataset_to_seqera("dataset_123", form_data) + + assert result.success is True diff --git a/tests/test_services_seqera.py b/tests/test_services_seqera.py new file mode 100644 index 0000000..4e99ba0 --- /dev/null +++ b/tests/test_services_seqera.py @@ -0,0 +1,237 @@ +"""Tests for Seqera service.""" + +from __future__ import annotations + +import os +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from app.schemas.workflows import WorkflowLaunchForm +from app.services.seqera import ( + SeqeraConfigurationError, + SeqeraLaunchResult, + SeqeraServiceError, + _get_required_env, + launch_seqera_workflow, +) + + +class TestGetRequiredEnv: + """Tests for _get_required_env helper.""" + + def test_get_existing_env_variable(self): + """Test getting an existing environment variable.""" + result = _get_required_env("SEQERA_API_URL") + assert result == "https://api.seqera.test" + + def test_get_missing_env_variable(self): + """Test that missing env variable raises error.""" + with pytest.raises(SeqeraConfigurationError, match="MISSING_VAR"): + _get_required_env("MISSING_VAR") + + +class TestLaunchSeqeraWorkflow: + """Tests for launch_seqera_workflow function.""" + + @patch("app.services.seqera.httpx.AsyncClient") + async def test_launch_success_minimal(self, mock_client_class): + """Test successful workflow launch with minimal parameters.""" + # Setup mock + mock_response = MagicMock() + mock_response.is_error = False + mock_response.json.return_value = { + "workflowId": "wf_test_123", + } + mock_response.reason_phrase = "OK" + + mock_client = AsyncMock() + mock_client.post = AsyncMock(return_value=mock_response) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + mock_client_class.return_value = mock_client + + # Create form + form = WorkflowLaunchForm( + pipeline="https://github.com/test/repo", + ) + + # Execute + result = await launch_seqera_workflow(form) + + # Verify + assert isinstance(result, SeqeraLaunchResult) + assert result.workflow_id == "wf_test_123" + assert result.status == "submitted" + + # Verify API call + mock_client.post.assert_called_once() + call_args = mock_client.post.call_args + assert "https://api.seqera.test/workflow/launch" in call_args[0][0] + + @patch("app.services.seqera.httpx.AsyncClient") + async def test_launch_success_with_all_params(self, mock_client_class): + """Test successful launch with all parameters.""" + mock_response = MagicMock() + mock_response.is_error = False + mock_response.json.return_value = { + "workflowId": "wf_full_456", + } + mock_response.reason_phrase = "OK" + + mock_client = AsyncMock() + mock_client.post = AsyncMock(return_value=mock_response) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + mock_client_class.return_value = mock_client + + form = WorkflowLaunchForm( + pipeline="https://github.com/test/repo", + revision="main", + runName="my-custom-run", + configProfiles=["docker", "test"], + paramsText="custom_param: value", + ) + + result = await launch_seqera_workflow(form, dataset_id="dataset_789") + + assert result.workflow_id == "wf_full_456" + + # Verify the payload includes dataset + call_args = mock_client.post.call_args + payload = call_args[1]["json"] + assert "datasetIds" in payload["launch"] + assert "dataset_789" in payload["launch"]["datasetIds"] + + @patch("app.services.seqera.httpx.AsyncClient") + async def test_launch_includes_default_params(self, mock_client_class): + """Test that default parameters are included.""" + mock_response = MagicMock() + mock_response.is_error = False + mock_response.json.return_value = {"workflowId": "wf_123"} + mock_response.reason_phrase = "OK" + + mock_client = MagicMock() + mock_client.post = AsyncMock(return_value=mock_response) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + mock_client_class.return_value = mock_client + + form = WorkflowLaunchForm(pipeline="https://github.com/test/repo") + + await launch_seqera_workflow(form) + + call_args = mock_client.post.call_args + payload = call_args[1]["json"] + params_text = payload["launch"]["paramsText"] + + # Check default params are included + assert "use_dgxa100: false" in params_text + assert 'project: "za08"' in params_text + assert "outdir:" in params_text + + @patch("app.services.seqera.httpx.AsyncClient") + async def test_launch_with_dataset_adds_input_url(self, mock_client_class): + """Test that providing a dataset ID adds it to launch payload.""" + mock_response = MagicMock() + mock_response.is_error = False + mock_response.json.return_value = {"workflowId": "wf_dataset_999"} + mock_response.reason_phrase = "OK" + + mock_client = MagicMock() + mock_client.post = AsyncMock(return_value=mock_response) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + mock_client_class.return_value = mock_client + + form = WorkflowLaunchForm(pipeline="https://github.com/test/repo") + + await launch_seqera_workflow(form, dataset_id="ds_abc") + + call_args = mock_client.post.call_args + payload = call_args[1]["json"] + params_text = payload["launch"]["paramsText"] + + assert "input:" in params_text + assert "ds_abc" in params_text + assert "samplesheet.csv" in params_text + + @patch("app.services.seqera.httpx.AsyncClient") + async def test_launch_api_error_response(self, mock_client_class): + """Test handling of API error response.""" + mock_response = AsyncMock() + mock_response.is_error = True + mock_response.status_code = 400 + mock_response.text = "Invalid request" + mock_response.reason_phrase = "Bad Request" + + mock_client = AsyncMock() + mock_client.post = AsyncMock(return_value=mock_response) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + mock_client_class.return_value = mock_client + + form = WorkflowLaunchForm(pipeline="https://github.com/test/repo") + + with pytest.raises(SeqeraServiceError, match="400"): + await launch_seqera_workflow(form) + + @patch("app.services.seqera.httpx.AsyncClient") + async def test_launch_missing_workflow_id_in_response(self, mock_client_class): + """Test error handling when API response lacks workflowId.""" + mock_response = MagicMock() + mock_response.is_error = False + mock_response.json.return_value = {"status": "success"} + mock_response.reason_phrase = "OK" + + mock_client = MagicMock() + mock_client.post = AsyncMock(return_value=mock_response) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + mock_client_class.return_value = mock_client + + form = WorkflowLaunchForm(pipeline="https://github.com/test/repo") + + with pytest.raises(SeqeraServiceError, match="workflowId"): + await launch_seqera_workflow(form) + + def test_launch_missing_env_vars(self): + """Test that missing environment variables raise error.""" + form = WorkflowLaunchForm(pipeline="https://github.com/test/repo") + + with patch.dict(os.environ, {}, clear=True): + with pytest.raises(SeqeraConfigurationError): + # This will fail synchronously when trying to get env vars + import asyncio + + asyncio.run(launch_seqera_workflow(form)) + + @patch("app.services.seqera.httpx.AsyncClient") + async def test_launch_with_custom_params_text(self, mock_client_class): + """Test launch with custom paramsText.""" + mock_response = MagicMock() + mock_response.is_error = False + mock_response.json.return_value = {"workflowId": "wf_params_xyz"} + mock_response.reason_phrase = "OK" + + mock_client = MagicMock() + mock_client.post = AsyncMock(return_value=mock_response) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + mock_client_class.return_value = mock_client + + form = WorkflowLaunchForm( + pipeline="https://github.com/test/repo", + paramsText="my_custom_param: 42\nanother_param: test", + ) + + await launch_seqera_workflow(form) + + call_args = mock_client.post.call_args + payload = call_args[1]["json"] + params_text = payload["launch"]["paramsText"] + + # Should contain both default and custom params + assert "use_dgxa100: false" in params_text # default + assert "my_custom_param: 42" in params_text # custom + assert "another_param: test" in params_text # custom