Init commit
This commit is contained in:
515
tests/test_flight_thr_mhd_e2e.py
Normal file
515
tests/test_flight_thr_mhd_e2e.py
Normal file
@@ -0,0 +1,515 @@
|
||||
# test_flight_thr_mhd_e2e.py
|
||||
import requests
|
||||
import pytest
|
||||
|
||||
from conftest import (
|
||||
BASE_URL,
|
||||
TEST_EMAIL,
|
||||
TEST_PHONE_NUMBER,
|
||||
TRAVELER_FIRST_NAME,
|
||||
TRAVELER_LAST_NAME,
|
||||
TRAVELER_GENDER,
|
||||
TRAVELER_NATIONAL_CODE,
|
||||
TRAVELER_DATE_OF_BIRTH,
|
||||
TRAVELER_PLACE_OF_BIRTH,
|
||||
)
|
||||
|
||||
|
||||
# =====================================================================================
|
||||
# IATA / Airport discovery tests
|
||||
# =====================================================================================
|
||||
|
||||
|
||||
def test_iata_domestic_default_list(auth_header):
|
||||
"""
|
||||
Validate the default domestic IATA list.
|
||||
|
||||
Scenario:
|
||||
• Perform GET /customer/iata with:
|
||||
- keyword = "" (no filter)
|
||||
- is_international = false
|
||||
• Assert:
|
||||
- Response is HTTP 200.
|
||||
- "data" is a non-empty list.
|
||||
- Both THR and MHD are present in the list (core domestic airports).
|
||||
"""
|
||||
res = requests.get(
|
||||
f"{BASE_URL}/customer/iata",
|
||||
params={
|
||||
"keyword": "",
|
||||
"is_international": "false",
|
||||
"other_iata_code": "",
|
||||
},
|
||||
headers=auth_header,
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
assert res.status_code == 200, "Expected HTTP 200 for domestic IATA default list"
|
||||
data = res.json()
|
||||
assert "data" in data and isinstance(data["data"], list), "Response should contain a 'data' list"
|
||||
assert data["data"], "Domestic IATA list should not be empty"
|
||||
|
||||
codes = {item["code"] for item in data["data"]}
|
||||
assert "THR" in codes, "THR should be present in domestic IATA list"
|
||||
assert "MHD" in codes, "MHD should be present in domestic IATA list"
|
||||
|
||||
|
||||
def test_iata_search_origin_tehran(auth_header):
|
||||
"""
|
||||
Validate origin search for Tehran.
|
||||
|
||||
Scenario:
|
||||
• Perform GET /customer/iata with:
|
||||
- keyword = "tehran"
|
||||
- is_international = false
|
||||
• Assert:
|
||||
- Response is HTTP 200.
|
||||
- "data" is a non-empty list.
|
||||
- THR is returned as one of the matches.
|
||||
"""
|
||||
res = requests.get(
|
||||
f"{BASE_URL}/customer/iata",
|
||||
params={
|
||||
"keyword": "tehran",
|
||||
"is_international": "false",
|
||||
"other_iata_code": "",
|
||||
},
|
||||
headers=auth_header,
|
||||
timeout=10,
|
||||
)
|
||||
assert res.status_code == 200, "Expected HTTP 200 for IATA search with keyword 'tehran'"
|
||||
|
||||
data = res.json()
|
||||
assert "data" in data and isinstance(data["data"], list), "Response should contain a 'data' list"
|
||||
assert data["data"], "IATA search(tehran) should not be empty"
|
||||
|
||||
codes = {item["code"] for item in data["data"]}
|
||||
assert "THR" in codes, "THR should be present in IATA search result for 'tehran'"
|
||||
|
||||
|
||||
def test_iata_search_destination_mashhad_excluding_origin(auth_header):
|
||||
"""
|
||||
Validate destination search for Mashhad while excluding origin.
|
||||
|
||||
Scenario:
|
||||
• Perform GET /customer/iata with:
|
||||
- keyword = "mashhad"
|
||||
- is_international = false
|
||||
- other_iata_code = "THR"
|
||||
• Assert:
|
||||
- Response is HTTP 200.
|
||||
- "data" is a non-empty list.
|
||||
- MHD is present in results.
|
||||
- THR is explicitly NOT present (origin and destination must differ).
|
||||
"""
|
||||
res = requests.get(
|
||||
f"{BASE_URL}/customer/iata",
|
||||
params={
|
||||
"keyword": "mashhad",
|
||||
"is_international": "false",
|
||||
"other_iata_code": "THR",
|
||||
},
|
||||
headers=auth_header,
|
||||
timeout=10,
|
||||
)
|
||||
assert res.status_code == 200, "Expected HTTP 200 for IATA search with keyword 'mashhad'"
|
||||
|
||||
data = res.json()
|
||||
assert "data" in data and isinstance(data["data"], list), "Response should contain a 'data' list"
|
||||
assert data["data"], "IATA search(mashhad) should not be empty"
|
||||
|
||||
codes = {item["code"] for item in data["data"]}
|
||||
assert "MHD" in codes, "MHD should be in Mashhad search result"
|
||||
assert "THR" not in codes, "THR must NOT appear when other_iata_code=THR (origin must be excluded)"
|
||||
|
||||
|
||||
# =====================================================================================
|
||||
# General and refund rules
|
||||
# =====================================================================================
|
||||
|
||||
|
||||
def test_general_and_refund_rules(auth_header):
|
||||
"""
|
||||
Validate general rules and refund rules configuration.
|
||||
|
||||
Endpoints under test:
|
||||
• GET /customer/setting/get/general-rules
|
||||
• GET /customer/setting/get/refund-rules
|
||||
|
||||
Minimal structure validation:
|
||||
• general-rules:
|
||||
- "data.general-rules" exists and is non-empty (typically an HTML string).
|
||||
• refund-rules:
|
||||
- "data.refund_rules.domestic.data" is a non-empty list of items with
|
||||
"description" and "penalty" fields.
|
||||
- "data.refund_rules.international.data" behaves similarly.
|
||||
"""
|
||||
|
||||
# --- general-rules ---
|
||||
res_gen = requests.get(
|
||||
f"{BASE_URL}/customer/setting/get/general-rules",
|
||||
headers=auth_header,
|
||||
timeout=10,
|
||||
)
|
||||
assert res_gen.status_code == 200, "Expected HTTP 200 for general-rules endpoint"
|
||||
data_gen = res_gen.json()
|
||||
assert "data" in data_gen, "general-rules response must contain 'data' field"
|
||||
assert data_gen["data"].get("general-rules"), "general-rules must be present and non-empty"
|
||||
|
||||
# --- refund-rules ---
|
||||
res_ref = requests.get(
|
||||
f"{BASE_URL}/customer/setting/get/refund-rules",
|
||||
headers=auth_header,
|
||||
timeout=10,
|
||||
)
|
||||
assert res_ref.status_code == 200, "Expected HTTP 200 for refund-rules endpoint"
|
||||
|
||||
data_ref = res_ref.json()
|
||||
refund_rules = data_ref.get("data", {}).get("refund_rules")
|
||||
assert refund_rules is not None, "refund_rules should exist under data.refund_rules"
|
||||
|
||||
for key in ("domestic", "international"):
|
||||
section = refund_rules.get(key)
|
||||
assert section is not None, f"refund_rules.{key} should exist"
|
||||
|
||||
items = section.get("data")
|
||||
assert isinstance(items, list) and items, f"refund_rules.{key}.data should be a non-empty list"
|
||||
|
||||
first = items[0]
|
||||
assert "description" in first, f"refund_rules.{key}.data[0] must contain 'description'"
|
||||
assert "penalty" in first, f"refund_rules.{key}.data[0] must contain 'penalty'"
|
||||
|
||||
|
||||
# =====================================================================================
|
||||
# E2E: THR → MHD single adult flow (search → book → issue → refund)
|
||||
# =====================================================================================
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_thr_mhd_single_adult_book_issue_refund_e2e(
|
||||
auth_header,
|
||||
thr_to_mhd_search_params,
|
||||
api_wait,
|
||||
):
|
||||
"""
|
||||
End-to-end happy path for a single adult THR → MHD domestic flight.
|
||||
|
||||
High-level flow:
|
||||
1) Search for THR → MHD flights for (today + 7 days).
|
||||
2) Select the first itinerary (sorted by lowest-price).
|
||||
3) Book the itinerary for a single Adult passenger.
|
||||
4) Verify that the user's wallet credit is >= booking price.
|
||||
5) Issue the ticket using wallet credit (pay_with_credit = true).
|
||||
6) Retrieve customer tickets and locate the ticket associated with this booking.
|
||||
7) Call refund-penalty and validate:
|
||||
- No penalty (penalty_percent == 0).
|
||||
- Full amount is refundable (amount_that_should_pay_back_to_passenger == booking_price).
|
||||
8) Perform the actual refund.
|
||||
9) Retrieve tickets again and assert the ticket's refund_status reflects the refund.
|
||||
"""
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 1) Search THR → MHD
|
||||
# -------------------------------------------------------------------------
|
||||
print("\n[STEP 1] Searching for THR → MHD itineraries ...")
|
||||
|
||||
search_payload = {
|
||||
"origin_destination_option_list": [
|
||||
{
|
||||
"OriginIataCode": thr_to_mhd_search_params["origin_iata"],
|
||||
"DestinationIataCode": thr_to_mhd_search_params["destination_iata"],
|
||||
"FlightDate": thr_to_mhd_search_params["flight_date"],
|
||||
}
|
||||
],
|
||||
"page": thr_to_mhd_search_params["page"],
|
||||
"page_size": thr_to_mhd_search_params["page_size"],
|
||||
"sort_option": thr_to_mhd_search_params["sort_option"],
|
||||
}
|
||||
|
||||
res_search = requests.post(
|
||||
f"{BASE_URL}/customer/flight/search",
|
||||
json=search_payload,
|
||||
headers={**auth_header, "Content-Type": "application/json"},
|
||||
timeout=15,
|
||||
)
|
||||
assert res_search.status_code == 200, "Expected HTTP 200 for flight search"
|
||||
|
||||
data_search = res_search.json()
|
||||
itinerary_list = data_search.get("itinerary_list") or []
|
||||
assert itinerary_list, "No itineraries returned for THR→MHD search"
|
||||
|
||||
itinerary = itinerary_list[0]
|
||||
flight_id = itinerary.get("flight_id")
|
||||
assert flight_id, "flight_id must be present for the selected itinerary"
|
||||
|
||||
# Minimal segment validation – we assert the route is THR → MHD.
|
||||
segments = itinerary.get("flight_segment_list") or []
|
||||
assert segments, "flight_segment_list should not be empty"
|
||||
|
||||
first_segment = segments[0]
|
||||
assert first_segment.get("origin_iata_code") == "THR", "Segment origin_iata_code must be THR"
|
||||
assert first_segment.get("destination_iata_code") == "MHD", "Segment destination_iata_code must be MHD"
|
||||
|
||||
# Ticket must not be free – ensure we are dealing with a priced product.
|
||||
price_info = itinerary.get("price_info") or {}
|
||||
total_fare = price_info.get("total_fare")
|
||||
assert isinstance(total_fare, (int, float)) and total_fare > 0, "total_fare must be a positive number"
|
||||
|
||||
print(
|
||||
f"[STEP 1] Selected itinerary flight_id={flight_id} "
|
||||
f"with total_fare={total_fare} and {len(segments)} segment(s)."
|
||||
)
|
||||
|
||||
api_wait(0.5)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 2) Book the selected itinerary for a single Adult passenger
|
||||
# -------------------------------------------------------------------------
|
||||
print("[STEP 2] Booking selected itinerary for a single adult passenger ...")
|
||||
|
||||
book_payload = {
|
||||
"country_code": "98",
|
||||
"email": TEST_EMAIL,
|
||||
"flight_id": flight_id,
|
||||
"phone_number": TEST_PHONE_NUMBER,
|
||||
"travelers": [
|
||||
{
|
||||
"date_of_birth": TRAVELER_DATE_OF_BIRTH,
|
||||
"first_name": TRAVELER_FIRST_NAME,
|
||||
"gender": TRAVELER_GENDER,
|
||||
"last_name": TRAVELER_LAST_NAME,
|
||||
"national_code": TRAVELER_NATIONAL_CODE,
|
||||
"place_of_birth": TRAVELER_PLACE_OF_BIRTH,
|
||||
"type": "ADT",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
res_book = requests.post(
|
||||
f"{BASE_URL}/customer/flight/book",
|
||||
json=book_payload,
|
||||
headers={**auth_header, "Content-Type": "application/json"},
|
||||
timeout=15,
|
||||
)
|
||||
assert res_book.status_code == 200, "Expected HTTP 200 for flight book"
|
||||
|
||||
data_book = res_book.json()
|
||||
booking_id = data_book.get("id")
|
||||
expires_at = data_book.get("expires_at")
|
||||
booking_price = data_book.get("price")
|
||||
|
||||
assert isinstance(booking_id, int) and booking_id > 0, "Booking id must be a positive integer"
|
||||
assert isinstance(expires_at, int) and expires_at > 0, "Booking expiry (minutes) must be a positive integer"
|
||||
assert isinstance(booking_price, (int, float)) and booking_price > 0, "Booking price must be > 0"
|
||||
|
||||
print(
|
||||
f"[STEP 2] Booking created successfully. "
|
||||
f"id={booking_id}, price={booking_price}, expires_in={expires_at} minutes."
|
||||
)
|
||||
|
||||
api_wait(1.0)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 3) Validate wallet credit is sufficient for the booking price
|
||||
# -------------------------------------------------------------------------
|
||||
print("[STEP 3] Verifying wallet credit is sufficient for booking price ...")
|
||||
|
||||
res_credit = requests.get(
|
||||
f"{BASE_URL}/admin/user/credit",
|
||||
headers=auth_header,
|
||||
timeout=10,
|
||||
)
|
||||
assert res_credit.status_code == 200, "Expected HTTP 200 for user credit endpoint"
|
||||
|
||||
data_credit = res_credit.json()
|
||||
credit = data_credit.get("credit")
|
||||
assert isinstance(credit, (int, float)), "credit must be numeric"
|
||||
assert credit >= booking_price, "User credit is not sufficient for booking price"
|
||||
|
||||
print(f"[STEP 3] Wallet credit={credit} is sufficient for booking_price={booking_price}.")
|
||||
|
||||
api_wait(0.5)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 4) Issue the ticket using wallet credit
|
||||
# -------------------------------------------------------------------------
|
||||
print("[STEP 4] Issuing the ticket using wallet credit ...")
|
||||
|
||||
issue_payload = {
|
||||
"pay_with_credit": True,
|
||||
"pay_with_flight_coin": False,
|
||||
"payment_gateway": "",
|
||||
"pay_for": "ticket",
|
||||
"platform": "mobile",
|
||||
}
|
||||
|
||||
res_issue = requests.post(
|
||||
f"{BASE_URL}/customer/flight/book/{booking_id}/issue",
|
||||
json=issue_payload,
|
||||
headers={**auth_header, "Content-Type": "application/json"},
|
||||
timeout=30,
|
||||
)
|
||||
assert res_issue.status_code == 204, "Expected HTTP 204 (No Content) for ticket issue"
|
||||
|
||||
print("[STEP 4] Ticket issue request completed with HTTP 204. Backend is finalizing issuance ...")
|
||||
|
||||
api_wait(2.0)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 5) Retrieve tickets and locate the one associated with this booking
|
||||
# -------------------------------------------------------------------------
|
||||
print("[STEP 5] Fetching active tickets and locating the issued one ...")
|
||||
|
||||
res_tickets_after_issue = requests.get(
|
||||
f"{BASE_URL}/customer/ticket",
|
||||
params={
|
||||
"page": 1,
|
||||
"page_size": 20,
|
||||
"is_past": "false",
|
||||
"statuses": "confirmed",
|
||||
"sort": "issue_date",
|
||||
"sort_dir": "DESC",
|
||||
},
|
||||
headers=auth_header,
|
||||
timeout=15,
|
||||
)
|
||||
assert res_tickets_after_issue.status_code == 200, "Expected HTTP 200 for ticket list after issue"
|
||||
|
||||
tickets_data = res_tickets_after_issue.json()
|
||||
ticket_records = tickets_data.get("data") or []
|
||||
assert ticket_records, "Ticket list after issue should not be empty"
|
||||
|
||||
# For simplicity, locate the first ticket whose total matches our booking price.
|
||||
ticket = None
|
||||
for t in ticket_records:
|
||||
if t.get("total") == booking_price:
|
||||
ticket = t
|
||||
break
|
||||
|
||||
assert ticket is not None, "Issued ticket with matching price not found in ticket list"
|
||||
|
||||
ticket_id = ticket["id"]
|
||||
print(f"[STEP 5] Located ticket id={ticket_id} with total={ticket.get('total')}.")
|
||||
|
||||
travelers = ticket.get("travelers") or []
|
||||
assert travelers, "Ticket must have at least one traveler"
|
||||
|
||||
traveler = travelers[0] # Single adult in this scenario
|
||||
ticket_traveler_id = traveler["id"]
|
||||
|
||||
segments = ticket.get("segments") or []
|
||||
assert segments, "Ticket must have at least one segment"
|
||||
|
||||
segment = segments[0]
|
||||
ticket_segment_id = segment["id"]
|
||||
|
||||
print(
|
||||
f"[STEP 5] Using ticket_segment_id={ticket_segment_id}, "
|
||||
f"ticket_traveler_id={ticket_traveler_id} for refund operations."
|
||||
)
|
||||
|
||||
api_wait(1.5)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 6) Call refund-penalty and validate zero-penalty scenario
|
||||
# -------------------------------------------------------------------------
|
||||
print("[STEP 6] Calculating refund penalty and validating zero-penalty scenario ...")
|
||||
|
||||
refund_penalty_payload = {
|
||||
"ticket_segment_id": ticket_segment_id,
|
||||
"ticket_traveler_ids": [ticket_traveler_id],
|
||||
}
|
||||
|
||||
res_refund_penalty = requests.post(
|
||||
f"{BASE_URL}/customer/flight/refund-penalty",
|
||||
json=refund_penalty_payload,
|
||||
headers={**auth_header, "Content-Type": "application/json"},
|
||||
timeout=15,
|
||||
)
|
||||
assert res_refund_penalty.status_code == 200, "Expected HTTP 200 for refund-penalty endpoint"
|
||||
|
||||
data_refund_penalty = res_refund_penalty.json()
|
||||
penalty_items = data_refund_penalty.get("data") or []
|
||||
assert penalty_items, "refund-penalty data list must not be empty"
|
||||
|
||||
pitem = penalty_items[0]
|
||||
payable = pitem.get("payable")
|
||||
amount_back = pitem.get("amount_that_should_pay_back_to_passenger")
|
||||
penalty_percent = pitem.get("penalty_percent")
|
||||
returned_traveler_id = pitem.get("ticket_traveler_id")
|
||||
|
||||
assert isinstance(payable, (int, float)) and payable > 0, "payable must be > 0"
|
||||
assert isinstance(amount_back, (int, float)) and amount_back > 0, "amount_that_should_pay_back_to_passenger must be > 0"
|
||||
assert penalty_percent == 0, "This E2E scenario is expected to have zero penalty"
|
||||
assert amount_back == booking_price, "Full amount should be refunded to passenger"
|
||||
assert returned_traveler_id == ticket_traveler_id, "ticket_traveler_id must match the requested one"
|
||||
|
||||
print(
|
||||
f"[STEP 6] Refund penalty validated. penalty_percent={penalty_percent}, "
|
||||
f"amount_back={amount_back}, payable={payable}."
|
||||
)
|
||||
|
||||
api_wait(0.5)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 7) Perform the actual refund
|
||||
# -------------------------------------------------------------------------
|
||||
print("[STEP 7] Performing actual refund ...")
|
||||
|
||||
refund_payload = {
|
||||
"ticket_segment_id": ticket_segment_id,
|
||||
"ticket_traveler_ids": [ticket_traveler_id],
|
||||
}
|
||||
|
||||
res_refund = requests.post(
|
||||
f"{BASE_URL}/customer/flight/refund",
|
||||
json=refund_payload,
|
||||
headers={**auth_header, "Content-Type": "application/json"},
|
||||
timeout=30,
|
||||
)
|
||||
assert res_refund.status_code == 200, "Expected HTTP 200 for refund endpoint"
|
||||
|
||||
print("[STEP 7] Refund request accepted. Waiting for status to converge ...")
|
||||
|
||||
api_wait(2.0)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 8) Verify that the ticket is now marked as refunded/refunded_partially
|
||||
# -------------------------------------------------------------------------
|
||||
print("[STEP 8] Fetching tickets after refund and validating refund_status ...")
|
||||
|
||||
res_tickets_after_refund = requests.get(
|
||||
f"{BASE_URL}/customer/ticket",
|
||||
params={
|
||||
"page": 1,
|
||||
"page_size": 20,
|
||||
"is_past": "true",
|
||||
"statuses": "confirmed,refunded_partially,refunded",
|
||||
"sort": "issue_date",
|
||||
"sort_dir": "DESC",
|
||||
},
|
||||
headers=auth_header,
|
||||
timeout=15,
|
||||
)
|
||||
assert res_tickets_after_refund.status_code == 200, "Expected HTTP 200 for ticket list after refund"
|
||||
|
||||
tickets_after_ref_data = res_tickets_after_refund.json()
|
||||
tickets_after_ref = tickets_after_ref_data.get("data") or []
|
||||
|
||||
refunded_ticket = None
|
||||
for t in tickets_after_ref:
|
||||
if t.get("id") == ticket_id:
|
||||
refunded_ticket = t
|
||||
break
|
||||
|
||||
assert refunded_ticket is not None, "Refunded ticket not found in ticket list after refund"
|
||||
|
||||
refund_status = refunded_ticket.get("refund_status")
|
||||
assert refund_status in ("refunded", "refunded_partially"), (
|
||||
f"Unexpected refund_status for ticket {ticket_id}: {refund_status}"
|
||||
)
|
||||
|
||||
print(
|
||||
f"[STEP 8] Refund flow completed successfully for ticket_id={ticket_id} "
|
||||
f"with refund_status={refund_status}.\n"
|
||||
)
|
||||
Reference in New Issue
Block a user