Init commit

This commit is contained in:
Reza Esmaeili
2025-12-04 12:46:23 +03:30
commit 9e69b14249
6 changed files with 1316 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
__pycache__/
*.pyc
.env

548
README.md Normal file
View File

@@ -0,0 +1,548 @@
# AvaAir E2E Flight Flow Test Suite
This test suite exercises the **end-to-end customer journey** for AvaAirs domestic flights (THR → MHD) using real API calls against a live environment (staging/production-like).
It is intentionally designed to:
* Validate **critical customer flows**: airport discovery, rules endpoints, flight search, booking, ticket issuance, and refund.
* Run both **interactively** (developer manually enters OTP) and **fully automated** (CI with pre-configured OTP).
* Be **readable and presentable** as a “production-grade” test harness.
---
## 1. High-level Architecture
The test suite is composed of:
* `conftest.py`
Global configuration and reusable fixtures shared across tests:
* Environment-driven configuration.
* Customer authentication (OTP flow).
* Authorization header fixture.
* Canonical THR → MHD search parameters.
* A small `api_wait` pacing helper for stabilizing async flows.
* `test_flight_thr_mhd_e2e.py`
Actual test cases:
* IATA / airport discovery tests.
* Rules endpoints sanity checks.
* A full end-to-end scenario for:
* Search → Book → Wallet check → Issue → Refund penalty → Refund → Post-refund validation.
The design intentionally centralizes configuration and “plumbing” in `conftest.py` so that individual tests remain concise and focused on business logic.
---
## 2. Environment & Configuration
All configuration is driven through environment variables with reasonable defaults, so the suite can be used:
* Quickly on a developer machine (with defaults).
* Safely in CI (with explicit overrides).
### 2.1 Base API URL
```bash
API_BASE_URL=http://192.168.19.19:8200/api/v1
```
* Default: `http://192.168.19.19:8200/api/v1`
* Use this to point the suite to **staging**, **pre-prod**, or **production**.
---
### 2.2 Test User Mobile & OTP
```bash
TEST_MOBILE_COUNTRY_CODE=98
TEST_MOBILE_NUMBER=9014332990
TEST_OTP_CODE=<optional pre-shared OTP>
```
* `TEST_MOBILE_COUNTRY_CODE` and `TEST_MOBILE_NUMBER` define the test customer account used for OTP login.
* `TEST_OTP_CODE` controls how OTP is resolved:
#### Interactive mode (local dev)
If `TEST_OTP_CODE` is **not** set:
* The suite sends an OTP to the configured mobile.
* The developer is prompted in the terminal:
```text
➡️ Enter OTP received on the test device:
```
* The entered OTP is then used for `/users/otp/check`.
#### Non-interactive mode (CI / automated)
If `TEST_OTP_CODE` **is** set:
* The suite **does not** prompt.
* The value is used directly in the OTP verify call.
* This enables fully headless execution in pipelines.
---
### 2.3 Booking Contact Info
```bash
TEST_BOOK_EMAIL=iamrezazoom@gmail.com
TEST_BOOK_PHONE=9014332990
```
* Email and phone number used in `/customer/flight/book` payloads.
* Defaults match the test users profile but can be overridden in more advanced setups.
---
### 2.4 Traveler Profile
These fields define the passive traveler object used during booking:
```bash
TEST_TRAVELER_FIRST_NAME=REZA
TEST_TRAVELER_LAST_NAME=ESMAEILI
TEST_TRAVELER_GENDER=male
TEST_TRAVELER_NATIONAL_CODE=0890500363
TEST_TRAVELER_DATE_OF_BIRTH=1998-05-20
TEST_TRAVELER_PLACE_OF_BIRTH=IRN
```
All of these have sensible defaults in `conftest.py`, but **in a real production-like setup** you should:
* Override them with a dedicated test passenger identity.
* Ensure the national ID and passport-related fields are valid for your environment.
---
## 3. Core Fixtures (conftest.py)
### 3.1 `_today_plus_days(days: int) -> str`
Utility that returns an ISO date (`YYYY-MM-DD`) relative to “today”:
* Used to always search for flights **7 days in the future**.
* Prevents hitting past or expired inventory.
```python
def _today_plus_days(days: int) -> str:
return (dt.date.today() + dt.timedelta(days=days)).isoformat()
```
---
### 3.2 `api_wait` Fixture
A small timing helper used to **pace** calls in E2E flows:
```python
@pytest.fixture
def api_wait():
import time
def _do(seconds: float = 1.0) -> None:
time.sleep(seconds)
return _do
```
Why it exists:
* Some backend operations are not purely synchronous:
* ticket issuance,
* wallet balance updates,
* ticket status changes.
* Adding short, explicit waits between operations makes the E2E flow **deterministic** even on real environments.
Usage example (from the E2E test):
```python
res_issue = requests.post(...)
assert res_issue.status_code == 204
api_wait(2.0) # Let backend finalize issuance before querying tickets
```
---
### 3.3 `customer_auth` Fixture
This is the **entry point** into the tested system from a customer perspective.
High-level behavior:
1. Sends OTP request:
```http
POST /users/otp/request
```
2. Resolves OTP from:
* `TEST_OTP_CODE` (if set), or
* interactive `input()` prompt.
3. Validates OTP via:
```http
POST /users/otp/check
```
4. Asserts the response contains:
* `auth_token`
* `refresh_token`
* `user`
5. Returns an auth context dict:
```python
{
"auth_token": "...",
"refresh_token": "...",
"user": {...},
}
```
If anything in this flow changes on the backend (contract change, field rename), the fixture fails **fast** with a clear assertion.
---
### 3.4 `auth_header` Fixture
Thin wrapper that exposes the **canonical Authorization header**:
```python
@pytest.fixture(scope="session")
def auth_header(customer_auth):
return {
"Authorization": f"Bearer {customer_auth['auth_token']}",
"accept": "application/json",
}
```
All tests consume this instead of re-implementing their own header logic.
---
### 3.5 `thr_to_mhd_search_params` Fixture
Single source of truth for THR → MHD search parameters:
```python
@pytest.fixture(scope="session")
def thr_to_mhd_search_params():
return {
"origin_iata": "THR",
"destination_iata": "MHD",
"flight_date": _today_plus_days(7),
"page": 1,
"page_size": 20,
"sort_option": "lowest-price",
}
```
Used by the E2E test to:
* Always search a known route (THR → MHD).
* Always target `today + 7` days.
* Always sort by `lowest-price` to make the first itinerary deterministic.
---
## 4. Test Cases Overview (`test_flight_thr_mhd_e2e.py`)
### 4.1 IATA / Airport Discovery Tests
These cover the **airport search layer** exposed to the customer:
#### `test_iata_domestic_default_list`
* **Endpoint**: `GET /customer/iata`
* **Parameters**:
* `keyword = ""`
* `is_international = false`
* **Validations**:
* HTTP 200.
* `data` is a non-empty list.
* Both `THR` and `MHD` are present in the code list.
Purpose:
Ensures the default domestic IATA catalog is healthy and includes key airports.
---
#### `test_iata_search_origin_tehran`
* **Endpoint**: `GET /customer/iata`
* **Parameters**:
* `keyword = "tehran"`
* `is_international = false`
* **Validations**:
* HTTP 200.
* `data` is a non-empty list.
* `THR` appears in results.
Purpose:
Validates keyword search for origin airports (Tehran).
---
#### `test_iata_search_destination_mashhad_excluding_origin`
* **Endpoint**: `GET /customer/iata`
* **Parameters**:
* `keyword = "mashhad"`
* `is_international = false`
* `other_iata_code = "THR"`
* **Validations**:
* HTTP 200.
* `data` is a non-empty list.
* `MHD` appears.
* `THR` **does not** appear (origin must be excluded).
Purpose:
Confirms that destination search respects the “other airport” exclusion logic.
---
### 4.2 General & Refund Rules Test
#### `test_general_and_refund_rules`
Endpoints:
* `GET /customer/setting/get/general-rules`
* `GET /customer/setting/get/refund-rules`
Validations:
* `general-rules`:
* `data.general-rules` exists and is non-empty.
* `refund-rules`:
* `data.refund_rules.domestic.data` is a non-empty list of objects.
* `data.refund_rules.international.data` the same.
* First item in each has `description` and `penalty`.
Purpose:
Quickly validates that the **configuration surface** (general rules and refund rules shown to customers) is available and structurally sound.
---
### 4.3 E2E: THR → MHD Single Adult Flow
#### `@pytest.mark.e2e test_thr_mhd_single_adult_book_issue_refund_e2e(...)`
This is the **flagship scenario**.
It simulates a real customer:
1. **Search flights (THR → MHD, D+7)**
* `POST /customer/flight/search`
* Validates:
* HTTP 200.
* Non-empty `itinerary_list`.
* First itinerary has:
* valid `flight_id`,
* at least one segment,
* segment from `THR` to `MHD`,
* `total_fare > 0`.
2. **Book the first itinerary**
* `POST /customer/flight/book`
* Payload includes:
* Contact info (`email`, `phone_number`).
* Single ADT traveler (driven by env / defaults).
* Validates:
* HTTP 200.
* `id` (booking_id) is positive.
* `expires_at > 0`.
* `price > 0` (booking_price).
3. **Verify wallet credit**
* `GET /admin/user/credit`
* Validates:
* HTTP 200.
* `credit` is numeric.
* `credit >= booking_price`.
* This step guarantees the issue step using wallet credit will succeed and is not blocked by insufficient balance.
4. **Issue the ticket using wallet credit**
* `POST /customer/flight/book/{booking_id}/issue`
* Payload:
* `pay_with_credit = true`
* `pay_for = "ticket"`
* `platform = "mobile"`
* Validates:
* HTTP 204 (No Content).
* Followed by `api_wait(2.0)` to allow backend to finalize issuance.
5. **Fetch active tickets and locate the issued one**
* `GET /customer/ticket` with:
* `is_past = false`
* `statuses = confirmed`
* Validates:
* HTTP 200.
* Non-empty ticket list.
* Ticket with `total == booking_price` exists.
* Extracts:
* `ticket_id`
* `ticket_traveler_id` (first traveler)
* `ticket_segment_id` (first segment)
6. **Calculate refund penalty**
* `POST /customer/flight/refund-penalty`
* Payload:
* `ticket_segment_id`
* `[ticket_traveler_id]`
* Validations (for this no-penalty scenario):
* HTTP 200.
* Non-empty `data` array.
* First item:
* `payable > 0`
* `amount_that_should_pay_back_to_passenger > 0`
* `penalty_percent == 0`
* `amount_that_should_pay_back_to_passenger == booking_price`
* `ticket_traveler_id` matches the one sent.
7. **Perform refund**
* `POST /customer/flight/refund`
* Payload:
* same `ticket_segment_id` and `ticket_traveler_id`.
* Validates:
* HTTP 200.
* Followed by `api_wait(2.0)` to allow status propagation.
8. **Validate post-refund ticket state**
* `GET /customer/ticket` with:
* `is_past = true`
* `statuses = confirmed,refunded_partially,refunded`
* Locates the same `ticket_id`.
* Validates:
* `refund_status` is in `("refunded", "refunded_partially")`.
Throughout the flow, the test logs each step with clear `[STEP X]` messages, making it easy to follow in CI logs or during a live demo.
---
## 5. Running the Tests
### 5.1 Install dependencies
```bash
pip install pytest requests
```
(Or include them in your projects `requirements.txt` / `poetry` / `pipenv`.)
---
### 5.2 Run all tests
```bash
pytest -q -s
```
* `-s` keeps the step-by-step console output:
* OTP flow logs,
* `[STEP X]` E2E narration.
---
### 5.3 Run only the E2E scenario
The E2E test is marked with `@pytest.mark.e2e`. To run only that:
```bash
pytest -q -s -m e2e
```
To avoid Pytests “unknown mark” warning, add a `pytest.ini` in the repo root:
```ini
# pytest.ini
[pytest]
markers =
e2e: end-to-end tests that hit real external services (search, book, issue, refund)
```
---
## 6. Production Safety Notes
Because this suite can be pointed to a **live environment**:
* It **does**:
* Create real bookings.
* Issue real tickets.
* Immediately refund them.
* It **assumes**:
* The test accounts wallet has enough credit.
* The fare and policy selected result in **zero penalty** for the scenario.
Recommendations:
* Use a **dedicated test customer** with:
* Preloaded credit.
* No penalty restrictions.
* Run E2E tests:
* On staging/pre-prod when possible.
* On production only as part of **controlled smoke** runs.
---
## 7. Summary
This test suite gives you:
* A **high-signal E2E validation** of the critical THR → MHD domestic flow.
* A set of **supporting tests** for IATA and rules configuration.
* A **presentable**, narrative output that clearly shows each phase:
* Authentication
* Discovery
* Booking
* Wallet validation
* Issuance
* Refund calculation
* Final refund state verification

200
conftest.py Normal file
View File

@@ -0,0 +1,200 @@
# conftest.py
import os
import datetime as dt
import pytest
import requests
# =====================================================================================
# Global test configuration
# =====================================================================================
#: Base URL for the AvaAir public API under test.
#: Can be overridden via `API_BASE_URL` to point to staging/production.
BASE_URL = os.getenv("API_BASE_URL", "http://192.168.19.19:8200/api/v1")
#: Default mobile configuration for the test user.
MOBILE_COUNTRY_CODE = int(os.getenv("TEST_MOBILE_COUNTRY_CODE", "98"))
MOBILE_NUMBER = os.getenv("TEST_MOBILE_NUMBER", "9014332990")
#: Contact information used when creating flight bookings.
TEST_EMAIL = os.getenv("TEST_BOOK_EMAIL", "iamrezazoom@gmail.com")
TEST_PHONE_NUMBER = os.getenv("TEST_BOOK_PHONE", MOBILE_NUMBER)
# Passenger profile used across E2E booking / refund scenarios.
# These values should be overridden by environment variables in real environments
# (e.g. CI, staging, production-like smoke tests).
TRAVELER_FIRST_NAME = os.getenv("TEST_TRAVELER_FIRST_NAME", "REZA")
TRAVELER_LAST_NAME = os.getenv("TEST_TRAVELER_LAST_NAME", "ESMAEILI")
TRAVELER_GENDER = os.getenv("TEST_TRAVELER_GENDER", "male")
TRAVELER_NATIONAL_CODE = os.getenv("TEST_TRAVELER_NATIONAL_CODE", "0890500363")
TRAVELER_DATE_OF_BIRTH = os.getenv("TEST_TRAVELER_DATE_OF_BIRTH", "1998-05-20")
TRAVELER_PLACE_OF_BIRTH = os.getenv("TEST_TRAVELER_PLACE_OF_BIRTH", "IRN")
def _today_plus_days(days: int) -> str:
"""
Return an ISO-formatted date (YYYY-MM-DD) relative to "today".
This helper is intentionally minimal: it ensures that test dates are always
slightly in the future (e.g. +7 days) so that we consistently hit
"upcoming flights" in search results instead of past/expired inventory.
"""
return (dt.date.today() + dt.timedelta(days=days)).isoformat()
@pytest.fixture
def api_wait():
"""
Lightweight timing utility used to orchestrate API call pacing.
Some flows (search → book → issue → refund) rely on asynchronous processing
on the backend (e.g. ticket issuance, wallet updates, sync with suppliers).
Introducing small, explicit delays between steps makes the E2E flow much
more deterministic on real environments (staging/production).
Usage:
def test_something(api_wait):
... call API A ...
api_wait(1.0) # let the system converge
... call API B ...
"""
import time
def _do(seconds: float = 1.0) -> None:
"""
Block the current test for the given number of seconds.
The default delay (1.0s) is conservative and can be tuned per call site
based on how heavy the underlying operation is (e.g. issue/refund may
require a slightly longer buffer than a simple GET).
"""
time.sleep(seconds)
return _do
@pytest.fixture(scope="session")
def customer_auth():
"""
Establish an authenticated customer session using the OTP (One-Time Password) flow.
Behavior:
• Always triggers an OTP request for the configured test mobile number.
• If the `TEST_OTP_CODE` environment variable is set, it is used directly
(ideal for CI and fully automated test runs).
• If `TEST_OTP_CODE` is NOT set, the fixture falls back to interactive
input and prompts the user to type the OTP received on their device.
The fixture returns a small auth context dictionary containing:
{ "auth_token": str, "refresh_token": str, "user": dict }
This structure is intentionally simple and reused by downstream fixtures
(e.g. `auth_header`) and E2E tests.
"""
# -------------------------------------------------------------------------
# Step 1: Request OTP for the configured mobile number
# -------------------------------------------------------------------------
print(f"\n🔐 Initiating OTP login flow against {BASE_URL} ...")
res = requests.post(
f"{BASE_URL}/users/otp/request",
json={
"mobile_country_code": MOBILE_COUNTRY_CODE,
"mobile_number": MOBILE_NUMBER,
},
headers={
"accept": "application/json",
"Content-Type": "application/json",
},
timeout=10,
)
res.raise_for_status()
print("📨 OTP request accepted by API. Please check the device associated with the test number.\n")
# -------------------------------------------------------------------------
# Step 2: Resolve the OTP code
# - Prefer non-interactive mode via TEST_OTP_CODE for CI.
# - Fallback to interactive prompt in local/dev environments.
# -------------------------------------------------------------------------
otp = os.getenv("TEST_OTP_CODE")
if otp:
print("⚙️ Using OTP from TEST_OTP_CODE environment variable.")
else:
otp = input("➡️ Enter OTP received on the test device: ").strip()
# -------------------------------------------------------------------------
# Step 3: Verify OTP and obtain tokens + user payload
# -------------------------------------------------------------------------
verify_payload = {
"description": "E2E FLIGHT FLOW",
"mobile_country_code": MOBILE_COUNTRY_CODE,
"mobile_number": MOBILE_NUMBER,
"otp_code": otp,
}
res2 = requests.post(
f"{BASE_URL}/users/otp/check",
json=verify_payload,
headers={
"accept": "application/json",
"Content-Type": "application/json",
},
timeout=10,
)
res2.raise_for_status()
data = res2.json()
# Defensive validation to fail fast with clear messaging if the auth
# contract ever changes on the backend.
assert "auth_token" in data and data["auth_token"], "OTP response missing auth_token"
assert "refresh_token" in data and data["refresh_token"], "OTP response missing refresh_token"
assert "user" in data and data["user"], "OTP response missing user object"
print("✅ OTP verified successfully. Customer session is now authenticated.\n")
return {
"auth_token": data["auth_token"],
"refresh_token": data["refresh_token"],
"user": data["user"],
}
@pytest.fixture(scope="session")
def auth_header(customer_auth):
"""
Canonical Authorization header for all customer-facing API calls.
This fixture is intentionally kept small and reusable. Any test that talks
to the authenticated customer API surface should depend on this fixture
instead of constructing its own headers.
"""
return {
"Authorization": f"Bearer {customer_auth['auth_token']}",
"accept": "application/json",
}
@pytest.fixture(scope="session")
def thr_to_mhd_search_params():
"""
Shared search configuration for the canonical THR → MHD domestic flight scenario.
This single-source-of-truth is consumed by the E2E tests to:
• Always search from Tehran (THR) to Mashhad (MHD).
• Always target a date 7 days in the future (relative to test execution).
• Use a deterministic pagination and sorting strategy (lowest-price first).
Adjustments to the default search behavior (e.g. multi-passenger, date
offsets, or sorting strategies) should be funneled through this fixture
to keep the test suite consistent and maintainable.
"""
return {
"origin_iata": "THR",
"destination_iata": "MHD",
"flight_date": _today_plus_days(7),
"page": 1,
"page_size": 20,
"sort_option": "lowest-price",
}

3
requirements.txt Normal file
View File

@@ -0,0 +1,3 @@
pytest
requests
python-dotenv

View 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"
)

View File

@@ -0,0 +1,47 @@
import requests
import pytest
from conftest import BASE_URL
def test_customer_profile_basic(auth_header, customer_auth):
"""
Validate basic customer profile integrity.
Scenario:
• Perform GET /customer/profile using the authenticated user context.
• Assert HTTP 200 success.
• Validate presence of essential profile fields (id, username, mobile_number).
• Cross-check that returned values match the authenticated user (from OTP login).
"""
print("\n[PROFILE] Fetching customer profile ...")
res = requests.get(
f"{BASE_URL}/customer/profile",
headers=auth_header,
timeout=10,
)
assert res.status_code == 200, "Expected HTTP 200 for /customer/profile"
data = res.json()
# Required fields that must always exist in the profile schema
required_fields = ["id", "username", "mobile_number"]
for key in required_fields:
assert key in data, f"Missing expected field '{key}' in /customer/profile response"
# Structural consistency:
# Ensure that the profile returned for this token belongs to the same user
# who successfully passed OTP authentication.
assert data["id"] == customer_auth["user"]["id"], (
"Profile 'id' does not match authenticated user id"
)
assert data["mobile_number"] == customer_auth["user"]["mobile_number"], (
"Profile 'mobile_number' does not match authenticated user mobile_number"
)
print(
f"[PROFILE] Profile validated successfully. "
f"User: {data['username']} (id={data['id']})\n"
)