diff --git a/ .env.example b/ .env.example new file mode 100644 index 0000000..b1cef45 --- /dev/null +++ b/ .env.example @@ -0,0 +1,7 @@ +POSTGRES_DB=tasks +POSTGRES_USER=tasks +POSTGRES_PASSWORD=change-me +DATABASE_URL=postgresql+psycopg2://tasks:change-me@db:5432/tasks +APP_NAME=Task API +LOG_LEVEL=INFO +API_PORT=8000 diff --git a/.agents/plans/wf-draft-7f4395e5f64d/DETAIL.md b/.agents/plans/wf-draft-7f4395e5f64d/DETAIL.md new file mode 100644 index 0000000..a799c9f --- /dev/null +++ b/.agents/plans/wf-draft-7f4395e5f64d/DETAIL.md @@ -0,0 +1,67 @@ +# Plan Detail + +## Step 0: Implement the task REST API service with FastAPI, including standard CRUD endpoints, HTTP status semantics, request and response models, and structured error handling. + +- **Capability:** Build a FastAPI service that exposes validated task CRUD operations and returns appropriate HTTP responses for successful and invalid requests. +- **Plan label:** gap +- **Reusable capability:** False +- **Rationale:** This is the primary deliverable-specific API implementation for the requested tasks resource, not a standalone capability intended for reuse across unrelated future plans. +- **Input schema:** `{'requirements': 'object'}` +- **Output schema:** `{'service_name': 'string', 'source_bundle': 'object', 'api_contract': 'object', 'implementation_notes': 'string[]'}` +- **Acceptance criteria:** (none) +- **Success conditions:** (none) + +## Step 1: Add a PostgreSQL persistence layer for the task service using SQLAlchemy models, session management, database initialization or migrations, and durable task storage integrated with the existing API contract. + +- **Capability:** Implement task-specific PostgreSQL persistence with validated database models, managed sessions, schema initialization, and CRUD repository operations. +- **Plan label:** gap +- **Reusable capability:** False +- **Rationale:** The persistence implementation is tailored to this API's task schema and deployment; it is part of the deliverable rather than an independently reusable registry agent. +- **Input schema:** `{'service_name': 'string', 'source_bundle': 'object', 'api_contract': 'object', 'implementation_notes': 'string[]'}` +- **Output schema:** `{'service_name': 'string', 'source_bundle': 'object', 'api_contract': 'object', 'persistence_contract': 'object', 'implementation_notes': 'string[]'}` +- **Acceptance criteria:** (none) +- **Success conditions:** (none) + +## Step 2: Define and integrate Pydantic request and response schemas for task fields, status values, identifiers, timestamps, and validation errors across the existing FastAPI service and persistence contract. + +- **Capability:** Implement typed Pydantic validation models that enforce the task API's input and output schema, including identifier and timestamp serialization rules. +- **Plan label:** gap +- **Reusable capability:** False +- **Rationale:** These schemas define the requested task API contract and are deliverable content for this service, not a general-purpose reusable capability. +- **Input schema:** `{'service_name': 'string', 'source_bundle': 'object', 'api_contract': 'object', 'persistence_contract': 'object', 'implementation_notes': 'string[]'}` +- **Output schema:** `{'service_name': 'string', 'source_bundle': 'object', 'api_contract': 'object', 'persistence_contract': 'object', 'validation_contract': 'object', 'implementation_notes': 'string[]'}` +- **Acceptance criteria:** (none) +- **Success conditions:** (none) + +## Step 3: Add automated pytest coverage for the task API, including CRUD behavior, request and response validation failures, HTTP status semantics, and persistence interactions using isolated test data. + +- **Capability:** Create repeatable API tests that exercise validated task CRUD operations, error handling, and database-backed persistence behavior. +- **Plan label:** gap +- **Reusable capability:** False +- **Rationale:** Tests verify this specific task API and its chosen persistence stack, so they belong to the single deliverable. +- **Input schema:** `{'service_name': 'string', 'source_bundle': 'object', 'api_contract': 'object', 'persistence_contract': 'object', 'validation_contract': 'object', 'implementation_notes': 'string[]'}` +- **Output schema:** `{'service_name': 'string', 'source_bundle': 'object', 'api_contract': 'object', 'persistence_contract': 'object', 'validation_contract': 'object', 'test_bundle': 'object', 'test_contract': 'object', 'implementation_notes': 'string[]'}` +- **Acceptance criteria:** (none) +- **Success conditions:** (none) + +## Step 4: Containerize the FastAPI task service and PostgreSQL persistence service with Docker Compose, including environment-based configuration, health checks, networking, startup dependencies, and persistent database storage. + +- **Capability:** Package the API and database as cooperating Docker Compose services with reproducible configuration, health-aware orchestration, isolated networking, and durable volumes. +- **Plan label:** gap +- **Reusable capability:** False +- **Rationale:** The Compose topology and container configuration are specific to this requested project and are not a standalone reusable registry capability. +- **Input schema:** `{'service_name': 'string', 'source_bundle': 'object', 'api_contract': 'object', 'persistence_contract': 'object', 'validation_contract': 'object', 'test_bundle': 'object', 'test_contract': 'object', 'implementation_notes': 'string[]'}` +- **Output schema:** `{'service_name': 'string', 'source_bundle': 'object', 'deployment_bundle': 'object', 'deployment_contract': 'object', 'api_contract': 'object', 'persistence_contract': 'object', 'validation_contract': 'object', 'test_bundle': 'object', 'test_contract': 'object', 'implementation_notes': 'string[]'}` +- **Acceptance criteria:** (none) +- **Success conditions:** (none) + +## Step 5: Create project documentation and operational guidance covering local setup, Docker Compose configuration, environment variables, API endpoints, validation and error responses, testing commands, persistence behavior, and relevant best-practice notes. + +- **Capability:** Document how to configure, run, use, test, and operate the containerized task REST API and its PostgreSQL dependency. +- **Plan label:** gap +- **Reusable capability:** False +- **Rationale:** Documentation describes and operates this concrete project, so it is deliverable content rather than a reusable capability. +- **Input schema:** `{'service_name': 'string', 'source_bundle': 'object', 'deployment_bundle': 'object', 'deployment_contract': 'object', 'api_contract': 'object', 'persistence_contract': 'object', 'validation_contract': 'object', 'test_bundle': 'object', 'test_contract': 'object', 'implementation_notes': 'string[]'}` +- **Output schema:** `{'service_name': 'string', 'documentation_bundle': 'object', 'documentation_contract': 'object'}` +- **Acceptance criteria:** (none) +- **Success conditions:** (none) diff --git a/.agents/plans/wf-draft-7f4395e5f64d/MAP.md b/.agents/plans/wf-draft-7f4395e5f64d/MAP.md new file mode 100644 index 0000000..be4e6af --- /dev/null +++ b/.agents/plans/wf-draft-7f4395e5f64d/MAP.md @@ -0,0 +1,23 @@ +# Plan Map + +**Workflow:** wf-draft-7f4395e5f64d +**Intent:** Build a lightweight REST API using python and docker that includes standard CRUD endpoints, data persistence, schema validation, and basic test coverage. It should be built using microservices and following best practices +**This repo covers:** step 0, step 1, step 2, step 3, step 4, step 5 + +## Dependency graph + +- Step 0: no dependencies +- Step 1: depends on step 0 +- Step 2: depends on step 0, step 1 +- Step 3: depends on step 0, step 1, step 2 +- Step 4: depends on step 0, step 1, step 2, step 3 +- Step 5: depends on step 0, step 1, step 2, step 3, step 4 + +## Phase table + +Phase 1: step 0 +Phase 2: step 1 +Phase 3: step 2 +Phase 4: step 3 +Phase 5: step 4 +Phase 6: step 5 diff --git a/Dockerfile b/Dockerfile index 0884d6c..e6ed426 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1 +1,8 @@ -# TODO: generation subagent fills this in. +FROM python:3.12-slim +WORKDIR /app +ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY app ./app +EXPOSE 8000 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md index dab09aa..a31b7ec 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,58 @@ -# crucible-agent-build-lightweight-rest +# Task API -Auto-generated agent: build-lightweight-rest \ No newline at end of file +A containerized FastAPI task REST API backed by PostgreSQL and SQLAlchemy. + +## Quick start + +```bash +cp .env.example .env +docker compose up --build +curl http://localhost:8000/health +``` + +The API is available at `http://localhost:8000`; interactive OpenAPI docs are at `/docs`. + +For local development, create a virtual environment and run: + +```bash +pip install -r requirements.txt +uvicorn app.main:app --reload +pytest -q +``` + +The default local database URL is PostgreSQL. Tests override the database dependency with isolated SQLite databases, so no database server is required for the test suite. + +## Configuration + +| Variable | Default | Description | +|---|---|---| +| `DATABASE_URL` | `postgresql+psycopg2://tasks:tasks@db:5432/tasks` | SQLAlchemy database URL | +| `APP_NAME` | `Task API` | API display name | +| `LOG_LEVEL` | `INFO` | Uvicorn log level | + +Do not commit production secrets. The Compose database uses the `POSTGRES_*` variables from `.env` and persists data in the `postgres_data` volume. + +## API contract + +Tasks have a UUID `id`, required `title` (1–200 characters), optional `description` (maximum 5000), `status` (`pending`, `in_progress`, or `completed`), optional timezone-aware `due_at`, and server-managed UTC `created_at` and `updated_at` timestamps. + +- `POST /tasks` — create; returns `201` +- `GET /tasks` — list, with optional `status`, `skip`, and `limit`; returns `200` +- `GET /tasks/{id}` — fetch; returns `200`, or `404` if absent +- `PATCH /tasks/{id}` — partial update; returns `200`, or `404` +- `DELETE /tasks/{id}` — delete; returns `204`, or `404` +- `GET /health` — database-backed readiness check; returns `200` or `503` + +Invalid JSON, fields, UUIDs, or query parameters return a structured `422` response: + +```json +{"error":{"code":"VALIDATION_ERROR","message":"Request validation failed","details":[...]}} +``` + +Not-found and database failures use the same envelope with codes `TASK_NOT_FOUND` and `DATABASE_ERROR`. The API does not expose database internals. + +## Operations + +`docker compose up --build` waits for the PostgreSQL health check before starting the API. On startup the API calls `Base.metadata.create_all`; this is convenient for this small service. For production, replace it with reviewed Alembic migrations before deploying schema changes. `docker compose down` preserves data; `docker compose down -v` removes the database volume. + +Run `pytest -q` for CRUD, validation, status semantics, error envelopes, and persistence-isolation coverage. diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..a4cd703 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,2 @@ +# Schema initialization is currently performed by app.db.init_db at startup. +# Adopt Alembic migrations before production schema evolution. diff --git a/app/config.py b/app/config.py index 6ad0184..f35b86d 100644 --- a/app/config.py +++ b/app/config.py @@ -1 +1,11 @@ -"""Typed settings.""" +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + app_name: str = "Task API" + database_url: str = "postgresql+psycopg2://tasks:tasks@localhost:5432/tasks" + log_level: str = "INFO" + model_config = SettingsConfigDict(env_file=".env", extra="ignore") + + +settings = Settings() diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..065b16a --- /dev/null +++ b/app/db.py @@ -0,0 +1,26 @@ +from collections.abc import Generator + +from sqlalchemy import create_engine +from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker + +from .config import settings + + +class Base(DeclarativeBase): + pass + + +engine = create_engine(settings.database_url, pool_pre_ping=True) +SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False) + + +def init_db() -> None: + Base.metadata.create_all(bind=engine) + + +def get_db() -> Generator[Session, None, None]: + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..526d05c --- /dev/null +++ b/app/main.py @@ -0,0 +1,110 @@ +from contextlib import asynccontextmanager +from uuid import UUID + +from fastapi import Depends, FastAPI, Query, Request, status +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from sqlalchemy import text +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Session + +from .config import settings +from .db import get_db, init_db +from .models import TaskStatus +from .repository import create_task, delete_task, get_task, list_tasks, update_task +from .schemas import ErrorResponse, TaskCreate, TaskRead, TaskUpdate + + +@asynccontextmanager +async def lifespan(app: FastAPI): + init_db() + yield + + +app = FastAPI(title=settings.app_name, version="1.0.0", lifespan=lifespan) + + +def error_response(code: str, message: str, details: list | None = None) -> JSONResponse: + return JSONResponse(status_code=404 if code == "TASK_NOT_FOUND" else 500, content={"error": {"code": code, "message": message, "details": details or []}}) + + +@app.exception_handler(RequestValidationError) +async def validation_exception_handler(request: Request, exc: RequestValidationError): + details = [{"loc": list(error.get("loc", [])), "msg": error.get("msg", "Invalid value"), "type": error.get("type")} for error in exc.errors()] + return JSONResponse(status_code=422, content={"error": {"code": "VALIDATION_ERROR", "message": "Request validation failed", "details": details}}) + + +@app.get("/health") +def health(db: Session = Depends(get_db)): + try: + db.execute(text("SELECT 1")) + return {"status": "ok"} + except SQLAlchemyError: + return JSONResponse(status_code=503, content={"error": {"code": "DATABASE_UNAVAILABLE", "message": "Database is unavailable", "details": []}}) + + +@app.post("/tasks", response_model=TaskRead, status_code=status.HTTP_201_CREATED, responses={422: {"model": ErrorResponse}}) +def create(data: TaskCreate, db: Session = Depends(get_db)): + try: + return create_task(db, data) + except SQLAlchemyError: + db.rollback() + return error_response("DATABASE_ERROR", "Unable to create task") + + +@app.get("/tasks", response_model=list[TaskRead]) +def list_all(status_filter: TaskStatus | None = Query(default=None, alias="status"), skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=100)): + # dependency is intentionally declared below to keep query parameters obvious in OpenAPI + return [] + + +@app.get("/tasks", response_model=list[TaskRead], include_in_schema=False) +def _list_shadow(db: Session = Depends(get_db)): + return [] + +# Replace the parameter-only route above with the database-backed implementation. +app.routes.pop(-2) + +@app.get("/tasks", response_model=list[TaskRead]) +def list_tasks_endpoint(status_filter: TaskStatus | None = Query(default=None, alias="status"), skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=100), db: Session = Depends(get_db)): + return list_tasks(db, status_filter, skip, limit) + + +def require_task(task_id: UUID, db: Session) : + task = get_task(db, task_id) + if task is None: + raise ValueError("TASK_NOT_FOUND") + return task + + +@app.get("/tasks/{task_id}", response_model=TaskRead, responses={404: {"model": ErrorResponse}}) +def get_one(task_id: UUID, db: Session = Depends(get_db)): + task = get_task(db, task_id) + if task is None: + return error_response("TASK_NOT_FOUND", "Task not found") + return task + + +@app.patch("/tasks/{task_id}", response_model=TaskRead, responses={404: {"model": ErrorResponse}}) +def patch(task_id: UUID, data: TaskUpdate, db: Session = Depends(get_db)): + task = get_task(db, task_id) + if task is None: + return error_response("TASK_NOT_FOUND", "Task not found") + try: + return update_task(db, task, data) + except SQLAlchemyError: + db.rollback() + return error_response("DATABASE_ERROR", "Unable to update task") + + +@app.delete("/tasks/{task_id}", status_code=status.HTTP_204_NO_CONTENT, responses={404: {"model": ErrorResponse}}) +def remove(task_id: UUID, db: Session = Depends(get_db)): + task = get_task(db, task_id) + if task is None: + return error_response("TASK_NOT_FOUND", "Task not found") + try: + delete_task(db, task) + return None + except SQLAlchemyError: + db.rollback() + return error_response("DATABASE_ERROR", "Unable to delete task") diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..82203a9 --- /dev/null +++ b/app/models.py @@ -0,0 +1,26 @@ +import enum +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, Enum, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column + +from .db import Base + + +class TaskStatus(str, enum.Enum): + pending = "pending" + in_progress = "in_progress" + completed = "completed" + + +class Task(Base): + __tablename__ = "tasks" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + title: Mapped[str] = mapped_column(String(200), nullable=False, index=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[TaskStatus] = mapped_column(Enum(TaskStatus, name="task_status"), nullable=False, default=TaskStatus.pending) + due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) diff --git a/app/repository.py b/app/repository.py new file mode 100644 index 0000000..a5d1085 --- /dev/null +++ b/app/repository.py @@ -0,0 +1,39 @@ +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from .models import Task, TaskStatus +from .schemas import TaskCreate, TaskUpdate + + +def create_task(db: Session, data: TaskCreate) -> Task: + task = Task(**data.model_dump()) + db.add(task) + db.commit() + db.refresh(task) + return task + + +def list_tasks(db: Session, status: TaskStatus | None, skip: int, limit: int) -> list[Task]: + query = select(Task).order_by(Task.created_at.desc()).offset(skip).limit(limit) + if status is not None: + query = query.where(Task.status == status) + return list(db.scalars(query).all()) + + +def get_task(db: Session, task_id: UUID) -> Task | None: + return db.get(Task, str(task_id)) + + +def update_task(db: Session, task: Task, data: TaskUpdate) -> Task: + for key, value in data.model_dump(exclude_unset=True).items(): + setattr(task, key, value) + db.commit() + db.refresh(task) + return task + + +def delete_task(db: Session, task: Task) -> None: + db.delete(task) + db.commit() diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 0000000..565aa31 --- /dev/null +++ b/app/schemas.py @@ -0,0 +1,64 @@ +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from .models import TaskStatus + + +class TaskBase(BaseModel): + title: str = Field(min_length=1, max_length=200) + description: str | None = Field(default=None, max_length=5000) + status: TaskStatus = TaskStatus.pending + due_at: datetime | None = None + + @field_validator("title") + @classmethod + def title_not_blank(cls, value: str) -> str: + value = value.strip() + if not value: + raise ValueError("title must not be blank") + return value + + +class TaskCreate(TaskBase): + pass + + +class TaskUpdate(BaseModel): + title: str | None = Field(default=None, min_length=1, max_length=200) + description: str | None = Field(default=None, max_length=5000) + status: TaskStatus | None = None + due_at: datetime | None = None + + @field_validator("title") + @classmethod + def title_not_blank(cls, value: str | None) -> str | None: + if value is not None: + value = value.strip() + if not value: + raise ValueError("title must not be blank") + return value + + +class TaskRead(TaskBase): + id: UUID + created_at: datetime + updated_at: datetime + model_config = ConfigDict(from_attributes=True) + + +class ErrorDetail(BaseModel): + loc: list[str | int] = [] + msg: str + type: str | None = None + + +class ErrorBody(BaseModel): + code: str + message: str + details: list[ErrorDetail] = [] + + +class ErrorResponse(BaseModel): + error: ErrorBody diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5409fa5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,37 @@ +services: + db: + image: postgres:16-alpine + environment: + POSTGRES_DB: ${POSTGRES_DB:-tasks} + POSTGRES_USER: ${POSTGRES_USER:-tasks} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-tasks} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 5s + timeout: 5s + retries: 10 + volumes: + - postgres_data:/var/lib/postgresql/data + networks: [tasks_net] + api: + build: . + environment: + DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg2://tasks:tasks@db:5432/tasks} + APP_NAME: ${APP_NAME:-Task API} + LOG_LEVEL: ${LOG_LEVEL:-INFO} + ports: + - "${API_PORT:-8000}:8000" + depends_on: + db: + condition: service_healthy + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"] + interval: 10s + timeout: 5s + retries: 5 + networks: [tasks_net] +volumes: + postgres_data: +networks: + tasks_net: + driver: bridge diff --git a/docs/api-contract.md b/docs/api-contract.md new file mode 100644 index 0000000..6d4aeab --- /dev/null +++ b/docs/api-contract.md @@ -0,0 +1,5 @@ +# API contract + +All successful task responses use the `TaskRead` schema. `status` is one of `pending`, `in_progress`, or `completed`. IDs are UUID strings and timestamps are ISO-8601 values emitted by Pydantic. Create requires a non-blank title; update is partial and rejects an empty title. Query `limit` is 1–100 and `skip` is non-negative. + +Errors are always wrapped as `{ "error": { "code", "message", "details" } }`. Validation errors are 422, missing tasks are 404, delete success is 204 with no body, and database readiness failures are 503. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0e909a2 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-q" diff --git a/requirements.txt b/requirements.txt index e69de29..1b81c6b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -0,0 +1,7 @@ +fastapi>=0.110,<1.0 +uvicorn[standard]>=0.29,<1.0 +SQLAlchemy>=2.0,<3.0 +psycopg2-binary>=2.9,<3.0 +pydantic>=2.6,<3.0 +pytest>=8.0,<9.0 +httpx>=0.27,<1.0 diff --git a/tests/conftest.py b/tests/conftest.py index 4016ff0..3d975e3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1 +1,27 @@ -# Env var setup that must run before app.config is imported. +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from app.db import Base, get_db +from app.main import app + + +@pytest.fixture() +def client(tmp_path): + engine = create_engine(f"sqlite:///{tmp_path}/test.db", connect_args={"check_same_thread": False}) + Base.metadata.create_all(engine) + TestingSession = sessionmaker(bind=engine, expire_on_commit=False) + + def override_db(): + db = TestingSession() + try: + yield db + finally: + db.close() + + app.dependency_overrides[get_db] = override_db + with TestClient(app) as test_client: + yield test_client + app.dependency_overrides.clear() + Base.metadata.drop_all(engine) diff --git a/tests/test_tasks.py b/tests/test_tasks.py new file mode 100644 index 0000000..7eb5b3d --- /dev/null +++ b/tests/test_tasks.py @@ -0,0 +1,31 @@ +def test_crud_and_persistence(client): + response = client.post('/tasks', json={'title': 'Ship API', 'description': 'release it'}) + assert response.status_code == 201 + task = response.json() + assert task['status'] == 'pending' + task_id = task['id'] + + assert client.get('/tasks').status_code == 200 + assert client.get(f'/tasks/{task_id}').json()['title'] == 'Ship API' + updated = client.patch(f'/tasks/{task_id}', json={'status': 'completed'}).json() + assert updated['status'] == 'completed' + assert client.delete(f'/tasks/{task_id}').status_code == 204 + assert client.get(f'/tasks/{task_id}').status_code == 404 + + +def test_validation_and_status_semantics(client): + result = client.post('/tasks', json={'title': ' '}) + assert result.status_code == 422 + assert result.json()['error']['code'] == 'VALIDATION_ERROR' + assert client.post('/tasks', json={'title': 'x', 'status': 'unknown'}).status_code == 422 + assert client.get('/tasks/not-a-uuid').status_code == 422 + assert client.get('/tasks?limit=0').status_code == 422 + + +def test_filter_and_missing_resource(client): + client.post('/tasks', json={'title': 'one', 'status': 'completed'}) + client.post('/tasks', json={'title': 'two', 'status': 'pending'}) + response = client.get('/tasks?status=completed') + assert response.status_code == 200 + assert len(response.json()) == 1 + assert client.patch('/tasks/00000000-0000-0000-0000-000000000000', json={'title': 'x'}).status_code == 404