From ed1748c2d1fe78eeaa99756b835910ef231474b3 Mon Sep 17 00:00:00 2001 From: "Konrad Barrek (Accenture)" Date: Thu, 9 Apr 2026 11:10:10 +0200 Subject: [PATCH 1/4] feat: 01 --- app/main.py | 39 ++++------------ app/models.py | 26 +++++++++++ pyproject.toml | 5 ++ tests/conftest.py | 11 +++++ tests/integration/__init__.py | 0 tests/integration/test_main.py | 83 ++++++++++++++++++++++++++++++++++ tests/unit/__init__.py | 0 tests/unit/test_main.py | 54 ++++++++++++++++++++++ 8 files changed, 188 insertions(+), 30 deletions(-) create mode 100644 app/models.py 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/app/main.py b/app/main.py index fecf5e2..c870797 100644 --- a/app/main.py +++ b/app/main.py @@ -1,29 +1,9 @@ -from typing import Dict import asyncio -from enum import Enum -from typing import List -from fastapi import FastAPI -from pydantic import BaseModel - -class TaskStatus(str, Enum): - """Available statuses for any task.""" - PENDING = "pending" - IN_PROGRESS = "in_progress" - COMPLETE = "complete" +from typing import Dict, List -class DeveloperTask(BaseModel): - """Model for a single task logged by a developer.""" - task_id: int - title: str - status: TaskStatus = TaskStatus.PENDING - hours_spent: float = 0.0 +from fastapi import FastAPI -class ProductivityReport(BaseModel): - """The final calculated report.""" - total_tasks: int - completed_tasks: int - total_hours_spent: float - completion_rate: float +from app.models import DeveloperTask, ProductivityReport, TaskStatus # --- Mock Database / In-Memory Service Logic @@ -44,7 +24,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 @@ -61,7 +41,7 @@ async def generate_productivity_report() -> ProductivityReport: app = FastAPI(title="Productivity Reporting System") @app.get("/status") -def get_status(): +async def get_status() -> dict: return {"status": "ok"} @@ -77,10 +57,9 @@ async def get_productivity_report(): return await generate_productivity_report() -@app.post("/log_task") -async def log_task(task: DeveloperTask): +@app.post("/log_task", response_model=DeveloperTask) +async def log_task(task: DeveloperTask) -> DeveloperTask: new_id = max(MOCK_TASKS.keys()) + 1 if MOCK_TASKS else 1 - task.task_id = new_id + task = task.model_copy(update={"task_id": new_id}) MOCK_TASKS[new_id] = task - - return f"Task ID {task.task_id} logged successfully." + return task diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..83b2af0 --- /dev/null +++ b/app/models.py @@ -0,0 +1,26 @@ +from enum import Enum + +from pydantic import BaseModel + + +class TaskStatus(str, Enum): + """Available statuses for any task.""" + PENDING = "pending" + IN_PROGRESS = "in_progress" + COMPLETE = "complete" + + +class DeveloperTask(BaseModel): + """Model for a single task logged by a developer.""" + task_id: int + title: str + status: TaskStatus = TaskStatus.PENDING + hours_spent: float = 0.0 + + +class ProductivityReport(BaseModel): + """The final calculated report.""" + total_tasks: int + completed_tasks: int + total_hours_spent: float + completion_rate: float 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..6cd8dfb --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,11 @@ +import pytest +import pytest_asyncio +from httpx import AsyncClient, ASGITransport + +from app.main import app + + +@pytest_asyncio.fixture +async def client() -> AsyncClient: + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac: + yield ac 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..dee5802 --- /dev/null +++ b/tests/integration/test_main.py @@ -0,0 +1,83 @@ +import pytest +from httpx import AsyncClient + +from app.models import DeveloperTask, ProductivityReport, TaskStatus + + +@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_ok(client: AsyncClient) -> None: + response = await client.get("/status") + assert response.json() == {"status": "ok"} + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_get_all_tasks_returns_200(client: AsyncClient) -> None: + response = await client.get("/tasks") + assert response.status_code == 200 + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_get_all_tasks_returns_list(client: AsyncClient) -> None: + response = await client.get("/tasks") + data = response.json() + assert isinstance(data, list) + assert len(data) > 0 + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_get_all_tasks_validates_against_model(client: AsyncClient) -> None: + response = await client.get("/tasks") + tasks = [DeveloperTask(**item) for item in response.json()] + assert all(isinstance(t, DeveloperTask) for t in tasks) + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_get_report_returns_200(client: AsyncClient) -> None: + response = await client.get("/report") + assert response.status_code == 200 + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_get_report_validates_against_model(client: AsyncClient) -> None: + response = await client.get("/report") + report = ProductivityReport(**response.json()) + assert isinstance(report, ProductivityReport) + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_log_task_returns_201(client: AsyncClient) -> None: + payload = {"task_id": 0, "title": "New test task", "status": TaskStatus.PENDING, "hours_spent": 2.0} + response = await client.post("/log_task", json=payload) + assert response.status_code == 200 + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_log_task_returns_developer_task(client: AsyncClient) -> None: + payload = {"task_id": 0, "title": "New test task", "status": TaskStatus.PENDING, "hours_spent": 2.0} + response = await client.post("/log_task", json=payload) + task = DeveloperTask(**response.json()) + assert isinstance(task, DeveloperTask) + assert task.title == "New test task" + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_log_task_validation_error_on_missing_title(client: AsyncClient) -> None: + payload = {"task_id": 0, "hours_spent": 1.0} + response = await client.post("/log_task", json=payload) + assert response.status_code == 422 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..d0fefbd --- /dev/null +++ b/tests/unit/test_main.py @@ -0,0 +1,54 @@ +import pytest + +from app.main import fetch_all_tasks, generate_productivity_report, MOCK_TASKS +from app.models import DeveloperTask, ProductivityReport, TaskStatus + + +@pytest.mark.asyncio +async def test_fetch_all_tasks_returns_list() -> None: + result = await fetch_all_tasks() + assert isinstance(result, list) + + +@pytest.mark.asyncio +async def test_fetch_all_tasks_returns_developer_task_instances() -> None: + result = await fetch_all_tasks() + assert all(isinstance(task, DeveloperTask) for task in result) + + +@pytest.mark.asyncio +async def test_fetch_all_tasks_matches_mock_db() -> None: + result = await fetch_all_tasks() + assert len(result) == len(MOCK_TASKS) + + +@pytest.mark.asyncio +async def test_generate_productivity_report_returns_model() -> None: + result = await generate_productivity_report() + assert isinstance(result, ProductivityReport) + + +@pytest.mark.asyncio +async def test_generate_productivity_report_total_tasks() -> None: + result = await generate_productivity_report() + assert result.total_tasks == len(MOCK_TASKS) + + +@pytest.mark.asyncio +async def test_generate_productivity_report_completion_rate_bounds() -> None: + result = await generate_productivity_report() + assert 0.0 <= result.completion_rate <= 1.0 + + +@pytest.mark.asyncio +async def test_generate_productivity_report_completed_tasks_counts_complete_status() -> None: + result = await generate_productivity_report() + expected = sum(1 for t in MOCK_TASKS.values() if t.status == TaskStatus.COMPLETE) + assert result.completed_tasks == expected + + +@pytest.mark.asyncio +async def test_generate_productivity_report_total_hours() -> None: + result = await generate_productivity_report() + expected = round(sum(t.hours_spent for t in MOCK_TASKS.values()), 2) + assert result.total_hours_spent == expected From e22f7769b5411142c3298195cea0307f7ee90d0a Mon Sep 17 00:00:00 2001 From: "Konrad Barrek (Accenture)" Date: Thu, 9 Apr 2026 11:24:07 +0200 Subject: [PATCH 2/4] feat: 02 --- .github/prompts/logs-audit.prompt.md | 20 ++++++++++++++++++++ app/main.py | 7 +++++++ 2 files changed, 27 insertions(+) create mode 100644 .github/prompts/logs-audit.prompt.md diff --git a/.github/prompts/logs-audit.prompt.md b/.github/prompts/logs-audit.prompt.md new file mode 100644 index 0000000..1d91870 --- /dev/null +++ b/.github/prompts/logs-audit.prompt.md @@ -0,0 +1,20 @@ +--- +mode: 'ask' +description: 'Performs a production logging audit of selected code, producing an actionable TODO list.' +--- +## Role: Production Logging Auditor + +Analyze the selected code block (#selection) and perform a production-readiness audit focused on observability and logging practices. + +Generate a structured report of issues found in the following format. Ensure the analysis is specific to the selected code, but consider the overall application context. + +### 🔴 High Priority (Immediate Fix) +- List any missing critical log events (e.g., unhandled exceptions not logged, authentication failures not recorded, data mutations with no audit trail). + +### 🟡 Medium Priority (Recommended Fix) +- List any issues with log quality (e.g., log messages that expose sensitive data like passwords or tokens, missing correlation IDs or request context, inappropriate log levels such as using DEBUG in a hot path or ERROR for expected conditions). + +### 🟢 Low Priority (Best Practice) +- List any suggestions for improving production observability (e.g., missing structured/JSON logging, no log sampling strategy for high-volume endpoints, absence of performance/latency logging, lack of log retention or rotation configuration). + +Return the report as a Markdown TODO list (using `- [ ]`) to facilitate tracking. diff --git a/app/main.py b/app/main.py index c870797..46f020d 100644 --- a/app/main.py +++ b/app/main.py @@ -5,6 +5,13 @@ from app.models import DeveloperTask, ProductivityReport, TaskStatus +@app.get("/task/{task_id}/status") +async def get_task_status(task_id: int) -> dict: + """Returns the status of a specific task by its ID.""" + task = MOCK_TASKS.get(task_id) + if not task: + return {"error": "Task not found"} + return {"task_id": task_id, "status": task.status} # --- Mock Database / In-Memory Service Logic MOCK_TASKS: Dict[int, DeveloperTask] = { From 04480d5398b92af1090b6815971603681daa9abc Mon Sep 17 00:00:00 2001 From: "Konrad Barrek (Accenture)" Date: Thu, 9 Apr 2026 11:59:24 +0200 Subject: [PATCH 3/4] feat/04 --- .github/copilot-instructions.md | 28 ++++++++++++++++++++- app/main.py | 22 +++++++++-------- pyproject.toml | 1 + tests/integration/test_main.py | 43 +++++++++++++++++++++++++++++++++ tests/unit/test_main.py | 37 +++++++++++++++++++++++++++- uv.lock | 40 ++++++++++++++++++++++++++++++ 6 files changed, 159 insertions(+), 12 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 2b67e2a..3c46ba8 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -14,4 +14,30 @@ This is a small RESTful API built with Python and the FastAPI framework. We prio 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 +5. **Return Type:** API endpoints must return standard Python dicts/lists or Pydantic models, not f-strings or raw strings. + +## Testing Standards +### Directory Structure +- Unit tests go in `tests/unit/`, integration tests go in `tests/integration/`. +- Shared fixtures (e.g., `app`, `client`) are defined in `tests/conftest.py`. +- Do **not** create test files outside the `tests/` directory. + +### Naming Conventions +- Test files: `test_.py` (e.g., `app/main.py` → `tests/unit/test_main.py`). +- Test functions: `test__` (e.g., `test_get_tasks_returns_list`). + +### Unit Tests +- Test one function or class in isolation. +- Mock all external/async I/O dependencies using `unittest.mock` or `pytest-mock`. +- Use `pytest.raises` to assert expected exceptions. +- Do **not** spin up the FastAPI app for unit tests. + +### Integration Tests +- Use `httpx.AsyncClient` with the FastAPI `app` directly (no live server needed). +- Mark every integration test with both `@pytest.mark.asyncio` and `@pytest.mark.integration`. +- Cover: happy path, validation errors (422), not-found cases (404), and edge cases. +- Assert HTTP status codes, JSON response structure, and Pydantic model conformance. + +### Coverage +- Run coverage with: `uv run pytest --cov=app tests/` +- Target **85%+** coverage for any changed module. \ No newline at end of file diff --git a/app/main.py b/app/main.py index 46f020d..b364e31 100644 --- a/app/main.py +++ b/app/main.py @@ -5,13 +5,8 @@ from app.models import DeveloperTask, ProductivityReport, TaskStatus -@app.get("/task/{task_id}/status") -async def get_task_status(task_id: int) -> dict: - """Returns the status of a specific task by its ID.""" - task = MOCK_TASKS.get(task_id) - if not task: - return {"error": "Task not found"} - return {"task_id": task_id, "status": task.status} +# --- FastAPI Initialization --- +app = FastAPI(title="Productivity Reporting System") # --- Mock Database / In-Memory Service Logic MOCK_TASKS: Dict[int, DeveloperTask] = { @@ -44,9 +39,7 @@ async def generate_productivity_report() -> ProductivityReport: ) -# --- FastAPI Initialization and Routes --- -app = FastAPI(title="Productivity Reporting System") - +# --- Routes --- @app.get("/status") async def get_status() -> dict: return {"status": "ok"} @@ -64,6 +57,15 @@ async def get_productivity_report(): return await generate_productivity_report() +@app.get("/task/{task_id}/status") +async def get_task_status(task_id: int) -> dict: + """Returns the status of a specific task by its ID.""" + task = MOCK_TASKS.get(task_id) + if not task: + return {"error": "Task not found"} + return {"task_id": task_id, "status": task.status} + + @app.post("/log_task", response_model=DeveloperTask) async def log_task(task: DeveloperTask) -> DeveloperTask: new_id = max(MOCK_TASKS.keys()) + 1 if MOCK_TASKS else 1 diff --git a/pyproject.toml b/pyproject.toml index c3d1c71..6420925 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ dev = [ "pytest>=8.1.1", "pytest-asyncio>=0.21.0", + "pytest-cov>=7.1.0", ] diff --git a/tests/integration/test_main.py b/tests/integration/test_main.py index dee5802..5c50f23 100644 --- a/tests/integration/test_main.py +++ b/tests/integration/test_main.py @@ -81,3 +81,46 @@ async def test_log_task_validation_error_on_missing_title(client: AsyncClient) - payload = {"task_id": 0, "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_get_report_returns_correct_values(client: AsyncClient) -> None: + response = await client.get("/report") + report = ProductivityReport(**response.json()) + assert report.total_tasks > 0 + assert report.completed_tasks >= 1 + assert report.total_hours_spent > 0 + assert 0.0 <= report.completion_rate <= 1.0 + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_get_report_completion_rate_is_float(client: AsyncClient) -> None: + response = await client.get("/report") + report = ProductivityReport(**response.json()) + assert isinstance(report.completion_rate, float) + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_get_task_status_returns_200(client: AsyncClient) -> None: + response = await client.get("/task/1/status") + assert response.status_code == 200 + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_get_task_status_returns_correct_status(client: AsyncClient) -> None: + response = await client.get("/task/1/status") + data = response.json() + assert data["task_id"] == 1 + assert data["status"] == TaskStatus.COMPLETE + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_get_task_status_not_found_returns_error(client: AsyncClient) -> None: + response = await client.get("/task/9999/status") + assert response.status_code == 200 + assert response.json() == {"error": "Task not found"} \ No newline at end of file diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py index d0fefbd..33cf564 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -1,6 +1,6 @@ import pytest -from app.main import fetch_all_tasks, generate_productivity_report, MOCK_TASKS +from app.main import fetch_all_tasks, generate_productivity_report, get_task_status, log_task, MOCK_TASKS from app.models import DeveloperTask, ProductivityReport, TaskStatus @@ -52,3 +52,38 @@ async def test_generate_productivity_report_total_hours() -> None: result = await generate_productivity_report() expected = round(sum(t.hours_spent for t in MOCK_TASKS.values()), 2) assert result.total_hours_spent == expected + + +@pytest.mark.asyncio +async def test_get_task_status_returns_status_for_existing_task() -> None: + result = await get_task_status(1) + assert result == {"task_id": 1, "status": TaskStatus.COMPLETE} + + +@pytest.mark.asyncio +async def test_get_task_status_returns_error_for_missing_task() -> None: + result = await get_task_status(9999) + assert result == {"error": "Task not found"} + + +@pytest.mark.asyncio +async def test_log_task_assigns_new_id() -> None: + initial_max = max(MOCK_TASKS.keys()) + new_task = DeveloperTask(task_id=0, title="Test task", status=TaskStatus.PENDING, hours_spent=1.0) + result = await log_task(new_task) + assert result.task_id == initial_max + 1 + + +@pytest.mark.asyncio +async def test_log_task_stores_task_in_mock_db() -> None: + new_task = DeveloperTask(task_id=0, title="Stored task", status=TaskStatus.IN_PROGRESS, hours_spent=3.0) + result = await log_task(new_task) + assert result.task_id in MOCK_TASKS + assert MOCK_TASKS[result.task_id].title == "Stored task" + + +@pytest.mark.asyncio +async def test_log_task_returns_developer_task_instance() -> None: + new_task = DeveloperTask(task_id=0, title="Return type task", status=TaskStatus.PENDING, hours_spent=0.5) + result = await log_task(new_task) + assert isinstance(result, DeveloperTask) diff --git a/uv.lock b/uv.lock index 6b4097b..3730aba 100644 --- a/uv.lock +++ b/uv.lock @@ -49,6 +49,7 @@ dependencies = [ dev = [ { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pytest-cov" }, ] [package.metadata] @@ -63,6 +64,7 @@ requires-dist = [ dev = [ { name = "pytest", specifier = ">=8.1.1" }, { name = "pytest-asyncio", specifier = ">=0.21.0" }, + { name = "pytest-cov", specifier = ">=7.1.0" }, ] [[package]] @@ -95,6 +97,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "coverage" +version = "7.13.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" }, + { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" }, + { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" }, + { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" }, + { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" }, + { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" }, + { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" }, + { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" }, + { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" }, + { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" }, + { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, +] + [[package]] name = "dnspython" version = "2.8.0" @@ -445,6 +471,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, ] +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.1" From 02ef3b167e9f2713b06618befa98fc4d85085732 Mon Sep 17 00:00:00 2001 From: "Konrad Barrek (Accenture)" Date: Thu, 9 Apr 2026 12:06:46 +0200 Subject: [PATCH 4/4] feat: 05 --- .github/agents/CoverageGuardian.md | 50 ++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 .github/agents/CoverageGuardian.md diff --git a/.github/agents/CoverageGuardian.md b/.github/agents/CoverageGuardian.md new file mode 100644 index 0000000..25b1922 --- /dev/null +++ b/.github/agents/CoverageGuardian.md @@ -0,0 +1,50 @@ +--- +name: CoverageGuardian +description: Ensures test coverage never drops below 85% by identifying uncovered code and writing the missing tests. +tools: ["read", "edit", "test", "shell"] +--- + +# Agent Instructions: CoverageGuardian + +Your primary goal is to ensure that every module in `app/` meets the minimum **85% test coverage** threshold defined in the project's coding guidelines. + +1. **Iterative loop:** Run the coverage check after every new test you write. Do not open a Pull Request until all modules report 85%+ coverage. +2. **Strictly adhere to the coding guidelines** defined in `.github/copilot-instructions.md` — especially naming conventions, file placement, and the unit/integration test split. +3. **Prioritize uncovered lines:** Focus on the module with the lowest coverage first. Read the uncovered lines before writing tests — understand the logic, then test it. +4. **Test quality over quantity:** Write meaningful tests that assert real behaviour (status codes, response structure, return values). Do not write trivially-passing tests just to inflate coverage numbers. +5. **Commit messages:** Use clear, conventional commit messages prefixed with `test(coverage):`. + +# Agent Execution + +## Step 1 — Measure current coverage +Run the following command from the project root to get a line-by-line coverage report: + +```bash +uv run pytest --cov=app --cov-report=term-missing tests/ +``` + +Identify every module reporting less than 85% coverage and note the exact line numbers that are not covered. + +## Step 2 — Analyse uncovered lines +For each uncovered line, read the source file to understand what the code does. Determine whether a **unit test** or an **integration test** is more appropriate: +- Pure functions and service logic → `tests/unit/test_.py` +- HTTP endpoints → `tests/integration/test_.py` + +## Step 3 — Write the missing tests +Follow the project conventions from `.github/copilot-instructions.md`: +- Unit tests: isolated, use `unittest.mock` or `pytest-mock` for I/O, `pytest.raises` for exceptions. +- Integration tests: use `httpx.AsyncClient` via the `client` fixture from `tests/conftest.py`, mark with `@pytest.mark.asyncio` and `@pytest.mark.integration`. +- Name every test function: `test__`. + +## Step 4 — Re-run coverage and verify +```bash +uv run pytest --cov=app --cov-report=term-missing tests/ +``` + +Confirm all modules are at 85%+. If any remain below threshold, return to Step 2. + +## Step 5 — Open a Pull Request +Only open a PR once the coverage check passes for all modules. The PR description must include: +- Which modules were below threshold before the fix +- Which tests were added and why +- The final coverage report (copy the terminal output table)