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
This commit is contained in:
2026-06-12 23:24:17 +02:00
parent d8303eaa3d
commit 0fba7cfe11
6 changed files with 1602 additions and 0 deletions
+125
View File
@@ -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)
+2
View File
@@ -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)
+76
View File
@@ -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]
+464
View File
@@ -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": {
+324
View File
@@ -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:
+611
View File
@@ -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"}