Init commit
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
548
README.md
Normal file
548
README.md
Normal file
@@ -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=<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 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
|
||||||
200
conftest.py
Normal file
200
conftest.py
Normal 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
3
requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pytest
|
||||||
|
requests
|
||||||
|
python-dotenv
|
||||||
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"
|
||||||
|
)
|
||||||
47
tests/test_get_customer_profile.py
Normal file
47
tests/test_get_customer_profile.py
Normal 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"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user