From 808276e2259ee6c7a0c6361339cd4d5257a2f664 Mon Sep 17 00:00:00 2001 From: Armor00 <2654988228@qq.com> Date: Thu, 9 Jul 2026 21:47:29 +0800 Subject: [PATCH] fix(topology): validate time and list missions --- tests/test_topology_api.py | 35 ++++++++++++++++++++++ topology_api/queries/snapshot_queries.py | 31 ++++++++++++++++++++ topology_api/routes/snapshot.py | 37 ++++++++++++++++++++++-- 3 files changed, 101 insertions(+), 2 deletions(-) diff --git a/tests/test_topology_api.py b/tests/test_topology_api.py index bb4a8b3..3f2fd8e 100644 --- a/tests/test_topology_api.py +++ b/tests/test_topology_api.py @@ -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__": unittest.main() diff --git a/topology_api/queries/snapshot_queries.py b/topology_api/queries/snapshot_queries.py index 3ae946b..ae59bf9 100644 --- a/topology_api/queries/snapshot_queries.py +++ b/topology_api/queries/snapshot_queries.py @@ -3,6 +3,37 @@ 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): """Determine the snapshot timestamp per spec. diff --git a/topology_api/routes/snapshot.py b/topology_api/routes/snapshot.py index 5a2d474..79e542b 100644 --- a/topology_api/routes/snapshot.py +++ b/topology_api/routes/snapshot.py @@ -1,12 +1,36 @@ # topology_api/routes/snapshot.py +from datetime import datetime + from flask import Blueprint, request, jsonify 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__) +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//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//snapshot") def get_snapshot(base_code): if base_code != "guanghan": @@ -17,10 +41,19 @@ def get_snapshot(base_code): requested_mission = request.args.get("mission") 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( requested_mission=requested_mission, - requested_at=requested_at, + requested_at=normalized_at, ) if anchor_source == "mission_not_found":