commit 9e69b14249aaad123932a2c74884881a87e6ce9e Author: Reza Esmaeili Date: Thu Dec 4 12:46:23 2025 +0330 Init commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b6cf5f0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.pyc +.env diff --git a/README.md b/README.md new file mode 100644 index 0000000..29ecc56 --- /dev/null +++ b/README.md @@ -0,0 +1,548 @@ +# AvaAir E2E Flight Flow Test Suite + +This test suite exercises the **end-to-end customer journey** for AvaAir’s 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= +``` + +* `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 user’s 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 project’s `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 Pytest’s “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 account’s 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 diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..171a12b --- /dev/null +++ b/conftest.py @@ -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", + } diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..49969b2 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +pytest +requests +python-dotenv \ No newline at end of file diff --git a/tests/test_flight_thr_mhd_e2e.py b/tests/test_flight_thr_mhd_e2e.py new file mode 100644 index 0000000..cef3438 --- /dev/null +++ b/tests/test_flight_thr_mhd_e2e.py @@ -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" + ) diff --git a/tests/test_get_customer_profile.py b/tests/test_get_customer_profile.py new file mode 100644 index 0000000..a4a4843 --- /dev/null +++ b/tests/test_get_customer_profile.py @@ -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" + )