avios-cli 0.1.1__tar.gz → 0.1.3__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 (44) hide show
  1. {avios_cli-0.1.1 → avios_cli-0.1.3}/CHANGELOG.md +26 -1
  2. {avios_cli-0.1.1 → avios_cli-0.1.3}/PKG-INFO +15 -9
  3. {avios_cli-0.1.1 → avios_cli-0.1.3}/README.md +14 -8
  4. {avios_cli-0.1.1 → avios_cli-0.1.3}/src/avios/__init__.py +1 -1
  5. {avios_cli-0.1.1 → avios_cli-0.1.3}/src/avios/auth.py +63 -19
  6. {avios_cli-0.1.1 → avios_cli-0.1.3}/src/avios/cli.py +6 -4
  7. {avios_cli-0.1.1 → avios_cli-0.1.3}/src/avios/endpoints.py +4 -0
  8. avios_cli-0.1.3/tests/test_auth.py +243 -0
  9. avios_cli-0.1.1/tests/test_auth.py +0 -108
  10. {avios_cli-0.1.1 → avios_cli-0.1.3}/.editorconfig +0 -0
  11. {avios_cli-0.1.1 → avios_cli-0.1.3}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  12. {avios_cli-0.1.1 → avios_cli-0.1.3}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  13. {avios_cli-0.1.1 → avios_cli-0.1.3}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  14. {avios_cli-0.1.1 → avios_cli-0.1.3}/.github/dependabot.yml +0 -0
  15. {avios_cli-0.1.1 → avios_cli-0.1.3}/.github/workflows/ci.yml +0 -0
  16. {avios_cli-0.1.1 → avios_cli-0.1.3}/.github/workflows/publish.yml +0 -0
  17. {avios_cli-0.1.1 → avios_cli-0.1.3}/.gitignore +0 -0
  18. {avios_cli-0.1.1 → avios_cli-0.1.3}/.pre-commit-config.yaml +0 -0
  19. {avios_cli-0.1.1 → avios_cli-0.1.3}/.python-version +0 -0
  20. {avios_cli-0.1.1 → avios_cli-0.1.3}/CONTRIBUTING.md +0 -0
  21. {avios_cli-0.1.1 → avios_cli-0.1.3}/LICENSE +0 -0
  22. {avios_cli-0.1.1 → avios_cli-0.1.3}/SECURITY.md +0 -0
  23. {avios_cli-0.1.1 → avios_cli-0.1.3}/docs/dashboard.svg +0 -0
  24. {avios_cli-0.1.1 → avios_cli-0.1.3}/pyproject.toml +0 -0
  25. {avios_cli-0.1.1 → avios_cli-0.1.3}/scripts/screenshot.py +0 -0
  26. {avios_cli-0.1.1 → avios_cli-0.1.3}/src/avios/client.py +0 -0
  27. {avios_cli-0.1.1 → avios_cli-0.1.3}/src/avios/config.py +0 -0
  28. {avios_cli-0.1.1 → avios_cli-0.1.3}/src/avios/models.py +0 -0
  29. {avios_cli-0.1.1 → avios_cli-0.1.3}/src/avios/session.py +0 -0
  30. {avios_cli-0.1.1 → avios_cli-0.1.3}/src/avios/tui/__init__.py +0 -0
  31. {avios_cli-0.1.1 → avios_cli-0.1.3}/src/avios/tui/app.py +0 -0
  32. {avios_cli-0.1.1 → avios_cli-0.1.3}/src/avios/tui/art.py +0 -0
  33. {avios_cli-0.1.1 → avios_cli-0.1.3}/src/avios/tui/styles.tcss +0 -0
  34. {avios_cli-0.1.1 → avios_cli-0.1.3}/src/avios/tui/widgets.py +0 -0
  35. {avios_cli-0.1.1 → avios_cli-0.1.3}/tests/conftest.py +0 -0
  36. {avios_cli-0.1.1 → avios_cli-0.1.3}/tests/fixtures/accounts.json +0 -0
  37. {avios_cli-0.1.1 → avios_cli-0.1.3}/tests/fixtures/balance.json +0 -0
  38. {avios_cli-0.1.1 → avios_cli-0.1.3}/tests/fixtures/transactions.json +0 -0
  39. {avios_cli-0.1.1 → avios_cli-0.1.3}/tests/test_cli.py +0 -0
  40. {avios_cli-0.1.1 → avios_cli-0.1.3}/tests/test_client.py +0 -0
  41. {avios_cli-0.1.1 → avios_cli-0.1.3}/tests/test_models.py +0 -0
  42. {avios_cli-0.1.1 → avios_cli-0.1.3}/tests/test_session.py +0 -0
  43. {avios_cli-0.1.1 → avios_cli-0.1.3}/tests/test_tui.py +0 -0
  44. {avios_cli-0.1.1 → avios_cli-0.1.3}/uv.lock +0 -0
@@ -6,6 +6,29 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.1.3] - 2026-07-24
10
+
11
+ ### Fixed
12
+ - **Login never actually captured an authenticated session.** The dashboard is a
13
+ client-side SPA, so `wait_for_url("**/manage-avios/**")` matched the initial
14
+ navigation immediately and the browser closed before login — saving anonymous
15
+ cookies, so every call then failed with "Session expired". Login now polls
16
+ `/auth-gateway/user` until it returns 200 (i.e. you're actually logged in) and
17
+ only then captures the cookies.
18
+ - Login launches your **system Chrome** (`channel="chrome"`) when available, so no
19
+ 150 MB Chromium download is needed; falls back to bundled Chromium.
20
+ - Corrected the Chromium install command to `uvx --from playwright playwright
21
+ install chromium`.
22
+
23
+ ## [0.1.2] - 2026-07-24
24
+
25
+ ### Fixed
26
+ - Login error messages: `[login]` was swallowed by Rich markup (showed
27
+ `avios-cli` instead of `avios-cli[login]`) — error text is now escaped.
28
+ - Login guidance is uvx-aware: suggests `uvx --from 'avios-cli[login]' avios login`,
29
+ the one-time `playwright install chromium`, and the `--from-browser` alternative;
30
+ a missing Chromium at launch now gives a clear install hint.
31
+
9
32
  ## [0.1.1] - 2026-07-24
10
33
 
11
34
  ### Fixed
@@ -40,6 +63,8 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
40
63
  - Reward-flight **availability** search is not yet implemented (needs a British
41
64
  Airways capture).
42
65
 
43
- [Unreleased]: https://github.com/alexechoi/avios-cli/compare/v0.1.1...HEAD
66
+ [Unreleased]: https://github.com/alexechoi/avios-cli/compare/v0.1.3...HEAD
67
+ [0.1.3]: https://github.com/alexechoi/avios-cli/compare/v0.1.2...v0.1.3
68
+ [0.1.2]: https://github.com/alexechoi/avios-cli/compare/v0.1.1...v0.1.2
44
69
  [0.1.1]: https://github.com/alexechoi/avios-cli/compare/v0.1.0...v0.1.1
45
70
  [0.1.0]: https://github.com/alexechoi/avios-cli/releases/tag/v0.1.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: avios-cli
3
- Version: 0.1.1
3
+ Version: 0.1.3
4
4
  Summary: A CLI and TUI for avios.com — view your Avios balance and transactions from the terminal.
5
5
  Project-URL: Homepage, https://github.com/alexechoi/avios-cli
6
6
  Project-URL: Repository, https://github.com/alexechoi/avios-cli
@@ -78,22 +78,28 @@ uv run avios --help
78
78
  ## Log in
79
79
 
80
80
  avios.com has no credential API — login is Auth0 Universal Login behind hCaptcha and
81
- SMS/passkey MFA — so `avios login` rides a real browser session (like `gh`/`aws`
82
- login). Install the login extra once:
81
+ SMS/passkey MFA — so `avios login` opens a real browser, **waits while you finish
82
+ logging in** (password, captcha, SMS code), and captures the session once you land
83
+ on the dashboard. It needs the **`login` extra**:
83
84
 
84
85
  ```bash
85
- uv sync --extra login # or: pip install "avios-cli[login]"
86
- uv run playwright install chromium
86
+ uvx --from 'avios-cli[login]' avios login
87
87
  ```
88
88
 
89
- Then either open a browser to log in, or import the cookie from a browser you're
90
- already logged into:
89
+ It launches your installed **Google Chrome** (no extra download). If you don't have
90
+ Chrome, install Playwright's browser once with
91
+ `uvx --from playwright playwright install chromium`.
92
+
93
+ Prefer not to open a browser? Import the cookie from a Chrome you're already logged
94
+ into avios.com with:
91
95
 
92
96
  ```bash
93
- avios login # opens a browser; log in once, cookie is captured
94
- avios login --from-browser # instead, read the avios.com cookie from Chrome
97
+ uvx --from 'avios-cli[login]' avios login --from-browser
95
98
  ```
96
99
 
100
+ Plain `uvx avios-cli login` won't work — the isolated environment doesn't include
101
+ the `login` extra.
102
+
97
103
  Your session cookie is stored at `~/.config/avios/state.json` (mode `600`). It
98
104
  expires after ~a day; just run `avios login` again. Run `avios logout` to remove it.
99
105
 
@@ -43,22 +43,28 @@ uv run avios --help
43
43
  ## Log in
44
44
 
45
45
  avios.com has no credential API — login is Auth0 Universal Login behind hCaptcha and
46
- SMS/passkey MFA — so `avios login` rides a real browser session (like `gh`/`aws`
47
- login). Install the login extra once:
46
+ SMS/passkey MFA — so `avios login` opens a real browser, **waits while you finish
47
+ logging in** (password, captcha, SMS code), and captures the session once you land
48
+ on the dashboard. It needs the **`login` extra**:
48
49
 
49
50
  ```bash
50
- uv sync --extra login # or: pip install "avios-cli[login]"
51
- uv run playwright install chromium
51
+ uvx --from 'avios-cli[login]' avios login
52
52
  ```
53
53
 
54
- Then either open a browser to log in, or import the cookie from a browser you're
55
- already logged into:
54
+ It launches your installed **Google Chrome** (no extra download). If you don't have
55
+ Chrome, install Playwright's browser once with
56
+ `uvx --from playwright playwright install chromium`.
57
+
58
+ Prefer not to open a browser? Import the cookie from a Chrome you're already logged
59
+ into avios.com with:
56
60
 
57
61
  ```bash
58
- avios login # opens a browser; log in once, cookie is captured
59
- avios login --from-browser # instead, read the avios.com cookie from Chrome
62
+ uvx --from 'avios-cli[login]' avios login --from-browser
60
63
  ```
61
64
 
65
+ Plain `uvx avios-cli login` won't work — the isolated environment doesn't include
66
+ the `login` extra.
67
+
62
68
  Your session cookie is stored at `~/.config/avios/state.json` (mode `600`). It
63
69
  expires after ~a day; just run `avios login` again. Run `avios logout` to remove it.
64
70
 
@@ -3,4 +3,4 @@
3
3
  View your Avios balance, transactions and account details from the terminal.
4
4
  """
5
5
 
6
- __version__ = "0.1.1"
6
+ __version__ = "0.1.3"
@@ -20,10 +20,12 @@ from __future__ import annotations
20
20
  from collections.abc import Callable, Iterable
21
21
  from typing import Any, Protocol
22
22
 
23
+ from avios import endpoints
23
24
  from avios.session import Session
24
25
 
25
26
  DASHBOARD_PATH = "/manage-avios/dashboard"
26
27
  LOGIN_TIMEOUT_MS = 300_000 # 5 minutes to complete password + captcha + MFA
28
+ LOGIN_POLL_MS = 2_000 # how often to check whether the session is authenticated
27
29
  SUPPORTED_BROWSERS = ("chrome", "firefox", "edge", "brave", "safari", "chromium")
28
30
 
29
31
 
@@ -51,46 +53,88 @@ def login_via_browser(
51
53
  *,
52
54
  headless: bool = False,
53
55
  timeout_ms: int = LOGIN_TIMEOUT_MS,
56
+ playwright_factory: Any = None,
54
57
  ) -> int:
55
- """Open a browser, wait for the user to log in, and save the session cookie.
58
+ """Open a browser, wait until the user is actually logged in, save the cookie.
56
59
 
57
- Returns the number of avios.com cookies captured.
60
+ Returns the number of avios.com cookies captured. ``playwright_factory`` is an
61
+ injection point for tests; production uses the real ``sync_playwright``.
58
62
  """
59
63
  session = session or Session()
60
- try:
61
- from playwright.sync_api import sync_playwright
62
- except ImportError as exc:
63
- raise LoginError(
64
- 'Playwright is required. Install with: pip install "avios-cli[login]" '
65
- "then: playwright install chromium"
66
- ) from exc
67
-
64
+ factory = playwright_factory or _import_sync_playwright()
68
65
  base_url = session.settings.base_url
69
- with sync_playwright() as pw: # pragma: no cover - requires a real browser
70
- browser = pw.chromium.launch(headless=headless)
66
+
67
+ with factory() as pw:
68
+ browser = _launch_browser(pw, headless=headless)
71
69
  ctx = browser.new_context(user_agent=session.settings.user_agent)
72
70
  page = ctx.new_page()
73
71
  page.goto(f"{base_url}{DASHBOARD_PATH}")
74
- try:
75
- page.wait_for_url("**/manage-avios/**", timeout=timeout_ms)
76
- page.wait_for_timeout(1500) # let the session cookie settle
77
- except Exception as exc:
78
- browser.close()
79
- raise LoginError("Did not reach the dashboard in time. Please try again.") from exc
72
+ authed = _wait_for_auth(ctx, page, base_url, timeout_ms)
80
73
  cookies = list(ctx.cookies())
81
74
  browser.close()
82
75
 
76
+ if not authed:
77
+ raise LoginError("Timed out waiting for login. Run `avios login` again.")
83
78
  session.save_cookies(cookies)
84
79
  return len(_only_avios(cookies))
85
80
 
86
81
 
82
+ def _import_sync_playwright() -> Any:
83
+ try:
84
+ from playwright.sync_api import sync_playwright
85
+ except ImportError as exc:
86
+ raise LoginError(
87
+ "Playwright isn't installed. Log in using the 'login' extra:\n"
88
+ " uvx --from 'avios-cli[login]' avios login\n"
89
+ " (first time only: uvx --from playwright playwright install chromium)\n"
90
+ "Or import the cookie from Chrome instead (no browser download):\n"
91
+ " uvx --from 'avios-cli[login]' avios login --from-browser"
92
+ ) from exc
93
+ return sync_playwright
94
+
95
+
96
+ def _wait_for_auth(ctx: Any, page: Any, base_url: str, timeout_ms: int) -> bool:
97
+ """Poll the auth endpoint until the session is authenticated (or time out).
98
+
99
+ The dashboard is a client-side SPA, so its URL is NOT a reliable signal — it
100
+ matches the moment we navigate, before login. ``/auth-gateway/user`` returns
101
+ 401 until logged in, then 200.
102
+ """
103
+ for _ in range(max(1, timeout_ms // LOGIN_POLL_MS)):
104
+ if _is_authenticated(ctx, base_url):
105
+ return True
106
+ page.wait_for_timeout(LOGIN_POLL_MS)
107
+ return False
108
+
109
+
110
+ def _is_authenticated(ctx: Any, base_url: str) -> bool:
111
+ try:
112
+ return bool(ctx.request.get(f"{base_url}{endpoints.AUTH_USER}").ok)
113
+ except Exception:
114
+ return False
115
+
116
+
117
+ def _launch_browser(pw: Any, *, headless: bool) -> Any:
118
+ """Launch the user's system Chrome if available, else bundled Chromium."""
119
+ for kwargs in ({"channel": "chrome"}, {}):
120
+ try:
121
+ return pw.chromium.launch(headless=headless, **kwargs)
122
+ except Exception:
123
+ continue
124
+ raise LoginError(
125
+ "Couldn't launch a browser. Install Chromium once with:\n"
126
+ " uvx --from playwright playwright install chromium"
127
+ )
128
+
129
+
87
130
  def _default_loader(browser: str) -> Callable[[], Iterable[_RawCookie]]:
88
131
  """Return a callable that reads avios.com cookies from ``browser``."""
89
132
  try:
90
133
  import browser_cookie3 as bc3
91
134
  except ImportError as exc:
92
135
  raise LoginError(
93
- 'browser-cookie3 is required. Install with: pip install "avios-cli[login]"'
136
+ "browser-cookie3 isn't installed. Run with the 'login' extra:\n"
137
+ " uvx --from 'avios-cli[login]' avios login --from-browser"
94
138
  ) from exc
95
139
  fn = getattr(bc3, browser, None)
96
140
  if fn is None:
@@ -14,6 +14,7 @@ from typing import Any
14
14
  import httpx
15
15
  import typer
16
16
  from rich.console import Console
17
+ from rich.markup import escape
17
18
  from rich.table import Table
18
19
 
19
20
  from avios import __version__
@@ -69,7 +70,7 @@ def _handle_errors() -> Iterator[None]:
69
70
  console.print("[red]Session expired.[/] Run [bold]avios login[/] again.")
70
71
  raise typer.Exit(2) from exc
71
72
  except httpx.HTTPError as exc:
72
- console.print(f"[red]Request failed:[/] {exc}")
73
+ console.print(f"[red]Request failed:[/] {escape(str(exc))}")
73
74
  raise typer.Exit(1) from exc
74
75
 
75
76
 
@@ -109,11 +110,12 @@ def login(
109
110
  else:
110
111
  console.print(
111
112
  "Opening a browser — log in normally (password, captcha, SMS code). "
112
- "I'll capture the session once you reach the dashboard."
113
+ "Keep the window open; it waits until you reach the dashboard, then "
114
+ "captures your session automatically."
113
115
  )
114
116
  count = login_via_browser(session, headless=headless)
115
117
  except LoginError as exc:
116
- console.print(f"[red]{exc}[/]")
118
+ console.print(f"[red]{escape(str(exc))}[/]")
117
119
  raise typer.Exit(1) from exc
118
120
 
119
121
  console.print(f"[green]Logged in.[/] Saved {count} avios cookie(s).")
@@ -123,7 +125,7 @@ def login(
123
125
  f"[green]✓ Session works.[/] Balance: [bold cyan]{balance.balance:,}[/] Avios"
124
126
  )
125
127
  except (NotAuthenticated, SessionExpired, httpx.HTTPError) as exc:
126
- console.print(f"[yellow]Saved, but a test call failed:[/] {exc}")
128
+ console.print(f"[yellow]Saved, but a test call failed:[/] {escape(str(exc))}")
127
129
 
128
130
 
129
131
  @app.command()
@@ -11,6 +11,10 @@ BALANCE = "/en-GB/spend-avios/api/avios-balance"
11
11
  # Balance under the manage-avios API — kept as a fallback.
12
12
  BALANCE_ALT = "/manage-avios/api/user/current/balance"
13
13
 
14
+ # Auth-gateway endpoint: 401 when unauthenticated, 200 once logged in. Used as the
15
+ # "is the browser session authenticated yet?" signal during login.
16
+ AUTH_USER = "/auth-gateway/user"
17
+
14
18
  PROFILE = "/manage-avios/api/user/current"
15
19
  OVERVIEW = "/manage-avios/api/user/current/dashboard-overview"
16
20
  TRANSACTIONS = "/manage-avios/api/user/current/transactions"
@@ -0,0 +1,243 @@
1
+ """Tests for the login helpers.
2
+
3
+ The Playwright browser flow can't be automated (Auth0 + hCaptcha + MFA need a
4
+ human), so we test the cookie-conversion/import helpers and the missing-dependency
5
+ paths. ``browser_cookie3`` is faked via ``sys.modules`` so tests don't depend on
6
+ the optional ``login`` extra or a real browser profile.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import sys
12
+ import types
13
+
14
+ import pytest
15
+
16
+ from avios.auth import (
17
+ LoginError,
18
+ _default_loader,
19
+ _only_avios,
20
+ _to_cookie_dicts,
21
+ import_from_browser,
22
+ login_via_browser,
23
+ )
24
+ from avios.config import Settings
25
+ from avios.session import Session
26
+
27
+
28
+ class FakeCookie:
29
+ def __init__(self, name: str, value: str, domain: str) -> None:
30
+ self.name = name
31
+ self.value = value
32
+ self.domain = domain
33
+
34
+
35
+ def _session(settings: Settings) -> Session:
36
+ return Session(settings)
37
+
38
+
39
+ def test_to_cookie_dicts() -> None:
40
+ raw = [FakeCookie("appSession", "abc", "www.avios.com")]
41
+ assert _to_cookie_dicts(raw) == [
42
+ {"name": "appSession", "value": "abc", "domain": "www.avios.com"}
43
+ ]
44
+
45
+
46
+ def test_only_avios_filters_foreign_domains() -> None:
47
+ cookies = [
48
+ {"name": "appSession", "value": "abc", "domain": "www.avios.com"},
49
+ {"name": "_ga", "value": "junk", "domain": ".google.com"},
50
+ ]
51
+ assert _only_avios(cookies) == [cookies[0]]
52
+
53
+
54
+ def test_import_from_browser_saves_only_avios(settings: Settings) -> None:
55
+ session = _session(settings)
56
+ count = import_from_browser(
57
+ session,
58
+ loader=lambda: [
59
+ FakeCookie("appSession", "abc", "www.avios.com"),
60
+ FakeCookie("_ga", "junk", ".google.com"),
61
+ ],
62
+ )
63
+ assert count == 1
64
+ assert session.is_authenticated()
65
+ assert session.load_cookies() == [
66
+ {"name": "appSession", "value": "abc", "domain": "www.avios.com"}
67
+ ]
68
+
69
+
70
+ def test_import_from_browser_no_cookies_raises(settings: Settings) -> None:
71
+ with pytest.raises(LoginError, match="No avios.com cookies"):
72
+ import_from_browser(_session(settings), loader=lambda: [FakeCookie("_ga", "j", ".x.com")])
73
+
74
+
75
+ def _fake_bc3(monkeypatch: pytest.MonkeyPatch, **funcs: object) -> None:
76
+ module = types.ModuleType("browser_cookie3")
77
+ for name, fn in funcs.items():
78
+ setattr(module, name, fn)
79
+ monkeypatch.setitem(sys.modules, "browser_cookie3", module)
80
+
81
+
82
+ def test_default_loader_passes_domain(monkeypatch: pytest.MonkeyPatch) -> None:
83
+ seen: dict[str, str] = {}
84
+
85
+ def chrome(**kwargs: str) -> list[FakeCookie]:
86
+ seen.update(kwargs)
87
+ return [FakeCookie("appSession", "x", "www.avios.com")]
88
+
89
+ _fake_bc3(monkeypatch, chrome=chrome)
90
+ loader = _default_loader("chrome")
91
+ cookies = list(loader())
92
+ assert seen["domain_name"] == "avios.com"
93
+ assert cookies[0].name == "appSession"
94
+
95
+
96
+ def test_default_loader_unknown_browser(monkeypatch: pytest.MonkeyPatch) -> None:
97
+ _fake_bc3(monkeypatch, chrome=lambda **k: [])
98
+ with pytest.raises(LoginError, match="Unknown browser"):
99
+ _default_loader("nope")
100
+
101
+
102
+ def test_login_via_browser_without_playwright(
103
+ settings: Settings, monkeypatch: pytest.MonkeyPatch
104
+ ) -> None:
105
+ monkeypatch.setitem(sys.modules, "playwright", None)
106
+ monkeypatch.setitem(sys.modules, "playwright.sync_api", None)
107
+ with pytest.raises(LoginError, match="Playwright isn't installed"):
108
+ login_via_browser(_session(settings))
109
+
110
+
111
+ # --- fake Playwright, to exercise the poll-until-authenticated flow --------------
112
+ class _FakeResponse:
113
+ def __init__(self, ok: bool) -> None:
114
+ self.ok = ok
115
+
116
+
117
+ class _FakeRequest:
118
+ """Auth endpoint returns 401 until `ok_after` calls, then 200."""
119
+
120
+ def __init__(self, ok_after: int) -> None:
121
+ self.ok_after = ok_after
122
+ self.calls = 0
123
+
124
+ def get(self, url: str) -> _FakeResponse:
125
+ self.calls += 1
126
+ return _FakeResponse(self.calls >= self.ok_after)
127
+
128
+
129
+ class _FakePage:
130
+ def goto(self, url: str) -> None:
131
+ pass
132
+
133
+ def wait_for_timeout(self, ms: int) -> None:
134
+ pass
135
+
136
+
137
+ class _FakeContext:
138
+ def __init__(self, ok_after: int, cookies: list[dict[str, str]]) -> None:
139
+ self.request = _FakeRequest(ok_after)
140
+ self._cookies = cookies
141
+
142
+ def new_page(self) -> _FakePage:
143
+ return _FakePage()
144
+
145
+ def cookies(self) -> list[dict[str, str]]:
146
+ return self._cookies
147
+
148
+
149
+ class _FakeBrowser:
150
+ def __init__(self, ctx: _FakeContext) -> None:
151
+ self._ctx = ctx
152
+ self.closed = False
153
+
154
+ def new_context(self, **kwargs: object) -> _FakeContext:
155
+ return self._ctx
156
+
157
+ def close(self) -> None:
158
+ self.closed = True
159
+
160
+
161
+ class _FakePlaywright:
162
+ def __init__(self, browser: _FakeBrowser) -> None:
163
+ self.chromium = _FakeChromium(browser)
164
+
165
+
166
+ class _FakeChromium:
167
+ def __init__(self, browser: _FakeBrowser) -> None:
168
+ self._browser = browser
169
+ self.launches: list[str | None] = []
170
+
171
+ def launch(self, **kwargs: object) -> _FakeBrowser:
172
+ self.launches.append(kwargs.get("channel")) # type: ignore[arg-type]
173
+ return self._browser
174
+
175
+
176
+ class _FakeFactory:
177
+ """Callable that returns a context manager yielding a fake Playwright."""
178
+
179
+ def __init__(self, browser: _FakeBrowser) -> None:
180
+ self._pw = _FakePlaywright(browser)
181
+
182
+ def __call__(self) -> _FakeFactory:
183
+ return self
184
+
185
+ def __enter__(self) -> _FakePlaywright:
186
+ return self._pw
187
+
188
+ def __exit__(self, *args: object) -> bool:
189
+ return False
190
+
191
+
192
+ def test_login_captures_only_after_authenticated(settings: Settings) -> None:
193
+ cookies = [
194
+ {"name": "appSession", "value": "x", "domain": "www.avios.com"},
195
+ {"name": "_ga", "value": "junk", "domain": ".google.com"},
196
+ ]
197
+ ctx = _FakeContext(ok_after=3, cookies=cookies)
198
+ browser = _FakeBrowser(ctx)
199
+ session = _session(settings)
200
+
201
+ count = login_via_browser(session, timeout_ms=100_000, playwright_factory=_FakeFactory(browser))
202
+
203
+ assert count == 1 # only the avios cookie
204
+ assert session.load_cookies() == cookies
205
+ assert browser.closed
206
+ assert ctx.request.calls >= 3 # polled until authenticated, not immediately
207
+
208
+
209
+ def test_login_times_out_without_saving(settings: Settings) -> None:
210
+ ctx = _FakeContext(
211
+ ok_after=999, cookies=[{"name": "appSession", "value": "x", "domain": "www.avios.com"}]
212
+ )
213
+ browser = _FakeBrowser(ctx)
214
+ session = _session(settings)
215
+
216
+ with pytest.raises(LoginError, match="Timed out"):
217
+ login_via_browser(session, timeout_ms=6_000, playwright_factory=_FakeFactory(browser))
218
+
219
+ assert session.is_authenticated() is False # nothing saved on timeout
220
+ assert browser.closed
221
+
222
+
223
+ def test_launch_browser_prefers_chrome_then_falls_back() -> None:
224
+ from avios.auth import _launch_browser
225
+
226
+ class Chromium:
227
+ def __init__(self) -> None:
228
+ self.channels: list[str | None] = []
229
+
230
+ def launch(self, **kwargs: object) -> str:
231
+ channel = kwargs.get("channel")
232
+ self.channels.append(channel) # type: ignore[arg-type]
233
+ if channel == "chrome":
234
+ raise RuntimeError("no system chrome")
235
+ return "browser"
236
+
237
+ class PW:
238
+ def __init__(self) -> None:
239
+ self.chromium = Chromium()
240
+
241
+ pw = PW()
242
+ assert _launch_browser(pw, headless=True) == "browser"
243
+ assert pw.chromium.channels == ["chrome", None]
@@ -1,108 +0,0 @@
1
- """Tests for the login helpers.
2
-
3
- The Playwright browser flow can't be automated (Auth0 + hCaptcha + MFA need a
4
- human), so we test the cookie-conversion/import helpers and the missing-dependency
5
- paths. ``browser_cookie3`` is faked via ``sys.modules`` so tests don't depend on
6
- the optional ``login`` extra or a real browser profile.
7
- """
8
-
9
- from __future__ import annotations
10
-
11
- import sys
12
- import types
13
-
14
- import pytest
15
-
16
- from avios.auth import (
17
- LoginError,
18
- _default_loader,
19
- _only_avios,
20
- _to_cookie_dicts,
21
- import_from_browser,
22
- login_via_browser,
23
- )
24
- from avios.config import Settings
25
- from avios.session import Session
26
-
27
-
28
- class FakeCookie:
29
- def __init__(self, name: str, value: str, domain: str) -> None:
30
- self.name = name
31
- self.value = value
32
- self.domain = domain
33
-
34
-
35
- def _session(settings: Settings) -> Session:
36
- return Session(settings)
37
-
38
-
39
- def test_to_cookie_dicts() -> None:
40
- raw = [FakeCookie("appSession", "abc", "www.avios.com")]
41
- assert _to_cookie_dicts(raw) == [
42
- {"name": "appSession", "value": "abc", "domain": "www.avios.com"}
43
- ]
44
-
45
-
46
- def test_only_avios_filters_foreign_domains() -> None:
47
- cookies = [
48
- {"name": "appSession", "value": "abc", "domain": "www.avios.com"},
49
- {"name": "_ga", "value": "junk", "domain": ".google.com"},
50
- ]
51
- assert _only_avios(cookies) == [cookies[0]]
52
-
53
-
54
- def test_import_from_browser_saves_only_avios(settings: Settings) -> None:
55
- session = _session(settings)
56
- count = import_from_browser(
57
- session,
58
- loader=lambda: [
59
- FakeCookie("appSession", "abc", "www.avios.com"),
60
- FakeCookie("_ga", "junk", ".google.com"),
61
- ],
62
- )
63
- assert count == 1
64
- assert session.is_authenticated()
65
- assert session.load_cookies() == [
66
- {"name": "appSession", "value": "abc", "domain": "www.avios.com"}
67
- ]
68
-
69
-
70
- def test_import_from_browser_no_cookies_raises(settings: Settings) -> None:
71
- with pytest.raises(LoginError, match="No avios.com cookies"):
72
- import_from_browser(_session(settings), loader=lambda: [FakeCookie("_ga", "j", ".x.com")])
73
-
74
-
75
- def _fake_bc3(monkeypatch: pytest.MonkeyPatch, **funcs: object) -> None:
76
- module = types.ModuleType("browser_cookie3")
77
- for name, fn in funcs.items():
78
- setattr(module, name, fn)
79
- monkeypatch.setitem(sys.modules, "browser_cookie3", module)
80
-
81
-
82
- def test_default_loader_passes_domain(monkeypatch: pytest.MonkeyPatch) -> None:
83
- seen: dict[str, str] = {}
84
-
85
- def chrome(**kwargs: str) -> list[FakeCookie]:
86
- seen.update(kwargs)
87
- return [FakeCookie("appSession", "x", "www.avios.com")]
88
-
89
- _fake_bc3(monkeypatch, chrome=chrome)
90
- loader = _default_loader("chrome")
91
- cookies = list(loader())
92
- assert seen["domain_name"] == "avios.com"
93
- assert cookies[0].name == "appSession"
94
-
95
-
96
- def test_default_loader_unknown_browser(monkeypatch: pytest.MonkeyPatch) -> None:
97
- _fake_bc3(monkeypatch, chrome=lambda **k: [])
98
- with pytest.raises(LoginError, match="Unknown browser"):
99
- _default_loader("nope")
100
-
101
-
102
- def test_login_via_browser_without_playwright(
103
- settings: Settings, monkeypatch: pytest.MonkeyPatch
104
- ) -> None:
105
- monkeypatch.setitem(sys.modules, "playwright", None)
106
- monkeypatch.setitem(sys.modules, "playwright.sync_api", None)
107
- with pytest.raises(LoginError, match="Playwright is required"):
108
- login_via_browser(_session(settings))
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