From f3c45adea585c3acc7d8591b29b1934b1f651d33 Mon Sep 17 00:00:00 2001 From: ELC Date: Mon, 23 Mar 2026 17:22:13 +0100 Subject: [PATCH] Enhance project documentation and implement new features - Updated copilot instructions for clarity and added repository conventions. - Introduced project development instructions for better onboarding. - Added task status endpoint and improved productivity report logic. - Implemented integration tests for task status and logging functionality. - Established unit tests for task fetching and productivity report generation. - Configured pytest markers for integration tests in the project setup. --- .github/copilot-instructions.md | 56 +++- .github/instructions/project.instructions.md | 43 +++ app/main.py | 16 +- pyproject.toml | 5 + tests/conftest.py | 31 +++ tests/integration/__init__.py | 0 tests/integration/test_main.py | 263 +++++++++++++++++++ tests/unit/__init__.py | 0 tests/unit/test_main.py | 40 +++ 9 files changed, 437 insertions(+), 17 deletions(-) create mode 100644 .github/instructions/project.instructions.md create mode 100644 tests/conftest.py create mode 100644 tests/integration/__init__.py create mode 100644 tests/integration/test_main.py create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/test_main.py diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 2b67e2a..8e82cf3 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,17 +1,45 @@ -# 🐍 GitHub Copilot Instructions for FastAPI Project +# GitHub Copilot Project Instructions -## Project Overview -This is a small RESTful API built with Python and the FastAPI framework. We prioritize clean, modern Python standards and clear separation of concerns. +## Purpose +This repository is a FastAPI training project for practicing GitHub Copilot workflows. Keep changes simple, explicit, and easy to review. -## Tech Stack & Structure -* **Primary Language:** Python 3.10+ -* **Framework:** FastAPI (with Uvicorn). -* **Dependency Manager:** uv (configured via pyproject.toml). -* **Data Models:** Pydantic models for all data validation and serialization. +## Stack +- Python 3.12 +- FastAPI + Uvicorn +- Pydantic models +- pytest + pytest-asyncio + httpx +- uv for dependency and command execution -## Mandatory Coding Guidelines (Copilot Context) -1. **Asynchronous Code:** All route handlers and I/O-bound functions **must** be defined using `async def` and utilize `await`. -2. **Type Hints:** All function signatures (parameters and return values) **must** use explicit, descriptive type hints. -3. **Testing:** Any new endpoint or utility function **must** have a corresponding test in the `tests/` directory using `pytest` and `httpx.AsyncClient`. -4. **Model Location:** All Pydantic data models **must** be placed in a dedicated `app/models.py` file. -5. **Return Type:** API endpoints must return standard Python dicts/lists or Pydantic models, not f-strings or raw strings. \ No newline at end of file +## Repository Conventions +- Main API module is `app/main.py`. +- Tests are under `tests/` and split into: +- `tests/unit/` for unit tests +- `tests/integration/` for endpoint/integration tests +- Shared test fixtures are in `tests/conftest.py`. +- Integration test files must use `@pytest.mark.integration`. + +## Coding Rules +1. Prefer `async def` for route handlers and I/O-bound functions. +2. Use explicit type hints for function parameters and return values. +3. Keep API responses as structured JSON-compatible values (dict/list/Pydantic), not ad-hoc text strings for new endpoints. +4. Keep changes focused; avoid unrelated refactors. +5. Follow existing naming and formatting patterns in nearby files. + +## Testing Rules +1. Add or update tests for every behavior change. +2. For endpoints, use integration tests with `httpx.AsyncClient`. +3. Cover happy path and negative/validation scenarios. +4. Keep tests deterministic; isolate mutable global state when needed. + +## Run Commands +- Install and sync dependencies: `uv sync` +- Run all tests: `uv run pytest` +- Run integration tests only: `uv run pytest tests/integration -q` +- Run unit tests only: `uv run pytest tests/unit -q` +- Run app locally: `uv run uvicorn app.main:app --reload` + +## Review Checklist +- Is behavior correct and test-covered? +- Are async and typing conventions followed? +- Are error paths validated? +- Are changes minimal and scoped to the request? \ No newline at end of file diff --git a/.github/instructions/project.instructions.md b/.github/instructions/project.instructions.md new file mode 100644 index 0000000..b33a3c5 --- /dev/null +++ b/.github/instructions/project.instructions.md @@ -0,0 +1,43 @@ +--- +applyTo: "**/*.py" +--- + +# Project Development Instructions + +## Scope +Use these instructions for general development tasks in this repository. + +## Project Context +- FastAPI training project for GitHub Copilot workflows. +- Python 3.12 runtime. +- Dependency management and command execution with uv. + +## Code Location +- API application: app/main.py +- Unit tests: tests/unit/ +- Integration tests: tests/integration/ +- Shared fixtures: tests/conftest.py + +## Implementation Standards +1. Prefer async route handlers and async I/O functions. +2. Use explicit type hints on function signatures. +3. Keep changes focused and minimal. +4. Preserve existing naming and style in the touched files. + +## API and Validation Standards +1. Return structured JSON-compatible values (dict, list, or Pydantic models). +2. Include clear validation and error handling paths for new behaviors. + +## Testing Standards +1. Add tests for each change in behavior. +2. Use httpx.AsyncClient for endpoint integration tests. +3. Mark integration tests with @pytest.mark.integration. +4. Cover both happy path and negative scenarios. +5. Keep tests deterministic and independent. + +## Common Commands +- uv sync +- uv run pytest +- uv run pytest tests/integration -q +- uv run pytest tests/unit -q +- uv run uvicorn app.main:app --reload diff --git a/app/main.py b/app/main.py index fecf5e2..705f86c 100644 --- a/app/main.py +++ b/app/main.py @@ -2,7 +2,7 @@ import asyncio from enum import Enum from typing import List -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException from pydantic import BaseModel class TaskStatus(str, Enum): @@ -44,7 +44,7 @@ async def generate_productivity_report() -> ProductivityReport: tasks = await fetch_all_tasks() total_tasks = len(tasks) - completed_tasks = sum(1 for task in tasks if task.status == TaskStatus.PENDING) + completed_tasks = sum(1 for task in tasks if task.status == TaskStatus.COMPLETE) total_hours_spent = sum(task.hours_spent for task in tasks) completion_rate = round(completed_tasks / total_tasks, 2) if total_tasks > 0 else 0.0 @@ -71,6 +71,15 @@ async def get_all_tasks(): return await fetch_all_tasks() +@app.get("/task/{task_id}/status") +async def get_task_status(task_id: int) -> dict[str, str | int]: + task = MOCK_TASKS.get(task_id) + if task is None: + raise HTTPException(status_code=404, detail=f"Task {task_id} not found") + + return {"task_id": task_id, "status": task.status} + + @app.get("/report", response_model=ProductivityReport) async def get_productivity_report(): """Returns the calculated productivity report.""" @@ -78,7 +87,8 @@ async def get_productivity_report(): @app.post("/log_task") -async def log_task(task: DeveloperTask): +async def log_task(task: DeveloperTask) -> str: + """Logs a task, assigns a new task_id on the server, and stores it in memory.""" new_id = max(MOCK_TASKS.keys()) + 1 if MOCK_TASKS else 1 task.task_id = new_id MOCK_TASKS[new_id] = task diff --git a/pyproject.toml b/pyproject.toml index 9c8492d..c3d1c71 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,3 +25,8 @@ build-backend = "hatchling.build" [tool.uv] default-groups = ["dev"] # Ensures dev group is installed by default during uv sync + +[tool.pytest.ini_options] +markers = [ + "integration: marks tests as integration tests", +] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..9d370dc --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,31 @@ +from typing import AsyncGenerator, Generator + +import pytest +import pytest_asyncio +from httpx import ASGITransport, AsyncClient + +from app.main import app as fastapi_app + + +@pytest.fixture +def app(): + return fastapi_app + + +@pytest_asyncio.fixture +async def client(app) -> AsyncGenerator[AsyncClient, None]: + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://testserver") as async_client: + yield async_client + + +@pytest.fixture +def db_session() -> Generator[None, None, None]: + # Placeholder fixture for future database-backed tests. + yield None + + +@pytest.fixture +def auth_token() -> str: + # Placeholder fixture for endpoints that require authentication. + return "test-auth-token" diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/test_main.py b/tests/integration/test_main.py new file mode 100644 index 0000000..acf860a --- /dev/null +++ b/tests/integration/test_main.py @@ -0,0 +1,263 @@ +import copy + +import pytest +from httpx import AsyncClient + +from app.main import MOCK_TASKS + + +@pytest.fixture(autouse=True) +def reset_mock_tasks() -> None: + original_tasks = copy.deepcopy(MOCK_TASKS) + yield + MOCK_TASKS.clear() + MOCK_TASKS.update(original_tasks) + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_status_returns_200(client: AsyncClient) -> None: + response = await client.get("/status") + + assert response.status_code == 200 + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_status_returns_expected_json_body(client: AsyncClient) -> None: + response = await client.get("/status") + + assert response.json() == {"status": "ok"} + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_status_response_content_type_is_json(client: AsyncClient) -> None: + response = await client.get("/status") + + assert "application/json" in response.headers["content-type"] + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_status_rejects_post_method(client: AsyncClient) -> None: + response = await client.post("/status") + + assert response.status_code == 405 + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_status_rejects_put_method(client: AsyncClient) -> None: + response = await client.put("/status") + + assert response.status_code == 405 + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_status_rejects_delete_method(client: AsyncClient) -> None: + response = await client.delete("/status") + + assert response.status_code == 405 + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_status_is_idempotent_across_multiple_calls(client: AsyncClient) -> None: + responses = [await client.get("/status") for _ in range(3)] + + assert all(response.status_code == 200 for response in responses) + assert all(response.json() == {"status": "ok"} for response in responses) + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_status_has_no_required_auth_headers(client: AsyncClient) -> None: + response = await client.get("/status", headers={}) + + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_task_status_returns_task_status_for_existing_task(client: AsyncClient) -> None: + response = await client.get("/task/1/status") + + assert response.status_code == 200 + assert response.json() == {"task_id": 1, "status": "complete"} + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_task_status_returns_404_for_missing_task(client: AsyncClient) -> None: + response = await client.get("/task/999/status") + + assert response.status_code == 404 + assert response.json() == {"detail": "Task 999 not found"} + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_task_status_rejects_non_integer_task_id(client: AsyncClient) -> None: + response = await client.get("/task/abc/status") + + assert response.status_code == 422 + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_report_returns_expected_productivity_metrics(client: AsyncClient) -> None: + response = await client.get("/report") + + assert response.status_code == 200 + assert response.json() == { + "total_tasks": 3, + "completed_tasks": 1, + "total_hours_spent": 23.5, + "completion_rate": 0.33, + } + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_log_task_returns_200_on_valid_payload(client: AsyncClient) -> None: + payload = {"task_id": 123, "title": "Draft API docs", "status": "pending", "hours_spent": 2.5} + + response = await client.post("/log_task", json=payload) + + assert response.status_code == 200 + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_log_task_assigns_new_task_id_ignoring_input_id(client: AsyncClient) -> None: + before_tasks = (await client.get("/tasks")).json() + expected_id = max(task["task_id"] for task in before_tasks) + 1 + + payload = {"task_id": 9999, "title": "Review pull request", "status": "in_progress", "hours_spent": 1.0} + response = await client.post("/log_task", json=payload) + + assert response.status_code == 200 + assert response.text == f"Task ID {expected_id} logged successfully." + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_log_task_increases_tasks_count_by_one(client: AsyncClient) -> None: + before_count = len((await client.get("/tasks")).json()) + + payload = {"task_id": 55, "title": "Implement pagination", "status": "pending", "hours_spent": 3.0} + response = await client.post("/log_task", json=payload) + after_count = len((await client.get("/tasks")).json()) + + assert response.status_code == 200 + assert after_count == before_count + 1 + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_log_task_persists_created_task_in_tasks_endpoint(client: AsyncClient) -> None: + payload = {"task_id": 11, "title": "Persisted task", "status": "complete", "hours_spent": 4.25} + + response = await client.post("/log_task", json=payload) + tasks = (await client.get("/tasks")).json() + + assert response.status_code == 200 + assert any( + task["title"] == payload["title"] + and task["status"] == payload["status"] + and task["hours_spent"] == payload["hours_spent"] + for task in tasks + ) + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_log_task_defaults_status_when_missing(client: AsyncClient) -> None: + payload = {"task_id": 77, "title": "Default status check", "hours_spent": 1.5} + + response = await client.post("/log_task", json=payload) + tasks = (await client.get("/tasks")).json() + created_task = next(task for task in tasks if task["title"] == payload["title"]) + + assert response.status_code == 200 + assert created_task["status"] == "pending" + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_log_task_defaults_hours_spent_when_missing(client: AsyncClient) -> None: + payload = {"task_id": 78, "title": "Default hours check", "status": "pending"} + + response = await client.post("/log_task", json=payload) + tasks = (await client.get("/tasks")).json() + created_task = next(task for task in tasks if task["title"] == payload["title"]) + + assert response.status_code == 200 + assert created_task["hours_spent"] == 0.0 + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_log_task_rejects_invalid_status_value(client: AsyncClient) -> None: + payload = {"task_id": 1, "title": "Invalid status", "status": "done", "hours_spent": 1.0} + + response = await client.post("/log_task", json=payload) + + assert response.status_code == 422 + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_log_task_rejects_missing_required_title(client: AsyncClient) -> None: + payload = {"task_id": 1, "status": "pending", "hours_spent": 1.0} + + response = await client.post("/log_task", json=payload) + + assert response.status_code == 422 + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_log_task_rejects_missing_required_task_id_field(client: AsyncClient) -> None: + payload = {"title": "Missing id", "status": "pending", "hours_spent": 1.0} + + response = await client.post("/log_task", json=payload) + + assert response.status_code == 422 + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_log_task_rejects_invalid_hours_spent_type(client: AsyncClient) -> None: + payload = {"task_id": 1, "title": "Invalid hours", "status": "pending", "hours_spent": "many"} + + response = await client.post("/log_task", json=payload) + + assert response.status_code == 422 + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_log_task_rejects_invalid_task_id_type(client: AsyncClient) -> None: + payload = {"task_id": "abc", "title": "Invalid id", "status": "pending", "hours_spent": 1.0} + + response = await client.post("/log_task", json=payload) + + assert response.status_code == 422 + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_log_task_response_message_contains_created_id(client: AsyncClient) -> None: + before_tasks = (await client.get("/tasks")).json() + expected_id = max(task["task_id"] for task in before_tasks) + 1 + + payload = {"task_id": 500, "title": "Message contains id", "status": "pending", "hours_spent": 0.25} + response = await client.post("/log_task", json=payload) + + assert response.status_code == 200 + assert str(expected_id) in response.text + assert "logged successfully" in response.text diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py new file mode 100644 index 0000000..770cb67 --- /dev/null +++ b/tests/unit/test_main.py @@ -0,0 +1,40 @@ +import pytest + +from app.main import TaskStatus, fetch_all_tasks, generate_productivity_report + + +@pytest.mark.asyncio +async def test_fetch_all_tasks_returns_non_empty_list() -> None: + tasks = await fetch_all_tasks() + + assert isinstance(tasks, list) + assert len(tasks) > 0 + + +@pytest.mark.asyncio +async def test_fetch_all_tasks_returns_task_models_with_expected_fields() -> None: + tasks = await fetch_all_tasks() + first_task = tasks[0] + + assert isinstance(first_task.task_id, int) + assert isinstance(first_task.title, str) + assert isinstance(first_task.status, TaskStatus) + assert isinstance(first_task.hours_spent, float) + + +@pytest.mark.asyncio +async def test_generate_productivity_report_returns_expected_totals() -> None: + report = await generate_productivity_report() + + assert report.total_tasks == 3 + assert report.completed_tasks == 1 + assert report.total_hours_spent == 23.5 + assert report.completion_rate == 0.33 + + +@pytest.mark.asyncio +async def test_generate_productivity_report_completion_rate_is_consistent_with_completed_tasks() -> None: + report = await generate_productivity_report() + + expected_completion_rate = round(report.completed_tasks / report.total_tasks, 2) + assert report.completion_rate == expected_completion_rate