diff --git a/nitrostack/cli/main.py b/nitrostack/cli/main.py index 29ff74f..89725e7 100644 --- a/nitrostack/cli/main.py +++ b/nitrostack/cli/main.py @@ -286,7 +286,7 @@ def _request(self, method: str, path: str, data: Optional[Dict[str, Any]] = None headers = { "Authorization": f"Bearer {self.api_key}", - "Duffel-Version": "v1", + "Duffel-Version": "v2", "Content-Type": "application/json" } url = f"https://api.duffel.com{path}" @@ -355,7 +355,7 @@ async def search_flights(self, params: Dict[str, Any]) -> Dict[str, Any]: "cabin_class": params.get("cabinClass", "economy"), "return_offers": True } - res = self._request("POST", "/offer_requests", duffel_params) + res = self._request("POST", "/air/offer_requests", duffel_params) return { "id": res.get("id"), "offers": res.get("offers", []), @@ -390,7 +390,7 @@ async def get_offer(self, offer_id: str) -> Dict[str, Any]: } ] } - return self._request("GET", f"/offers/{offer_id}") + return self._request("GET", f"/air/offers/{offer_id}") async def get_seats_for_offer(self, offer_id: str) -> List[Dict[str, Any]]: if self.is_mock: @@ -424,7 +424,7 @@ async def get_seats_for_offer(self, offer_id: str) -> List[Dict[str, Any]]: ] } ] - res = self._request("GET", f"/seat_maps?offer_id={offer_id}") + res = self._request("GET", f"/air/seat_maps?offer_id={offer_id}") return res if isinstance(res, list) else [] async def create_order(self, params: Dict[str, Any]) -> Dict[str, Any]: @@ -464,7 +464,7 @@ async def create_order(self, params: Dict[str, Any]) -> Dict[str, Any]: "passengers": params["passengers"], "type": "hold" } - return self._request("POST", "/orders", order_payload) + return self._request("POST", "/air/orders", order_payload) async def get_order(self, order_id: str) -> Dict[str, Any]: if self.is_mock: @@ -507,7 +507,7 @@ async def get_order(self, order_id: str) -> Dict[str, Any]: } ] } - return self._request("GET", f"/orders/{order_id}") + return self._request("GET", f"/air/orders/{order_id}") async def cancel_order(self, order_id: str) -> Dict[str, Any]: if self.is_mock: @@ -518,7 +518,7 @@ async def cancel_order(self, order_id: str) -> Dict[str, Any]: "confirmed_at": "2026-06-25T12:30:00Z" } cancel_payload = {"order_id": order_id} - return self._request("POST", "/order_cancellations", cancel_payload) + return self._request("POST", "/air/order_cancellations", cancel_payload) async def get_airlines(self) -> List[Dict[str, Any]]: if self.is_mock: @@ -528,7 +528,7 @@ async def get_airlines(self) -> List[Dict[str, Any]]: {"iata_code": "UA", "name": "United Airlines"}, {"iata_code": "BA", "name": "British Airways"} ] - res = self._request("GET", "/airlines") + res = self._request("GET", "/air/airlines") return res if isinstance(res, list) else [] """ diff --git a/nitrostack/templates/flight-booking/services/duffel_service.py b/nitrostack/templates/flight-booking/services/duffel_service.py index b2ce491..ead8914 100644 --- a/nitrostack/templates/flight-booking/services/duffel_service.py +++ b/nitrostack/templates/flight-booking/services/duffel_service.py @@ -44,7 +44,7 @@ def _request(self, method: str, path: str, data: Optional[Dict[str, Any]] = None headers = { "Authorization": f"Bearer {self.api_key}", - "Duffel-Version": "v1", + "Duffel-Version": "v2", "Content-Type": "application/json", "Accept": "application/json", } @@ -151,7 +151,7 @@ async def search_flights(self, params: Dict[str, Any]) -> Dict[str, Any]: } if params.get("maxConnections") is not None: duffel_params["max_connections"] = params["maxConnections"] - res = self._request("POST", "/offer_requests", duffel_params) + res = self._request("POST", "/air/offer_requests", duffel_params) return { "id": res.get("id"), "offers": res.get("offers", []), @@ -162,7 +162,7 @@ async def search_flights(self, params: Dict[str, Any]) -> Dict[str, Any]: async def get_offer(self, offer_id: str) -> Dict[str, Any]: if self.is_mock: return self._mock_offer(offer_id) - return self._request("GET", f"/offers/{offer_id}") + return self._request("GET", f"/air/offers/{offer_id}") async def get_seats_for_offer(self, offer_id: str) -> List[Dict[str, Any]]: if self.is_mock: @@ -196,7 +196,7 @@ async def get_seats_for_offer(self, offer_id: str) -> List[Dict[str, Any]]: ], } ] - res = self._request("GET", f"/seat_maps?offer_id={urllib.parse.quote(offer_id)}") + res = self._request("GET", f"/air/seat_maps?offer_id={urllib.parse.quote(offer_id)}") return res if isinstance(res, list) else [] async def create_order(self, params: Dict[str, Any]) -> Dict[str, Any]: @@ -228,7 +228,7 @@ async def create_order(self, params: Dict[str, Any]) -> Dict[str, Any]: "passengers": params["passengers"], "type": "hold", } - return self._request("POST", "/orders", order_payload) + return self._request("POST", "/air/orders", order_payload) async def get_order(self, order_id: str) -> Dict[str, Any]: if self.is_mock: @@ -253,7 +253,7 @@ async def get_order(self, order_id: str) -> Dict[str, Any]: ], "slices": offer["slices"], } - return self._request("GET", f"/orders/{order_id}") + return self._request("GET", f"/air/orders/{order_id}") async def cancel_order(self, order_id: str) -> Dict[str, Any]: if self.is_mock: @@ -263,7 +263,7 @@ async def cancel_order(self, order_id: str) -> Dict[str, Any]: "refund_currency": "USD", "confirmed_at": "2026-06-25T12:30:00Z", } - return self._request("POST", "/order_cancellations", {"order_id": order_id}) + return self._request("POST", "/air/order_cancellations", {"order_id": order_id}) async def get_airlines(self) -> List[Dict[str, Any]]: if self.is_mock: @@ -274,7 +274,7 @@ async def get_airlines(self) -> List[Dict[str, Any]]: {"iata_code": "BA", "name": "British Airways"}, {"iata_code": "AI", "name": "Air India"}, ] - res = self._request("GET", "/airlines") + res = self._request("GET", "/air/airlines") return res if isinstance(res, list) else [] async def search_airports(self, query: str) -> List[Dict[str, Any]]: @@ -283,8 +283,5 @@ async def search_airports(self, query: str) -> List[Dict[str, Any]]: return search_mock_airports(query) quoted = urllib.parse.quote(query or "") - try: - res = self._request("GET", f"/air/suggestions?query={quoted}") - except Exception: - res = self._request("GET", f"/places?type=airport&query={quoted}") + res = self._request("GET", f"/places/suggestions?query={quoted}") return res if isinstance(res, list) else [] diff --git a/tests/test_flight_booking.py b/tests/test_flight_booking.py index 101a460..de9b008 100644 --- a/tests/test_flight_booking.py +++ b/tests/test_flight_booking.py @@ -2,8 +2,10 @@ from __future__ import annotations import asyncio +import json import os import sys +import urllib.request from pathlib import Path sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) @@ -209,6 +211,68 @@ async def run(): asyncio.run(run()) +class _FakeDuffelResponse: + def __init__(self, payload): + self._payload = payload + + def read(self): + return json.dumps({"data": self._payload}).encode("utf-8") + + def __enter__(self): + return self + + def __exit__(self, *exc_info): + return False + + +def test_live_duffel_requests_target_v2_air_endpoints(): + """A real key must hit Duffel v2: flight resources under /air, places under /places.""" + sys.path.insert(0, str(ROOT / "nitrostack" / "templates" / "flight-booking")) + from services.duffel_service import DuffelService + + os.environ["DUFFEL_API_KEY"] = "duffel_live_regression_key" + original_urlopen = urllib.request.urlopen + captured = [] + + def fake_urlopen(req, timeout=None): + captured.append(req) + return _FakeDuffelResponse({}) + + try: + service = DuffelService() + assert service.is_mock is False, "non-placeholder key must leave mock mode" + urllib.request.urlopen = fake_urlopen + + async def run(): + await service.get_airlines() + await service.search_airports("London") + await service.search_flights( + {"origin": "JFK", "destination": "LAX", "departureDate": "2026-10-15", "adults": 1} + ) + await service.get_offer("off_1") + await service.get_seats_for_offer("off_1") + await service.create_order({"selectedOffers": ["off_1"], "passengers": []}) + await service.get_order("ord_1") + await service.cancel_order("ord_1") + + asyncio.run(run()) + finally: + urllib.request.urlopen = original_urlopen + os.environ.pop("DUFFEL_API_KEY", None) + + assert [(r.get_method(), r.full_url) for r in captured] == [ + ("GET", "https://api.duffel.com/air/airlines"), + ("GET", "https://api.duffel.com/places/suggestions?query=London"), + ("POST", "https://api.duffel.com/air/offer_requests"), + ("GET", "https://api.duffel.com/air/offers/off_1"), + ("GET", "https://api.duffel.com/air/seat_maps?offer_id=off_1"), + ("POST", "https://api.duffel.com/air/orders"), + ("GET", "https://api.duffel.com/air/orders/ord_1"), + ("POST", "https://api.duffel.com/air/order_cancellations"), + ] + assert {r.get_header("Duffel-version") for r in captured} == {"v2"} + + def test_live_preview_search_flights_without_token(): os.environ.pop("OAUTH_REQUIRED", None) DIContainer.reset()