diff --git a/app/reports.py b/app/reports.py index 2d7bf93..9ea26b1 100644 --- a/app/reports.py +++ b/app/reports.py @@ -6,7 +6,7 @@ from __future__ import annotations -from datetime import datetime +from datetime import datetime, timezone from typing import Iterable from app.data import all_reports @@ -16,6 +16,14 @@ _SORTABLE_FIELDS = {"id", "title", "status", "owner", "amount", "created_at"} +def _normalize_datetime(value: datetime | None) -> datetime | None: + if value is None: + return None + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + def query( *, status: ReportStatus | None = None, @@ -29,6 +37,9 @@ def query( if sort not in _SORTABLE_FIELDS: raise ValueError(f"Unsupported sort field: {sort!r}") + date_from = _normalize_datetime(date_from) + date_to = _normalize_datetime(date_to) + rows: Iterable[Report] = all_reports() if status is not None: diff --git a/app/test_main.py b/app/test_main.py new file mode 100644 index 0000000..842696f --- /dev/null +++ b/app/test_main.py @@ -0,0 +1,46 @@ +from fastapi.testclient import TestClient + +from app.main import app + + +client = TestClient(app) + + +def test_health_returns_ok() -> None: + response = client.get("/health") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + +def test_reports_accepts_all_query_params() -> None: + response = client.get( + "/reports", + params={ + "status": "approved", + "date_from": "2026-01-01T00:00:00Z", + "date_to": "2026-04-01T00:00:00Z", + "sort": "amount", + "descending": "false", + "offset": 2, + "limit": 5, + }, + ) + assert response.status_code == 200 + + payload = response.json() + assert payload["offset"] == 2 + assert payload["limit"] == 5 + assert isinstance(payload["total"], int) + assert len(payload["items"]) <= 5 + assert all(item["status"] == "approved" for item in payload["items"]) + + +def test_reports_date_filters_are_inclusive() -> None: + response = client.get( + "/reports", + params={"date_from": "2026-01-05", "date_to": "2026-01-05T23:59:59Z", "limit": 1}, + ) + assert response.status_code == 200 + payload = response.json() + assert payload["limit"] == 1 + assert len(payload["items"]) <= 1 diff --git a/openspec/specs/reports_api.yaml b/openspec/specs/reports_api.yaml new file mode 100644 index 0000000..9bc3a67 --- /dev/null +++ b/openspec/specs/reports_api.yaml @@ -0,0 +1,126 @@ +openapi: 3.0.3 +info: + title: Reports API + version: 0.1.0 +paths: + /health: + get: + summary: Health check + description: Returns a simple JSON payload indicating service availability. + responses: + '200': + description: Service is healthy + content: + application/json: + schema: + type: object + properties: + status: + type: string + enum: [ok] + required: [status] + /reports: + get: + summary: List reports + description: | + Returns a paginated list of public report records. + Supports filtering by status and created date range, sorting, and pagination. + parameters: + - name: status + in: query + description: Filter reports by status. + required: false + schema: + type: string + enum: [pending, approved, rejected, archived] + - name: date_from + in: query + description: Lower bound on `created_at` (inclusive). ISO 8601 datetime. + required: false + schema: + type: string + format: date-time + - name: date_to + in: query + description: Upper bound on `created_at` (inclusive). ISO 8601 datetime. + required: false + schema: + type: string + format: date-time + - name: sort + in: query + description: Field used to order results. + required: false + schema: + type: string + enum: [id, title, status, owner, amount, created_at] + default: created_at + - name: descending + in: query + description: Whether to sort results descending. + required: false + schema: + type: boolean + default: true + - name: offset + in: query + description: Pagination offset. + required: false + schema: + type: integer + minimum: 0 + default: 0 + - name: limit + in: query + description: Page size (maximum 200). + required: false + schema: + type: integer + minimum: 1 + maximum: 200 + default: 20 + responses: + '200': + description: Paginated report list + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: + type: object + properties: + id: + type: integer + title: + type: string + status: + type: string + enum: [pending, approved, rejected, archived] + owner: + type: string + amount: + type: number + created_at: + type: string + format: date-time + required: [id, title, status, owner, amount, created_at] + total: + type: integer + offset: + type: integer + limit: + type: integer + required: [items, total, offset, limit] + '400': + description: Invalid request parameters + content: + application/json: + schema: + type: object + properties: + detail: + type: string + required: [detail]