From 0fba7cfe11113af966aa6071aebd4bb92a76cb3b Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Fri, 12 Jun 2026 23:24:17 +0200 Subject: [PATCH] M2-T03: add read-only data JSON API - GET /api/locations (inclusive time window start/end, pagination, cap 5000) - GET /api/poo (pagination, cap 1000, newest first) - GET /api/public-ip (current state + recent history, cap 1000) - all session-protected, read-only, bounded (no full-table export) - typed response schemas; register router; regenerate openapi/ - tests/test_api_data.py --- app/api/routes/api/data.py | 125 ++++++++ app/main.py | 2 + app/schemas/data.py | 76 +++++ openapi/openapi.json | 464 ++++++++++++++++++++++++++++ openapi/openapi.yaml | 324 ++++++++++++++++++++ tests/test_api_data.py | 611 +++++++++++++++++++++++++++++++++++++ 6 files changed, 1602 insertions(+) create mode 100644 app/api/routes/api/data.py create mode 100644 app/schemas/data.py create mode 100644 tests/test_api_data.py diff --git a/app/api/routes/api/data.py b/app/api/routes/api/data.py new file mode 100644 index 0000000..eeaea61 --- /dev/null +++ b/app/api/routes/api/data.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import desc, select +from sqlalchemy.orm import Session + +from app.api.routes.api.deps import require_session +from app.dependencies import get_db +from app.models.location import Location +from app.models.poo import PooRecord +from app.models.public_ip import PublicIPHistory, PublicIPState +from app.schemas.data import ( + LocationRecord, + LocationsResponse, + PooRecord as PooRecordSchema, + PooResponse, + PublicIPHistorySchema, + PublicIPResponse, + PublicIPStateSchema, +) +from app.services.auth import AuthenticatedSession + +router = APIRouter(prefix="/api", tags=["api-data"]) + + +@router.get("/locations", response_model=LocationsResponse) +def get_locations( + limit: int = Query(default=1000, ge=1, le=5000), + offset: int = Query(default=0, ge=0), + start: str | None = Query(default=None), + end: str | None = Query(default=None), + db: Session = Depends(get_db), + _auth: AuthenticatedSession = Depends(require_session), +) -> LocationsResponse: + """ + Return location records with optional time-window filtering and pagination. + + - ``start`` / ``end`` are ISO8601 strings; filtering is **inclusive** on both bounds. + - Results are ordered by ``datetime`` ascending. + - ``limit`` is capped at 5000 to prevent full-table exports. + """ + stmt = select(Location) + + if start is not None: + stmt = stmt.where(Location.datetime >= start) + if end is not None: + stmt = stmt.where(Location.datetime <= end) + + stmt = stmt.order_by(Location.datetime).offset(offset).limit(limit) + + rows = db.execute(stmt).scalars().all() + + items = [ + LocationRecord( + person=row.person, + datetime=row.datetime, + latitude=row.latitude, + longitude=row.longitude, + altitude=row.altitude, + ) + for row in rows + ] + + return LocationsResponse(items=items, limit=limit, offset=offset) + + +@router.get("/poo", response_model=PooResponse) +def get_poo( + limit: int = Query(default=100, ge=1, le=1000), + offset: int = Query(default=0, ge=0), + db: Session = Depends(get_db), + _auth: AuthenticatedSession = Depends(require_session), +) -> PooResponse: + """ + Return poo records ordered by timestamp descending (most recent first). + + ``limit`` is capped at 1000 to prevent full-table exports. + """ + stmt = ( + select(PooRecord) + .order_by(desc(PooRecord.timestamp)) + .offset(offset) + .limit(limit) + ) + + rows = db.execute(stmt).scalars().all() + + items = [ + PooRecordSchema( + timestamp=row.timestamp, + status=row.status, + latitude=row.latitude, + longitude=row.longitude, + ) + for row in rows + ] + + return PooResponse(items=items, limit=limit, offset=offset) + + +@router.get("/public-ip", response_model=PublicIPResponse) +def get_public_ip( + limit: int = Query(default=100, ge=1, le=1000), + db: Session = Depends(get_db), + _auth: AuthenticatedSession = Depends(require_session), +) -> PublicIPResponse: + """ + Return the current public IP state and recent history. + + - ``state`` is ``null`` if no IP check has been performed yet. + - ``history`` is ordered by ``observed_at`` descending (most recent first). + - ``limit`` applies to the history list and is capped at 1000. + """ + state_row = db.execute( + select(PublicIPState).where(PublicIPState.id == 1).limit(1) + ).scalar_one_or_none() + + history_rows = db.execute( + select(PublicIPHistory).order_by(desc(PublicIPHistory.observed_at)).limit(limit) + ).scalars().all() + + state = PublicIPStateSchema.model_validate(state_row) if state_row is not None else None + history = [PublicIPHistorySchema.model_validate(row) for row in history_rows] + + return PublicIPResponse(state=state, history=history) diff --git a/app/main.py b/app/main.py index 16a6180..aa0d921 100644 --- a/app/main.py +++ b/app/main.py @@ -9,6 +9,7 @@ from sqlalchemy.orm import Session from app import models # noqa: F401 from app.api.routes.api.config import router as api_config_router +from app.api.routes.api.data import router as api_data_router from app.api.routes.api.session import router as api_session_router from app.api.routes.auth import router as auth_router from app.api.routes import pages, status @@ -94,6 +95,7 @@ def create_app() -> FastAPI: app.include_router(auth_router) app.include_router(pages.router) app.include_router(api_config_router) + app.include_router(api_data_router) app.include_router(api_session_router) app.include_router(homeassistant_router) app.include_router(location_router) diff --git a/app/schemas/data.py b/app/schemas/data.py new file mode 100644 index 0000000..3609e13 --- /dev/null +++ b/app/schemas/data.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel + + +# --------------------------------------------------------------------------- +# Location +# --------------------------------------------------------------------------- + + +class LocationRecord(BaseModel): + person: str + datetime: str + latitude: float + longitude: float + altitude: float | None + + +class LocationsResponse(BaseModel): + items: list[LocationRecord] + limit: int + offset: int + + +# --------------------------------------------------------------------------- +# Poo +# --------------------------------------------------------------------------- + + +class PooRecord(BaseModel): + timestamp: str + status: str + latitude: float + longitude: float + + +class PooResponse(BaseModel): + items: list[PooRecord] + limit: int + offset: int + + +# --------------------------------------------------------------------------- +# Public IP +# --------------------------------------------------------------------------- + + +class PublicIPStateSchema(BaseModel): + id: int + current_ipv4: str + previous_ipv4: str | None + first_seen_at: datetime + last_checked_at: datetime + last_changed_at: datetime | None + last_check_status: str + last_check_error: str | None + last_provider: str | None + + model_config = {"from_attributes": True} + + +class PublicIPHistorySchema(BaseModel): + id: int + ipv4: str + observed_at: datetime + change_type: str + provider: str | None + + model_config = {"from_attributes": True} + + +class PublicIPResponse(BaseModel): + state: PublicIPStateSchema | None + history: list[PublicIPHistorySchema] diff --git a/openapi/openapi.json b/openapi/openapi.json index 342ab18..1d76308 100644 --- a/openapi/openapi.json +++ b/openapi/openapi.json @@ -350,6 +350,198 @@ } } }, + "/api/locations": { + "get": { + "tags": [ + "api-data" + ], + "summary": "Get Locations", + "description": "Return location records with optional time-window filtering and pagination.\n\n- ``start`` / ``end`` are ISO8601 strings; filtering is **inclusive** on both bounds.\n- Results are ordered by ``datetime`` ascending.\n- ``limit`` is capped at 5000 to prevent full-table exports.", + "operationId": "get_locations_api_locations_get", + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 5000, + "minimum": 1, + "default": 1000, + "title": "Limit" + } + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 0, + "default": 0, + "title": "Offset" + } + }, + { + "name": "start", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Start" + } + }, + { + "name": "end", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "End" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LocationsResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/poo": { + "get": { + "tags": [ + "api-data" + ], + "summary": "Get Poo", + "description": "Return poo records ordered by timestamp descending (most recent first).\n\n``limit`` is capped at 1000 to prevent full-table exports.", + "operationId": "get_poo_api_poo_get", + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 1000, + "minimum": 1, + "default": 100, + "title": "Limit" + } + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 0, + "default": 0, + "title": "Offset" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PooResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/public-ip": { + "get": { + "tags": [ + "api-data" + ], + "summary": "Get Public Ip", + "description": "Return the current public IP state and recent history.\n\n- ``state`` is ``null`` if no IP check has been performed yet.\n- ``history`` is ordered by ``observed_at`` descending (most recent first).\n- ``limit`` applies to the history list and is capped at 1000.", + "operationId": "get_public_ip_api_public_ip_get", + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 1000, + "minimum": 1, + "default": 100, + "title": "Limit" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicIPResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/api/session": { "get": { "tags": [ @@ -843,6 +1035,72 @@ "type": "object", "title": "HTTPValidationError" }, + "LocationRecord": { + "properties": { + "person": { + "type": "string", + "title": "Person" + }, + "datetime": { + "type": "string", + "title": "Datetime" + }, + "latitude": { + "type": "number", + "title": "Latitude" + }, + "longitude": { + "type": "number", + "title": "Longitude" + }, + "altitude": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Altitude" + } + }, + "type": "object", + "required": [ + "person", + "datetime", + "latitude", + "longitude", + "altitude" + ], + "title": "LocationRecord" + }, + "LocationsResponse": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/LocationRecord" + }, + "type": "array", + "title": "Items" + }, + "limit": { + "type": "integer", + "title": "Limit" + }, + "offset": { + "type": "integer", + "title": "Offset" + } + }, + "type": "object", + "required": [ + "items", + "limit", + "offset" + ], + "title": "LocationsResponse" + }, "LoginRequest": { "properties": { "username": { @@ -884,6 +1142,60 @@ ], "title": "PasswordChangeRequest" }, + "PooRecord": { + "properties": { + "timestamp": { + "type": "string", + "title": "Timestamp" + }, + "status": { + "type": "string", + "title": "Status" + }, + "latitude": { + "type": "number", + "title": "Latitude" + }, + "longitude": { + "type": "number", + "title": "Longitude" + } + }, + "type": "object", + "required": [ + "timestamp", + "status", + "latitude", + "longitude" + ], + "title": "PooRecord" + }, + "PooResponse": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/PooRecord" + }, + "type": "array", + "title": "Items" + }, + "limit": { + "type": "integer", + "title": "Limit" + }, + "offset": { + "type": "integer", + "title": "Offset" + } + }, + "type": "object", + "required": [ + "items", + "limit", + "offset" + ], + "title": "PooResponse" + }, "PublicIPCheckResponse": { "properties": { "status": { @@ -914,6 +1226,158 @@ ], "title": "PublicIPCheckResponse" }, + "PublicIPHistorySchema": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "ipv4": { + "type": "string", + "title": "Ipv4" + }, + "observed_at": { + "type": "string", + "format": "date-time", + "title": "Observed At" + }, + "change_type": { + "type": "string", + "title": "Change Type" + }, + "provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Provider" + } + }, + "type": "object", + "required": [ + "id", + "ipv4", + "observed_at", + "change_type", + "provider" + ], + "title": "PublicIPHistorySchema" + }, + "PublicIPResponse": { + "properties": { + "state": { + "anyOf": [ + { + "$ref": "#/components/schemas/PublicIPStateSchema" + }, + { + "type": "null" + } + ] + }, + "history": { + "items": { + "$ref": "#/components/schemas/PublicIPHistorySchema" + }, + "type": "array", + "title": "History" + } + }, + "type": "object", + "required": [ + "state", + "history" + ], + "title": "PublicIPResponse" + }, + "PublicIPStateSchema": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "current_ipv4": { + "type": "string", + "title": "Current Ipv4" + }, + "previous_ipv4": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Previous Ipv4" + }, + "first_seen_at": { + "type": "string", + "format": "date-time", + "title": "First Seen At" + }, + "last_checked_at": { + "type": "string", + "format": "date-time", + "title": "Last Checked At" + }, + "last_changed_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Last Changed At" + }, + "last_check_status": { + "type": "string", + "title": "Last Check Status" + }, + "last_check_error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Last Check Error" + }, + "last_provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Last Provider" + } + }, + "type": "object", + "required": [ + "id", + "current_ipv4", + "previous_ipv4", + "first_seen_at", + "last_checked_at", + "last_changed_at", + "last_check_status", + "last_check_error", + "last_provider" + ], + "title": "PublicIPStateSchema" + }, "SessionResponse": { "properties": { "user": { diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index a74ea79..5c2b634 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -222,6 +222,148 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' + /api/locations: + get: + tags: + - api-data + summary: Get Locations + description: 'Return location records with optional time-window filtering and + pagination. + + + - ``start`` / ``end`` are ISO8601 strings; filtering is **inclusive** on both + bounds. + + - Results are ordered by ``datetime`` ascending. + + - ``limit`` is capped at 5000 to prevent full-table exports.' + operationId: get_locations_api_locations_get + parameters: + - name: limit + in: query + required: false + schema: + type: integer + maximum: 5000 + minimum: 1 + default: 1000 + title: Limit + - name: offset + in: query + required: false + schema: + type: integer + minimum: 0 + default: 0 + title: Offset + - name: start + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Start + - name: end + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: End + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/LocationsResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/poo: + get: + tags: + - api-data + summary: Get Poo + description: 'Return poo records ordered by timestamp descending (most recent + first). + + + ``limit`` is capped at 1000 to prevent full-table exports.' + operationId: get_poo_api_poo_get + parameters: + - name: limit + in: query + required: false + schema: + type: integer + maximum: 1000 + minimum: 1 + default: 100 + title: Limit + - name: offset + in: query + required: false + schema: + type: integer + minimum: 0 + default: 0 + title: Offset + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/PooResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/public-ip: + get: + tags: + - api-data + summary: Get Public Ip + description: 'Return the current public IP state and recent history. + + + - ``state`` is ``null`` if no IP check has been performed yet. + + - ``history`` is ordered by ``observed_at`` descending (most recent first). + + - ``limit`` applies to the history list and is capped at 1000.' + operationId: get_public_ip_api_public_ip_get + parameters: + - name: limit + in: query + required: false + schema: + type: integer + maximum: 1000 + minimum: 1 + default: 100 + title: Limit + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/PublicIPResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /api/session: get: tags: @@ -566,6 +708,52 @@ components: title: Detail type: object title: HTTPValidationError + LocationRecord: + properties: + person: + type: string + title: Person + datetime: + type: string + title: Datetime + latitude: + type: number + title: Latitude + longitude: + type: number + title: Longitude + altitude: + anyOf: + - type: number + - type: 'null' + title: Altitude + type: object + required: + - person + - datetime + - latitude + - longitude + - altitude + title: LocationRecord + LocationsResponse: + properties: + items: + items: + $ref: '#/components/schemas/LocationRecord' + type: array + title: Items + limit: + type: integer + title: Limit + offset: + type: integer + title: Offset + type: object + required: + - items + - limit + - offset + title: LocationsResponse LoginRequest: properties: username: @@ -596,6 +784,46 @@ components: - new_password - confirm_password title: PasswordChangeRequest + PooRecord: + properties: + timestamp: + type: string + title: Timestamp + status: + type: string + title: Status + latitude: + type: number + title: Latitude + longitude: + type: number + title: Longitude + type: object + required: + - timestamp + - status + - latitude + - longitude + title: PooRecord + PooResponse: + properties: + items: + items: + $ref: '#/components/schemas/PooRecord' + type: array + title: Items + limit: + type: integer + title: Limit + offset: + type: integer + title: Offset + type: object + required: + - items + - limit + - offset + title: PooResponse PublicIPCheckResponse: properties: status: @@ -619,6 +847,102 @@ components: - checked_at - changed title: PublicIPCheckResponse + PublicIPHistorySchema: + properties: + id: + type: integer + title: Id + ipv4: + type: string + title: Ipv4 + observed_at: + type: string + format: date-time + title: Observed At + change_type: + type: string + title: Change Type + provider: + anyOf: + - type: string + - type: 'null' + title: Provider + type: object + required: + - id + - ipv4 + - observed_at + - change_type + - provider + title: PublicIPHistorySchema + PublicIPResponse: + properties: + state: + anyOf: + - $ref: '#/components/schemas/PublicIPStateSchema' + - type: 'null' + history: + items: + $ref: '#/components/schemas/PublicIPHistorySchema' + type: array + title: History + type: object + required: + - state + - history + title: PublicIPResponse + PublicIPStateSchema: + properties: + id: + type: integer + title: Id + current_ipv4: + type: string + title: Current Ipv4 + previous_ipv4: + anyOf: + - type: string + - type: 'null' + title: Previous Ipv4 + first_seen_at: + type: string + format: date-time + title: First Seen At + last_checked_at: + type: string + format: date-time + title: Last Checked At + last_changed_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Last Changed At + last_check_status: + type: string + title: Last Check Status + last_check_error: + anyOf: + - type: string + - type: 'null' + title: Last Check Error + last_provider: + anyOf: + - type: string + - type: 'null' + title: Last Provider + type: object + required: + - id + - current_ipv4 + - previous_ipv4 + - first_seen_at + - last_checked_at + - last_changed_at + - last_check_status + - last_check_error + - last_provider + title: PublicIPStateSchema SessionResponse: properties: user: diff --git a/tests/test_api_data.py b/tests/test_api_data.py new file mode 100644 index 0000000..03dc1ce --- /dev/null +++ b/tests/test_api_data.py @@ -0,0 +1,611 @@ +"""Tests for M2-T03: GET /api/locations, GET /api/poo, GET /api/public-ip.""" +from __future__ import annotations + +import re +from datetime import UTC, datetime + +from fastapi.testclient import TestClient +from sqlalchemy import insert +from sqlalchemy.engine import Engine + +from app.models.location import Location +from app.models.poo import PooRecord +from app.models.public_ip import PublicIPHistory, PublicIPState + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _extract_csrf_token(html: str) -> str: + match = re.search(r'name="csrf_token" value="([^"]+)"', html) + assert match is not None, "csrf_token not found in HTML" + return match.group(1) + + +def _api_login(client: TestClient) -> None: + """Log in via POST /api/auth/login so the TestClient has a session cookie.""" + resp = client.post( + "/api/auth/login", + json={"username": "admin", "password": "test-password"}, + ) + assert resp.status_code == 200, f"Login failed: {resp.status_code}" + + +def _seed_locations(engine: Engine, rows: list[dict]) -> None: + with engine.begin() as conn: + conn.execute(insert(Location), rows) + + +def _seed_poo(engine: Engine, rows: list[dict]) -> None: + with engine.begin() as conn: + conn.execute(insert(PooRecord), rows) + + +def _seed_public_ip(engine: Engine) -> None: + now = datetime.now(UTC) + with engine.begin() as conn: + conn.execute( + insert(PublicIPState), + [ + { + "id": 1, + "current_ipv4": "1.2.3.4", + "previous_ipv4": "1.2.3.3", + "first_seen_at": now, + "last_checked_at": now, + "last_changed_at": now, + "last_check_status": "changed", + "last_check_error": None, + "last_provider": "ipify", + } + ], + ) + conn.execute( + insert(PublicIPHistory), + [ + { + "ipv4": "1.2.3.3", + "observed_at": datetime(2026, 1, 1, tzinfo=UTC), + "change_type": "first_seen", + "provider": "ipify", + }, + { + "ipv4": "1.2.3.4", + "observed_at": now, + "change_type": "changed", + "provider": "ipify", + }, + ], + ) + + +# --------------------------------------------------------------------------- +# Unauthenticated → 401 +# --------------------------------------------------------------------------- + + +def test_locations_unauthenticated_returns_401(client: TestClient) -> None: + response = client.get("/api/locations") + assert response.status_code == 401 + + +def test_poo_unauthenticated_returns_401(client: TestClient) -> None: + response = client.get("/api/poo") + assert response.status_code == 401 + + +def test_public_ip_unauthenticated_returns_401(client: TestClient) -> None: + response = client.get("/api/public-ip") + assert response.status_code == 401 + + +# --------------------------------------------------------------------------- +# GET /api/locations — basic +# --------------------------------------------------------------------------- + + +def test_locations_empty_returns_empty_list(location_client) -> None: + client, _engine = location_client + _api_login(client) + + resp = client.get("/api/locations") + + assert resp.status_code == 200 + body = resp.json() + assert body["items"] == [] + assert body["limit"] == 1000 + assert body["offset"] == 0 + + +def test_locations_returns_seeded_rows(location_client) -> None: + client, engine = location_client + _seed_locations( + engine, + [ + { + "person": "alice", + "datetime": "2026-06-01T10:00:00Z", + "latitude": 51.5, + "longitude": -0.1, + "altitude": None, + }, + { + "person": "bob", + "datetime": "2026-06-02T12:00:00Z", + "latitude": 48.8, + "longitude": 2.3, + "altitude": 35.0, + }, + ], + ) + _api_login(client) + + resp = client.get("/api/locations") + + assert resp.status_code == 200 + items = resp.json()["items"] + assert len(items) == 2 + # ordered by datetime ascending + assert items[0]["datetime"] == "2026-06-01T10:00:00Z" + assert items[1]["datetime"] == "2026-06-02T12:00:00Z" + # altitude nullable + assert items[0]["altitude"] is None + assert items[1]["altitude"] == 35.0 + + +def test_locations_returns_all_fields(location_client) -> None: + client, engine = location_client + _seed_locations( + engine, + [ + { + "person": "alice", + "datetime": "2026-06-01T10:00:00Z", + "latitude": 51.5, + "longitude": -0.1, + "altitude": 10.0, + } + ], + ) + _api_login(client) + + resp = client.get("/api/locations") + item = resp.json()["items"][0] + + assert set(item.keys()) == {"person", "datetime", "latitude", "longitude", "altitude"} + assert item["person"] == "alice" + assert item["latitude"] == 51.5 + assert item["longitude"] == -0.1 + assert item["altitude"] == 10.0 + + +# --------------------------------------------------------------------------- +# GET /api/locations — pagination +# --------------------------------------------------------------------------- + + +def test_locations_limit_and_offset(location_client) -> None: + client, engine = location_client + _seed_locations( + engine, + [ + { + "person": "alice", + "datetime": f"2026-06-{i:02d}T10:00:00Z", + "latitude": 51.0 + i, + "longitude": -0.1, + "altitude": None, + } + for i in range(1, 6) + ], + ) + _api_login(client) + + resp = client.get("/api/locations", params={"limit": 2, "offset": 1}) + + assert resp.status_code == 200 + body = resp.json() + assert body["limit"] == 2 + assert body["offset"] == 1 + items = body["items"] + assert len(items) == 2 + # offset=1 skips the first row (2026-06-01), so we start at 2026-06-02 + assert items[0]["datetime"] == "2026-06-02T10:00:00Z" + + +def test_locations_limit_at_cap_returns_200(location_client) -> None: + client, _engine = location_client + _api_login(client) + + resp = client.get("/api/locations", params={"limit": 5000}) + assert resp.status_code == 200 + assert resp.json()["limit"] == 5000 + + +def test_locations_limit_exceeds_cap_returns_422(location_client) -> None: + client, _engine = location_client + _api_login(client) + + resp = client.get("/api/locations", params={"limit": 5001}) + assert resp.status_code == 422 + + +def test_locations_limit_zero_returns_422(location_client) -> None: + client, _engine = location_client + _api_login(client) + + resp = client.get("/api/locations", params={"limit": 0}) + assert resp.status_code == 422 + + +def test_locations_negative_offset_returns_422(location_client) -> None: + client, _engine = location_client + _api_login(client) + + resp = client.get("/api/locations", params={"offset": -1}) + assert resp.status_code == 422 + + +# --------------------------------------------------------------------------- +# GET /api/locations — time-window filtering (inclusive bounds) +# --------------------------------------------------------------------------- + + +def test_locations_start_filter_inclusive(location_client) -> None: + client, engine = location_client + _seed_locations( + engine, + [ + { + "person": "alice", + "datetime": "2026-06-01T10:00:00Z", + "latitude": 51.0, + "longitude": -0.1, + "altitude": None, + }, + { + "person": "alice", + "datetime": "2026-06-02T10:00:00Z", + "latitude": 52.0, + "longitude": -0.1, + "altitude": None, + }, + { + "person": "alice", + "datetime": "2026-06-03T10:00:00Z", + "latitude": 53.0, + "longitude": -0.1, + "altitude": None, + }, + ], + ) + _api_login(client) + + # start is inclusive: 2026-06-02 should be included + resp = client.get("/api/locations", params={"start": "2026-06-02T10:00:00Z"}) + assert resp.status_code == 200 + items = resp.json()["items"] + datetimes = [it["datetime"] for it in items] + assert "2026-06-02T10:00:00Z" in datetimes # inclusive + assert "2026-06-03T10:00:00Z" in datetimes + assert "2026-06-01T10:00:00Z" not in datetimes + + +def test_locations_end_filter_inclusive(location_client) -> None: + client, engine = location_client + _seed_locations( + engine, + [ + { + "person": "alice", + "datetime": "2026-06-01T10:00:00Z", + "latitude": 51.0, + "longitude": -0.1, + "altitude": None, + }, + { + "person": "alice", + "datetime": "2026-06-02T10:00:00Z", + "latitude": 52.0, + "longitude": -0.1, + "altitude": None, + }, + { + "person": "alice", + "datetime": "2026-06-03T10:00:00Z", + "latitude": 53.0, + "longitude": -0.1, + "altitude": None, + }, + ], + ) + _api_login(client) + + # end is inclusive: 2026-06-02 should be included + resp = client.get("/api/locations", params={"end": "2026-06-02T10:00:00Z"}) + assert resp.status_code == 200 + items = resp.json()["items"] + datetimes = [it["datetime"] for it in items] + assert "2026-06-01T10:00:00Z" in datetimes + assert "2026-06-02T10:00:00Z" in datetimes # inclusive + assert "2026-06-03T10:00:00Z" not in datetimes + + +def test_locations_start_and_end_filter(location_client) -> None: + client, engine = location_client + _seed_locations( + engine, + [ + { + "person": "alice", + "datetime": "2026-06-01T10:00:00Z", + "latitude": 51.0, + "longitude": -0.1, + "altitude": None, + }, + { + "person": "alice", + "datetime": "2026-06-02T10:00:00Z", + "latitude": 52.0, + "longitude": -0.1, + "altitude": None, + }, + { + "person": "alice", + "datetime": "2026-06-03T10:00:00Z", + "latitude": 53.0, + "longitude": -0.1, + "altitude": None, + }, + ], + ) + _api_login(client) + + resp = client.get( + "/api/locations", + params={"start": "2026-06-02T10:00:00Z", "end": "2026-06-02T10:00:00Z"}, + ) + assert resp.status_code == 200 + items = resp.json()["items"] + assert len(items) == 1 + assert items[0]["datetime"] == "2026-06-02T10:00:00Z" + + +# --------------------------------------------------------------------------- +# GET /api/poo — basic +# --------------------------------------------------------------------------- + + +def test_poo_empty_returns_empty_list(poo_client) -> None: + client, _engine = poo_client + _api_login(client) + + resp = client.get("/api/poo") + + assert resp.status_code == 200 + body = resp.json() + assert body["items"] == [] + assert body["limit"] == 100 + assert body["offset"] == 0 + + +def test_poo_returns_seeded_rows_desc(poo_client) -> None: + client, engine = poo_client + _seed_poo( + engine, + [ + { + "timestamp": "2026-06-01T10:00Z", + "status": "success", + "latitude": 51.0, + "longitude": -0.1, + }, + { + "timestamp": "2026-06-03T10:00Z", + "status": "fail", + "latitude": 52.0, + "longitude": -0.2, + }, + { + "timestamp": "2026-06-02T10:00Z", + "status": "success", + "latitude": 53.0, + "longitude": -0.3, + }, + ], + ) + _api_login(client) + + resp = client.get("/api/poo") + + assert resp.status_code == 200 + items = resp.json()["items"] + assert len(items) == 3 + # ordered by timestamp desc + assert items[0]["timestamp"] == "2026-06-03T10:00Z" + assert items[1]["timestamp"] == "2026-06-02T10:00Z" + assert items[2]["timestamp"] == "2026-06-01T10:00Z" + + +def test_poo_returns_all_fields(poo_client) -> None: + client, engine = poo_client + _seed_poo( + engine, + [ + { + "timestamp": "2026-06-01T10:00Z", + "status": "success", + "latitude": 51.5, + "longitude": -0.1, + } + ], + ) + _api_login(client) + + resp = client.get("/api/poo") + item = resp.json()["items"][0] + + assert set(item.keys()) == {"timestamp", "status", "latitude", "longitude"} + assert item["status"] == "success" + + +# --------------------------------------------------------------------------- +# GET /api/poo — pagination +# --------------------------------------------------------------------------- + + +def test_poo_limit_and_offset(poo_client) -> None: + client, engine = poo_client + _seed_poo( + engine, + [ + { + "timestamp": f"2026-06-{i:02d}T10:00Z", + "status": "success", + "latitude": 51.0, + "longitude": -0.1, + } + for i in range(1, 6) + ], + ) + _api_login(client) + + resp = client.get("/api/poo", params={"limit": 2, "offset": 1}) + + assert resp.status_code == 200 + body = resp.json() + assert body["limit"] == 2 + assert body["offset"] == 1 + items = body["items"] + assert len(items) == 2 + # desc order: rows are 06-05, 06-04, 06-03, 06-02, 06-01 + # offset=1 skips 06-05, so first item should be 06-04 + assert items[0]["timestamp"] == "2026-06-04T10:00Z" + + +def test_poo_limit_at_cap_returns_200(poo_client) -> None: + client, _engine = poo_client + _api_login(client) + + resp = client.get("/api/poo", params={"limit": 1000}) + assert resp.status_code == 200 + assert resp.json()["limit"] == 1000 + + +def test_poo_limit_exceeds_cap_returns_422(poo_client) -> None: + client, _engine = poo_client + _api_login(client) + + resp = client.get("/api/poo", params={"limit": 1001}) + assert resp.status_code == 422 + + +def test_poo_limit_zero_returns_422(poo_client) -> None: + client, _engine = poo_client + _api_login(client) + + resp = client.get("/api/poo", params={"limit": 0}) + assert resp.status_code == 422 + + +def test_poo_negative_offset_returns_422(poo_client) -> None: + client, _engine = poo_client + _api_login(client) + + resp = client.get("/api/poo", params={"offset": -1}) + assert resp.status_code == 422 + + +# --------------------------------------------------------------------------- +# GET /api/public-ip +# --------------------------------------------------------------------------- + + +def test_public_ip_empty_returns_null_state_and_empty_history(client: TestClient) -> None: + _api_login(client) + + resp = client.get("/api/public-ip") + + assert resp.status_code == 200 + body = resp.json() + assert body["state"] is None + assert body["history"] == [] + + +def test_public_ip_returns_state_and_history(location_client) -> None: + client, engine = location_client + _seed_public_ip(engine) + _api_login(client) + + resp = client.get("/api/public-ip") + + assert resp.status_code == 200 + body = resp.json() + + state = body["state"] + assert state is not None + assert state["current_ipv4"] == "1.2.3.4" + assert state["previous_ipv4"] == "1.2.3.3" + assert state["last_check_status"] == "changed" + + history = body["history"] + assert len(history) == 2 + # ordered by observed_at desc — more recent item first + assert history[0]["ipv4"] == "1.2.3.4" + assert history[1]["ipv4"] == "1.2.3.3" + + +def test_public_ip_history_limit_at_cap_returns_200(client: TestClient) -> None: + _api_login(client) + + resp = client.get("/api/public-ip", params={"limit": 1000}) + assert resp.status_code == 200 + + +def test_public_ip_history_limit_exceeds_cap_returns_422(client: TestClient) -> None: + _api_login(client) + + resp = client.get("/api/public-ip", params={"limit": 1001}) + assert resp.status_code == 422 + + +def test_public_ip_history_limit_zero_returns_422(client: TestClient) -> None: + _api_login(client) + + resp = client.get("/api/public-ip", params={"limit": 0}) + assert resp.status_code == 422 + + +def test_public_ip_state_has_expected_fields(location_client) -> None: + client, engine = location_client + _seed_public_ip(engine) + _api_login(client) + + resp = client.get("/api/public-ip") + state = resp.json()["state"] + + expected_keys = { + "id", + "current_ipv4", + "previous_ipv4", + "first_seen_at", + "last_checked_at", + "last_changed_at", + "last_check_status", + "last_check_error", + "last_provider", + } + assert set(state.keys()) == expected_keys + + +def test_public_ip_history_has_expected_fields(location_client) -> None: + client, engine = location_client + _seed_public_ip(engine) + _api_login(client) + + resp = client.get("/api/public-ip") + h = resp.json()["history"][0] + + assert set(h.keys()) == {"id", "ipv4", "observed_at", "change_type", "provider"}