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.
Files changed (55) hide show
  1. {avios_cli-0.4.1 → avios_cli-0.4.2}/CHANGELOG.md +13 -1
  2. {avios_cli-0.4.1 → avios_cli-0.4.2}/PKG-INFO +7 -3
  3. {avios_cli-0.4.1 → avios_cli-0.4.2}/README.md +6 -2
  4. {avios_cli-0.4.1 → avios_cli-0.4.2}/SECURITY.md +4 -2
  5. {avios_cli-0.4.1 → avios_cli-0.4.2}/pyproject.toml +2 -2
  6. {avios_cli-0.4.1 → avios_cli-0.4.2}/src/avios/__init__.py +1 -1
  7. {avios_cli-0.4.1 → avios_cli-0.4.2}/src/avios/auth.py +60 -12
  8. {avios_cli-0.4.1 → avios_cli-0.4.2}/src/avios/cli.py +6 -1
  9. {avios_cli-0.4.1 → avios_cli-0.4.2}/src/avios/rewards.py +160 -0
  10. {avios_cli-0.4.1 → avios_cli-0.4.2}/src/avios/session.py +13 -2
  11. {avios_cli-0.4.1 → avios_cli-0.4.2}/tests/test_auth.py +23 -0
  12. avios_cli-0.4.2/tests/test_rewards.py +324 -0
  13. {avios_cli-0.4.1 → avios_cli-0.4.2}/tests/test_session.py +2 -0
  14. avios_cli-0.4.1/tests/test_rewards.py +0 -142
  15. {avios_cli-0.4.1 → avios_cli-0.4.2}/.editorconfig +0 -0
  16. {avios_cli-0.4.1 → avios_cli-0.4.2}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  17. {avios_cli-0.4.1 → avios_cli-0.4.2}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  18. {avios_cli-0.4.1 → avios_cli-0.4.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  19. {avios_cli-0.4.1 → avios_cli-0.4.2}/.github/dependabot.yml +0 -0
  20. {avios_cli-0.4.1 → avios_cli-0.4.2}/.github/workflows/ci.yml +0 -0
  21. {avios_cli-0.4.1 → avios_cli-0.4.2}/.github/workflows/publish.yml +0 -0
  22. {avios_cli-0.4.1 → avios_cli-0.4.2}/.gitignore +0 -0
  23. {avios_cli-0.4.1 → avios_cli-0.4.2}/.pre-commit-config.yaml +0 -0
  24. {avios_cli-0.4.1 → avios_cli-0.4.2}/.python-version +0 -0
  25. {avios_cli-0.4.1 → avios_cli-0.4.2}/CONTRIBUTING.md +0 -0
  26. {avios_cli-0.4.1 → avios_cli-0.4.2}/LICENSE +0 -0
  27. {avios_cli-0.4.1 → avios_cli-0.4.2}/docs/dashboard.svg +0 -0
  28. {avios_cli-0.4.1 → avios_cli-0.4.2}/docs/reward-flights.svg +0 -0
  29. {avios_cli-0.4.1 → avios_cli-0.4.2}/scripts/screenshot.py +0 -0
  30. {avios_cli-0.4.1 → avios_cli-0.4.2}/src/avios/accounts.py +0 -0
  31. {avios_cli-0.4.1 → avios_cli-0.4.2}/src/avios/aggregate.py +0 -0
  32. {avios_cli-0.4.1 → avios_cli-0.4.2}/src/avios/client.py +0 -0
  33. {avios_cli-0.4.1 → avios_cli-0.4.2}/src/avios/config.py +0 -0
  34. {avios_cli-0.4.1 → avios_cli-0.4.2}/src/avios/endpoints.py +0 -0
  35. {avios_cli-0.4.1 → avios_cli-0.4.2}/src/avios/finnair.py +0 -0
  36. {avios_cli-0.4.1 → avios_cli-0.4.2}/src/avios/models.py +0 -0
  37. {avios_cli-0.4.1 → avios_cli-0.4.2}/src/avios/programmes.py +0 -0
  38. {avios_cli-0.4.1 → avios_cli-0.4.2}/src/avios/tui/__init__.py +0 -0
  39. {avios_cli-0.4.1 → avios_cli-0.4.2}/src/avios/tui/app.py +0 -0
  40. {avios_cli-0.4.1 → avios_cli-0.4.2}/src/avios/tui/art.py +0 -0
  41. {avios_cli-0.4.1 → avios_cli-0.4.2}/src/avios/tui/rewards.py +0 -0
  42. {avios_cli-0.4.1 → avios_cli-0.4.2}/src/avios/tui/styles.tcss +0 -0
  43. {avios_cli-0.4.1 → avios_cli-0.4.2}/src/avios/tui/widgets.py +0 -0
  44. {avios_cli-0.4.1 → avios_cli-0.4.2}/tests/conftest.py +0 -0
  45. {avios_cli-0.4.1 → avios_cli-0.4.2}/tests/fixtures/balance.json +0 -0
  46. {avios_cli-0.4.1 → avios_cli-0.4.2}/tests/fixtures/reward_search.rsc +0 -0
  47. {avios_cli-0.4.1 → avios_cli-0.4.2}/tests/fixtures/transactions.json +0 -0
  48. {avios_cli-0.4.1 → avios_cli-0.4.2}/tests/test_accounts.py +0 -0
  49. {avios_cli-0.4.1 → avios_cli-0.4.2}/tests/test_aggregate.py +0 -0
  50. {avios_cli-0.4.1 → avios_cli-0.4.2}/tests/test_cli.py +0 -0
  51. {avios_cli-0.4.1 → avios_cli-0.4.2}/tests/test_client.py +0 -0
  52. {avios_cli-0.4.1 → avios_cli-0.4.2}/tests/test_finnair.py +0 -0
  53. {avios_cli-0.4.1 → avios_cli-0.4.2}/tests/test_models.py +0 -0
  54. {avios_cli-0.4.1 → avios_cli-0.4.2}/tests/test_tui.py +0 -0
  55. {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.1...HEAD
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.1
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 finder briefly so its
132
- separate `spend-avios` session is captured.
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 finder briefly so its
97
- separate `spend-avios` session is captured.
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 sends the same saved BA cookie only to `www.avios.com`.
15
- The RSC response's short-lived action tokens are neither exposed nor persisted.
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 the core stays lightweight and the
40
- # heavy Playwright browser download is only pulled when a user needs to log in.
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",
@@ -3,4 +3,4 @@
3
3
  View your Avios balance, transactions and reward-flight availability from the terminal.
4
4
  """
5
5
 
6
- __version__ = "0.4.1"
6
+ __version__ = "0.4.2"
@@ -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(pw, headless=headless, user_data_dir=profile_dir)
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
- _warm_ba_reward_search(page, programme.base_url)
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 _warm_ba_reward_search(page: Any, base_url: str) -> None:
126
- """Open BA's separate flight-search app so its session cookies are captured.
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
- Balance/profile authentication alone does not necessarily initialise the
129
- ``spend-avios`` Next.js app. This best-effort navigation runs while the real
130
- login browser is already open; failure does not invalidate account login.
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}/en-GB/spend-avios/search-reward-flights",
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
- pass
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(pw: Any, *, headless: bool, user_data_dir: str) -> Any:
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; it captures your session automatically."
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. Acquiring the cookie in
6
- the first place (browser-assisted login) lives in :mod:`avios.auth`.
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