diff --git a/agents/fce.py b/agents/fce.py index 8eb4227..24a1ea8 100644 --- a/agents/fce.py +++ b/agents/fce.py @@ -53,7 +53,9 @@ async def run_fce( status = flight_status.get("status", "") delay_minutes = flight_status.get("delay_minutes", 0) - should_notify = status in ("DELAYED", "CANCELLED", "DIVERTED") and delay_minutes >= 10 + should_notify = status in ("CANCELLED", "DIVERTED") or ( + status == "DELAYED" and delay_minutes >= 10 + ) await emit("node_exit", node="triage", result={"should_notify": should_notify}) diff --git a/mcp_servers/data/scenarios/weather_disruption_ord.py b/mcp_servers/data/scenarios/weather_disruption_ord.py index f7e5bf5..1e003ba 100644 --- a/mcp_servers/data/scenarios/weather_disruption_ord.py +++ b/mcp_servers/data/scenarios/weather_disruption_ord.py @@ -185,6 +185,31 @@ CREW: list[CrewMember] = [ CrewMember(crew_id="CR-1012", name="FA Garcia", role=CrewRole.FA, duty_hours_elapsed=11.5, duty_hours_limit=14.0, rest_hours_since_last=10.0, next_scheduled_flight="UA881", base_hub="ORD"), + CrewMember(crew_id="CR-1013", name="FA Herrera", role=CrewRole.FA, + duty_hours_elapsed=9.0, duty_hours_limit=14.0, + rest_hours_since_last=11.0, next_scheduled_flight="UA881", base_hub="ORD"), + CrewMember(crew_id="CR-1014", name="FA Novak", role=CrewRole.FA, + duty_hours_elapsed=7.5, duty_hours_limit=14.0, + rest_hours_since_last=15.0, next_scheduled_flight="UA233", base_hub="ORD"), + CrewMember(crew_id="CR-1015", name="FA Park", role=CrewRole.FA, + duty_hours_elapsed=6.0, duty_hours_limit=14.0, + rest_hours_since_last=17.0, next_scheduled_flight="UA094", base_hub="ORD"), + CrewMember(crew_id="CR-1016", name="FO Santos", role=CrewRole.FIRST_OFFICER, + duty_hours_elapsed=4.0, duty_hours_limit=14.0, + rest_hours_since_last=20.0, next_scheduled_flight=None, base_hub="ORD"), + # On-time flight crew + CrewMember(crew_id="CR-1017", name="Capt. Walsh", role=CrewRole.CAPTAIN, + duty_hours_elapsed=3.0, duty_hours_limit=14.0, + rest_hours_since_last=22.0, next_scheduled_flight="UA1220", base_hub="ORD"), + CrewMember(crew_id="CR-1018", name="FO Lindgren", role=CrewRole.FIRST_OFFICER, + duty_hours_elapsed=3.0, duty_hours_limit=14.0, + rest_hours_since_last=21.0, next_scheduled_flight="UA1220", base_hub="ORD"), + CrewMember(crew_id="CR-1019", name="Capt. Rivera", role=CrewRole.CAPTAIN, + duty_hours_elapsed=2.0, duty_hours_limit=14.0, + rest_hours_since_last=24.0, next_scheduled_flight="UA788", base_hub="ORD"), + CrewMember(crew_id="CR-1020", name="FO Cheng", role=CrewRole.FIRST_OFFICER, + duty_hours_elapsed=2.0, duty_hours_limit=14.0, + rest_hours_since_last=22.0, next_scheduled_flight="UA788", base_hub="ORD"), # Backup crew CrewMember(crew_id="CR-4421", name="Capt. Okafor", role=CrewRole.CAPTAIN, duty_hours_elapsed=0.0, duty_hours_limit=14.0, diff --git a/pyproject.toml b/pyproject.toml index 592d66c..d9f365d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ dependencies = [ dev = [ "pytest", "pytest-asyncio", + "httpx", "ruff", ] @@ -34,3 +35,6 @@ select = ["E", "F", "I"] [tool.pytest.ini_options] asyncio_mode = "auto" +markers = [ + "live: tests that hit external APIs (OpenMeteo, FAA)", +] diff --git a/tests/base.py b/tests/base.py new file mode 100644 index 0000000..bd50625 --- /dev/null +++ b/tests/base.py @@ -0,0 +1,82 @@ +"""Test base — two execution modes from one test suite. + +Modes (set via CONTRACT_TEST_MODE env var): + + inprocess (default) — httpx.AsyncClient with ASGI transport, full FastAPI + stack in-process. Fast, no server needed. + + live — httpx.AsyncClient against CONTRACT_TEST_URL. + Tests against a running deployment (Kind, EC2, etc). + +Usage: + # In-process (default) + pytest tests/ + + # Against live server + CONTRACT_TEST_MODE=live CONTRACT_TEST_URL=http://unt.local.ar pytest tests/ +""" + +import asyncio +import os + +import httpx +import pytest_asyncio + + +def get_mode() -> str: + return os.getenv("CONTRACT_TEST_MODE", "inprocess") + + +def get_base_url() -> str: + return os.getenv("CONTRACT_TEST_URL", "http://localhost:8040") + + +@pytest_asyncio.fixture +async def client() -> httpx.AsyncClient: + """Async HTTP client for API tests.""" + if get_mode() == "live": + async with httpx.AsyncClient(base_url=get_base_url(), timeout=30.0) as c: + yield c + else: + from httpx import ASGITransport + from api.main import app + + transport = ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + +# Alias for agent tests +agent_client = client + + +class ContractHelpers: + """Reusable assertion and utility methods.""" + + @staticmethod + def assert_status(response: httpx.Response, expected: int): + assert response.status_code == expected, ( + f"Expected {expected}, got {response.status_code}: {response.text[:200]}" + ) + + @staticmethod + def assert_has_fields(data: dict, *fields: str): + for f in fields: + assert f in data, f"Missing field: {f}. Keys: {list(data.keys())}" + + @staticmethod + def assert_is_list(data, min_length: int = 0): + assert isinstance(data, list), f"Expected list, got {type(data)}" + assert len(data) >= min_length, f"Expected >= {min_length} items, got {len(data)}" + + @staticmethod + async def poll_run(client: httpx.AsyncClient, run_id: str, timeout: int = 60) -> dict: + """Poll an agent run until completion.""" + deadline = asyncio.get_event_loop().time() + timeout + while asyncio.get_event_loop().time() < deadline: + res = await client.get(f"/agents/runs/{run_id}") + data = res.json() + if data.get("status") in ("completed", "error"): + return data + await asyncio.sleep(1) + raise TimeoutError(f"Run {run_id} did not complete in {timeout}s") diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..aec452b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,3 @@ +"""Root conftest — exports async client fixtures.""" + +from tests.base import client, agent_client # noqa: F401 diff --git a/tests/endpoints.py b/tests/endpoints.py new file mode 100644 index 0000000..4353926 --- /dev/null +++ b/tests/endpoints.py @@ -0,0 +1,35 @@ +"""Centralized API endpoint paths — single source of truth.""" + + +class Endpoints: + # Scenarios + SCENARIOS = "/scenarios" + SCENARIO_ACTIVE = "/scenarios/active" + + # Scenario data + DATA_FLIGHTS = "/scenarios/data/flights" + DATA_CREW = "/scenarios/data/crew" + DATA_CREW_NOTES = "/scenarios/data/crew-notes" + DATA_MAINTENANCE = "/scenarios/data/maintenance" + DATA_REBOOKINGS = "/scenarios/data/rebookings" + + @staticmethod + def flight(flight_id: str) -> str: + return f"/scenarios/data/flights/{flight_id}" + + @staticmethod + def crew(crew_id: str) -> str: + return f"/scenarios/data/crew/{crew_id}" + + @staticmethod + def crew_notes(flight_id: str) -> str: + return f"/scenarios/data/crew-notes/{flight_id}" + + # Agents + AGENT_FCE = "/agents/fce" + AGENT_HANDOVER = "/agents/handover" + AGENT_RUNS = "/agents/runs" + + @staticmethod + def run(run_id: str) -> str: + return f"/agents/runs/{run_id}" diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..bc003b8 --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,39 @@ +"""Reusable test data and setup helpers.""" + +import httpx + +from tests.endpoints import Endpoints as E + + +async def set_scenario(client: httpx.AsyncClient, scenario_id: str) -> dict: + res = await client.put(E.SCENARIO_ACTIVE, json={"scenario_id": scenario_id}) + return res.json() + + +async def get_flights(client: httpx.AsyncClient) -> list[dict]: + return (await client.get(E.DATA_FLIGHTS)).json() + + +async def get_disrupted_flights(client: httpx.AsyncClient) -> list[dict]: + return [f for f in await get_flights(client) if f["status"] != "ON_TIME"] + + +async def get_first_disrupted_flight_id(client: httpx.AsyncClient) -> str: + flights = await get_disrupted_flights(client) + assert len(flights) > 0, "No disrupted flights in current scenario" + return flights[0]["flight_id"] + + +async def trigger_fce(client: httpx.AsyncClient, flight_id: str) -> str: + res = await client.post(E.AGENT_FCE, json={"flight_id": flight_id}) + data = res.json() + assert "run_id" in data, f"FCE trigger failed: {data}" + return data["run_id"] + + +async def trigger_handover(client: httpx.AsyncClient, hubs: list[str] | None = None) -> str: + body = {"hubs": hubs} if hubs else {} + res = await client.post(E.AGENT_HANDOVER, json=body) + data = res.json() + assert "run_id" in data, f"Handover trigger failed: {data}" + return data["run_id"] diff --git a/tests/test_agents.py b/tests/test_agents.py new file mode 100644 index 0000000..bf0143b --- /dev/null +++ b/tests/test_agents.py @@ -0,0 +1,101 @@ +"""Contract tests — agent execution via API.""" + +import pytest + +from tests.base import ContractHelpers as H +from tests.endpoints import Endpoints as E +from tests.helpers import set_scenario, trigger_fce, trigger_handover + + +class TestFCEAgent: + @pytest.mark.asyncio + async def test_trigger_returns_run_id(self, agent_client): + await set_scenario(agent_client, "weather_disruption_ord") + res = await agent_client.post(E.AGENT_FCE, json={"flight_id": "UA432"}) + H.assert_status(res, 200) + H.assert_has_fields(res.json(), "run_id", "status") + assert res.json()["status"] == "running" + + @pytest.mark.asyncio + async def test_completes_with_notification(self, agent_client): + await set_scenario(agent_client, "weather_disruption_ord") + run_id = await trigger_fce(agent_client, "UA432") + result = await H.poll_run(agent_client, run_id) + + assert result["status"] == "completed" + r = result["result"] + H.assert_has_fields(r, "type", "status", "notification_text", "data_sources", "duration_ms") + assert r["type"].endswith("_NOTIFICATION") + assert len(r["notification_text"]) > 0 + assert r["duration_ms"] > 0 + + @pytest.mark.asyncio + async def test_includes_live_data(self, agent_client): + await set_scenario(agent_client, "weather_disruption_ord") + run_id = await trigger_fce(agent_client, "UA432") + result = await H.poll_run(agent_client, run_id) + + sources = result["result"]["data_sources"] + assert "weather_live" in sources + assert "faa_status_live" in sources + + @pytest.mark.asyncio + async def test_notification_mentions_flight(self, agent_client): + await set_scenario(agent_client, "weather_disruption_ord") + run_id = await trigger_fce(agent_client, "UA432") + result = await H.poll_run(agent_client, run_id) + + text = result["result"]["notification_text"] + assert "UA432" in text + assert "ORD" in text or "SFO" in text + + +class TestHandoverAgent: + @pytest.mark.asyncio + async def test_trigger_returns_run_id(self, agent_client): + res = await agent_client.post(E.AGENT_HANDOVER, json={}) + H.assert_status(res, 200) + H.assert_has_fields(res.json(), "run_id", "status") + + @pytest.mark.asyncio + async def test_completes_with_brief(self, agent_client): + await set_scenario(agent_client, "weather_disruption_ord") + run_id = await trigger_handover(agent_client, hubs=["ORD"]) + result = await H.poll_run(agent_client, run_id) + + assert result["status"] == "completed" + r = result["result"] + H.assert_has_fields(r, "type", "brief_text", "summary", "items", "duration_ms") + assert r["type"] == "HANDOVER_BRIEF" + assert len(r["brief_text"]) > 0 + + @pytest.mark.asyncio + async def test_identifies_disruptions(self, agent_client): + await set_scenario(agent_client, "weather_disruption_ord") + run_id = await trigger_handover(agent_client, hubs=["ORD"]) + result = await H.poll_run(agent_client, run_id) + + items = result["result"]["items"] + assert len(items["immediate"]) > 0, "Should find immediate items in disruption scenario" + + @pytest.mark.asyncio + async def test_hub_filtering(self, agent_client): + await set_scenario(agent_client, "weather_disruption_ord") + run_id = await trigger_handover(agent_client, hubs=["ORD"]) + result = await H.poll_run(agent_client, run_id) + + assert result["result"]["hubs"] == ["ORD"] + + +class TestAgentRuns: + @pytest.mark.asyncio + async def test_list_runs(self, agent_client): + res = await agent_client.get(E.AGENT_RUNS) + H.assert_status(res, 200) + H.assert_is_list(res.json()) + + @pytest.mark.asyncio + async def test_unknown_run(self, agent_client): + res = await agent_client.get(E.run("nonexistent")) + H.assert_status(res, 200) + assert "error" in res.json() diff --git a/tests/test_data_clients.py b/tests/test_data_clients.py new file mode 100644 index 0000000..32d9102 --- /dev/null +++ b/tests/test_data_clients.py @@ -0,0 +1,172 @@ +"""Tests for real data clients (OpenMeteo, FAA). + +These hit live APIs — marked with pytest.mark for selective running. +""" + +import pytest + +from mcp_servers.data.real.openmeteo import ( + HUB_COORDS, + WMO_CODES, + _interpolate_waypoints, + _interpret_weather, + get_weather_along_route, + get_weather_forecast_hubs, +) +from mcp_servers.data.real.faa import get_airport_status, _parse_status_xml + + +class TestOpenMeteoHelpers: + def test_interpolate_waypoints_count(self): + origin = (41.97, -87.91) + dest = (37.62, -122.38) + points = _interpolate_waypoints(origin, dest, n=2) + assert len(points) == 2 + + def test_interpolate_waypoints_between(self): + origin = (0.0, 0.0) + dest = (30.0, 60.0) + points = _interpolate_waypoints(origin, dest, n=2) + for lat, lon in points: + assert 0 < lat < 30 + assert 0 < lon < 60 + + def test_interpret_weather_clear(self): + result = _interpret_weather(0) + assert result["condition"] == "Clear sky" + assert result["is_significant"] is False + + def test_interpret_weather_thunderstorm(self): + result = _interpret_weather(95) + assert result["condition"] == "Thunderstorm" + assert result["is_significant"] is True + + def test_interpret_weather_unknown(self): + result = _interpret_weather(999) + assert "Unknown" in result["condition"] + + def test_hub_coords_complete(self): + for hub in ["ORD", "EWR", "IAH", "SFO", "DEN"]: + assert hub in HUB_COORDS + lat, lon = HUB_COORDS[hub] + assert -90 <= lat <= 90 + assert -180 <= lon <= 180 + + +class TestFAAHelpers: + def test_parse_status_xml_ground_stop(self): + xml = """ + + + Ground Stop Programs + + + SFO + thunderstorms + 6:45 pm PDT + + + + + """ + result = _parse_status_xml(xml) + assert "SFO" in result + assert len(result["SFO"]) == 1 + assert result["SFO"][0]["type"] == "ground_stop" + assert result["SFO"][0]["reason"] == "thunderstorms" + + def test_parse_status_xml_gdp(self): + xml = """ + + + Ground Delay Programs + + + EWR + wind + 45 minutes + 1 hour + + + + + """ + result = _parse_status_xml(xml) + assert "EWR" in result + assert result["EWR"][0]["type"] == "ground_delay_program" + assert result["EWR"][0]["average_delay"] == "45 minutes" + + def test_parse_status_xml_empty(self): + xml = "" + result = _parse_status_xml(xml) + assert result == {} + + def test_parse_status_xml_multiple_airports(self): + xml = """ + + + Ground Stop Programs + + SFOweather + ORDvolume + + + + """ + result = _parse_status_xml(xml) + assert "SFO" in result + assert "ORD" in result + + +@pytest.mark.live +class TestOpenMeteoLive: + """Tests that hit the live OpenMeteo API.""" + + @pytest.mark.asyncio + async def test_weather_along_route(self): + result = await get_weather_along_route("ORD", "SFO") + assert result["origin"] == "ORD" + assert result["destination"] == "SFO" + assert "waypoints" in result + assert "origin" in result["waypoints"] + assert "destination" in result["waypoints"] + # Check a waypoint has weather data + wp = result["waypoints"]["origin"] + assert "temperature_c" in wp + assert "weather" in wp + + @pytest.mark.asyncio + async def test_weather_unknown_airport(self): + result = await get_weather_along_route("ORD", "FAKE") + assert "error" in result + + @pytest.mark.asyncio + async def test_hub_forecasts(self): + result = await get_weather_forecast_hubs() + assert "hubs" in result + for hub in ["ORD", "EWR", "IAH", "SFO", "DEN"]: + assert hub in result["hubs"] + hub_data = result["hubs"][hub] + if "error" not in hub_data: + assert "forecast" in hub_data + assert len(hub_data["forecast"]) > 0 + + +@pytest.mark.live +class TestFAALive: + """Tests that hit the live FAA API.""" + + @pytest.mark.asyncio + async def test_airport_status_known(self): + result = await get_airport_status("ORD") + assert result["airport"] == "ORD" + assert result["source"] == "faa_nasstatus_live" + assert result["status"] in ("normal_operations", "delays_active", "status_unavailable") + + @pytest.mark.asyncio + async def test_airport_status_no_delays(self): + """Small airport unlikely to have delays.""" + result = await get_airport_status("BTV") + assert result["airport"] == "BTV" + # Either normal or unavailable, but should not crash + assert "status" in result diff --git a/tests/test_mcp_servers.py b/tests/test_mcp_servers.py new file mode 100644 index 0000000..2ba80bf --- /dev/null +++ b/tests/test_mcp_servers.py @@ -0,0 +1,206 @@ +"""Tests for MCP servers — tools, resources, prompts via the MCP protocol.""" + +import json + +import pytest + +from fastmcp import Client + + +def _client(module: str) -> Client: + return Client({"mcpServers": {"default": { + "command": "uv", + "args": ["run", "python", "-m", module], + }}}) + + +def _parse_result(result) -> any: + """Parse a fastmcp CallToolResult into Python data.""" + if hasattr(result, "content"): + texts = [c.text for c in result.content if hasattr(c, "text")] + elif isinstance(result, list): + texts = [c.text for c in result if hasattr(c, "text")] + else: + return result + + if not texts: + return None + combined = texts[0] if len(texts) == 1 else "[" + ",".join(texts) + "]" + try: + return json.loads(combined) + except (json.JSONDecodeError, TypeError): + return combined + + +class TestSharedServer: + @pytest.mark.asyncio + async def test_list_tools(self): + async with _client("mcp_servers.shared") as c: + tools = await c.list_tools() + names = [t.name for t in tools] + assert "get_flight_status" in names + assert "get_flight_details" in names + assert "get_irregular_ops" in names + assert "get_route_weather" in names + assert "get_hub_forecasts" in names + assert "get_airport_status" in names + assert "get_airport_congestion" in names + assert "get_maintenance_flags" in names + + @pytest.mark.asyncio + async def test_flight_status_found(self): + async with _client("mcp_servers.shared") as c: + result = await c.call_tool("get_flight_status", {"flight_id": "UA432"}) + data = _parse_result(result) + assert data["flight_id"] == "UA432" + assert data["status"] == "DELAYED" + assert data["delay_minutes"] == 55 + + @pytest.mark.asyncio + async def test_flight_status_not_found(self): + async with _client("mcp_servers.shared") as c: + result = await c.call_tool("get_flight_status", {"flight_id": "FAKE999"}) + data = _parse_result(result) + assert "error" in data + + @pytest.mark.asyncio + async def test_irregular_ops(self): + async with _client("mcp_servers.shared") as c: + result = await c.call_tool("get_irregular_ops", {"hub": "ORD"}) + data = _parse_result(result) + assert isinstance(data, list) + assert len(data) >= 4 + for f in data: + assert f["irrop_type"] in ("DELAYED", "CANCELLED", "DIVERTED") + + @pytest.mark.asyncio + async def test_maintenance_flags(self): + async with _client("mcp_servers.shared") as c: + result = await c.call_tool("get_maintenance_flags", {"aircraft_tail": "N78501"}) + data = _parse_result(result) + assert isinstance(data, list) + assert len(data) >= 1 + assert "system" in data[0] + + @pytest.mark.asyncio + async def test_maintenance_flags_empty(self): + async with _client("mcp_servers.shared") as c: + result = await c.call_tool("get_maintenance_flags", {"aircraft_tail": "FAKE"}) + data = _parse_result(result) + # Empty list may come back as [] or as "[]" string + if isinstance(data, list): + assert len(data) == 0 + else: + assert data == "[]" or data is None + + @pytest.mark.asyncio + async def test_list_prompts(self): + async with _client("mcp_servers.shared") as c: + prompts = await c.list_prompts() + names = [p.name for p in prompts] + assert "delay_explainer" in names + + +class TestOpsServer: + @pytest.mark.asyncio + async def test_list_tools(self): + async with _client("mcp_servers.ops") as c: + tools = await c.list_tools() + names = [t.name for t in tools] + assert "get_crew_notes" in names + assert "get_crew_duty_status" in names + assert "get_pending_rebookings" in names + assert "generate_narrative" in names + + @pytest.mark.asyncio + async def test_crew_notes(self): + async with _client("mcp_servers.ops") as c: + result = await c.call_tool("get_crew_notes", {"flight_id": "UA432"}) + data = _parse_result(result) + assert isinstance(data, list) + assert len(data) > 0 + + @pytest.mark.asyncio + async def test_crew_duty_status(self): + async with _client("mcp_servers.ops") as c: + result = await c.call_tool( + "get_crew_duty_status", + {"crew_ids": ["CR-1001", "CR-1003"]} + ) + data = _parse_result(result) + assert isinstance(data, list) + assert len(data) == 2 + for c_data in data: + assert "hours_until_limit" in c_data + assert "at_risk" in c_data + + @pytest.mark.asyncio + async def test_crew_at_risk_detection(self): + async with _client("mcp_servers.ops") as c: + result = await c.call_tool( + "get_crew_duty_status", + {"crew_ids": ["CR-1003"]} + ) + data = _parse_result(result) + crew = data[0] if isinstance(data, list) else data + assert crew["at_risk"] is True + assert crew["hours_until_limit"] <= 2.0 + + @pytest.mark.asyncio + async def test_pending_rebookings(self): + async with _client("mcp_servers.ops") as c: + result = await c.call_tool("get_pending_rebookings", {"hub": "ORD"}) + data = _parse_result(result) + assert isinstance(data, list) + assert len(data) > 0 + urgencies = [r["urgency"] for r in data] + assert urgencies[0] == "HIGH" + + @pytest.mark.asyncio + async def test_list_resources(self): + async with _client("mcp_servers.ops") as c: + resources = await c.list_resources() + uris = [str(r.uri) for r in resources] + assert "ops://crew/roster" in uris + assert "ops://handover/latest" in uris + + @pytest.mark.asyncio + async def test_list_prompts(self): + async with _client("mcp_servers.ops") as c: + prompts = await c.list_prompts() + names = [p.name for p in prompts] + assert "handover_brief" in names + + +class TestPassengerServer: + @pytest.mark.asyncio + async def test_list_tools(self): + async with _client("mcp_servers.passenger") as c: + tools = await c.list_tools() + names = [t.name for t in tools] + assert "generate_notification" in names + + @pytest.mark.asyncio + async def test_generate_notification(self): + async with _client("mcp_servers.passenger") as c: + result = await c.call_tool("generate_notification", { + "context": { + "flight_id": "UA432", + "origin": "ORD", + "destination": "SFO", + "status": "DELAYED", + "delay_minutes": 55, + "delay_cause": "WEATHER", + "gate": "H14", + } + }) + text = _parse_result(result) + assert "UA432" in text + assert "DELAYED" in text or "delayed" in text + + @pytest.mark.asyncio + async def test_list_prompts(self): + async with _client("mcp_servers.passenger") as c: + prompts = await c.list_prompts() + names = [p.name for p in prompts] + assert "passenger_notification" in names diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..c714ce9 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,150 @@ +"""Tests for data models and scenario manager.""" + +import pytest + +from mcp_servers.data.models import ( + HUBS, + CrewMember, + CrewRole, + DelayCause, + FlightData, + FlightStatus, + HubInfo, + MELItem, + MPStatus, + Passenger, + RebookingCase, +) +from mcp_servers.data.scenarios.manager import ScenarioManager, SCENARIO_MODULES + + +class TestModels: + def test_flight_data_serializes(self): + from datetime import datetime, timezone + + f = FlightData( + flight_id="UA100", + origin="ORD", + destination="SFO", + scheduled_departure=datetime(2026, 1, 1, 12, 0, tzinfo=timezone.utc), + scheduled_arrival=datetime(2026, 1, 1, 15, 0, tzinfo=timezone.utc), + status=FlightStatus.DELAYED, + delay_minutes=30, + delay_cause=DelayCause.WEATHER, + aircraft_tail="N12345", + gate="A1", + ) + d = f.model_dump(mode="json") + assert d["flight_id"] == "UA100" + assert d["status"] == "DELAYED" + assert d["delay_cause"] == "WEATHER" + + def test_crew_member_serializes(self): + c = CrewMember( + crew_id="CR-1", + name="Test Pilot", + role=CrewRole.CAPTAIN, + duty_hours_elapsed=10.0, + duty_hours_limit=14.0, + rest_hours_since_last=12.0, + base_hub="ORD", + ) + d = c.model_dump(mode="json") + assert d["role"] == "CAPTAIN" + assert d["duty_hours_elapsed"] == 10.0 + + def test_mp_status_values(self): + assert MPStatus.GLOBAL_SERVICES.value == "GLOBAL_SERVICES" + assert MPStatus.K1.value == "1K" + + def test_hubs_reference_data(self): + assert "ORD" in HUBS + assert "EWR" in HUBS + assert "IAH" in HUBS + assert "SFO" in HUBS + assert "DEN" in HUBS + assert len(HUBS) == 5 + + ord = HUBS["ORD"] + assert ord.code == "ORD" + assert ord.city == "Chicago" + assert ord.latitude == pytest.approx(41.97, abs=0.01) + assert ord.gates > 0 + assert ord.runways > 0 + + +class TestScenarioManager: + def test_list_scenarios(self): + mgr = ScenarioManager() + scenarios = mgr.list_scenarios() + assert len(scenarios) == len(SCENARIO_MODULES) + ids = [s["scenario_id"] for s in scenarios] + assert "normal_ops" in ids + assert "weather_disruption_ord" in ids + assert "maintenance_delay_sfo" in ids + assert "crew_swap_ewr" in ids + + def test_default_active_scenario(self): + mgr = ScenarioManager() + assert mgr.active_id == "weather_disruption_ord" + + def test_switch_scenario(self): + mgr = ScenarioManager() + result = mgr.set_active("normal_ops") + assert mgr.active_id == "normal_ops" + assert result["scenario_id"] == "normal_ops" + + def test_switch_invalid_scenario(self): + mgr = ScenarioManager() + with pytest.raises(ValueError, match="Unknown scenario"): + mgr.set_active("nonexistent") + + def test_flights_per_scenario(self): + mgr = ScenarioManager() + for sid in SCENARIO_MODULES: + mgr.set_active(sid) + assert len(mgr.flights) > 0, f"No flights in {sid}" + for f in mgr.flights: + assert f.flight_id, f"Empty flight_id in {sid}" + assert f.origin, f"Empty origin in {sid}" + + def test_crew_per_scenario(self): + mgr = ScenarioManager() + for sid in SCENARIO_MODULES: + mgr.set_active(sid) + assert len(mgr.crew) > 0, f"No crew in {sid}" + for c in mgr.crew: + assert c.crew_id + assert c.duty_hours_limit > 0 + + def test_scenario_internal_consistency(self): + """Crew IDs on flights should exist in the crew roster.""" + mgr = ScenarioManager() + for sid in SCENARIO_MODULES: + mgr.set_active(sid) + crew_ids = {c.crew_id for c in mgr.crew} + for f in mgr.flights: + for cid in f.crew_ids: + assert cid in crew_ids, ( + f"Flight {f.flight_id} in {sid} references crew {cid} " + f"not in roster" + ) + + def test_disrupted_flights_have_cause(self): + mgr = ScenarioManager() + for sid in SCENARIO_MODULES: + mgr.set_active(sid) + for f in mgr.flights: + if f.status != FlightStatus.ON_TIME: + assert f.delay_cause is not None, ( + f"Flight {f.flight_id} in {sid} is {f.status} " + f"but has no delay_cause" + ) + + def test_metadata_counts(self): + mgr = ScenarioManager() + mgr.set_active("weather_disruption_ord") + meta = mgr.get_metadata() + assert meta["flight_count"] == len(mgr.flights) + disrupted = sum(1 for f in mgr.flights if f.status != FlightStatus.ON_TIME) + assert meta["disrupted_flights"] == disrupted diff --git a/tests/test_scenario_data.py b/tests/test_scenario_data.py new file mode 100644 index 0000000..de4db39 --- /dev/null +++ b/tests/test_scenario_data.py @@ -0,0 +1,133 @@ +"""Contract tests — scenario data CRUD.""" + +import pytest + +from tests.base import ContractHelpers as H +from tests.endpoints import Endpoints as E +from tests.helpers import set_scenario + + +class TestFlights: + @pytest.mark.asyncio + async def test_list_flights(self, client): + await set_scenario(client, "weather_disruption_ord") + res = await client.get(E.DATA_FLIGHTS) + H.assert_status(res, 200) + data = res.json() + H.assert_is_list(data, min_length=1) + H.assert_has_fields(data[0], "flight_id", "origin", "destination", "status") + + @pytest.mark.asyncio + async def test_flights_change_with_scenario(self, client): + await set_scenario(client, "weather_disruption_ord") + ord_ids = {f["flight_id"] for f in (await client.get(E.DATA_FLIGHTS)).json()} + + await set_scenario(client, "crew_swap_ewr") + ewr_ids = {f["flight_id"] for f in (await client.get(E.DATA_FLIGHTS)).json()} + + assert ord_ids != ewr_ids, "Different scenarios should have different flights" + await set_scenario(client, "weather_disruption_ord") + + @pytest.mark.asyncio + async def test_patch_flight_delay(self, client): + await set_scenario(client, "weather_disruption_ord") + res = await client.patch(E.flight("UA432"), json={"delay_minutes": 120}) + H.assert_status(res, 200) + assert res.json()["delay_minutes"] == 120 + + flights = (await client.get(E.DATA_FLIGHTS)).json() + ua432 = next(f for f in flights if f["flight_id"] == "UA432") + assert ua432["delay_minutes"] == 120 + + @pytest.mark.asyncio + async def test_patch_flight_status(self, client): + await set_scenario(client, "weather_disruption_ord") + res = await client.patch(E.flight("UA432"), json={"status": "CANCELLED"}) + H.assert_status(res, 200) + assert res.json()["status"] == "CANCELLED" + + @pytest.mark.asyncio + async def test_patch_unknown_flight(self, client): + res = await client.patch(E.flight("FAKE"), json={"delay_minutes": 10}) + H.assert_status(res, 200) + assert "error" in res.json() + + +class TestCrew: + @pytest.mark.asyncio + async def test_list_crew(self, client): + await set_scenario(client, "weather_disruption_ord") + res = await client.get(E.DATA_CREW) + H.assert_status(res, 200) + data = res.json() + H.assert_is_list(data, min_length=1) + H.assert_has_fields(data[0], "crew_id", "name", "role", "hours_until_limit", "at_risk") + + @pytest.mark.asyncio + async def test_patch_crew_duty_hours(self, client): + await set_scenario(client, "weather_disruption_ord") + res = await client.patch(E.crew("CR-1001"), json={"duty_hours_elapsed": 13.5}) + H.assert_status(res, 200) + data = res.json() + assert data["duty_hours_elapsed"] == 13.5 + assert data["at_risk"] is True + assert data["hours_until_limit"] == 0.5 + + +class TestCrewNotes: + @pytest.mark.asyncio + async def test_get_notes(self, client): + await set_scenario(client, "weather_disruption_ord") + res = await client.get(E.DATA_CREW_NOTES) + H.assert_status(res, 200) + data = res.json() + assert isinstance(data, dict) + assert "UA432" in data + assert len(data["UA432"]) > 0 + + @pytest.mark.asyncio + async def test_update_notes(self, client): + await set_scenario(client, "weather_disruption_ord") + new_notes = ["Test note alpha", "Test note beta"] + res = await client.put(E.crew_notes("UA432"), json={"notes": new_notes}) + H.assert_status(res, 200) + assert res.json()["notes"] == new_notes + + all_notes = (await client.get(E.DATA_CREW_NOTES)).json() + assert all_notes["UA432"] == new_notes + + +class TestMaintenance: + @pytest.mark.asyncio + async def test_get_maintenance(self, client): + await set_scenario(client, "weather_disruption_ord") + res = await client.get(E.DATA_MAINTENANCE) + H.assert_status(res, 200) + assert isinstance(res.json(), dict) + + @pytest.mark.asyncio + async def test_maintenance_has_mel_items(self, client): + await set_scenario(client, "maintenance_delay_sfo") + data = (await client.get(E.DATA_MAINTENANCE)).json() + assert len(data) > 0 + for tail, items in data.items(): + for item in items: + H.assert_has_fields(item, "mel_id", "system", "description") + + +class TestRebookings: + @pytest.mark.asyncio + async def test_get_rebookings(self, client): + await set_scenario(client, "weather_disruption_ord") + res = await client.get(E.DATA_REBOOKINGS) + H.assert_status(res, 200) + data = res.json() + H.assert_is_list(data, min_length=1) + H.assert_has_fields(data[0], "pax_id", "name", "urgency", "original_flight") + + @pytest.mark.asyncio + async def test_empty_in_normal_ops(self, client): + await set_scenario(client, "normal_ops") + data = (await client.get(E.DATA_REBOOKINGS)).json() + assert len(data) == 0 + await set_scenario(client, "weather_disruption_ord") diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py new file mode 100644 index 0000000..bec9cd0 --- /dev/null +++ b/tests/test_scenarios.py @@ -0,0 +1,53 @@ +"""Contract tests — scenario management.""" + +import pytest + +from tests.base import ContractHelpers as H +from tests.endpoints import Endpoints as E + + +class TestListScenarios: + @pytest.mark.asyncio + async def test_returns_list(self, client): + res = await client.get(E.SCENARIOS) + H.assert_status(res, 200) + H.assert_is_list(res.json(), min_length=4) + + @pytest.mark.asyncio + async def test_each_has_metadata(self, client): + data = (await client.get(E.SCENARIOS)).json() + for s in data: + H.assert_has_fields(s, "scenario_id", "name", "description", "hubs", "flight_count") + + @pytest.mark.asyncio + async def test_known_scenarios_present(self, client): + ids = [s["scenario_id"] for s in (await client.get(E.SCENARIOS)).json()] + assert "normal_ops" in ids + assert "weather_disruption_ord" in ids + assert "maintenance_delay_sfo" in ids + assert "crew_swap_ewr" in ids + + +class TestActiveScenario: + @pytest.mark.asyncio + async def test_get_active(self, client): + res = await client.get(E.SCENARIO_ACTIVE) + H.assert_status(res, 200) + H.assert_has_fields(res.json(), "scenario_id", "name") + + @pytest.mark.asyncio + async def test_switch_scenario(self, client): + res = await client.put(E.SCENARIO_ACTIVE, json={"scenario_id": "normal_ops"}) + H.assert_status(res, 200) + assert res.json()["scenario_id"] == "normal_ops" + + res = await client.get(E.SCENARIO_ACTIVE) + assert res.json()["scenario_id"] == "normal_ops" + + await client.put(E.SCENARIO_ACTIVE, json={"scenario_id": "weather_disruption_ord"}) + + @pytest.mark.asyncio + async def test_switch_invalid(self, client): + res = await client.put(E.SCENARIO_ACTIVE, json={"scenario_id": "nonexistent"}) + H.assert_status(res, 200) + assert "error" in res.json() diff --git a/uv.lock b/uv.lock index 8041776..2f590f7 100644 --- a/uv.lock +++ b/uv.lock @@ -1880,6 +1880,56 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, ] +[[package]] +name = "stellar-ops" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "anthropic" }, + { name = "boto3" }, + { name = "fastapi" }, + { name = "fastmcp" }, + { name = "httpx" }, + { name = "langchain-anthropic" }, + { name = "langchain-aws" }, + { name = "langfuse" }, + { name = "langgraph" }, + { name = "mcp", extra = ["cli"] }, + { name = "pydantic" }, + { name = "uvicorn", extra = ["standard"] }, + { name = "websockets" }, +] + +[package.optional-dependencies] +dev = [ + { name = "httpx" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "anthropic" }, + { name = "boto3" }, + { name = "fastapi" }, + { name = "fastmcp", specifier = ">=3.0" }, + { name = "httpx" }, + { name = "httpx", marker = "extra == 'dev'" }, + { name = "langchain-anthropic" }, + { name = "langchain-aws" }, + { name = "langfuse" }, + { name = "langgraph" }, + { name = "mcp", extras = ["cli"] }, + { name = "pydantic", specifier = ">=2.0" }, + { name = "pytest", marker = "extra == 'dev'" }, + { name = "pytest-asyncio", marker = "extra == 'dev'" }, + { name = "ruff", marker = "extra == 'dev'" }, + { name = "uvicorn", extras = ["standard"] }, + { name = "websockets" }, +] +provides-extras = ["dev"] + [[package]] name = "tenacity" version = "9.1.4" @@ -1934,54 +1984,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/11/e1/7ec67882ad8fc9f86384bef6421fa252c9cbe5744f8df6ce77afc9eca1f5/uncalled_for-0.3.1-py3-none-any.whl", hash = "sha256:074cdc92da8356278f93d0ded6f2a66dd883dbecaf9bc89437646ee2289cc200", size = 11361, upload-time = "2026-04-07T13:05:05.341Z" }, ] -[[package]] -name = "united-ops" -version = "0.1.0" -source = { virtual = "." } -dependencies = [ - { name = "anthropic" }, - { name = "boto3" }, - { name = "fastapi" }, - { name = "fastmcp" }, - { name = "httpx" }, - { name = "langchain-anthropic" }, - { name = "langchain-aws" }, - { name = "langfuse" }, - { name = "langgraph" }, - { name = "mcp", extra = ["cli"] }, - { name = "pydantic" }, - { name = "uvicorn", extra = ["standard"] }, - { name = "websockets" }, -] - -[package.optional-dependencies] -dev = [ - { name = "pytest" }, - { name = "pytest-asyncio" }, - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [ - { name = "anthropic" }, - { name = "boto3" }, - { name = "fastapi" }, - { name = "fastmcp", specifier = ">=3.0" }, - { name = "httpx" }, - { name = "langchain-anthropic" }, - { name = "langchain-aws" }, - { name = "langfuse" }, - { name = "langgraph" }, - { name = "mcp", extras = ["cli"] }, - { name = "pydantic", specifier = ">=2.0" }, - { name = "pytest", marker = "extra == 'dev'" }, - { name = "pytest-asyncio", marker = "extra == 'dev'" }, - { name = "ruff", marker = "extra == 'dev'" }, - { name = "uvicorn", extras = ["standard"] }, - { name = "websockets" }, -] -provides-extras = ["dev"] - [[package]] name = "urllib3" version = "2.6.3"