From 15d24b762eb8a29b2963c3d16272db24b54c8d7c Mon Sep 17 00:00:00 2001 From: Raymond Christopher Date: Sat, 3 Jan 2026 01:13:42 +0700 Subject: [PATCH 1/3] chore: update environment configuration and dependencies - Set DEBUG to false in .env.example for production readiness. - Removed unnecessary database and LLM configuration options from .env.example. - Updated the Typer dependency in pyproject.toml and uv.lock to remove the 'all' extra, simplifying the installation process. - Improved developer quickstart instructions for installing dependencies and running the application. - Enhanced PR-002 task CRUD API documentation with additional details on response shapes and pagination. These changes aim to streamline configuration, clarify setup instructions, and improve API documentation. --- .env.example | 23 +++++++------------ .../pr-specs/PR-002-task-crud-api.md | 16 ++++++++++--- docs/DEVELOPER_QUICKSTART.md | 15 ++++++++---- pyproject.toml | 2 +- uv.lock | 2 +- 5 files changed, 34 insertions(+), 24 deletions(-) diff --git a/.env.example b/.env.example index aede14f..caf8309 100644 --- a/.env.example +++ b/.env.example @@ -1,21 +1,14 @@ +# TaskGenie Configuration +# Copy this file to .env and edit as needed + +# App metadata APP_NAME=TaskGenie APP_VERSION=0.1.0 -DEBUG=true +DEBUG=false +# Server HOST=127.0.0.1 PORT=8080 -DATABASE_URL=sqlite+aiosqlite:///./data/taskgenie.db - -LLM_PROVIDER=openrouter -LLM_API_KEY= -LLM_MODEL=anthropic/claude-3-haiku - -GMAIL_ENABLED=false -GMAIL_CREDENTIALS_PATH= - -GITHUB_TOKEN= -GITHUB_USERNAME= - -NOTIFICATIONS_ENABLED=true -NOTIFICATION_SCHEDULE=["24h", "6h"] +# Database (optional - defaults to ~/.taskgenie/data/taskgenie.db) +# DATABASE_URL=sqlite+aiosqlite:///./data/taskgenie.db diff --git a/docs/02-implementation/pr-specs/PR-002-task-crud-api.md b/docs/02-implementation/pr-specs/PR-002-task-crud-api.md index b35cda5..99789ae 100644 --- a/docs/02-implementation/pr-specs/PR-002-task-crud-api.md +++ b/docs/02-implementation/pr-specs/PR-002-task-crud-api.md @@ -2,7 +2,7 @@ **Status:** Spec Only **Depends on:** PR-001 -**Last Reviewed:** 2025-12-30 +**Last Reviewed:** 2026-01-02 ## Goal @@ -36,6 +36,7 @@ filtering and pagination. - REST endpoints for task CRUD. - Basic filters (status, priority, due_before, due_after) and pagination. - Input validation and consistent error shape. +- Task responses include `attachments: []` until PR-004 lands (no attachment joins). ### Out @@ -51,6 +52,8 @@ filtering and pagination. - Validate enums and required fields at the API boundary. - Standardize error responses (404 and validation). - Publish OpenAPI schemas with examples aligned to `API_REFERENCE.md`. +- List responses include `total`, `page`, and `page_size` (page derived from limit/offset). +- Default ordering: `created_at DESC, id ASC` for stable pagination. ## User Stories @@ -73,7 +76,7 @@ filtering and pagination. - `Task` fields: id (UUID string), title, description, status, priority, eta, created_at, updated_at, tags, metadata. -- Index on `(status, eta)` to support "what is due" queries. +- Use existing indexes from PR-001 (`status`, `priority`, `eta`, `created_at`); no new migrations. ### API Contract @@ -83,6 +86,9 @@ filtering and pagination. - `PATCH /api/v1/tasks/{id}` -> 200 OK, partial update. - `DELETE /api/v1/tasks/{id}` -> 204 No Content. - Error shape: `{"error": "...", "code": "TASK_NOT_FOUND"}` for 404s. +- List response shape: `{ "tasks": [...], "total": , "page": , "page_size": }` + where `page = floor(offset / limit) + 1` and `page_size = limit`. +- Task response includes `attachments: []` until PR-004. ### Background Jobs @@ -116,6 +122,8 @@ filtering and pagination. **Success Criteria:** - [ ] List endpoint supports status, priority, due_before, due_after. - [ ] Limit/offset default to 50/0 and are enforced. +- [ ] List response includes `total`, `page`, and `page_size` consistent with limit/offset. +- [ ] Default sort order is `created_at DESC, id ASC`. ## Test Plan @@ -123,6 +131,8 @@ filtering and pagination. - API tests for CRUD, validation, and 404s. - API tests for filters and pagination ordering. +- API tests for list response shape (`total`, `page`, `page_size`) and default ordering. +- API tests confirm `attachments` is an empty list for tasks. ### Manual @@ -131,4 +141,4 @@ filtering and pagination. ## Notes / Risks / Open Questions -- Decide default sort order (eta asc vs created_at desc). +- None. diff --git a/docs/DEVELOPER_QUICKSTART.md b/docs/DEVELOPER_QUICKSTART.md index 5f32ea1..50e27cc 100644 --- a/docs/DEVELOPER_QUICKSTART.md +++ b/docs/DEVELOPER_QUICKSTART.md @@ -10,24 +10,31 @@ Get a local dev environment running quickly. For full details, see `docs/SETUP.m ## Install ```bash -uv pip install -e . +# Install dev dependencies (includes FastAPI, uvicorn, and test tools) +make dev + +# Or manually: +# uv pip install -e ".[dev]" + +# Copy environment file template cp .env.example .env +# Edit .env with your configuration (optional for basic usage) ``` ## Run (backend + CLI) ```bash # Terminal 1: start API server (reads .env) -uv run python -m backend.main +python -m backend.main # Terminal 2: CLI (scaffolded; backend integration in progress) uv run tgenie --help -# Health check +# Health check (in another terminal) curl http://127.0.0.1:8080/health # OpenAPI (FastAPI Swagger UI) -open http://127.0.0.1:8080/docs # macOS (use xdg-open on Linux) +xdg-open http://127.0.0.1:8080/docs # Linux (use 'open' on macOS) ``` ## Common dev commands diff --git a/pyproject.toml b/pyproject.toml index 7487184..35f33b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ dependencies = [ "sqlalchemy[asyncio]>=2.0.23", "aiosqlite>=0.19.0", "alembic>=1.13.0", - "typer[all]>=0.9.0", + "typer>=0.9.0", "rich>=13.7.0", "pydantic>=2.5.0", "pydantic-settings>=2.1.0", diff --git a/uv.lock b/uv.lock index 1e3ff69..c994b8f 100644 --- a/uv.lock +++ b/uv.lock @@ -799,7 +799,7 @@ requires-dist = [ { name = "rich", specifier = ">=13.7.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.8" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.23" }, - { name = "typer", extras = ["all"], specifier = ">=0.9.0" }, + { name = "typer", specifier = ">=0.9.0" }, { name = "uvicorn", extras = ["standard"], marker = "extra == 'dev'", specifier = ">=0.24.0" }, ] provides-extras = ["dev", "all"] From 8b53130e59d5c65d09b229b77d83979c1b4a672a Mon Sep 17 00:00:00 2001 From: Raymond Christopher Date: Sat, 3 Jan 2026 11:08:00 +0700 Subject: [PATCH 2/3] feat(api): implement task management endpoints and error handling - Added a new API v1 for task management, including endpoints for creating, retrieving, updating, and deleting tasks. - Introduced task schemas for request validation and response formatting. - Implemented error handling for task not found scenarios with a standardized error response. - Updated the Makefile to include precommit checks in the test coverage command. - Removed linting step from CI workflow to streamline the testing process. These changes enhance the API functionality for task management and improve error handling, contributing to a more robust application. --- .github/workflows/ci.yml | 3 - Makefile | 4 +- backend/api/v1/__init__.py | 7 + backend/api/v1/tasks.py | 118 +++++ backend/main.py | 24 +- backend/schemas/task.py | 110 +++++ backend/services/__init__.py | 4 + backend/services/task_service.py | 246 ++++++++++ tests/api/__init__.py | 7 + tests/api/test_tasks.py | 696 ++++++++++++++++++++++++++++ tests/services/__init__.py | 1 + tests/services/test_task_service.py | 302 ++++++++++++ 12 files changed, 1516 insertions(+), 6 deletions(-) create mode 100644 backend/api/v1/__init__.py create mode 100644 backend/api/v1/tasks.py create mode 100644 backend/schemas/task.py create mode 100644 backend/services/task_service.py create mode 100644 tests/api/__init__.py create mode 100644 tests/api/test_tasks.py create mode 100644 tests/services/__init__.py create mode 100644 tests/services/test_task_service.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 66899d6..3b800a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,8 +30,5 @@ jobs: - name: Install dependencies run: uv pip install -e ".[dev]" - - name: Lint - run: make lint - - name: Tests run: make test-cov diff --git a/Makefile b/Makefile index fccdf61..8f70838 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,7 @@ help: @echo " make format Run ruff formatter" @echo " make typecheck Run mypy" @echo " make test Run pytest" - @echo " make test-cov Run pytest with coverage" + @echo " make test-cov Run precommit + pytest with coverage (matches CI)" @echo " make docs-check Validate docs links/naming" @echo " make check Run lint + typecheck + test" @@ -41,7 +41,7 @@ typecheck: test: uv run pytest -n 4 -test-cov: +test-cov: precommit uv run pytest -n 4 --cov=backend --cov-report=term-missing docs-check: diff --git a/backend/api/v1/__init__.py b/backend/api/v1/__init__.py new file mode 100644 index 0000000..5bfd5d3 --- /dev/null +++ b/backend/api/v1/__init__.py @@ -0,0 +1,7 @@ +"""API v1 package. + +Author: + Raymond Christopher (raymond.christopher@gdplabs.id) +""" + +__all__: list[str] = [] diff --git a/backend/api/v1/tasks.py b/backend/api/v1/tasks.py new file mode 100644 index 0000000..58ab49b --- /dev/null +++ b/backend/api/v1/tasks.py @@ -0,0 +1,118 @@ +"""Task API endpoints. + +Author: + Raymond Christopher (raymond.christopher@gdplabs.id) +""" + +from __future__ import annotations + +from datetime import datetime + +from fastapi import APIRouter, Depends, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.database import get_db +from backend.schemas.task import TaskCreate, TaskListResponse, TaskPriority, TaskResponse, TaskStatus, TaskUpdate +from backend.services.task_service import create_task, delete_task, get_task, list_tasks, update_task + +router = APIRouter(prefix="/tasks", tags=["tasks"]) + + +@router.post("", response_model=TaskResponse, status_code=status.HTTP_201_CREATED) +async def create_task_endpoint(task_data: TaskCreate, session: AsyncSession = Depends(get_db)) -> TaskResponse: + """Create a new task. + + Args: + task_data: Task creation data. + session: Database session. + + Returns: + TaskResponse: Created task. + """ + return await create_task(session, task_data) + + +@router.get("", response_model=TaskListResponse) +async def list_tasks_endpoint( + status: TaskStatus | None = Query(None, description="Filter by status"), + priority: TaskPriority | None = Query(None, description="Filter by priority"), + due_before: datetime | None = Query(None, description="Filter tasks due before this datetime"), + due_after: datetime | None = Query(None, description="Filter tasks due after this datetime"), + limit: int = Query(50, ge=1, description="Maximum number of results"), + offset: int = Query(0, ge=0, description="Pagination offset"), + session: AsyncSession = Depends(get_db), +) -> TaskListResponse: + """List tasks with optional filters and pagination. + + Args: + status: Filter by status (pending, in_progress, completed). + priority: Filter by priority (low, medium, high, critical). + due_before: Filter tasks with eta before this datetime. + due_after: Filter tasks with eta after this datetime. + limit: Maximum number of results (default: 50). + offset: Pagination offset (default: 0). + session: Database session. + + Returns: + TaskListResponse: Paginated task list. + """ + return await list_tasks( + session=session, + status=status.value if status else None, + priority=priority.value if priority else None, + due_before=due_before, + due_after=due_after, + limit=limit, + offset=offset, + ) + + +@router.get("/{task_id}", response_model=TaskResponse) +async def get_task_endpoint(task_id: str, session: AsyncSession = Depends(get_db)) -> TaskResponse: + """Get a task by ID. + + Args: + task_id: Task ID. + session: Database session. + + Returns: + TaskResponse: Task data. + + Raises: + TaskNotFoundError: If task is not found (handled by FastAPI exception handler). + """ + return await get_task(session, task_id) + + +@router.patch("/{task_id}", response_model=TaskResponse) +async def update_task_endpoint( + task_id: str, task_data: TaskUpdate, session: AsyncSession = Depends(get_db) +) -> TaskResponse: + """Update a task. + + Args: + task_id: Task ID. + task_data: Task update data. + session: Database session. + + Returns: + TaskResponse: Updated task. + + Raises: + TaskNotFoundError: If task is not found (handled by FastAPI exception handler). + """ + return await update_task(session, task_id, task_data) + + +@router.delete("/{task_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_task_endpoint(task_id: str, session: AsyncSession = Depends(get_db)) -> None: + """Delete a task. + + Args: + task_id: Task ID. + session: Database session. + + Raises: + TaskNotFoundError: If task is not found (handled by FastAPI exception handler). + """ + await delete_task(session, task_id) diff --git a/backend/main.py b/backend/main.py index f629be1..32cbddd 100644 --- a/backend/main.py +++ b/backend/main.py @@ -10,10 +10,14 @@ from contextlib import asynccontextmanager import uvicorn -from fastapi import FastAPI +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse +from backend.api.v1 import tasks as tasks_router from backend.config import get_settings from backend.database import close_db, init_db_async +from backend.schemas.task import ErrorResponse +from backend.services.task_service import TaskNotFoundError @asynccontextmanager @@ -30,6 +34,24 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: settings = get_settings() app = FastAPI(title=settings.app_name, version=settings.app_version, lifespan=lifespan) +# Register API routers +app.include_router(tasks_router.router, prefix="/api/v1") + + +@app.exception_handler(TaskNotFoundError) +async def task_not_found_handler(request: Request, exc: TaskNotFoundError) -> JSONResponse: + """Handle TaskNotFoundError exceptions. + + Args: + request: FastAPI request object. + exc: TaskNotFoundError exception. + + Returns: + JSONResponse: 404 response with error details. + """ + error_response = ErrorResponse(error="Task not found", code=exc.code) + return JSONResponse(status_code=404, content=error_response.model_dump()) + @app.get("/health") async def health_check() -> dict[str, str]: diff --git a/backend/schemas/task.py b/backend/schemas/task.py new file mode 100644 index 0000000..04c4179 --- /dev/null +++ b/backend/schemas/task.py @@ -0,0 +1,110 @@ +"""Task schemas for API request/response validation. + +Author: + Raymond Christopher (raymond.christopher@gdplabs.id) +""" + +from __future__ import annotations + +from datetime import datetime +from enum import Enum +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, model_validator + + +class TaskStatus(str, Enum): + """Task status enum.""" + + PENDING = "pending" + IN_PROGRESS = "in_progress" + COMPLETED = "completed" + + +class TaskPriority(str, Enum): + """Task priority enum.""" + + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + CRITICAL = "critical" + + +class TaskCreate(BaseModel): + """Schema for creating a task.""" + + title: str = Field(..., min_length=1, max_length=255) + description: str | None = None + status: TaskStatus = TaskStatus.PENDING + priority: TaskPriority = TaskPriority.MEDIUM + eta: datetime | None = None + tags: list[str] | None = None + metadata: dict[str, Any] | None = None + + +class TaskUpdate(BaseModel): + """Schema for updating a task (all fields optional).""" + + title: str | None = Field(None, min_length=1, max_length=255) + description: str | None = None + status: TaskStatus | None = None + priority: TaskPriority | None = None + eta: datetime | None = None + tags: list[str] | None = None + metadata: dict[str, Any] | None = None + + @model_validator(mode="before") + @classmethod + def validate_title_not_null(cls, data: dict[str, Any] | Any) -> dict[str, Any] | Any: + """Reject title=None to prevent database integrity errors. + + This validator runs before Pydantic's type coercion, so it can detect + when title is explicitly set to None in the input JSON. + + Args: + data: Raw input data (dict or model instance). + + Returns: + Data unchanged if valid. + + Raises: + ValueError: If title is explicitly set to None. + """ + if isinstance(data, dict) and "title" in data and data["title"] is None: + msg = "title cannot be null" + raise ValueError(msg) + return data + + +class TaskResponse(BaseModel): + """Schema for task response.""" + + model_config = ConfigDict(from_attributes=True, populate_by_name=True) + + id: str + title: str + description: str | None + status: TaskStatus + priority: TaskPriority + eta: datetime | None + created_at: datetime + updated_at: datetime + tags: list[str] | None = None + metadata: dict[str, Any] | None = Field(None, alias="meta_data", serialization_alias="metadata") + attachments: list[dict[str, Any]] = Field(default_factory=list) + + +class TaskListResponse(BaseModel): + """Schema for paginated task list response.""" + + tasks: list[TaskResponse] + total: int + page: int + page_size: int + + +class ErrorResponse(BaseModel): + """Standard error response schema.""" + + error: str + code: str diff --git a/backend/services/__init__.py b/backend/services/__init__.py index f588fe7..ba4c8a3 100644 --- a/backend/services/__init__.py +++ b/backend/services/__init__.py @@ -3,3 +3,7 @@ Author: Raymond Christopher (raymond.christopher@gdplabs.id) """ + +from backend.services.task_service import TaskNotFoundError, create_task, delete_task, get_task, list_tasks, update_task + +__all__: list[str] = ["TaskNotFoundError", "create_task", "delete_task", "get_task", "list_tasks", "update_task"] diff --git a/backend/services/task_service.py b/backend/services/task_service.py new file mode 100644 index 0000000..a9c6e2d --- /dev/null +++ b/backend/services/task_service.py @@ -0,0 +1,246 @@ +"""Task service layer for business logic. + +Author: + Raymond Christopher (raymond.christopher@gdplabs.id) +""" + +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Any + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.sql import Select + +from backend.models.task import Task +from backend.schemas.task import TaskCreate, TaskListResponse, TaskResponse, TaskUpdate + + +def _apply_task_filters( + query: Select[Any], + status: str | None = None, + priority: str | None = None, + due_before: datetime | None = None, + due_after: datetime | None = None, +) -> Select[Any]: + """Apply filter conditions to a task query. + + Args: + query: SQLAlchemy select query to filter. + status: Filter by status. + priority: Filter by priority. + due_before: Filter tasks with eta before this datetime. + due_after: Filter tasks with eta after this datetime. + + Returns: + Filtered query. + """ + if status is not None: + query = query.where(Task.status == status) + if priority is not None: + query = query.where(Task.priority == priority) + if due_before is not None: + query = query.where(Task.eta <= due_before) + if due_after is not None: + query = query.where(Task.eta >= due_after) + return query + + +def _task_to_response_dict(task: Task) -> dict[str, Any]: + """Convert Task model to dict for TaskResponse validation. + + Args: + task: Task model instance. + + Returns: + dict: Task data as dict with metadata mapped correctly. + """ + return { + "id": task.id, + "title": task.title, + "description": task.description, + "status": task.status, + "priority": task.priority, + "eta": task.eta, + "created_at": task.created_at, + "updated_at": task.updated_at, + "tags": task.tags, + "meta_data": task.meta_data, + "attachments": [], + } + + +class TaskNotFoundError(Exception): + """Exception raised when a task is not found.""" + + def __init__(self, task_id: str) -> None: + """Initialize TaskNotFoundError. + + Args: + task_id: The ID of the task that was not found. + """ + self.task_id = task_id + self.code = "TASK_NOT_FOUND" + self.message = f"Task not found: {task_id}" + super().__init__(self.message) + + +async def create_task(session: AsyncSession, task_data: TaskCreate) -> TaskResponse: + """Create a new task. + + Args: + session: Database session. + task_data: Task creation data. + + Returns: + TaskResponse: Created task. + """ + task_id = str(uuid.uuid4()) + task = Task( + id=task_id, + title=task_data.title, + description=task_data.description, + status=task_data.status.value, + priority=task_data.priority.value, + eta=task_data.eta, + tags=task_data.tags, + meta_data=task_data.metadata, + ) + session.add(task) + await session.flush() + await session.refresh(task) + return TaskResponse.model_validate(_task_to_response_dict(task)) + + +async def get_task(session: AsyncSession, task_id: str) -> TaskResponse: + """Get a task by ID. + + Args: + session: Database session. + task_id: Task ID. + + Returns: + TaskResponse: Task data. + + Raises: + TaskNotFoundError: If task is not found. + """ + result = await session.execute(select(Task).where(Task.id == task_id)) + task = result.scalar_one_or_none() + if task is None: + raise TaskNotFoundError(task_id) + return TaskResponse.model_validate(_task_to_response_dict(task)) + + +async def list_tasks( + session: AsyncSession, + status: str | None = None, + priority: str | None = None, + due_before: datetime | None = None, + due_after: datetime | None = None, + limit: int = 50, + offset: int = 0, +) -> TaskListResponse: + """List tasks with filtering and pagination. + + Args: + session: Database session. + status: Filter by status. + priority: Filter by priority. + due_before: Filter tasks with eta before this datetime. + due_after: Filter tasks with eta after this datetime. + limit: Maximum number of results. + offset: Pagination offset. + + Returns: + TaskListResponse: Paginated task list. + """ + query = select(Task) + + # Apply filters + query = _apply_task_filters(query, status, priority, due_before, due_after) + + # Get total count with same filters + # Build count query with same filters but without ordering/pagination + count_query = select(func.count(Task.id)) + count_query = _apply_task_filters(count_query, status, priority, due_before, due_after) + total_result = await session.execute(count_query) + total = total_result.scalar_one() + + # Apply ordering and pagination + query = query.order_by(Task.created_at.desc(), Task.id.asc()) + query = query.limit(limit).offset(offset) + + # Execute query + result = await session.execute(query) + tasks = result.scalars().all() + + # Calculate page and page_size + page = (offset // limit) + 1 + page_size = limit + + # Convert tasks to TaskResponse, excluding relationships + task_responses = [TaskResponse.model_validate(_task_to_response_dict(task)) for task in tasks] + + return TaskListResponse(tasks=task_responses, total=total, page=page, page_size=page_size) + + +async def update_task(session: AsyncSession, task_id: str, task_data: TaskUpdate) -> TaskResponse: + """Update a task. + + Args: + session: Database session. + task_id: Task ID. + task_data: Task update data. + + Returns: + TaskResponse: Updated task. + + Raises: + TaskNotFoundError: If task is not found. + """ + result = await session.execute(select(Task).where(Task.id == task_id)) + task = result.scalar_one_or_none() + if task is None: + raise TaskNotFoundError(task_id) + + # Update fields (only set provided fields) + update_dict = task_data.model_dump(exclude_unset=True) + if "status" in update_dict and update_dict["status"] is not None: + task.status = update_dict["status"].value + if "priority" in update_dict and update_dict["priority"] is not None: + task.priority = update_dict["priority"].value + if "title" in update_dict: + task.title = update_dict["title"] + if "description" in update_dict: + task.description = update_dict["description"] + if "eta" in update_dict: + task.eta = update_dict["eta"] + if "tags" in update_dict: + task.tags = update_dict["tags"] + if "metadata" in update_dict: + task.meta_data = update_dict["metadata"] + + await session.flush() + await session.refresh(task) + return TaskResponse.model_validate(_task_to_response_dict(task)) + + +async def delete_task(session: AsyncSession, task_id: str) -> None: + """Delete a task. + + Args: + session: Database session. + task_id: Task ID. + + Raises: + TaskNotFoundError: If task is not found. + """ + result = await session.execute(select(Task).where(Task.id == task_id)) + task = result.scalar_one_or_none() + if task is None: + raise TaskNotFoundError(task_id) + await session.delete(task) + await session.flush() diff --git a/tests/api/__init__.py b/tests/api/__init__.py new file mode 100644 index 0000000..2753dd4 --- /dev/null +++ b/tests/api/__init__.py @@ -0,0 +1,7 @@ +"""API tests package. + +Author: + Raymond Christopher (raymond.christopher@gdplabs.id) +""" + +__all__: list[str] = [] diff --git a/tests/api/test_tasks.py b/tests/api/test_tasks.py new file mode 100644 index 0000000..1bef6c0 --- /dev/null +++ b/tests/api/test_tasks.py @@ -0,0 +1,696 @@ +"""Tests for task API endpoints. + +Author: + Raymond Christopher (raymond.christopher@gdplabs.id) +""" + +from collections import defaultdict +from datetime import UTC, datetime +from pathlib import Path + +import pytest +from fastapi import status +from fastapi.testclient import TestClient + +from backend import config +from backend.main import app + +# Constants +DEFAULT_PAGE_SIZE = 50 +DEFAULT_OFFSET = 0 + + +@pytest.fixture +def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient: + """Create a test client with isolated database.""" + db_path = tmp_path / "test.db" + db_url = f"sqlite+aiosqlite:///{db_path}" + monkeypatch.setenv("DATABASE_URL", db_url) + config.get_settings.cache_clear() + + # Initialize database + from backend.database import init_db # noqa: PLC0415 + + init_db() + + return TestClient(app) + + +def test_create_task(client: TestClient) -> None: + """Test creating a task.""" + response = client.post( + "/api/v1/tasks", + json={"title": "Test Task", "description": "Test Description", "status": "pending", "priority": "high"}, + ) + assert response.status_code == status.HTTP_201_CREATED + data = response.json() + assert data["title"] == "Test Task" + assert data["description"] == "Test Description" + assert data["status"] == "pending" + assert data["priority"] == "high" + assert "id" in data + assert "created_at" in data + assert "updated_at" in data + assert data["attachments"] == [] + + +def test_create_task_with_all_fields(client: TestClient) -> None: + """Test creating a task with all fields.""" + eta = datetime.now(UTC).isoformat().replace("+00:00", "Z") + response = client.post( + "/api/v1/tasks", + json={ + "title": "Complete Task", + "description": "Full description", + "status": "in_progress", + "priority": "critical", + "eta": eta, + "tags": ["tag1", "tag2"], + "metadata": {"key": "value"}, + }, + ) + assert response.status_code == status.HTTP_201_CREATED + data = response.json() + assert data["title"] == "Complete Task" + assert data["description"] == "Full description" + assert data["status"] == "in_progress" + assert data["priority"] == "critical" + assert data["tags"] == ["tag1", "tag2"] + assert data["metadata"] == {"key": "value"} + assert data["attachments"] == [] + + +def test_create_task_validation_empty_title(client: TestClient) -> None: + """Test that empty title is rejected.""" + response = client.post("/api/v1/tasks", json={"title": ""}) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + + +def test_create_task_validation_invalid_status(client: TestClient) -> None: + """Test that invalid status is rejected.""" + response = client.post("/api/v1/tasks", json={"title": "Test", "status": "invalid"}) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + + +def test_create_task_validation_invalid_priority(client: TestClient) -> None: + """Test that invalid priority is rejected.""" + response = client.post("/api/v1/tasks", json={"title": "Test", "priority": "invalid"}) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + + +def test_create_task_validation_title_too_long(client: TestClient) -> None: + """Test that title exceeding max length is rejected.""" + long_title = "a" * 256 + response = client.post("/api/v1/tasks", json={"title": long_title}) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + + +def test_get_task(client: TestClient) -> None: + """Test getting a task by ID.""" + # Create a task first + create_response = client.post("/api/v1/tasks", json={"title": "Get Test Task"}) + assert create_response.status_code == status.HTTP_201_CREATED + task_id = create_response.json()["id"] + + # Get the task + response = client.get(f"/api/v1/tasks/{task_id}") + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["id"] == task_id + assert data["title"] == "Get Test Task" + assert data["attachments"] == [] + + +def test_get_task_not_found(client: TestClient) -> None: + """Test getting a non-existent task returns 404 with standard error shape.""" + response = client.get("/api/v1/tasks/nonexistent-id") + assert response.status_code == status.HTTP_404_NOT_FOUND + data = response.json() + assert "error" in data + assert "code" in data + assert data["code"] == "TASK_NOT_FOUND" + + +def test_update_task(client: TestClient) -> None: + """Test updating a task.""" + # Create a task first + create_response = client.post("/api/v1/tasks", json={"title": "Original Title", "status": "pending"}) + assert create_response.status_code == status.HTTP_201_CREATED + task_id = create_response.json()["id"] + + # Update the task + response = client.patch(f"/api/v1/tasks/{task_id}", json={"title": "Updated Title", "status": "in_progress"}) + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["title"] == "Updated Title" + assert data["status"] == "in_progress" + + # Verify update persisted + get_response = client.get(f"/api/v1/tasks/{task_id}") + assert get_response.status_code == status.HTTP_200_OK + assert get_response.json()["title"] == "Updated Title" + assert get_response.json()["status"] == "in_progress" + + +def test_update_task_partial(client: TestClient) -> None: + """Test partial update of a task.""" + # Create a task first + create_response = client.post( + "/api/v1/tasks", json={"title": "Original", "description": "Original Description", "priority": "low"} + ) + assert create_response.status_code == status.HTTP_201_CREATED + task_id = create_response.json()["id"] + + # Update only title + response = client.patch(f"/api/v1/tasks/{task_id}", json={"title": "Updated"}) + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["title"] == "Updated" + assert data["description"] == "Original Description" # Unchanged + assert data["priority"] == "low" # Unchanged + + +def test_update_task_all_fields_individually(client: TestClient) -> None: + """Test updating each field individually to cover all update paths.""" + # Create a task + create_response = client.post( + "/api/v1/tasks", json={"title": "Original", "description": "Original", "status": "pending", "priority": "low"} + ) + assert create_response.status_code == status.HTTP_201_CREATED + task_id = create_response.json()["id"] + + # Update status + response = client.patch(f"/api/v1/tasks/{task_id}", json={"status": "in_progress"}) + assert response.status_code == status.HTTP_200_OK + assert response.json()["status"] == "in_progress" + + # Update priority + response = client.patch(f"/api/v1/tasks/{task_id}", json={"priority": "high"}) + assert response.status_code == status.HTTP_200_OK + assert response.json()["priority"] == "high" + + # Update description + response = client.patch(f"/api/v1/tasks/{task_id}", json={"description": "New Description"}) + assert response.status_code == status.HTTP_200_OK + assert response.json()["description"] == "New Description" + + # Update eta + eta_dt = datetime.now(UTC) + eta_str = eta_dt.isoformat().replace("+00:00", "Z") + response = client.patch(f"/api/v1/tasks/{task_id}", json={"eta": eta_str}) + assert response.status_code == status.HTTP_200_OK + # Response may not include 'Z' suffix, so compare datetime objects + response_eta_str = response.json()["eta"] + if response_eta_str.endswith("Z"): + response_eta = datetime.fromisoformat(response_eta_str.replace("Z", "+00:00")) + else: + response_eta = datetime.fromisoformat(response_eta_str).replace(tzinfo=UTC) + assert abs((response_eta - eta_dt).total_seconds()) < 1 # Within 1 second + + # Update tags + response = client.patch(f"/api/v1/tasks/{task_id}", json={"tags": ["tag1", "tag2"]}) + assert response.status_code == status.HTTP_200_OK + assert response.json()["tags"] == ["tag1", "tag2"] + + # Update metadata + response = client.patch(f"/api/v1/tasks/{task_id}", json={"metadata": {"key": "value"}}) + assert response.status_code == status.HTTP_200_OK + assert response.json()["metadata"] == {"key": "value"} + + # Update title (to cover title update path) + response = client.patch(f"/api/v1/tasks/{task_id}", json={"title": "Updated Title"}) + assert response.status_code == status.HTTP_200_OK + assert response.json()["title"] == "Updated Title" + + # Test setting nullable fields to None (to cover None value paths) + response = client.patch(f"/api/v1/tasks/{task_id}", json={"description": None}) + assert response.status_code == status.HTTP_200_OK + assert response.json()["description"] is None + + response = client.patch(f"/api/v1/tasks/{task_id}", json={"eta": None}) + assert response.status_code == status.HTTP_200_OK + assert response.json()["eta"] is None + + response = client.patch(f"/api/v1/tasks/{task_id}", json={"tags": None}) + assert response.status_code == status.HTTP_200_OK + assert response.json()["tags"] is None + + response = client.patch(f"/api/v1/tasks/{task_id}", json={"metadata": None}) + assert response.status_code == status.HTTP_200_OK + assert response.json()["metadata"] is None + + +def test_update_task_not_found(client: TestClient) -> None: + """Test updating a non-existent task returns 404 with standard error shape.""" + response = client.patch("/api/v1/tasks/nonexistent-id", json={"title": "Updated"}) + assert response.status_code == status.HTTP_404_NOT_FOUND + data = response.json() + assert "error" in data + assert "code" in data + assert data["code"] == "TASK_NOT_FOUND" + + +def test_delete_task(client: TestClient) -> None: + """Test deleting a task.""" + # Create a task first + create_response = client.post("/api/v1/tasks", json={"title": "Task to Delete"}) + assert create_response.status_code == status.HTTP_201_CREATED + task_id = create_response.json()["id"] + + # Delete the task + response = client.delete(f"/api/v1/tasks/{task_id}") + assert response.status_code == status.HTTP_204_NO_CONTENT + + # Verify task is deleted + get_response = client.get(f"/api/v1/tasks/{task_id}") + assert get_response.status_code == status.HTTP_404_NOT_FOUND + + +def test_delete_task_not_found(client: TestClient) -> None: + """Test deleting a non-existent task returns 404 with standard error shape.""" + response = client.delete("/api/v1/tasks/nonexistent-id") + assert response.status_code == status.HTTP_404_NOT_FOUND + data = response.json() + assert "error" in data + assert "code" in data + assert data["code"] == "TASK_NOT_FOUND" + assert data["error"] == "Task not found" + + +def test_list_tasks_empty(client: TestClient) -> None: + """Test listing tasks when none exist.""" + response = client.get("/api/v1/tasks") + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["tasks"] == [] + assert data["total"] == 0 + assert data["page"] == 1 + assert data["page_size"] == DEFAULT_PAGE_SIZE + + +def test_list_tasks_defaults(client: TestClient) -> None: + """Test list tasks with default pagination.""" + # Create some tasks + for i in range(3): + client.post("/api/v1/tasks", json={"title": f"Task {i}"}) + + response = client.get("/api/v1/tasks") + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert len(data["tasks"]) == 3 # noqa: PLR2004 + assert data["total"] == 3 # noqa: PLR2004 + assert data["page"] == 1 + assert data["page_size"] == DEFAULT_PAGE_SIZE + + +def test_list_tasks_pagination(client: TestClient) -> None: + """Test list tasks with pagination.""" + # Create 5 tasks + for i in range(5): + client.post("/api/v1/tasks", json={"title": f"Task {i}"}) + + # Get first page (limit=2, offset=0) + response = client.get("/api/v1/tasks?limit=2&offset=0") + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert len(data["tasks"]) == 2 # noqa: PLR2004 + assert data["total"] == 5 # noqa: PLR2004 + assert data["page"] == 1 + assert data["page_size"] == 2 # noqa: PLR2004 + + # Get second page (limit=2, offset=2) + response = client.get("/api/v1/tasks?limit=2&offset=2") + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert len(data["tasks"]) == 2 # noqa: PLR2004 + assert data["total"] == 5 # noqa: PLR2004 + assert data["page"] == 2 # noqa: PLR2004 + assert data["page_size"] == 2 # noqa: PLR2004 + + +def test_list_tasks_filter_status(client: TestClient) -> None: + """Test filtering tasks by status.""" + # Create tasks with different statuses + client.post("/api/v1/tasks", json={"title": "Pending Task", "status": "pending"}) + client.post("/api/v1/tasks", json={"title": "In Progress Task", "status": "in_progress"}) + client.post("/api/v1/tasks", json={"title": "Completed Task", "status": "completed"}) + + # Filter by pending + response = client.get("/api/v1/tasks?status=pending") + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert len(data["tasks"]) == 1 + assert data["tasks"][0]["status"] == "pending" + assert data["total"] == 1 + + +def test_list_tasks_filter_priority(client: TestClient) -> None: + """Test filtering tasks by priority.""" + # Create tasks with different priorities + client.post("/api/v1/tasks", json={"title": "Low Priority", "priority": "low"}) + client.post("/api/v1/tasks", json={"title": "High Priority", "priority": "high"}) + client.post("/api/v1/tasks", json={"title": "Medium Priority", "priority": "medium"}) + + # Filter by high priority + response = client.get("/api/v1/tasks?priority=high") + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert len(data["tasks"]) == 1 + assert data["tasks"][0]["priority"] == "high" + assert data["total"] == 1 + + +def test_list_tasks_filter_due_before(client: TestClient) -> None: + """Test filtering tasks by due_before.""" + now = datetime.now(UTC) + before = (now.replace(hour=10, minute=0, second=0, microsecond=0)).isoformat().replace("+00:00", "Z") + after = (now.replace(hour=14, minute=0, second=0, microsecond=0)).isoformat().replace("+00:00", "Z") + + # Create tasks with different ETAs + client.post("/api/v1/tasks", json={"title": "Early Task", "eta": before}) + client.post("/api/v1/tasks", json={"title": "Late Task", "eta": after}) + + # Filter by due_before (noon) + noon = (now.replace(hour=12, minute=0, second=0, microsecond=0)).isoformat().replace("+00:00", "Z") + response = client.get(f"/api/v1/tasks?due_before={noon}") + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert len(data["tasks"]) == 1 + assert data["tasks"][0]["title"] == "Early Task" + assert data["total"] == 1 + + +def test_list_tasks_filter_due_after(client: TestClient) -> None: + """Test filtering tasks by due_after.""" + now = datetime.now(UTC) + before = (now.replace(hour=10, minute=0, second=0, microsecond=0)).isoformat().replace("+00:00", "Z") + after = (now.replace(hour=14, minute=0, second=0, microsecond=0)).isoformat().replace("+00:00", "Z") + + # Create tasks with different ETAs + client.post("/api/v1/tasks", json={"title": "Early Task", "eta": before}) + client.post("/api/v1/tasks", json={"title": "Late Task", "eta": after}) + + # Filter by due_after (noon) + noon = (now.replace(hour=12, minute=0, second=0, microsecond=0)).isoformat().replace("+00:00", "Z") + response = client.get(f"/api/v1/tasks?due_after={noon}") + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert len(data["tasks"]) == 1 + assert data["tasks"][0]["title"] == "Late Task" + assert data["total"] == 1 + + +def test_list_tasks_invalid_status(client: TestClient) -> None: + """Test that invalid status enum value returns 422.""" + response = client.get("/api/v1/tasks?status=invalid_status") + assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + + +def test_list_tasks_invalid_priority(client: TestClient) -> None: + """Test that invalid priority enum value returns 422.""" + response = client.get("/api/v1/tasks?priority=invalid_priority") + assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + + +def test_list_tasks_ordering(client: TestClient) -> None: + """Test that tasks are ordered by created_at DESC, id ASC.""" + # Create tasks (they will have different created_at timestamps) + _ = client.post("/api/v1/tasks", json={"title": "Task 1"}) + _ = client.post("/api/v1/tasks", json={"title": "Task 2"}) + _ = client.post("/api/v1/tasks", json={"title": "Task 3"}) + + response = client.get("/api/v1/tasks") + assert response.status_code == status.HTTP_200_OK + data = response.json() + tasks = data["tasks"] + + # Verify we got all 3 tasks + assert len(tasks) == 3 # noqa: PLR2004 + + # Verify created_at is descending (or equal) + created_dates = [datetime.fromisoformat(t["created_at"].replace("Z", "+00:00")) for t in tasks] + # Check that dates are in descending order (or equal) + for i in range(len(created_dates) - 1): + assert created_dates[i] >= created_dates[i + 1], "Tasks should be ordered by created_at DESC" + + # Verify that when created_at is equal, id is ascending + # Group tasks by created_at and verify id ordering within each group + tasks_by_date: defaultdict[str, list[dict[str, str]]] = defaultdict(list) + for task in tasks: + tasks_by_date[task["created_at"]].append(task) + + for date_key, date_tasks in tasks_by_date.items(): + if len(date_tasks) > 1: + # Within same created_at, ids should be in ascending order + ids = [t["id"] for t in date_tasks] + assert ids == sorted(ids), "When created_at is equal, tasks should be ordered by id ASC" + + +def test_list_tasks_limit_enforcement(client: TestClient) -> None: + """Test that limit is enforced.""" + # Create 10 tasks + for i in range(10): + client.post("/api/v1/tasks", json={"title": f"Task {i}"}) + + # Request with limit=3 + response = client.get("/api/v1/tasks?limit=3") + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert len(data["tasks"]) == 3 # noqa: PLR2004 + assert data["total"] == 10 # noqa: PLR2004 + assert data["page_size"] == 3 # noqa: PLR2004 + + +def test_list_tasks_offset(client: TestClient) -> None: + """Test that offset works correctly.""" + # Create 5 tasks + task_ids = [] + for i in range(5): + response = client.post("/api/v1/tasks", json={"title": f"Task {i}"}) + task_ids.append(response.json()["id"]) + + # Get all tasks to see order + all_response = client.get("/api/v1/tasks") + all_tasks = all_response.json()["tasks"] + all_task_ids = [t["id"] for t in all_tasks] + + # Get with offset=2 + response = client.get("/api/v1/tasks?limit=2&offset=2") + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert len(data["tasks"]) == 2 # noqa: PLR2004 + # Should get tasks starting from index 2 + assert data["tasks"][0]["id"] == all_task_ids[2] # noqa: PLR2004 + assert data["tasks"][1]["id"] == all_task_ids[3] # noqa: PLR2004 + assert data["page"] == 2 # noqa: PLR2004 # offset=2, limit=2 -> page 2 + + +def test_list_tasks_empty_result_set(client: TestClient) -> None: + """Test list_tasks with filters that return no results to cover empty list path.""" + # Create a task with specific attributes + client.post("/api/v1/tasks", json={"title": "Task", "status": "pending", "priority": "low"}) + + # Filter that returns no results + response = client.get("/api/v1/tasks?status=completed") + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert len(data["tasks"]) == 0 + assert data["total"] == 0 + assert data["page"] == 1 + + +def test_list_tasks_with_all_filters(client: TestClient) -> None: + """Test list_tasks with all filters applied simultaneously.""" + now = datetime.now(UTC) + eta1 = (now.replace(hour=10, minute=0, second=0, microsecond=0)).isoformat().replace("+00:00", "Z") + eta2 = (now.replace(hour=14, minute=0, second=0, microsecond=0)).isoformat().replace("+00:00", "Z") + + # Create tasks with different attributes + client.post("/api/v1/tasks", json={"title": "Task 1", "status": "pending", "priority": "low", "eta": eta1}) + client.post("/api/v1/tasks", json={"title": "Task 2", "status": "completed", "priority": "high", "eta": eta2}) + client.post("/api/v1/tasks", json={"title": "Task 3", "status": "pending", "priority": "high", "eta": eta1}) + + # Filter with all parameters + noon = (now.replace(hour=12, minute=0, second=0, microsecond=0)).isoformat().replace("+00:00", "Z") + response = client.get(f"/api/v1/tasks?status=pending&priority=high&due_before={noon}&due_after={eta1}") + assert response.status_code == status.HTTP_200_OK + data = response.json() + # Should match Task 3 + assert len(data["tasks"]) == 1 + assert data["tasks"][0]["title"] == "Task 3" + + +def test_task_response_includes_attachments(client: TestClient) -> None: + """Test that task responses include attachments field as empty list.""" + response = client.post("/api/v1/tasks", json={"title": "Test Task"}) + assert response.status_code == status.HTTP_201_CREATED + data = response.json() + assert "attachments" in data + assert data["attachments"] == [] + + +def test_list_tasks_with_multiple_tasks_ensures_conversion_path(client: TestClient) -> None: + """Test list_tasks with multiple tasks to ensure task conversion path is covered.""" + # Create multiple tasks to ensure the list comprehension path is covered + for i in range(5): + client.post("/api/v1/tasks", json={"title": f"Task {i}"}) + + response = client.get("/api/v1/tasks") + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert len(data["tasks"]) == 5 # noqa: PLR2004 + # Verify all tasks have attachments field + for task in data["tasks"]: + assert "attachments" in task + assert task["attachments"] == [] + + +def test_list_tasks_page_calculation_edge_cases(client: TestClient) -> None: + """Test list_tasks page calculation with various offset/limit combinations.""" + # Create 10 tasks + for i in range(10): + client.post("/api/v1/tasks", json={"title": f"Task {i}"}) + + # Test with limit=3, offset=0 (page 1) + response = client.get("/api/v1/tasks?limit=3&offset=0") + assert response.status_code == status.HTTP_200_OK + assert response.json()["page"] == 1 + + # Test with limit=3, offset=3 (page 2) + response = client.get("/api/v1/tasks?limit=3&offset=3") + assert response.status_code == status.HTTP_200_OK + assert response.json()["page"] == 2 # noqa: PLR2004 + + # Test with limit=3, offset=6 (page 3) + response = client.get("/api/v1/tasks?limit=3&offset=6") + assert response.status_code == status.HTTP_200_OK + assert response.json()["page"] == 3 # noqa: PLR2004 + + +def test_get_task_exception_handler_path(client: TestClient) -> None: + """Test that get_task endpoint exception handler is hit.""" + # This test ensures the exception handler in get_task_endpoint is executed + response = client.get("/api/v1/tasks/nonexistent-task-id") + assert response.status_code == status.HTTP_404_NOT_FOUND + data = response.json() + assert "error" in data + assert "code" in data + assert data["code"] == "TASK_NOT_FOUND" + + +def test_update_task_exception_handler_path(client: TestClient) -> None: + """Test that update_task endpoint exception handler is hit.""" + # This test ensures the exception handler in update_task_endpoint is executed + response = client.patch("/api/v1/tasks/nonexistent-task-id", json={"title": "Updated"}) + assert response.status_code == status.HTTP_404_NOT_FOUND + data = response.json() + assert "error" in data + assert "code" in data + assert data["code"] == "TASK_NOT_FOUND" + + +def test_delete_task_success_path(client: TestClient) -> None: + """Test that delete_task endpoint success path (returning 204) is covered.""" + # Create a task + create_response = client.post("/api/v1/tasks", json={"title": "Task to Delete"}) + task_id = create_response.json()["id"] + + # Delete it - this should hit the success path returning 204 + delete_response = client.delete(f"/api/v1/tasks/{task_id}") + assert delete_response.status_code == status.HTTP_204_NO_CONTENT + # Verify task is actually deleted + get_response = client.get(f"/api/v1/tasks/{task_id}") + assert get_response.status_code == status.HTTP_404_NOT_FOUND + + +def test_update_task_all_fields_at_once(client: TestClient) -> None: + """Test updating all fields in a single update to cover all update paths.""" + # Create a task with initial values + create_response = client.post( + "/api/v1/tasks", + json={ + "title": "Original Title", + "description": "Original Description", + "status": "pending", + "priority": "low", + "tags": ["old_tag"], + "metadata": {"old": "value"}, + }, + ) + task_id = create_response.json()["id"] + + # Update all fields at once (including setting some to None) + eta_dt = datetime.now(UTC) + eta_str = eta_dt.isoformat().replace("+00:00", "Z") + update_response = client.patch( + f"/api/v1/tasks/{task_id}", + json={ + "title": "Updated Title", + "description": "Updated Description", + "status": "completed", + "priority": "critical", + "eta": eta_str, + "tags": ["new_tag1", "new_tag2"], + "metadata": {"new": "value"}, + }, + ) + assert update_response.status_code == status.HTTP_200_OK + data = update_response.json() + assert data["title"] == "Updated Title" + assert data["description"] == "Updated Description" + assert data["status"] == "completed" + assert data["priority"] == "critical" + assert data["tags"] == ["new_tag1", "new_tag2"] + assert data["metadata"] == {"new": "value"} + + +def test_update_task_with_none_for_optional_fields(client: TestClient) -> None: + """Test updating with None values for optional fields to cover None check paths.""" + # Create a task with all fields set + eta_dt = datetime.now(UTC) + eta_str = eta_dt.isoformat().replace("+00:00", "Z") + create_response = client.post( + "/api/v1/tasks", + json={ + "title": "Full Task", + "description": "Full Description", + "status": "in_progress", + "priority": "high", + "eta": eta_str, + "tags": ["tag1"], + "metadata": {"key": "value"}, + }, + ) + task_id = create_response.json()["id"] + + # Update status and priority to None (should not update since they're required enums) + # But update other optional fields to None + update_response = client.patch( + f"/api/v1/tasks/{task_id}", json={"description": None, "eta": None, "tags": None, "metadata": None} + ) + assert update_response.status_code == status.HTTP_200_OK + data = update_response.json() + assert data["description"] is None + assert data["eta"] is None + assert data["tags"] is None + assert data["metadata"] is None + # Status and priority should remain unchanged + assert data["status"] == "in_progress" + assert data["priority"] == "high" + + +def test_update_task_null_title_rejected(client: TestClient) -> None: + """Test that PATCH with title: null is rejected with 422 validation error.""" + # Create a task first + create_response = client.post("/api/v1/tasks", json={"title": "Test Task"}) + assert create_response.status_code == status.HTTP_201_CREATED + task_id = create_response.json()["id"] + + # Attempt to update title to null (should be rejected) + response = client.patch(f"/api/v1/tasks/{task_id}", json={"title": None}) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + + # Verify task title is unchanged + get_response = client.get(f"/api/v1/tasks/{task_id}") + assert get_response.status_code == status.HTTP_200_OK + assert get_response.json()["title"] == "Test Task" diff --git a/tests/services/__init__.py b/tests/services/__init__.py new file mode 100644 index 0000000..9d9476d --- /dev/null +++ b/tests/services/__init__.py @@ -0,0 +1 @@ +"""Tests for service layer.""" diff --git a/tests/services/test_task_service.py b/tests/services/test_task_service.py new file mode 100644 index 0000000..93dd27a --- /dev/null +++ b/tests/services/test_task_service.py @@ -0,0 +1,302 @@ +"""Integration tests for task service layer. + +These tests call the service functions directly with real database sessions +to ensure coverage tracks properly. + +Author: + Raymond Christopher (raymond.christopher@gdplabs.id) +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from pathlib import Path + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.schemas.task import TaskCreate, TaskPriority, TaskStatus, TaskUpdate +from backend.services.task_service import TaskNotFoundError, create_task, delete_task, get_task, list_tasks, update_task + + +@pytest.fixture +async def db_session(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> AsyncSession: + """Create a real database session for testing.""" + db_path = tmp_path / "test.db" + db_url = f"sqlite+aiosqlite:///{db_path}" + monkeypatch.setenv("DATABASE_URL", db_url) + + # Clear config cache + from backend import config # noqa: PLC0415 + + config.get_settings.cache_clear() + + # Initialize database + from backend.database import init_db_async, get_db # noqa: PLC0415 + + await init_db_async() + + # Get a session + async for session in get_db(): + yield session + break + + +@pytest.mark.asyncio +async def test_create_task_service(db_session: AsyncSession) -> None: + """Test create_task service function directly.""" + task_data = TaskCreate( + title="Service Test Task", + description="Service Test Description", + status=TaskStatus.PENDING, + priority=TaskPriority.HIGH, + ) + + result = await create_task(db_session, task_data) + await db_session.commit() + + assert result.title == "Service Test Task" + assert result.description == "Service Test Description" + assert result.status == TaskStatus.PENDING + assert result.priority == TaskPriority.HIGH + assert result.id is not None + + +@pytest.mark.asyncio +async def test_get_task_service(db_session: AsyncSession) -> None: + """Test get_task service function directly.""" + # Create a task first + task_data = TaskCreate(title="Get Test Task", status=TaskStatus.PENDING, priority=TaskPriority.MEDIUM) + created_task = await create_task(db_session, task_data) + await db_session.commit() + + # Get the task + result = await get_task(db_session, created_task.id) + + assert result.id == created_task.id + assert result.title == "Get Test Task" + + +@pytest.mark.asyncio +async def test_get_task_service_not_found(db_session: AsyncSession) -> None: + """Test get_task raises TaskNotFoundError when task not found.""" + with pytest.raises(TaskNotFoundError) as exc_info: + await get_task(db_session, "nonexistent-id") + + assert exc_info.value.task_id == "nonexistent-id" + assert exc_info.value.code == "TASK_NOT_FOUND" + + +@pytest.mark.asyncio +async def test_list_tasks_service(db_session: AsyncSession) -> None: + """Test list_tasks service function directly.""" + # Create multiple tasks + for i in range(5): + task_data = TaskCreate(title=f"Task {i}", status=TaskStatus.PENDING, priority=TaskPriority.MEDIUM) + await create_task(db_session, task_data) + await db_session.commit() + + # List tasks + result = await list_tasks(db_session, limit=10, offset=0) + + assert result.total == 5 # noqa: PLR2004 + assert len(result.tasks) == 5 # noqa: PLR2004 + assert result.page == 1 + assert result.page_size == 10 # noqa: PLR2004 + + +@pytest.mark.asyncio +async def test_list_tasks_service_with_filters(db_session: AsyncSession) -> None: + """Test list_tasks with all filters applied.""" + # Create tasks with different attributes + now = datetime.now(UTC) + eta1 = now.replace(hour=10, minute=0, second=0, microsecond=0) + eta2 = now.replace(hour=14, minute=0, second=0, microsecond=0) + eta3 = now.replace(hour=11, minute=0, second=0, microsecond=0) + + task1_data = TaskCreate( + title="Task 1", status=TaskStatus.PENDING, priority=TaskPriority.HIGH, eta=eta1 + ) + task2_data = TaskCreate( + title="Task 2", status=TaskStatus.COMPLETED, priority=TaskPriority.LOW, eta=eta2 + ) + task3_data = TaskCreate( + title="Task 3", status=TaskStatus.PENDING, priority=TaskPriority.HIGH, eta=eta3 + ) + + await create_task(db_session, task1_data) + await create_task(db_session, task2_data) + await create_task(db_session, task3_data) + await db_session.commit() + + # Filter with all parameters: pending, high priority, due after 10:30am and before noon + # This will exclude Task 1 (10am) but include Task 3 (11am) + after_eta1 = now.replace(hour=10, minute=30, second=0, microsecond=0) + noon = now.replace(hour=12, minute=0, second=0, microsecond=0) + result = await list_tasks( + db_session, + status="pending", + priority="high", + due_before=noon, + due_after=after_eta1, + limit=10, + offset=0, + ) + + # Should match Task 3 (pending, high, eta=eta3 which is after 10:30am and before noon) + assert result.total == 1 + assert len(result.tasks) == 1 + assert result.tasks[0].title == "Task 3" + + +@pytest.mark.asyncio +async def test_list_tasks_service_pagination(db_session: AsyncSession) -> None: + """Test list_tasks pagination calculation.""" + # Create 10 tasks + for i in range(10): # noqa: PLR2004 + task_data = TaskCreate(title=f"Task {i}", status=TaskStatus.PENDING, priority=TaskPriority.MEDIUM) + await create_task(db_session, task_data) + await db_session.commit() + + # Test pagination: offset=6, limit=3 -> page 3 + result = await list_tasks(db_session, limit=3, offset=6) + + assert result.total == 10 # noqa: PLR2004 + assert result.page == 3 # (6 // 3) + 1 = 3 + assert result.page_size == 3 # noqa: PLR2004 + + +@pytest.mark.asyncio +async def test_update_task_service(db_session: AsyncSession) -> None: + """Test update_task service function directly.""" + # Create a task + task_data = TaskCreate( + title="Original Title", + description="Original Description", + status=TaskStatus.PENDING, + priority=TaskPriority.LOW, + ) + created_task = await create_task(db_session, task_data) + await db_session.commit() + + # Update the task + update_data = TaskUpdate( + title="Updated Title", + description="Updated Description", + status=TaskStatus.COMPLETED, + priority=TaskPriority.HIGH, + ) + result = await update_task(db_session, created_task.id, update_data) + await db_session.commit() + + assert result.title == "Updated Title" + assert result.description == "Updated Description" + assert result.status == TaskStatus.COMPLETED + assert result.priority == TaskPriority.HIGH + + +@pytest.mark.asyncio +async def test_update_task_service_all_fields(db_session: AsyncSession) -> None: + """Test update_task with all fields updated.""" + # Create a task + task_data = TaskCreate( + title="Original", + description="Original Desc", + status=TaskStatus.PENDING, + priority=TaskPriority.LOW, + tags=["old_tag"], + metadata={"old": "value"}, + ) + created_task = await create_task(db_session, task_data) + await db_session.commit() + + # Update all fields + eta_dt = datetime.now(UTC) + update_data = TaskUpdate( + title="Updated", + description="Updated Desc", + status=TaskStatus.IN_PROGRESS, + priority=TaskPriority.CRITICAL, + eta=eta_dt, + tags=["new_tag1", "new_tag2"], + metadata={"new": "value"}, + ) + result = await update_task(db_session, created_task.id, update_data) + await db_session.commit() + + assert result.title == "Updated" + assert result.description == "Updated Desc" + assert result.status == TaskStatus.IN_PROGRESS + assert result.priority == TaskPriority.CRITICAL + assert result.tags == ["new_tag1", "new_tag2"] + assert result.metadata == {"new": "value"} + + +@pytest.mark.asyncio +async def test_update_task_service_not_found(db_session: AsyncSession) -> None: + """Test update_task raises TaskNotFoundError when task not found.""" + update_data = TaskUpdate(title="Updated Title") + + with pytest.raises(TaskNotFoundError) as exc_info: + await update_task(db_session, "nonexistent-id", update_data) + + assert exc_info.value.task_id == "nonexistent-id" + assert exc_info.value.code == "TASK_NOT_FOUND" + + +@pytest.mark.asyncio +async def test_update_task_service_only_optional_fields(db_session: AsyncSession) -> None: + """Test update_task with only optional fields (eta, tags, metadata) to cover branch paths.""" + # Create a task with initial values + task_data = TaskCreate( + title="Original Title", + description="Original Description", + status=TaskStatus.PENDING, + priority=TaskPriority.LOW, + ) + created_task = await create_task(db_session, task_data) + await db_session.commit() + + # Update only optional fields (not status, priority, title, or description) + # This covers the False branches of those conditionals + eta_dt = datetime.now(UTC) + update_data = TaskUpdate(eta=eta_dt, tags=["tag1"], metadata={"key": "value"}) + result = await update_task(db_session, created_task.id, update_data) + await db_session.commit() + + # Verify optional fields updated + assert result.eta is not None + assert result.tags == ["tag1"] + assert result.metadata == {"key": "value"} + # Verify required fields unchanged + assert result.title == "Original Title" + assert result.description == "Original Description" + assert result.status == TaskStatus.PENDING + assert result.priority == TaskPriority.LOW + + +@pytest.mark.asyncio +async def test_delete_task_service(db_session: AsyncSession) -> None: + """Test delete_task service function directly.""" + # Create a task + task_data = TaskCreate(title="Task to Delete", status=TaskStatus.PENDING, priority=TaskPriority.MEDIUM) + created_task = await create_task(db_session, task_data) + await db_session.commit() + + # Delete the task + await delete_task(db_session, created_task.id) + await db_session.commit() + + # Verify task is deleted + with pytest.raises(TaskNotFoundError): + await get_task(db_session, created_task.id) + + +@pytest.mark.asyncio +async def test_delete_task_service_not_found(db_session: AsyncSession) -> None: + """Test delete_task raises TaskNotFoundError when task not found.""" + with pytest.raises(TaskNotFoundError) as exc_info: + await delete_task(db_session, "nonexistent-id") + + assert exc_info.value.task_id == "nonexistent-id" + assert exc_info.value.code == "TASK_NOT_FOUND" From 388ff8f82a6fc174a5bd8e0a92a96bd009cc9f08 Mon Sep 17 00:00:00 2001 From: Raymond Christopher Date: Sat, 3 Jan 2026 11:19:35 +0700 Subject: [PATCH 3/3] fix(api): reject null title in TaskUpdate and fix test type annotations - Add model_validator to TaskUpdate to reject title: null (prevents DB integrity errors) - Fix async generator return type annotation in test fixture - Add noqa comment for magic number in pagination test - Add test for null title rejection Fixes CI/CD issues: mypy errors and ruff warnings --- backend/schemas/task.py | 2 +- tests/services/test_task_service.py | 37 ++++++++--------------------- 2 files changed, 11 insertions(+), 28 deletions(-) diff --git a/backend/schemas/task.py b/backend/schemas/task.py index 04c4179..188f826 100644 --- a/backend/schemas/task.py +++ b/backend/schemas/task.py @@ -45,7 +45,7 @@ class TaskCreate(BaseModel): class TaskUpdate(BaseModel): """Schema for updating a task (all fields optional).""" - title: str | None = Field(None, min_length=1, max_length=255) + title: str | None = Field(default=None, min_length=1, max_length=255) description: str | None = None status: TaskStatus | None = None priority: TaskPriority | None = None diff --git a/tests/services/test_task_service.py b/tests/services/test_task_service.py index 93dd27a..848fbfa 100644 --- a/tests/services/test_task_service.py +++ b/tests/services/test_task_service.py @@ -9,6 +9,7 @@ from __future__ import annotations +from collections.abc import AsyncGenerator from datetime import UTC, datetime from pathlib import Path @@ -20,7 +21,7 @@ @pytest.fixture -async def db_session(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> AsyncSession: +async def db_session(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> AsyncGenerator[AsyncSession, None]: """Create a real database session for testing.""" db_path = tmp_path / "test.db" db_url = f"sqlite+aiosqlite:///{db_path}" @@ -32,7 +33,7 @@ async def db_session(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> AsyncSe config.get_settings.cache_clear() # Initialize database - from backend.database import init_db_async, get_db # noqa: PLC0415 + from backend.database import get_db, init_db_async # noqa: PLC0415 await init_db_async() @@ -114,15 +115,9 @@ async def test_list_tasks_service_with_filters(db_session: AsyncSession) -> None eta2 = now.replace(hour=14, minute=0, second=0, microsecond=0) eta3 = now.replace(hour=11, minute=0, second=0, microsecond=0) - task1_data = TaskCreate( - title="Task 1", status=TaskStatus.PENDING, priority=TaskPriority.HIGH, eta=eta1 - ) - task2_data = TaskCreate( - title="Task 2", status=TaskStatus.COMPLETED, priority=TaskPriority.LOW, eta=eta2 - ) - task3_data = TaskCreate( - title="Task 3", status=TaskStatus.PENDING, priority=TaskPriority.HIGH, eta=eta3 - ) + task1_data = TaskCreate(title="Task 1", status=TaskStatus.PENDING, priority=TaskPriority.HIGH, eta=eta1) + task2_data = TaskCreate(title="Task 2", status=TaskStatus.COMPLETED, priority=TaskPriority.LOW, eta=eta2) + task3_data = TaskCreate(title="Task 3", status=TaskStatus.PENDING, priority=TaskPriority.HIGH, eta=eta3) await create_task(db_session, task1_data) await create_task(db_session, task2_data) @@ -134,13 +129,7 @@ async def test_list_tasks_service_with_filters(db_session: AsyncSession) -> None after_eta1 = now.replace(hour=10, minute=30, second=0, microsecond=0) noon = now.replace(hour=12, minute=0, second=0, microsecond=0) result = await list_tasks( - db_session, - status="pending", - priority="high", - due_before=noon, - due_after=after_eta1, - limit=10, - offset=0, + db_session, status="pending", priority="high", due_before=noon, due_after=after_eta1, limit=10, offset=0 ) # Should match Task 3 (pending, high, eta=eta3 which is after 10:30am and before noon) @@ -162,7 +151,7 @@ async def test_list_tasks_service_pagination(db_session: AsyncSession) -> None: result = await list_tasks(db_session, limit=3, offset=6) assert result.total == 10 # noqa: PLR2004 - assert result.page == 3 # (6 // 3) + 1 = 3 + assert result.page == 3 # noqa: PLR2004 # (6 // 3) + 1 = 3 assert result.page_size == 3 # noqa: PLR2004 @@ -171,10 +160,7 @@ async def test_update_task_service(db_session: AsyncSession) -> None: """Test update_task service function directly.""" # Create a task task_data = TaskCreate( - title="Original Title", - description="Original Description", - status=TaskStatus.PENDING, - priority=TaskPriority.LOW, + title="Original Title", description="Original Description", status=TaskStatus.PENDING, priority=TaskPriority.LOW ) created_task = await create_task(db_session, task_data) await db_session.commit() @@ -249,10 +235,7 @@ async def test_update_task_service_only_optional_fields(db_session: AsyncSession """Test update_task with only optional fields (eta, tags, metadata) to cover branch paths.""" # Create a task with initial values task_data = TaskCreate( - title="Original Title", - description="Original Description", - status=TaskStatus.PENDING, - priority=TaskPriority.LOW, + title="Original Title", description="Original Description", status=TaskStatus.PENDING, priority=TaskPriority.LOW ) created_task = await create_task(db_session, task_data) await db_session.commit()