fix(topology): validate time and list missions
This commit is contained in:
@@ -41,5 +41,40 @@ class TopologyApiServingTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TopologyApiQueryTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls) -> None:
|
||||||
|
app = create_app()
|
||||||
|
app.config.update(TESTING=True)
|
||||||
|
cls.client = app.test_client()
|
||||||
|
|
||||||
|
def test_invalid_time_is_json_400(self) -> None:
|
||||||
|
response = self.client.get(
|
||||||
|
"/api/v1/bases/guanghan/snapshot?at=not-a-time"
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
self.assertEqual(response.get_json()["error"], "invalid_time")
|
||||||
|
|
||||||
|
def test_time_and_mission_preserve_context(self) -> None:
|
||||||
|
response = self.client.get(
|
||||||
|
"/api/v1/bases/guanghan/snapshot"
|
||||||
|
"?mission=GHC-01&at=2025-01-02T08:00:00%2B08:00"
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
query = response.get_json()["query"]
|
||||||
|
self.assertEqual(query["requestedMission"], "GHC-01")
|
||||||
|
self.assertEqual(query["anchorSource"], "explicit_time")
|
||||||
|
|
||||||
|
def test_mission_list_is_database_backed(self) -> None:
|
||||||
|
response = self.client.get("/api/v1/bases/guanghan/missions")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
missions = response.get_json()["missions"]
|
||||||
|
self.assertIn("GHC-05", [mission["code"] for mission in missions])
|
||||||
|
for mission in missions:
|
||||||
|
self.assertIn("title", mission)
|
||||||
|
self.assertIn("occurrenceStatus", mission)
|
||||||
|
self.assertIn("lastEventAt", mission)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -3,6 +3,37 @@
|
|||||||
from topology_api.db import read_cursor
|
from topology_api.db import read_cursor
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_missions(base_code):
|
||||||
|
"""Return missions for a base with their latest recorded event."""
|
||||||
|
with read_cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT
|
||||||
|
m.code,
|
||||||
|
m.title,
|
||||||
|
m.occurrence_status,
|
||||||
|
MAX(me.effective_at) AS last_event_at
|
||||||
|
FROM missions m
|
||||||
|
JOIN bases b ON b.id = m.base_id
|
||||||
|
LEFT JOIN mission_events me ON me.mission_id = m.id
|
||||||
|
WHERE b.code = %(base_code)s
|
||||||
|
GROUP BY m.id, m.code, m.title, m.occurrence_status
|
||||||
|
ORDER BY MIN(me.effective_at) NULLS LAST, m.code
|
||||||
|
""", {"base_code": base_code})
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"code": row["code"],
|
||||||
|
"title": row["title"],
|
||||||
|
"occurrenceStatus": row["occurrence_status"],
|
||||||
|
"lastEventAt": (
|
||||||
|
row["last_event_at"].isoformat()
|
||||||
|
if row["last_event_at"] is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
for row in cur.fetchall()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def resolve_snapshot_time(requested_mission=None, requested_at=None):
|
def resolve_snapshot_time(requested_mission=None, requested_at=None):
|
||||||
"""Determine the snapshot timestamp per spec.
|
"""Determine the snapshot timestamp per spec.
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,36 @@
|
|||||||
# topology_api/routes/snapshot.py
|
# topology_api/routes/snapshot.py
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from flask import Blueprint, request, jsonify
|
from flask import Blueprint, request, jsonify
|
||||||
from topology_api.queries.snapshot_queries import (
|
from topology_api.queries.snapshot_queries import (
|
||||||
resolve_snapshot_time, fetch_snapshot, fetch_layout,
|
resolve_snapshot_time, fetch_snapshot, fetch_layout, fetch_missions,
|
||||||
)
|
)
|
||||||
|
|
||||||
snapshot_bp = Blueprint("snapshot", __name__)
|
snapshot_bp = Blueprint("snapshot", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_iso_timestamp(value):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
return None
|
||||||
|
return parsed.isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
@snapshot_bp.route("/api/v1/bases/<base_code>/missions")
|
||||||
|
def get_missions(base_code):
|
||||||
|
if base_code != "guanghan":
|
||||||
|
return jsonify({
|
||||||
|
"error": "base_not_found",
|
||||||
|
"message": f"Base '{base_code}' not found.",
|
||||||
|
}), 404
|
||||||
|
return jsonify({"missions": fetch_missions(base_code)})
|
||||||
|
|
||||||
|
|
||||||
@snapshot_bp.route("/api/v1/bases/<base_code>/snapshot")
|
@snapshot_bp.route("/api/v1/bases/<base_code>/snapshot")
|
||||||
def get_snapshot(base_code):
|
def get_snapshot(base_code):
|
||||||
if base_code != "guanghan":
|
if base_code != "guanghan":
|
||||||
@@ -17,10 +41,19 @@ def get_snapshot(base_code):
|
|||||||
|
|
||||||
requested_mission = request.args.get("mission")
|
requested_mission = request.args.get("mission")
|
||||||
requested_at = request.args.get("at")
|
requested_at = request.args.get("at")
|
||||||
|
normalized_at = _parse_iso_timestamp(requested_at)
|
||||||
|
|
||||||
|
if requested_at is not None and normalized_at is None:
|
||||||
|
return jsonify({
|
||||||
|
"error": "invalid_time",
|
||||||
|
"message": (
|
||||||
|
"Parameter 'at' must be an ISO 8601 timestamp with a timezone."
|
||||||
|
),
|
||||||
|
}), 400
|
||||||
|
|
||||||
snapshot_at, anchor_source = resolve_snapshot_time(
|
snapshot_at, anchor_source = resolve_snapshot_time(
|
||||||
requested_mission=requested_mission,
|
requested_mission=requested_mission,
|
||||||
requested_at=requested_at,
|
requested_at=normalized_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
if anchor_source == "mission_not_found":
|
if anchor_source == "mission_not_found":
|
||||||
|
|||||||
Reference in New Issue
Block a user