avios-cli 0.1.2__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.2 → avios_cli-0.1.3}/CHANGELOG.md +16 -1
  2. {avios_cli-0.1.2 → avios_cli-0.1.3}/PKG-INFO +15 -10
  3. {avios_cli-0.1.2 → avios_cli-0.1.3}/README.md +14 -9
  4. {avios_cli-0.1.2 → avios_cli-0.1.3}/src/avios/__init__.py +1 -1
  5. {avios_cli-0.1.2 → avios_cli-0.1.3}/src/avios/auth.py +59 -25
  6. {avios_cli-0.1.2 → avios_cli-0.1.3}/src/avios/cli.py +2 -1
  7. {avios_cli-0.1.2 → 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.2/tests/test_auth.py +0 -108
  10. {avios_cli-0.1.2 → avios_cli-0.1.3}/.editorconfig +0 -0
  11. {avios_cli-0.1.2 → avios_cli-0.1.3}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  12. {avios_cli-0.1.2 → avios_cli-0.1.3}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  13. {avios_cli-0.1.2 → avios_cli-0.1.3}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  14. {avios_cli-0.1.2 → avios_cli-0.1.3}/.github/dependabot.yml +0 -0
  15. {avios_cli-0.1.2 → avios_cli-0.1.3}/.github/workflows/ci.yml +0 -0
  16. {avios_cli-0.1.2 → avios_cli-0.1.3}/.github/workflows/publish.yml +0 -0
  17. {avios_cli-0.1.2 → avios_cli-0.1.3}/.gitignore +0 -0
  18. {avios_cli-0.1.2 → avios_cli-0.1.3}/.pre-commit-config.yaml +0 -0
  19. {avios_cli-0.1.2 → avios_cli-0.1.3}/.python-version +0 -0
  20. {avios_cli-0.1.2 → avios_cli-0.1.3}/CONTRIBUTING.md +0 -0
  21. {avios_cli-0.1.2 → avios_cli-0.1.3}/LICENSE +0 -0
  22. {avios_cli-0.1.2 → avios_cli-0.1.3}/SECURITY.md +0 -0
  23. {avios_cli-0.1.2 → avios_cli-0.1.3}/docs/dashboard.svg +0 -0
  24. {avios_cli-0.1.2 → avios_cli-0.1.3}/pyproject.toml +0 -0
  25. {avios_cli-0.1.2 → avios_cli-0.1.3}/scripts/screenshot.py +0 -0
  26. {avios_cli-0.1.2 → avios_cli-0.1.3}/src/avios/client.py +0 -0
  27. {avios_cli-0.1.2 → avios_cli-0.1.3}/src/avios/config.py +0 -0
  28. {avios_cli-0.1.2 → avios_cli-0.1.3}/src/avios/models.py +0 -0
  29. {avios_cli-0.1.2 → avios_cli-0.1.3}/src/avios/session.py +0 -0
  30. {avios_cli-0.1.2 → avios_cli-0.1.3}/src/avios/tui/__init__.py +0 -0
  31. {avios_cli-0.1.2 → avios_cli-0.1.3}/src/avios/tui/app.py +0 -0
  32. {avios_cli-0.1.2 → avios_cli-0.1.3}/src/avios/tui/art.py +0 -0
  33. {avios_cli-0.1.2 → avios_cli-0.1.3}/src/avios/tui/styles.tcss +0 -0
  34. {avios_cli-0.1.2 → avios_cli-0.1.3}/src/avios/tui/widgets.py +0 -0
  35. {avios_cli-0.1.2 → avios_cli-0.1.3}/tests/conftest.py +0 -0
  36. {avios_cli-0.1.2 → avios_cli-0.1.3}/tests/fixtures/accounts.json +0 -0
  37. {avios_cli-0.1.2 → avios_cli-0.1.3}/tests/fixtures/balance.json +0 -0
  38. {avios_cli-0.1.2 → avios_cli-0.1.3}/tests/fixtures/transactions.json +0 -0
  39. {avios_cli-0.1.2 → avios_cli-0.1.3}/tests/test_cli.py +0 -0
  40. {avios_cli-0.1.2 → avios_cli-0.1.3}/tests/test_client.py +0 -0
  41. {avios_cli-0.1.2 → avios_cli-0.1.3}/tests/test_models.py +0 -0
  42. {avios_cli-0.1.2 → avios_cli-0.1.3}/tests/test_session.py +0 -0
  43. {avios_cli-0.1.2 → avios_cli-0.1.3}/tests/test_tui.py +0 -0
  44. {avios_cli-0.1.2 → avios_cli-0.1.3}/uv.lock +0 -0
@@ -6,6 +6,20 @@ 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
+
9
23
  ## [0.1.2] - 2026-07-24
10
24
 
11
25
  ### Fixed
@@ -49,7 +63,8 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
49
63
  - Reward-flight **availability** search is not yet implemented (needs a British
50
64
  Airways capture).
51
65
 
52
- [Unreleased]: https://github.com/alexechoi/avios-cli/compare/v0.1.2...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
53
68
  [0.1.2]: https://github.com/alexechoi/avios-cli/compare/v0.1.1...v0.1.2
54
69
  [0.1.1]: https://github.com/alexechoi/avios-cli/compare/v0.1.0...v0.1.1
55
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.2
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,27 @@ 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). Login needs the **`login` extra**, so run it with `avios-cli[login]`:
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
- # open a browser and log in once (first run also downloads Chromium):
86
- uvx --from 'avios-cli[login]' playwright install chromium
87
86
  uvx --from 'avios-cli[login]' avios login
87
+ ```
88
+
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`.
88
92
 
89
- # ...or skip the browser download and import the cookie from Chrome you're
90
- # already logged into avios.com with:
93
+ Prefer not to open a browser? Import the cookie from a Chrome you're already logged
94
+ into avios.com with:
95
+
96
+ ```bash
91
97
  uvx --from 'avios-cli[login]' avios login --from-browser
92
98
  ```
93
99
 
94
- If you installed with `pip install "avios-cli[login]"`, just run `avios login`
95
- (and `playwright install chromium` once). Plain `uvx avios-cli login` won't work —
96
- the isolated environment doesn't include the `login` extra.
100
+ Plain `uvx avios-cli login` won't work the isolated environment doesn't include
101
+ the `login` extra.
97
102
 
98
103
  Your session cookie is stored at `~/.config/avios/state.json` (mode `600`). It
99
104
  expires after ~a day; just run `avios login` again. Run `avios logout` to remove it.
@@ -43,22 +43,27 @@ 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). Login needs the **`login` extra**, so run it with `avios-cli[login]`:
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
- # open a browser and log in once (first run also downloads Chromium):
51
- uvx --from 'avios-cli[login]' playwright install chromium
52
51
  uvx --from 'avios-cli[login]' avios login
52
+ ```
53
+
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`.
53
57
 
54
- # ...or skip the browser download and import the cookie from Chrome you're
55
- # already logged into avios.com with:
58
+ Prefer not to open a browser? Import the cookie from a Chrome you're already logged
59
+ into avios.com with:
60
+
61
+ ```bash
56
62
  uvx --from 'avios-cli[login]' avios login --from-browser
57
63
  ```
58
64
 
59
- If you installed with `pip install "avios-cli[login]"`, just run `avios login`
60
- (and `playwright install chromium` once). Plain `uvx avios-cli login` won't work —
61
- the isolated environment doesn't include the `login` extra.
65
+ Plain `uvx avios-cli login` won't work the isolated environment doesn't include
66
+ the `login` extra.
62
67
 
63
68
  Your session cookie is stored at `~/.config/avios/state.json` (mode `600`). It
64
69
  expires after ~a day; just run `avios login` again. Run `avios logout` to remove it.
@@ -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.2"
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,78 @@ 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()
64
+ factory = playwright_factory or _import_sync_playwright()
65
+ base_url = session.settings.base_url
66
+
67
+ with factory() as pw:
68
+ browser = _launch_browser(pw, headless=headless)
69
+ ctx = browser.new_context(user_agent=session.settings.user_agent)
70
+ page = ctx.new_page()
71
+ page.goto(f"{base_url}{DASHBOARD_PATH}")
72
+ authed = _wait_for_auth(ctx, page, base_url, timeout_ms)
73
+ cookies = list(ctx.cookies())
74
+ browser.close()
75
+
76
+ if not authed:
77
+ raise LoginError("Timed out waiting for login. Run `avios login` again.")
78
+ session.save_cookies(cookies)
79
+ return len(_only_avios(cookies))
80
+
81
+
82
+ def _import_sync_playwright() -> Any:
60
83
  try:
61
84
  from playwright.sync_api import sync_playwright
62
85
  except ImportError as exc:
63
86
  raise LoginError(
64
87
  "Playwright isn't installed. Log in using the 'login' extra:\n"
65
88
  " uvx --from 'avios-cli[login]' avios login\n"
66
- " (first time only: uvx --from 'avios-cli[login]' playwright install chromium)\n"
89
+ " (first time only: uvx --from playwright playwright install chromium)\n"
67
90
  "Or import the cookie from Chrome instead (no browser download):\n"
68
91
  " uvx --from 'avios-cli[login]' avios login --from-browser"
69
92
  ) from exc
93
+ return sync_playwright
70
94
 
71
- base_url = session.settings.base_url
72
- with sync_playwright() as pw: # pragma: no cover - requires a real browser
73
- try:
74
- browser = pw.chromium.launch(headless=headless)
75
- except Exception as exc:
76
- raise LoginError(
77
- "Couldn't launch Chromium. Install the browser once with:\n"
78
- " uvx --from 'avios-cli[login]' playwright install chromium"
79
- ) from exc
80
- ctx = browser.new_context(user_agent=session.settings.user_agent)
81
- page = ctx.new_page()
82
- page.goto(f"{base_url}{DASHBOARD_PATH}")
83
- try:
84
- page.wait_for_url("**/manage-avios/**", timeout=timeout_ms)
85
- page.wait_for_timeout(1500) # let the session cookie settle
86
- except Exception as exc:
87
- browser.close()
88
- raise LoginError("Did not reach the dashboard in time. Please try again.") from exc
89
- cookies = list(ctx.cookies())
90
- browser.close()
91
95
 
92
- session.save_cookies(cookies)
93
- return len(_only_avios(cookies))
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
+ )
94
128
 
95
129
 
96
130
  def _default_loader(browser: str) -> Callable[[], Iterable[_RawCookie]]:
@@ -110,7 +110,8 @@ def login(
110
110
  else:
111
111
  console.print(
112
112
  "Opening a browser — log in normally (password, captcha, SMS code). "
113
- "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."
114
115
  )
115
116
  count = login_via_browser(session, headless=headless)
116
117
  except LoginError as exc:
@@ -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 isn't installed"):
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