avios-cli 0.4.1__tar.gz → 0.4.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {avios_cli-0.4.1 → avios_cli-0.4.2}/CHANGELOG.md +13 -1
- {avios_cli-0.4.1 → avios_cli-0.4.2}/PKG-INFO +7 -3
- {avios_cli-0.4.1 → avios_cli-0.4.2}/README.md +6 -2
- {avios_cli-0.4.1 → avios_cli-0.4.2}/SECURITY.md +4 -2
- {avios_cli-0.4.1 → avios_cli-0.4.2}/pyproject.toml +2 -2
- {avios_cli-0.4.1 → avios_cli-0.4.2}/src/avios/__init__.py +1 -1
- {avios_cli-0.4.1 → avios_cli-0.4.2}/src/avios/auth.py +60 -12
- {avios_cli-0.4.1 → avios_cli-0.4.2}/src/avios/cli.py +6 -1
- {avios_cli-0.4.1 → avios_cli-0.4.2}/src/avios/rewards.py +160 -0
- {avios_cli-0.4.1 → avios_cli-0.4.2}/src/avios/session.py +13 -2
- {avios_cli-0.4.1 → avios_cli-0.4.2}/tests/test_auth.py +23 -0
- avios_cli-0.4.2/tests/test_rewards.py +324 -0
- {avios_cli-0.4.1 → avios_cli-0.4.2}/tests/test_session.py +2 -0
- avios_cli-0.4.1/tests/test_rewards.py +0 -142
- {avios_cli-0.4.1 → avios_cli-0.4.2}/.editorconfig +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.2}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.2}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.2}/.github/dependabot.yml +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.2}/.github/workflows/ci.yml +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.2}/.github/workflows/publish.yml +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.2}/.gitignore +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.2}/.pre-commit-config.yaml +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.2}/.python-version +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.2}/CONTRIBUTING.md +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.2}/LICENSE +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.2}/docs/dashboard.svg +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.2}/docs/reward-flights.svg +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.2}/scripts/screenshot.py +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.2}/src/avios/accounts.py +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.2}/src/avios/aggregate.py +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.2}/src/avios/client.py +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.2}/src/avios/config.py +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.2}/src/avios/endpoints.py +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.2}/src/avios/finnair.py +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.2}/src/avios/models.py +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.2}/src/avios/programmes.py +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.2}/src/avios/tui/__init__.py +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.2}/src/avios/tui/app.py +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.2}/src/avios/tui/art.py +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.2}/src/avios/tui/rewards.py +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.2}/src/avios/tui/styles.tcss +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.2}/src/avios/tui/widgets.py +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.2}/tests/conftest.py +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.2}/tests/fixtures/balance.json +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.2}/tests/fixtures/reward_search.rsc +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.2}/tests/fixtures/transactions.json +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.2}/tests/test_accounts.py +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.2}/tests/test_aggregate.py +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.2}/tests/test_cli.py +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.2}/tests/test_client.py +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.2}/tests/test_finnair.py +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.2}/tests/test_models.py +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.2}/tests/test_tui.py +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.2}/uv.lock +0 -0
|
@@ -6,6 +6,17 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.4.2] - 2026-07-27
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- **BA reward-flight searches failed with 403 after a successful login.** The
|
|
13
|
+
flight finder has a separate Auth0 session and rejects plain HTTP or automated
|
|
14
|
+
headless requests at Akamai. BA login now waits for the second reward-flight
|
|
15
|
+
prompt, and searches drive the site's own form in a background Chrome window
|
|
16
|
+
before parsing the returned RSC availability.
|
|
17
|
+
- Stored Akamai cookies are no longer replayed over Chrome's fresher browser jar,
|
|
18
|
+
and a transient Next.js shell response is retried once.
|
|
19
|
+
|
|
9
20
|
## [0.4.1] - 2026-07-27
|
|
10
21
|
|
|
11
22
|
### Fixed
|
|
@@ -187,7 +198,8 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
187
198
|
- Reward-flight **availability** search is not yet implemented (needs a British
|
|
188
199
|
Airways capture).
|
|
189
200
|
|
|
190
|
-
[Unreleased]: https://github.com/alexechoi/avios-cli/compare/v0.4.
|
|
201
|
+
[Unreleased]: https://github.com/alexechoi/avios-cli/compare/v0.4.2...HEAD
|
|
202
|
+
[0.4.2]: https://github.com/alexechoi/avios-cli/compare/v0.4.1...v0.4.2
|
|
191
203
|
[0.4.1]: https://github.com/alexechoi/avios-cli/compare/v0.4.0...v0.4.1
|
|
192
204
|
[0.4.0]: https://github.com/alexechoi/avios-cli/compare/v0.3.0...v0.4.0
|
|
193
205
|
[0.3.0]: https://github.com/alexechoi/avios-cli/compare/v0.2.1...v0.3.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: avios-cli
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.2
|
|
4
4
|
Summary: A CLI and TUI for Avios balances, transactions, and BA reward-flight availability.
|
|
5
5
|
Project-URL: Homepage, https://github.com/alexechoi/avios-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/alexechoi/avios-cli
|
|
@@ -128,8 +128,12 @@ for Finnair because browser-cookie import cannot read that token.
|
|
|
128
128
|
Each programme session is stored at `~/.config/avios/accounts/<programme>.json`
|
|
129
129
|
(mode `600`). Sessions expire; just log in to that programme again. Use
|
|
130
130
|
`avios logout <programme>` for one account or `avios logout` for all accounts.
|
|
131
|
-
For BA, browser-assisted login also opens the reward-flight
|
|
132
|
-
|
|
131
|
+
For BA, browser-assisted login also opens the separate reward-flight application.
|
|
132
|
+
British Airways may show a second login prompt; keep the browser open until the
|
|
133
|
+
CLI confirms success. Reward searches drive the site's own form in a background
|
|
134
|
+
Chrome window because the flight-search route rejects plain HTTP and automated
|
|
135
|
+
headless requests, so the `login` extra remains required when running
|
|
136
|
+
`avios flights` or the Reward Flights TUI tab.
|
|
133
137
|
|
|
134
138
|
## Usage
|
|
135
139
|
|
|
@@ -93,8 +93,12 @@ for Finnair because browser-cookie import cannot read that token.
|
|
|
93
93
|
Each programme session is stored at `~/.config/avios/accounts/<programme>.json`
|
|
94
94
|
(mode `600`). Sessions expire; just log in to that programme again. Use
|
|
95
95
|
`avios logout <programme>` for one account or `avios logout` for all accounts.
|
|
96
|
-
For BA, browser-assisted login also opens the reward-flight
|
|
97
|
-
|
|
96
|
+
For BA, browser-assisted login also opens the separate reward-flight application.
|
|
97
|
+
British Airways may show a second login prompt; keep the browser open until the
|
|
98
|
+
CLI confirms success. Reward searches drive the site's own form in a background
|
|
99
|
+
Chrome window because the flight-search route rejects plain HTTP and automated
|
|
100
|
+
headless requests, so the `login` extra remains required when running
|
|
101
|
+
`avios flights` or the Reward Flights TUI tab.
|
|
98
102
|
|
|
99
103
|
## Usage
|
|
100
104
|
|
|
@@ -11,8 +11,10 @@
|
|
|
11
11
|
- `avios logout` deletes stored sessions. They also expire on the server side.
|
|
12
12
|
- `AVIOS_COOKIE` may be set to supply a cookie via the environment instead of the
|
|
13
13
|
stored file — avoid committing it or storing it in shell history.
|
|
14
|
-
- Reward-flight search
|
|
15
|
-
|
|
14
|
+
- Reward-flight search launches the isolated BA Chrome profile in the background;
|
|
15
|
+
Chrome sends the saved application cookie only to `www.avios.com` and refreshes
|
|
16
|
+
its own anti-bot cookies. The RSC response's short-lived action tokens are
|
|
17
|
+
neither exposed nor persisted.
|
|
16
18
|
|
|
17
19
|
## Unofficial software
|
|
18
20
|
|
|
@@ -36,8 +36,8 @@ dependencies = [
|
|
|
36
36
|
]
|
|
37
37
|
|
|
38
38
|
[project.optional-dependencies]
|
|
39
|
-
# Browser-assisted login. Kept optional so
|
|
40
|
-
#
|
|
39
|
+
# Browser-assisted login and BA reward search. Kept optional so account-only
|
|
40
|
+
# commands remain lightweight when a user does not need a browser.
|
|
41
41
|
login = [
|
|
42
42
|
"playwright>=1.44",
|
|
43
43
|
"browser-cookie3>=0.19",
|
|
@@ -33,6 +33,7 @@ from avios.session import cookie_header_from
|
|
|
33
33
|
LOGIN_TIMEOUT_MS = 300_000 # 5 minutes to complete password + captcha + MFA
|
|
34
34
|
LOGIN_POLL_MS = 2_000 # how often to check whether the session is authenticated
|
|
35
35
|
SUPPORTED_BROWSERS = ("chrome", "firefox", "edge", "brave", "safari", "chromium")
|
|
36
|
+
BA_REWARD_SEARCH_PATH = "/en-GB/spend-avios/search-reward-flights"
|
|
36
37
|
|
|
37
38
|
# Chromium-family user-data roots per browser (relative to home), used to discover
|
|
38
39
|
# per-profile cookie databases (Default, "Profile 1", ...).
|
|
@@ -108,36 +109,67 @@ def login_via_browser(
|
|
|
108
109
|
profile_dir = str(settings.config_dir / "chrome-profile" / programme.slug)
|
|
109
110
|
|
|
110
111
|
with factory() as pw:
|
|
111
|
-
ctx = _open_login_context(
|
|
112
|
+
ctx = _open_login_context(
|
|
113
|
+
pw,
|
|
114
|
+
headless=headless,
|
|
115
|
+
user_data_dir=profile_dir,
|
|
116
|
+
user_agent=settings.user_agent,
|
|
117
|
+
)
|
|
112
118
|
page = ctx.new_page()
|
|
113
119
|
page.goto(programme.login_url)
|
|
114
120
|
authed = _wait_for_auth(ctx, page, programme.base_url, timeout_ms)
|
|
121
|
+
reward_authed = True
|
|
115
122
|
if authed and programme.slug == "ba":
|
|
116
|
-
|
|
123
|
+
reward_authed = _wait_for_ba_reward_auth(
|
|
124
|
+
ctx,
|
|
125
|
+
page,
|
|
126
|
+
programme.base_url,
|
|
127
|
+
timeout_ms,
|
|
128
|
+
)
|
|
117
129
|
cookies = list(ctx.cookies())
|
|
118
130
|
ctx.close()
|
|
119
131
|
|
|
120
132
|
if not authed:
|
|
121
133
|
raise LoginError("Timed out waiting for login. Run `avios login` again.")
|
|
134
|
+
if not reward_authed:
|
|
135
|
+
raise LoginError(
|
|
136
|
+
"British Airways account login succeeded, but reward-flight login did not. "
|
|
137
|
+
"Run `avios login ba` again, complete the second BA prompt, and keep the "
|
|
138
|
+
"browser open until this command confirms success."
|
|
139
|
+
)
|
|
122
140
|
return _only_avios(cookies)
|
|
123
141
|
|
|
124
142
|
|
|
125
|
-
def
|
|
126
|
-
"""
|
|
143
|
+
def _has_ba_reward_session(ctx: Any) -> bool:
|
|
144
|
+
"""Whether BA's separate reward-search application session is present."""
|
|
145
|
+
names = {
|
|
146
|
+
str(cookie.get("name", ""))
|
|
147
|
+
for cookie in ctx.cookies()
|
|
148
|
+
if "avios.com" in str(cookie.get("domain", ""))
|
|
149
|
+
}
|
|
150
|
+
return "__session" in names or any(name.startswith("appSession") for name in names)
|
|
127
151
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
"""
|
|
152
|
+
|
|
153
|
+
def _wait_for_ba_reward_auth(ctx: Any, page: Any, base_url: str, timeout_ms: int) -> bool:
|
|
154
|
+
"""Open BA's reward app and wait for its separate Auth0 flow to complete."""
|
|
132
155
|
try:
|
|
133
156
|
page.goto(
|
|
134
|
-
f"{base_url}
|
|
157
|
+
f"{base_url}{BA_REWARD_SEARCH_PATH}",
|
|
135
158
|
wait_until="domcontentloaded",
|
|
136
159
|
timeout=60_000,
|
|
137
160
|
)
|
|
138
|
-
page.wait_for_timeout(3_000)
|
|
139
161
|
except Exception:
|
|
140
|
-
|
|
162
|
+
return False
|
|
163
|
+
|
|
164
|
+
for _ in range(max(1, timeout_ms // LOGIN_POLL_MS)):
|
|
165
|
+
if _has_ba_reward_session(ctx) and page.url.startswith(
|
|
166
|
+
f"{base_url}{BA_REWARD_SEARCH_PATH}"
|
|
167
|
+
):
|
|
168
|
+
with contextlib.suppress(Exception):
|
|
169
|
+
if "search reward flights" in page.title().lower():
|
|
170
|
+
return True
|
|
171
|
+
page.wait_for_timeout(LOGIN_POLL_MS)
|
|
172
|
+
return False
|
|
141
173
|
|
|
142
174
|
|
|
143
175
|
def _import_sync_playwright() -> Any:
|
|
@@ -177,7 +209,14 @@ def _is_authenticated(ctx: Any, base_url: str) -> bool:
|
|
|
177
209
|
return False
|
|
178
210
|
|
|
179
211
|
|
|
180
|
-
def _open_login_context(
|
|
212
|
+
def _open_login_context(
|
|
213
|
+
pw: Any,
|
|
214
|
+
*,
|
|
215
|
+
headless: bool,
|
|
216
|
+
user_data_dir: str,
|
|
217
|
+
user_agent: str | None = None,
|
|
218
|
+
background: bool = False,
|
|
219
|
+
) -> Any:
|
|
181
220
|
"""Open a browser context for login, tuned to avoid the hCaptcha bot loop.
|
|
182
221
|
|
|
183
222
|
hCaptcha/Akamai serve endless challenges to obviously-automated browsers, so:
|
|
@@ -189,6 +228,14 @@ def _open_login_context(pw: Any, *, headless: bool, user_data_dir: str) -> Any:
|
|
|
189
228
|
"""
|
|
190
229
|
args = ["--disable-blink-features=AutomationControlled"]
|
|
191
230
|
ignore_default_args = ["--enable-automation"]
|
|
231
|
+
launch_options: dict[str, Any] = {}
|
|
232
|
+
if background:
|
|
233
|
+
args.append("--window-position=-32000,-32000")
|
|
234
|
+
launch_options["viewport"] = {"width": 1280, "height": 900}
|
|
235
|
+
if headless and user_agent:
|
|
236
|
+
# Chrome's native headless networking can reach BA's login page, but its
|
|
237
|
+
# default ``HeadlessChrome`` UA is rejected. Keep the normal Chrome UA.
|
|
238
|
+
launch_options["user_agent"] = user_agent
|
|
192
239
|
for extra in ({"channel": "chrome"}, {}):
|
|
193
240
|
try:
|
|
194
241
|
return pw.chromium.launch_persistent_context(
|
|
@@ -196,6 +243,7 @@ def _open_login_context(pw: Any, *, headless: bool, user_data_dir: str) -> Any:
|
|
|
196
243
|
headless=headless,
|
|
197
244
|
args=args,
|
|
198
245
|
ignore_default_args=ignore_default_args,
|
|
246
|
+
**launch_options,
|
|
199
247
|
**extra,
|
|
200
248
|
)
|
|
201
249
|
except Exception:
|
|
@@ -179,9 +179,14 @@ def login(
|
|
|
179
179
|
account = Account.from_programme(prog, cookies=result.cookies)
|
|
180
180
|
saved_label = f"{result.count} cookie(s){where}"
|
|
181
181
|
else:
|
|
182
|
+
ba_note = (
|
|
183
|
+
" British Airways may show a second login prompt for reward flights."
|
|
184
|
+
if prog.slug == "ba"
|
|
185
|
+
else ""
|
|
186
|
+
)
|
|
182
187
|
console.print(
|
|
183
188
|
f"Opening a browser — log in to [bold]{prog.name}[/] (password, captcha, "
|
|
184
|
-
"SMS code). Keep the window open
|
|
189
|
+
f"SMS code).{ba_note} Keep the window open until this command confirms success."
|
|
185
190
|
)
|
|
186
191
|
cookies = login_via_browser(prog, headless=headless)
|
|
187
192
|
account = Account.from_programme(prog, cookies=cookies)
|
|
@@ -89,6 +89,8 @@ class PassengerCounts(RewardModel):
|
|
|
89
89
|
def infants_need_adults(self) -> PassengerCounts:
|
|
90
90
|
if self.infants > self.adults:
|
|
91
91
|
raise ValueError("infants cannot exceed adults")
|
|
92
|
+
if self.adults + self.young_adults + self.children + self.infants > 9:
|
|
93
|
+
raise ValueError("British Airways allows at most 9 travellers")
|
|
92
94
|
return self
|
|
93
95
|
|
|
94
96
|
def query_items(self) -> list[tuple[str, str]]:
|
|
@@ -354,6 +356,17 @@ def search_reward_calendar(session: Session, query: RewardSearchQuery) -> Reward
|
|
|
354
356
|
"next-url": NEXT_URL,
|
|
355
357
|
"referer": referer,
|
|
356
358
|
}
|
|
359
|
+
|
|
360
|
+
if not session.has_custom_transport:
|
|
361
|
+
payload = _fetch_reward_rsc_via_browser(session, query)
|
|
362
|
+
try:
|
|
363
|
+
return parse_reward_rsc(payload, query)
|
|
364
|
+
except RewardSearchProtocolError:
|
|
365
|
+
# The Next.js router occasionally returns only its small shell during
|
|
366
|
+
# rapid consecutive searches. One fresh browser navigation recovers.
|
|
367
|
+
payload = _fetch_reward_rsc_via_browser(session, query)
|
|
368
|
+
return parse_reward_rsc(payload, query)
|
|
369
|
+
|
|
357
370
|
try:
|
|
358
371
|
payload = session.get_text(
|
|
359
372
|
endpoints.REWARD_FLIGHT_RESULTS,
|
|
@@ -368,3 +381,150 @@ def search_reward_calendar(session: Session, query: RewardSearchQuery) -> Reward
|
|
|
368
381
|
) from exc
|
|
369
382
|
raise
|
|
370
383
|
return parse_reward_rsc(payload, query)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _playwright_cookies(session: Session) -> list[dict[str, Any]]:
|
|
387
|
+
"""Return only app-session cookies safe to seed into the Chrome profile.
|
|
388
|
+
|
|
389
|
+
Akamai cookies are bound to browser state and are refreshed by Chrome. Replaying
|
|
390
|
+
stored ``_abck``/``bm_*`` values over the profile's current jar triggers a 403.
|
|
391
|
+
"""
|
|
392
|
+
allowed = {
|
|
393
|
+
"name",
|
|
394
|
+
"value",
|
|
395
|
+
"domain",
|
|
396
|
+
"path",
|
|
397
|
+
"expires",
|
|
398
|
+
"httpOnly",
|
|
399
|
+
"secure",
|
|
400
|
+
"sameSite",
|
|
401
|
+
"partitionKey",
|
|
402
|
+
}
|
|
403
|
+
normalised: list[dict[str, Any]] = []
|
|
404
|
+
for stored in session.browser_cookies():
|
|
405
|
+
name = str(stored.get("name", ""))
|
|
406
|
+
if name != "__session" and not name.startswith("appSession"):
|
|
407
|
+
continue
|
|
408
|
+
cookie = {key: value for key, value in stored.items() if key in allowed}
|
|
409
|
+
if not cookie.get("name") or not cookie.get("value") or not cookie.get("domain"):
|
|
410
|
+
continue
|
|
411
|
+
cookie.setdefault("path", "/")
|
|
412
|
+
normalised.append(cookie)
|
|
413
|
+
return normalised
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _fetch_reward_rsc_via_browser(
|
|
417
|
+
session: Session,
|
|
418
|
+
query: RewardSearchQuery,
|
|
419
|
+
*,
|
|
420
|
+
playwright_factory: Any = None,
|
|
421
|
+
search_driver: Any = None,
|
|
422
|
+
) -> str:
|
|
423
|
+
"""Drive BA's real search form and capture its RSC response through Chrome."""
|
|
424
|
+
from avios.auth import LoginError, _import_sync_playwright, _open_login_context
|
|
425
|
+
|
|
426
|
+
factory = playwright_factory or _import_sync_playwright()
|
|
427
|
+
driver = search_driver or _drive_reward_search
|
|
428
|
+
profile_dir = str(session.settings.config_dir / "chrome-profile" / "ba")
|
|
429
|
+
|
|
430
|
+
try:
|
|
431
|
+
with factory() as pw:
|
|
432
|
+
ctx = _open_login_context(
|
|
433
|
+
pw,
|
|
434
|
+
headless=False,
|
|
435
|
+
user_data_dir=profile_dir,
|
|
436
|
+
user_agent=session.settings.user_agent,
|
|
437
|
+
background=True,
|
|
438
|
+
)
|
|
439
|
+
try:
|
|
440
|
+
cookies = _playwright_cookies(session)
|
|
441
|
+
profile_cookie_names = {str(cookie.get("name", "")) for cookie in ctx.cookies()}
|
|
442
|
+
profile_has_reward_session = "__session" in profile_cookie_names or any(
|
|
443
|
+
name.startswith("appSession") for name in profile_cookie_names
|
|
444
|
+
)
|
|
445
|
+
if cookies and not profile_has_reward_session:
|
|
446
|
+
ctx.add_cookies(cookies)
|
|
447
|
+
page = ctx.new_page()
|
|
448
|
+
body = driver(page, session.base_url, query)
|
|
449
|
+
finally:
|
|
450
|
+
ctx.close()
|
|
451
|
+
except LoginError as exc:
|
|
452
|
+
raise RewardSearchAccessError(str(exc)) from exc
|
|
453
|
+
except RewardSearchAccessError:
|
|
454
|
+
raise
|
|
455
|
+
except Exception as exc:
|
|
456
|
+
raise RewardSearchAccessError(
|
|
457
|
+
"Chrome could not fetch British Airways reward flights. "
|
|
458
|
+
"Run `avios login ba` again, then retry."
|
|
459
|
+
) from exc
|
|
460
|
+
|
|
461
|
+
if not isinstance(body, str):
|
|
462
|
+
raise RewardSearchProtocolError("Chrome returned a non-text reward-flight response")
|
|
463
|
+
return body
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _select_airport(page: Any, field: str, code: str) -> None:
|
|
467
|
+
airport = page.locator(f'input[name="{field}"]')
|
|
468
|
+
if airport.input_value().rstrip().endswith(f"({code})"):
|
|
469
|
+
return
|
|
470
|
+
airport.fill(code)
|
|
471
|
+
page.get_by_test_id(f"{field}-{code}-airport-option").click(timeout=20_000)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _set_passengers(page: Any, passengers: PassengerCounts) -> None:
|
|
475
|
+
increments = {
|
|
476
|
+
"Adults": passengers.adults - 1,
|
|
477
|
+
"Young Adults": passengers.young_adults,
|
|
478
|
+
"Children": passengers.children,
|
|
479
|
+
"Infants": passengers.infants,
|
|
480
|
+
}
|
|
481
|
+
if not any(increments.values()):
|
|
482
|
+
return
|
|
483
|
+
selector = page.get_by_test_id("passenger number select")
|
|
484
|
+
selector.click()
|
|
485
|
+
for label, count in increments.items():
|
|
486
|
+
for _ in range(count):
|
|
487
|
+
page.get_by_test_id(f"{label}-plus").click()
|
|
488
|
+
selector.click()
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _drive_reward_search(page: Any, base_url: str, query: RewardSearchQuery) -> str:
|
|
492
|
+
"""Use the site's form so Next.js and Akamai generate valid request state."""
|
|
493
|
+
from avios.auth import BA_REWARD_SEARCH_PATH
|
|
494
|
+
|
|
495
|
+
landing = page.goto(
|
|
496
|
+
f"{base_url}{BA_REWARD_SEARCH_PATH}",
|
|
497
|
+
wait_until="domcontentloaded",
|
|
498
|
+
timeout=60_000,
|
|
499
|
+
)
|
|
500
|
+
if landing is not None and landing.status == 403:
|
|
501
|
+
raise RewardSearchAccessError(
|
|
502
|
+
"British Airways rejected the Chrome reward-search session. Run `avios login ba` again."
|
|
503
|
+
)
|
|
504
|
+
if "accounts.britishairways.com" in page.url:
|
|
505
|
+
raise RewardSearchAccessError(
|
|
506
|
+
"British Airways reward-flight login is incomplete. Run `avios login ba` "
|
|
507
|
+
"again and complete the second BA prompt."
|
|
508
|
+
)
|
|
509
|
+
page.wait_for_timeout(1_500)
|
|
510
|
+
page.get_by_role("tab", name="One Way").click()
|
|
511
|
+
_select_airport(page, "origin", query.origin)
|
|
512
|
+
_select_airport(page, "destination", query.destination)
|
|
513
|
+
_set_passengers(page, query.passengers)
|
|
514
|
+
|
|
515
|
+
with page.expect_response(
|
|
516
|
+
lambda response: (
|
|
517
|
+
endpoints.REWARD_FLIGHT_RESULTS in response.url and response.request.method == "GET"
|
|
518
|
+
),
|
|
519
|
+
timeout=60_000,
|
|
520
|
+
) as response_info:
|
|
521
|
+
page.get_by_role("button", name="Search", exact=True).click()
|
|
522
|
+
response = response_info.value
|
|
523
|
+
body = str(response.text())
|
|
524
|
+
if response.status in (401, 403):
|
|
525
|
+
raise RewardSearchAccessError(
|
|
526
|
+
"British Airways rejected the Chrome reward-search session. Run `avios login ba` again."
|
|
527
|
+
)
|
|
528
|
+
if response.status >= 400:
|
|
529
|
+
raise RewardSearchError(f"British Airways reward search returned HTTP {response.status}")
|
|
530
|
+
return body
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
avios.com authenticates its internal JSON endpoints with a browser session cookie
|
|
4
4
|
(no bearer token, no request signing). This module stores that cookie jar and
|
|
5
|
-
builds pre-authenticated :class:`httpx.Client` instances.
|
|
6
|
-
|
|
5
|
+
builds pre-authenticated :class:`httpx.Client` instances. BA reward search is the
|
|
6
|
+
exception: its Akamai-protected web flow uses Chrome directly. Acquiring cookies
|
|
7
|
+
in the first place (browser-assisted login) lives in :mod:`avios.auth`.
|
|
7
8
|
"""
|
|
8
9
|
|
|
9
10
|
from __future__ import annotations
|
|
@@ -100,6 +101,16 @@ class Session:
|
|
|
100
101
|
cookies = self._cookies if self._cookies is not None else self.load_cookies()
|
|
101
102
|
return cookie_header_from(cookies)
|
|
102
103
|
|
|
104
|
+
def browser_cookies(self) -> list[dict[str, Any]]:
|
|
105
|
+
"""Return stored structured cookies suitable for seeding a browser context."""
|
|
106
|
+
cookies = self._cookies if self._cookies is not None else self.load_cookies()
|
|
107
|
+
return [dict(cookie) for cookie in cookies]
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def has_custom_transport(self) -> bool:
|
|
111
|
+
"""Whether requests are routed through an injected transport (normally tests)."""
|
|
112
|
+
return self._transport is not None
|
|
113
|
+
|
|
103
114
|
def is_authenticated(self) -> bool:
|
|
104
115
|
return bool(self.cookie_header())
|
|
105
116
|
|
|
@@ -126,13 +126,18 @@ class _FakeRequest:
|
|
|
126
126
|
class _FakePage:
|
|
127
127
|
def __init__(self) -> None:
|
|
128
128
|
self.visited: list[str] = []
|
|
129
|
+
self.url = ""
|
|
129
130
|
|
|
130
131
|
def goto(self, url: str, **kwargs: object) -> None:
|
|
131
132
|
self.visited.append(url)
|
|
133
|
+
self.url = url
|
|
132
134
|
|
|
133
135
|
def wait_for_timeout(self, ms: int) -> None:
|
|
134
136
|
pass
|
|
135
137
|
|
|
138
|
+
def title(self) -> str:
|
|
139
|
+
return "Search Reward Flights"
|
|
140
|
+
|
|
136
141
|
|
|
137
142
|
class _FakeContext:
|
|
138
143
|
def __init__(self, ok_after: int, cookies: list[dict[str, str]]) -> None:
|
|
@@ -203,6 +208,24 @@ def test_login_captures_only_after_authenticated(settings: Settings) -> None:
|
|
|
203
208
|
assert ctx.page.visited[-1].endswith("/en-GB/spend-avios/search-reward-flights")
|
|
204
209
|
|
|
205
210
|
|
|
211
|
+
def test_ba_login_waits_for_separate_reward_session(settings: Settings) -> None:
|
|
212
|
+
ctx = _FakeContext(
|
|
213
|
+
ok_after=1,
|
|
214
|
+
cookies=[{"name": "ba_session_id", "value": "x", "domain": "www.avios.com"}],
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
with pytest.raises(LoginError, match="reward-flight login"):
|
|
218
|
+
login_via_browser(
|
|
219
|
+
BA,
|
|
220
|
+
settings=settings,
|
|
221
|
+
timeout_ms=4_000,
|
|
222
|
+
playwright_factory=_FakeFactory(ctx),
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
assert ctx.page.visited[-1].endswith("/en-GB/spend-avios/search-reward-flights")
|
|
226
|
+
assert ctx.closed
|
|
227
|
+
|
|
228
|
+
|
|
206
229
|
def test_login_times_out_without_returning(settings: Settings) -> None:
|
|
207
230
|
ctx = _FakeContext(
|
|
208
231
|
ok_after=999, cookies=[{"name": "appSession", "value": "x", "domain": "www.avios.com"}]
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""Tests for BA reward-flight RSC parsing and requests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
import pytest
|
|
10
|
+
from pydantic import ValidationError
|
|
11
|
+
|
|
12
|
+
from avios.client import AviosClient
|
|
13
|
+
from avios.config import Settings
|
|
14
|
+
from avios.rewards import (
|
|
15
|
+
Cabin,
|
|
16
|
+
PassengerCounts,
|
|
17
|
+
RewardSearchAccessError,
|
|
18
|
+
RewardSearchProtocolError,
|
|
19
|
+
RewardSearchQuery,
|
|
20
|
+
RewardSearchRangeError,
|
|
21
|
+
UnsupportedProgramme,
|
|
22
|
+
_fetch_reward_rsc_via_browser,
|
|
23
|
+
_select_airport,
|
|
24
|
+
_set_passengers,
|
|
25
|
+
parse_reward_rsc,
|
|
26
|
+
)
|
|
27
|
+
from avios.session import Session, SessionExpired
|
|
28
|
+
|
|
29
|
+
FIXTURE = Path(__file__).parent / "fixtures" / "reward_search.rsc"
|
|
30
|
+
COOKIE = [{"name": "appSession", "value": "x", "domain": "www.avios.com"}]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def query(**overrides: Any) -> RewardSearchQuery:
|
|
34
|
+
values: dict[str, Any] = {
|
|
35
|
+
"origin": "lon",
|
|
36
|
+
"destination": "abz",
|
|
37
|
+
"month": "2099-11",
|
|
38
|
+
}
|
|
39
|
+
values.update(overrides)
|
|
40
|
+
return RewardSearchQuery(**values)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_query_normalises_and_validates() -> None:
|
|
44
|
+
result = query(
|
|
45
|
+
passengers=PassengerCounts(adults=2, children=1),
|
|
46
|
+
cabins=(Cabin.ECONOMY, Cabin.BUSINESS, Cabin.ECONOMY),
|
|
47
|
+
)
|
|
48
|
+
assert result.origin == "LON"
|
|
49
|
+
assert result.destination == "ABZ"
|
|
50
|
+
assert result.cabins == (Cabin.ECONOMY, Cabin.BUSINESS)
|
|
51
|
+
|
|
52
|
+
with pytest.raises(ValidationError):
|
|
53
|
+
query(origin="London")
|
|
54
|
+
with pytest.raises(ValidationError):
|
|
55
|
+
query(destination="LON")
|
|
56
|
+
with pytest.raises(ValidationError):
|
|
57
|
+
query(month="2020-01")
|
|
58
|
+
with pytest.raises(ValidationError):
|
|
59
|
+
query(passengers=PassengerCounts(adults=1, infants=2))
|
|
60
|
+
with pytest.raises(ValidationError, match="at most 9"):
|
|
61
|
+
query(passengers=PassengerCounts(adults=9, children=1))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_parse_realistic_rsc_without_fixed_record_position() -> None:
|
|
65
|
+
result = parse_reward_rsc(FIXTURE.read_text(), query())
|
|
66
|
+
assert result.origin_city_name == "London"
|
|
67
|
+
assert result.destination_city_name == "Aberdeen"
|
|
68
|
+
assert len(result.days) == 2
|
|
69
|
+
|
|
70
|
+
day = result.day("2099-11-05")
|
|
71
|
+
assert day.availability_level == 2
|
|
72
|
+
assert len(day.flights) == 2
|
|
73
|
+
assert day.flights[0].marketing.flight_number == "1300"
|
|
74
|
+
assert day.flights[0].seats_for(Cabin.BUSINESS) == 9
|
|
75
|
+
assert day.flights[0].seats_for(Cabin.ECONOMY) == 0
|
|
76
|
+
assert day.flights[1].peak is True
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_parser_filters_to_selected_cabins() -> None:
|
|
80
|
+
result = parse_reward_rsc(FIXTURE.read_text(), query(cabins=(Cabin.ECONOMY,)))
|
|
81
|
+
availability = result.day("2099-11-05").flights[0].availability
|
|
82
|
+
assert [item.cabin for item in availability] == ["M"]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_parser_distinguishes_expiry_protocol_and_range() -> None:
|
|
86
|
+
with pytest.raises(SessionExpired):
|
|
87
|
+
parse_reward_rsc('0:{"message":"Sign in to continue"}', query())
|
|
88
|
+
with pytest.raises(RewardSearchProtocolError):
|
|
89
|
+
parse_reward_rsc('0:{"message":"unexpected"}', query())
|
|
90
|
+
with pytest.raises(RewardSearchRangeError):
|
|
91
|
+
parse_reward_rsc(FIXTURE.read_text(), query(month="2099-12"))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_client_sends_captured_rsc_contract(settings: Settings) -> None:
|
|
95
|
+
captured: list[httpx.Request] = []
|
|
96
|
+
|
|
97
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
98
|
+
captured.append(request)
|
|
99
|
+
return httpx.Response(
|
|
100
|
+
200,
|
|
101
|
+
text=FIXTURE.read_text(),
|
|
102
|
+
headers={"content-type": "text/x-component"},
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
session = Session(
|
|
106
|
+
settings,
|
|
107
|
+
opco="BAEC",
|
|
108
|
+
base_url="https://www.avios.com",
|
|
109
|
+
cookies=COOKIE,
|
|
110
|
+
transport=httpx.MockTransport(handler),
|
|
111
|
+
)
|
|
112
|
+
result = AviosClient(session).search_reward_calendar(query())
|
|
113
|
+
|
|
114
|
+
assert result.month == "2099-11"
|
|
115
|
+
request = captured[0]
|
|
116
|
+
assert request.url.path == "/en-GB/spend-avios/search-reward-flights/results"
|
|
117
|
+
params = request.url.params.multi_items()
|
|
118
|
+
assert ("originAirport", "LON") in params
|
|
119
|
+
assert ("destinationAirport", "ABZ") in params
|
|
120
|
+
assert ("month", "11") in params
|
|
121
|
+
assert [value for key, value in params if key == "cabin"] == [
|
|
122
|
+
"Economy",
|
|
123
|
+
"Premium Economy",
|
|
124
|
+
"Business",
|
|
125
|
+
"First",
|
|
126
|
+
]
|
|
127
|
+
assert request.headers["rsc"] == "1"
|
|
128
|
+
assert request.headers["next-url"] == "/en-GB/BA/spend-avios/search-reward-flights"
|
|
129
|
+
assert "next-router-state-tree" in request.headers
|
|
130
|
+
assert "_rsc" in request.url.params
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_non_ba_session_is_rejected_before_network(settings: Settings) -> None:
|
|
134
|
+
session = Session(settings, opco="IBP", cookies=COOKIE)
|
|
135
|
+
with pytest.raises(UnsupportedProgramme):
|
|
136
|
+
AviosClient(session).search_reward_calendar(query())
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_flight_search_403_has_actionable_login_error(settings: Settings) -> None:
|
|
140
|
+
session = Session(
|
|
141
|
+
settings,
|
|
142
|
+
opco="BAEC",
|
|
143
|
+
cookies=COOKIE,
|
|
144
|
+
transport=httpx.MockTransport(lambda request: httpx.Response(403, request=request)),
|
|
145
|
+
)
|
|
146
|
+
with pytest.raises(RewardSearchAccessError, match="avios login ba"):
|
|
147
|
+
AviosClient(session).search_reward_calendar(query())
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class _BrowserResponse:
|
|
151
|
+
status = 200
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class _BrowserPage:
|
|
155
|
+
url = "https://www.avios.com/en-GB/spend-avios/search-reward-flights"
|
|
156
|
+
|
|
157
|
+
def __init__(self) -> None:
|
|
158
|
+
self.fetch_args: dict[str, Any] = {}
|
|
159
|
+
|
|
160
|
+
def goto(self, url: str, **kwargs: object) -> _BrowserResponse:
|
|
161
|
+
return _BrowserResponse()
|
|
162
|
+
|
|
163
|
+
def wait_for_timeout(self, milliseconds: int) -> None:
|
|
164
|
+
pass
|
|
165
|
+
|
|
166
|
+
def evaluate(self, expression: str, arg: dict[str, Any]) -> dict[str, Any]:
|
|
167
|
+
self.fetch_args = arg
|
|
168
|
+
return {
|
|
169
|
+
"status": 200,
|
|
170
|
+
"url": arg["url"],
|
|
171
|
+
"body": FIXTURE.read_text(),
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class _BrowserContext:
|
|
176
|
+
def __init__(self) -> None:
|
|
177
|
+
self.page = _BrowserPage()
|
|
178
|
+
self.seeded_cookies: list[dict[str, Any]] = []
|
|
179
|
+
self.closed = False
|
|
180
|
+
|
|
181
|
+
def add_cookies(self, cookies: list[dict[str, Any]]) -> None:
|
|
182
|
+
self.seeded_cookies = cookies
|
|
183
|
+
|
|
184
|
+
def cookies(self) -> list[dict[str, Any]]:
|
|
185
|
+
return []
|
|
186
|
+
|
|
187
|
+
def new_page(self) -> _BrowserPage:
|
|
188
|
+
return self.page
|
|
189
|
+
|
|
190
|
+
def close(self) -> None:
|
|
191
|
+
self.closed = True
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class _BrowserChromium:
|
|
195
|
+
def __init__(self, ctx: _BrowserContext) -> None:
|
|
196
|
+
self.ctx = ctx
|
|
197
|
+
|
|
198
|
+
def launch_persistent_context(self, path: str, **kwargs: object) -> _BrowserContext:
|
|
199
|
+
return self.ctx
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class _BrowserPlaywright:
|
|
203
|
+
def __init__(self, ctx: _BrowserContext) -> None:
|
|
204
|
+
self.chromium = _BrowserChromium(ctx)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class _BrowserFactory:
|
|
208
|
+
def __init__(self, ctx: _BrowserContext) -> None:
|
|
209
|
+
self.playwright = _BrowserPlaywright(ctx)
|
|
210
|
+
|
|
211
|
+
def __call__(self) -> _BrowserFactory:
|
|
212
|
+
return self
|
|
213
|
+
|
|
214
|
+
def __enter__(self) -> _BrowserPlaywright:
|
|
215
|
+
return self.playwright
|
|
216
|
+
|
|
217
|
+
def __exit__(self, *args: object) -> bool:
|
|
218
|
+
return False
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def test_browser_fetch_seeds_cookies_and_returns_rsc(settings: Settings) -> None:
|
|
222
|
+
ctx = _BrowserContext()
|
|
223
|
+
captured: list[tuple[str, RewardSearchQuery]] = []
|
|
224
|
+
session = Session(
|
|
225
|
+
settings,
|
|
226
|
+
opco="BAEC",
|
|
227
|
+
base_url="https://www.avios.com",
|
|
228
|
+
cookies=COOKIE,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
def driver(page: object, base_url: str, reward_query: RewardSearchQuery) -> str:
|
|
232
|
+
captured.append((base_url, reward_query))
|
|
233
|
+
return FIXTURE.read_text()
|
|
234
|
+
|
|
235
|
+
payload = _fetch_reward_rsc_via_browser(
|
|
236
|
+
session,
|
|
237
|
+
query(),
|
|
238
|
+
playwright_factory=_BrowserFactory(ctx),
|
|
239
|
+
search_driver=driver,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
assert payload == FIXTURE.read_text()
|
|
243
|
+
assert ctx.seeded_cookies == [
|
|
244
|
+
{"name": "appSession", "value": "x", "domain": "www.avios.com", "path": "/"}
|
|
245
|
+
]
|
|
246
|
+
assert captured[0][0] == "https://www.avios.com"
|
|
247
|
+
assert captured[0][1].origin == "LON"
|
|
248
|
+
assert ctx.closed
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def test_browser_search_retries_one_nextjs_shell(
|
|
252
|
+
settings: Settings,
|
|
253
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
254
|
+
) -> None:
|
|
255
|
+
payloads = iter(['0:{"message":"router shell"}', FIXTURE.read_text()])
|
|
256
|
+
calls = 0
|
|
257
|
+
|
|
258
|
+
def fetch(session: Session, reward_query: RewardSearchQuery) -> str:
|
|
259
|
+
nonlocal calls
|
|
260
|
+
calls += 1
|
|
261
|
+
return next(payloads)
|
|
262
|
+
|
|
263
|
+
monkeypatch.setattr("avios.rewards._fetch_reward_rsc_via_browser", fetch)
|
|
264
|
+
session = Session(settings, opco="BAEC", base_url="https://www.avios.com", cookies=COOKIE)
|
|
265
|
+
|
|
266
|
+
result = AviosClient(session).search_reward_calendar(query())
|
|
267
|
+
|
|
268
|
+
assert result.month == "2099-11"
|
|
269
|
+
assert calls == 2
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class _ClickTarget:
|
|
273
|
+
def __init__(self) -> None:
|
|
274
|
+
self.clicks = 0
|
|
275
|
+
|
|
276
|
+
def click(self, **kwargs: object) -> None:
|
|
277
|
+
self.clicks += 1
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
class _AirportInput:
|
|
281
|
+
def __init__(self, value: str) -> None:
|
|
282
|
+
self.value = value
|
|
283
|
+
self.fills: list[str] = []
|
|
284
|
+
|
|
285
|
+
def input_value(self) -> str:
|
|
286
|
+
return self.value
|
|
287
|
+
|
|
288
|
+
def fill(self, value: str) -> None:
|
|
289
|
+
self.fills.append(value)
|
|
290
|
+
self.value = value
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
class _FormPage:
|
|
294
|
+
def __init__(self, airport_value: str) -> None:
|
|
295
|
+
self.airport = _AirportInput(airport_value)
|
|
296
|
+
self.targets: dict[str, _ClickTarget] = {}
|
|
297
|
+
|
|
298
|
+
def locator(self, selector: str) -> _AirportInput:
|
|
299
|
+
return self.airport
|
|
300
|
+
|
|
301
|
+
def get_by_test_id(self, test_id: str) -> _ClickTarget:
|
|
302
|
+
return self.targets.setdefault(test_id, _ClickTarget())
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def test_airport_selection_preserves_matching_default() -> None:
|
|
306
|
+
page = _FormPage("London All Airports (LON)")
|
|
307
|
+
|
|
308
|
+
_select_airport(page, "origin", "LON")
|
|
309
|
+
|
|
310
|
+
assert page.airport.fills == []
|
|
311
|
+
assert page.targets == {}
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def test_airport_and_passenger_controls_drive_site_test_ids() -> None:
|
|
315
|
+
page = _FormPage("")
|
|
316
|
+
|
|
317
|
+
_select_airport(page, "destination", "ABZ")
|
|
318
|
+
_set_passengers(page, PassengerCounts(adults=2, children=1))
|
|
319
|
+
|
|
320
|
+
assert page.airport.fills == ["ABZ"]
|
|
321
|
+
assert page.targets["destination-ABZ-airport-option"].clicks == 1
|
|
322
|
+
assert page.targets["passenger number select"].clicks == 2
|
|
323
|
+
assert page.targets["Adults-plus"].clicks == 1
|
|
324
|
+
assert page.targets["Children-plus"].clicks == 1
|
|
@@ -21,6 +21,8 @@ def test_save_and_load_cookies_round_trip(settings: Settings) -> None:
|
|
|
21
21
|
session = _session(settings)
|
|
22
22
|
session.save_cookies(AVIOS_COOKIE)
|
|
23
23
|
assert session.load_cookies() == AVIOS_COOKIE
|
|
24
|
+
assert session.browser_cookies() == AVIOS_COOKIE
|
|
25
|
+
assert session.has_custom_transport is False
|
|
24
26
|
|
|
25
27
|
|
|
26
28
|
def test_state_file_is_private(settings: Settings) -> None:
|
|
@@ -1,142 +0,0 @@
|
|
|
1
|
-
"""Tests for BA reward-flight RSC parsing and requests."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from pathlib import Path
|
|
6
|
-
from typing import Any
|
|
7
|
-
|
|
8
|
-
import httpx
|
|
9
|
-
import pytest
|
|
10
|
-
from pydantic import ValidationError
|
|
11
|
-
|
|
12
|
-
from avios.client import AviosClient
|
|
13
|
-
from avios.config import Settings
|
|
14
|
-
from avios.rewards import (
|
|
15
|
-
Cabin,
|
|
16
|
-
PassengerCounts,
|
|
17
|
-
RewardSearchAccessError,
|
|
18
|
-
RewardSearchProtocolError,
|
|
19
|
-
RewardSearchQuery,
|
|
20
|
-
RewardSearchRangeError,
|
|
21
|
-
UnsupportedProgramme,
|
|
22
|
-
parse_reward_rsc,
|
|
23
|
-
)
|
|
24
|
-
from avios.session import Session, SessionExpired
|
|
25
|
-
|
|
26
|
-
FIXTURE = Path(__file__).parent / "fixtures" / "reward_search.rsc"
|
|
27
|
-
COOKIE = [{"name": "appSession", "value": "x", "domain": "www.avios.com"}]
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def query(**overrides: Any) -> RewardSearchQuery:
|
|
31
|
-
values: dict[str, Any] = {
|
|
32
|
-
"origin": "lon",
|
|
33
|
-
"destination": "abz",
|
|
34
|
-
"month": "2099-11",
|
|
35
|
-
}
|
|
36
|
-
values.update(overrides)
|
|
37
|
-
return RewardSearchQuery(**values)
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def test_query_normalises_and_validates() -> None:
|
|
41
|
-
result = query(
|
|
42
|
-
passengers=PassengerCounts(adults=2, children=1),
|
|
43
|
-
cabins=(Cabin.ECONOMY, Cabin.BUSINESS, Cabin.ECONOMY),
|
|
44
|
-
)
|
|
45
|
-
assert result.origin == "LON"
|
|
46
|
-
assert result.destination == "ABZ"
|
|
47
|
-
assert result.cabins == (Cabin.ECONOMY, Cabin.BUSINESS)
|
|
48
|
-
|
|
49
|
-
with pytest.raises(ValidationError):
|
|
50
|
-
query(origin="London")
|
|
51
|
-
with pytest.raises(ValidationError):
|
|
52
|
-
query(destination="LON")
|
|
53
|
-
with pytest.raises(ValidationError):
|
|
54
|
-
query(month="2020-01")
|
|
55
|
-
with pytest.raises(ValidationError):
|
|
56
|
-
query(passengers=PassengerCounts(adults=1, infants=2))
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def test_parse_realistic_rsc_without_fixed_record_position() -> None:
|
|
60
|
-
result = parse_reward_rsc(FIXTURE.read_text(), query())
|
|
61
|
-
assert result.origin_city_name == "London"
|
|
62
|
-
assert result.destination_city_name == "Aberdeen"
|
|
63
|
-
assert len(result.days) == 2
|
|
64
|
-
|
|
65
|
-
day = result.day("2099-11-05")
|
|
66
|
-
assert day.availability_level == 2
|
|
67
|
-
assert len(day.flights) == 2
|
|
68
|
-
assert day.flights[0].marketing.flight_number == "1300"
|
|
69
|
-
assert day.flights[0].seats_for(Cabin.BUSINESS) == 9
|
|
70
|
-
assert day.flights[0].seats_for(Cabin.ECONOMY) == 0
|
|
71
|
-
assert day.flights[1].peak is True
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
def test_parser_filters_to_selected_cabins() -> None:
|
|
75
|
-
result = parse_reward_rsc(FIXTURE.read_text(), query(cabins=(Cabin.ECONOMY,)))
|
|
76
|
-
availability = result.day("2099-11-05").flights[0].availability
|
|
77
|
-
assert [item.cabin for item in availability] == ["M"]
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
def test_parser_distinguishes_expiry_protocol_and_range() -> None:
|
|
81
|
-
with pytest.raises(SessionExpired):
|
|
82
|
-
parse_reward_rsc('0:{"message":"Sign in to continue"}', query())
|
|
83
|
-
with pytest.raises(RewardSearchProtocolError):
|
|
84
|
-
parse_reward_rsc('0:{"message":"unexpected"}', query())
|
|
85
|
-
with pytest.raises(RewardSearchRangeError):
|
|
86
|
-
parse_reward_rsc(FIXTURE.read_text(), query(month="2099-12"))
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
def test_client_sends_captured_rsc_contract(settings: Settings) -> None:
|
|
90
|
-
captured: list[httpx.Request] = []
|
|
91
|
-
|
|
92
|
-
def handler(request: httpx.Request) -> httpx.Response:
|
|
93
|
-
captured.append(request)
|
|
94
|
-
return httpx.Response(
|
|
95
|
-
200,
|
|
96
|
-
text=FIXTURE.read_text(),
|
|
97
|
-
headers={"content-type": "text/x-component"},
|
|
98
|
-
)
|
|
99
|
-
|
|
100
|
-
session = Session(
|
|
101
|
-
settings,
|
|
102
|
-
opco="BAEC",
|
|
103
|
-
base_url="https://www.avios.com",
|
|
104
|
-
cookies=COOKIE,
|
|
105
|
-
transport=httpx.MockTransport(handler),
|
|
106
|
-
)
|
|
107
|
-
result = AviosClient(session).search_reward_calendar(query())
|
|
108
|
-
|
|
109
|
-
assert result.month == "2099-11"
|
|
110
|
-
request = captured[0]
|
|
111
|
-
assert request.url.path == "/en-GB/spend-avios/search-reward-flights/results"
|
|
112
|
-
params = request.url.params.multi_items()
|
|
113
|
-
assert ("originAirport", "LON") in params
|
|
114
|
-
assert ("destinationAirport", "ABZ") in params
|
|
115
|
-
assert ("month", "11") in params
|
|
116
|
-
assert [value for key, value in params if key == "cabin"] == [
|
|
117
|
-
"Economy",
|
|
118
|
-
"Premium Economy",
|
|
119
|
-
"Business",
|
|
120
|
-
"First",
|
|
121
|
-
]
|
|
122
|
-
assert request.headers["rsc"] == "1"
|
|
123
|
-
assert request.headers["next-url"] == "/en-GB/BA/spend-avios/search-reward-flights"
|
|
124
|
-
assert "next-router-state-tree" in request.headers
|
|
125
|
-
assert "_rsc" in request.url.params
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
def test_non_ba_session_is_rejected_before_network(settings: Settings) -> None:
|
|
129
|
-
session = Session(settings, opco="IBP", cookies=COOKIE)
|
|
130
|
-
with pytest.raises(UnsupportedProgramme):
|
|
131
|
-
AviosClient(session).search_reward_calendar(query())
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
def test_flight_search_403_has_actionable_login_error(settings: Settings) -> None:
|
|
135
|
-
session = Session(
|
|
136
|
-
settings,
|
|
137
|
-
opco="BAEC",
|
|
138
|
-
cookies=COOKIE,
|
|
139
|
-
transport=httpx.MockTransport(lambda request: httpx.Response(403, request=request)),
|
|
140
|
-
)
|
|
141
|
-
with pytest.raises(RewardSearchAccessError, match="avios login ba"):
|
|
142
|
-
AviosClient(session).search_reward_calendar(query())
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|