avios-cli 0.1.4__tar.gz → 0.1.6__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {avios_cli-0.1.4 → avios_cli-0.1.6}/CHANGELOG.md +36 -1
- {avios_cli-0.1.4 → avios_cli-0.1.6}/PKG-INFO +22 -10
- {avios_cli-0.1.4 → avios_cli-0.1.6}/README.md +21 -9
- {avios_cli-0.1.4 → avios_cli-0.1.6}/src/avios/__init__.py +1 -1
- {avios_cli-0.1.4 → avios_cli-0.1.6}/src/avios/auth.py +142 -13
- {avios_cli-0.1.4 → avios_cli-0.1.6}/src/avios/cli.py +49 -23
- {avios_cli-0.1.4 → avios_cli-0.1.6}/src/avios/client.py +6 -7
- {avios_cli-0.1.4 → avios_cli-0.1.6}/src/avios/models.py +7 -3
- {avios_cli-0.1.4 → avios_cli-0.1.6}/src/avios/tui/app.py +9 -3
- {avios_cli-0.1.4 → avios_cli-0.1.6}/src/avios/tui/widgets.py +2 -2
- avios_cli-0.1.6/tests/fixtures/balance.json +1 -0
- {avios_cli-0.1.4 → avios_cli-0.1.6}/tests/test_auth.py +28 -30
- {avios_cli-0.1.4 → avios_cli-0.1.6}/tests/test_cli.py +36 -13
- {avios_cli-0.1.4 → avios_cli-0.1.6}/tests/test_client.py +8 -8
- {avios_cli-0.1.4 → avios_cli-0.1.6}/tests/test_models.py +9 -7
- {avios_cli-0.1.4 → avios_cli-0.1.6}/tests/test_tui.py +1 -1
- avios_cli-0.1.4/tests/fixtures/accounts.json +0 -3
- avios_cli-0.1.4/tests/fixtures/balance.json +0 -1
- {avios_cli-0.1.4 → avios_cli-0.1.6}/.editorconfig +0 -0
- {avios_cli-0.1.4 → avios_cli-0.1.6}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {avios_cli-0.1.4 → avios_cli-0.1.6}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {avios_cli-0.1.4 → avios_cli-0.1.6}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {avios_cli-0.1.4 → avios_cli-0.1.6}/.github/dependabot.yml +0 -0
- {avios_cli-0.1.4 → avios_cli-0.1.6}/.github/workflows/ci.yml +0 -0
- {avios_cli-0.1.4 → avios_cli-0.1.6}/.github/workflows/publish.yml +0 -0
- {avios_cli-0.1.4 → avios_cli-0.1.6}/.gitignore +0 -0
- {avios_cli-0.1.4 → avios_cli-0.1.6}/.pre-commit-config.yaml +0 -0
- {avios_cli-0.1.4 → avios_cli-0.1.6}/.python-version +0 -0
- {avios_cli-0.1.4 → avios_cli-0.1.6}/CONTRIBUTING.md +0 -0
- {avios_cli-0.1.4 → avios_cli-0.1.6}/LICENSE +0 -0
- {avios_cli-0.1.4 → avios_cli-0.1.6}/SECURITY.md +0 -0
- {avios_cli-0.1.4 → avios_cli-0.1.6}/docs/dashboard.svg +0 -0
- {avios_cli-0.1.4 → avios_cli-0.1.6}/pyproject.toml +0 -0
- {avios_cli-0.1.4 → avios_cli-0.1.6}/scripts/screenshot.py +0 -0
- {avios_cli-0.1.4 → avios_cli-0.1.6}/src/avios/config.py +0 -0
- {avios_cli-0.1.4 → avios_cli-0.1.6}/src/avios/endpoints.py +0 -0
- {avios_cli-0.1.4 → avios_cli-0.1.6}/src/avios/session.py +0 -0
- {avios_cli-0.1.4 → avios_cli-0.1.6}/src/avios/tui/__init__.py +0 -0
- {avios_cli-0.1.4 → avios_cli-0.1.6}/src/avios/tui/art.py +0 -0
- {avios_cli-0.1.4 → avios_cli-0.1.6}/src/avios/tui/styles.tcss +0 -0
- {avios_cli-0.1.4 → avios_cli-0.1.6}/tests/conftest.py +0 -0
- {avios_cli-0.1.4 → avios_cli-0.1.6}/tests/fixtures/transactions.json +0 -0
- {avios_cli-0.1.4 → avios_cli-0.1.6}/tests/test_session.py +0 -0
- {avios_cli-0.1.4 → avios_cli-0.1.6}/uv.lock +0 -0
|
@@ -6,6 +6,39 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.1.6] - 2026-07-24
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- **`balance` finally works after login.** It (and `whoami`) were querying the
|
|
13
|
+
`manage-avios`/`spend-avios` micro-app APIs, which need a separate per-app
|
|
14
|
+
session and returned 401 ("Session expired") even when you were logged in.
|
|
15
|
+
Probing a real session showed the balance is served by
|
|
16
|
+
`/shell/api/users/current/accounts` (`{balance, individual, household}`) and the
|
|
17
|
+
profile by `/auth-gateway/user` — both of which authenticate with the browser
|
|
18
|
+
session. `balance`/`whoami` now use those.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
- `balance` shows total / individual / household; `whoami` shows name, tier,
|
|
22
|
+
membership and email.
|
|
23
|
+
- The TUI shows your balance even if transactions are unavailable.
|
|
24
|
+
- `transactions`, `pending` and `overview` are marked **experimental** — they need
|
|
25
|
+
the `manage-avios` app session, which the cookie/SSO login can't yet establish.
|
|
26
|
+
- Removed the `accounts` command (its endpoint is the balance source, now shown by
|
|
27
|
+
`balance`).
|
|
28
|
+
|
|
29
|
+
## [0.1.5] - 2026-07-24
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
- **`--from-browser` only read Chrome's `Default` profile.** If you were logged
|
|
33
|
+
into avios.com in another profile (Profile 1, a work profile, ...), it grabbed
|
|
34
|
+
the wrong profile's cookies and every call failed with "Session expired". It now
|
|
35
|
+
**scans all Chrome profiles, verifies which one actually authenticates**
|
|
36
|
+
(against `/auth-gateway/user`), and uses that — and reports which profile it used.
|
|
37
|
+
|
|
38
|
+
### Added
|
|
39
|
+
- `avios login --from-browser --profile "Profile 1"` to target a specific browser
|
|
40
|
+
profile, and a clear warning when the imported cookies aren't a logged-in session.
|
|
41
|
+
|
|
9
42
|
## [0.1.4] - 2026-07-24
|
|
10
43
|
|
|
11
44
|
### Fixed
|
|
@@ -74,7 +107,9 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
74
107
|
- Reward-flight **availability** search is not yet implemented (needs a British
|
|
75
108
|
Airways capture).
|
|
76
109
|
|
|
77
|
-
[Unreleased]: https://github.com/alexechoi/avios-cli/compare/v0.1.
|
|
110
|
+
[Unreleased]: https://github.com/alexechoi/avios-cli/compare/v0.1.6...HEAD
|
|
111
|
+
[0.1.6]: https://github.com/alexechoi/avios-cli/compare/v0.1.5...v0.1.6
|
|
112
|
+
[0.1.5]: https://github.com/alexechoi/avios-cli/compare/v0.1.4...v0.1.5
|
|
78
113
|
[0.1.4]: https://github.com/alexechoi/avios-cli/compare/v0.1.3...v0.1.4
|
|
79
114
|
[0.1.3]: https://github.com/alexechoi/avios-cli/compare/v0.1.2...v0.1.3
|
|
80
115
|
[0.1.2]: https://github.com/alexechoi/avios-cli/compare/v0.1.1...v0.1.2
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: avios-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.6
|
|
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
|
|
@@ -97,6 +97,14 @@ into avios.com with:
|
|
|
97
97
|
uvx --from 'avios-cli[login]' avios login --from-browser
|
|
98
98
|
```
|
|
99
99
|
|
|
100
|
+
This scans **all your Chrome profiles** and uses whichever one is actually logged
|
|
101
|
+
into avios.com (it tells you which). If you have several profiles, you can target
|
|
102
|
+
one directly:
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
uvx --from 'avios-cli[login]' avios login --from-browser --profile "Profile 1"
|
|
106
|
+
```
|
|
107
|
+
|
|
100
108
|
Plain `uvx avios-cli login` won't work — the isolated environment doesn't include
|
|
101
109
|
the `login` extra.
|
|
102
110
|
|
|
@@ -111,17 +119,21 @@ expires after ~a day; just run `avios login` again. Run `avios logout` to remove
|
|
|
111
119
|
## Usage
|
|
112
120
|
|
|
113
121
|
```bash
|
|
114
|
-
avios balance #
|
|
115
|
-
avios
|
|
116
|
-
avios
|
|
117
|
-
avios accounts # linked loyalty accounts
|
|
118
|
-
avios overview # dashboard summary (raw JSON)
|
|
119
|
-
avios whoami # your profile (raw JSON)
|
|
120
|
-
avios raw /manage-avios/api/user/current # hit any endpoint directly
|
|
122
|
+
avios balance # total / individual / household Avios
|
|
123
|
+
avios whoami # name, tier, membership, email
|
|
124
|
+
avios raw /shell/api/users/current/accounts # hit any endpoint directly
|
|
121
125
|
```
|
|
122
126
|
|
|
123
|
-
Add `--json` to `balance
|
|
124
|
-
|
|
127
|
+
Add `--json` to `balance` or `whoami` for scriptable output.
|
|
128
|
+
|
|
129
|
+
**Experimental** (need the `manage-avios` app session, which the cookie/SSO login
|
|
130
|
+
can't yet establish — these currently return "session expired"):
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
avios transactions --limit 20
|
|
134
|
+
avios pending
|
|
135
|
+
avios overview
|
|
136
|
+
```
|
|
125
137
|
|
|
126
138
|
## TUI
|
|
127
139
|
|
|
@@ -62,6 +62,14 @@ into avios.com with:
|
|
|
62
62
|
uvx --from 'avios-cli[login]' avios login --from-browser
|
|
63
63
|
```
|
|
64
64
|
|
|
65
|
+
This scans **all your Chrome profiles** and uses whichever one is actually logged
|
|
66
|
+
into avios.com (it tells you which). If you have several profiles, you can target
|
|
67
|
+
one directly:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
uvx --from 'avios-cli[login]' avios login --from-browser --profile "Profile 1"
|
|
71
|
+
```
|
|
72
|
+
|
|
65
73
|
Plain `uvx avios-cli login` won't work — the isolated environment doesn't include
|
|
66
74
|
the `login` extra.
|
|
67
75
|
|
|
@@ -76,17 +84,21 @@ expires after ~a day; just run `avios login` again. Run `avios logout` to remove
|
|
|
76
84
|
## Usage
|
|
77
85
|
|
|
78
86
|
```bash
|
|
79
|
-
avios balance #
|
|
80
|
-
avios
|
|
81
|
-
avios
|
|
82
|
-
avios accounts # linked loyalty accounts
|
|
83
|
-
avios overview # dashboard summary (raw JSON)
|
|
84
|
-
avios whoami # your profile (raw JSON)
|
|
85
|
-
avios raw /manage-avios/api/user/current # hit any endpoint directly
|
|
87
|
+
avios balance # total / individual / household Avios
|
|
88
|
+
avios whoami # name, tier, membership, email
|
|
89
|
+
avios raw /shell/api/users/current/accounts # hit any endpoint directly
|
|
86
90
|
```
|
|
87
91
|
|
|
88
|
-
Add `--json` to `balance
|
|
89
|
-
|
|
92
|
+
Add `--json` to `balance` or `whoami` for scriptable output.
|
|
93
|
+
|
|
94
|
+
**Experimental** (need the `manage-avios` app session, which the cookie/SSO login
|
|
95
|
+
can't yet establish — these currently return "session expired"):
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
avios transactions --limit 20
|
|
99
|
+
avios pending
|
|
100
|
+
avios overview
|
|
101
|
+
```
|
|
90
102
|
|
|
91
103
|
## TUI
|
|
92
104
|
|
|
@@ -17,22 +17,62 @@ Two strategies, both requiring the optional ``login`` extra
|
|
|
17
17
|
|
|
18
18
|
from __future__ import annotations
|
|
19
19
|
|
|
20
|
+
import contextlib
|
|
20
21
|
from collections.abc import Callable, Iterable
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
21
24
|
from typing import Any, Protocol
|
|
22
25
|
|
|
26
|
+
import httpx
|
|
27
|
+
|
|
23
28
|
from avios import endpoints
|
|
24
|
-
from avios.
|
|
29
|
+
from avios.config import Settings
|
|
30
|
+
from avios.session import Session, cookie_header_from
|
|
25
31
|
|
|
26
32
|
DASHBOARD_PATH = "/manage-avios/dashboard"
|
|
27
33
|
LOGIN_TIMEOUT_MS = 300_000 # 5 minutes to complete password + captcha + MFA
|
|
28
34
|
LOGIN_POLL_MS = 2_000 # how often to check whether the session is authenticated
|
|
29
35
|
SUPPORTED_BROWSERS = ("chrome", "firefox", "edge", "brave", "safari", "chromium")
|
|
30
36
|
|
|
37
|
+
# Chromium-family user-data roots per browser (relative to home), used to discover
|
|
38
|
+
# per-profile cookie databases (Default, "Profile 1", ...).
|
|
39
|
+
_CHROMIUM_ROOTS = {
|
|
40
|
+
"chrome": [
|
|
41
|
+
"Library/Application Support/Google/Chrome",
|
|
42
|
+
".config/google-chrome",
|
|
43
|
+
"AppData/Local/Google/Chrome/User Data",
|
|
44
|
+
],
|
|
45
|
+
"brave": [
|
|
46
|
+
"Library/Application Support/BraveSoftware/Brave-Browser",
|
|
47
|
+
".config/BraveSoftware/Brave-Browser",
|
|
48
|
+
"AppData/Local/BraveSoftware/Brave-Browser/User Data",
|
|
49
|
+
],
|
|
50
|
+
"edge": [
|
|
51
|
+
"Library/Application Support/Microsoft Edge",
|
|
52
|
+
".config/microsoft-edge",
|
|
53
|
+
"AppData/Local/Microsoft/Edge/User Data",
|
|
54
|
+
],
|
|
55
|
+
"chromium": [
|
|
56
|
+
"Library/Application Support/Chromium",
|
|
57
|
+
".config/chromium",
|
|
58
|
+
"AppData/Local/Chromium/User Data",
|
|
59
|
+
],
|
|
60
|
+
}
|
|
61
|
+
|
|
31
62
|
|
|
32
63
|
class LoginError(RuntimeError):
|
|
33
64
|
"""Login could not be completed."""
|
|
34
65
|
|
|
35
66
|
|
|
67
|
+
@dataclass
|
|
68
|
+
class ImportResult:
|
|
69
|
+
"""Outcome of importing cookies from a browser."""
|
|
70
|
+
|
|
71
|
+
count: int
|
|
72
|
+
profile: str | None
|
|
73
|
+
authenticated: bool
|
|
74
|
+
|
|
75
|
+
|
|
36
76
|
class _RawCookie(Protocol):
|
|
37
77
|
name: str
|
|
38
78
|
value: str
|
|
@@ -109,7 +149,9 @@ def _wait_for_auth(ctx: Any, page: Any, base_url: str, timeout_ms: int) -> bool:
|
|
|
109
149
|
|
|
110
150
|
def _is_authenticated(ctx: Any, base_url: str) -> bool:
|
|
111
151
|
try:
|
|
112
|
-
|
|
152
|
+
# Use the same endpoint `balance` reads, so a successful login guarantees
|
|
153
|
+
# the balance command will work.
|
|
154
|
+
return bool(ctx.request.get(f"{base_url}{endpoints.ACCOUNTS}").ok)
|
|
113
155
|
except Exception:
|
|
114
156
|
return False
|
|
115
157
|
|
|
@@ -143,8 +185,7 @@ def _open_login_context(pw: Any, *, headless: bool, user_data_dir: str) -> Any:
|
|
|
143
185
|
)
|
|
144
186
|
|
|
145
187
|
|
|
146
|
-
def
|
|
147
|
-
"""Return a callable that reads avios.com cookies from ``browser``."""
|
|
188
|
+
def _bc3() -> Any:
|
|
148
189
|
try:
|
|
149
190
|
import browser_cookie3 as bc3
|
|
150
191
|
except ImportError as exc:
|
|
@@ -152,30 +193,118 @@ def _default_loader(browser: str) -> Callable[[], Iterable[_RawCookie]]:
|
|
|
152
193
|
"browser-cookie3 isn't installed. Run with the 'login' extra:\n"
|
|
153
194
|
" uvx --from 'avios-cli[login]' avios login --from-browser"
|
|
154
195
|
) from exc
|
|
196
|
+
return bc3
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _browser_fn(bc3: Any, browser: str) -> Any:
|
|
155
200
|
fn = getattr(bc3, browser, None)
|
|
156
201
|
if fn is None:
|
|
157
202
|
raise LoginError(
|
|
158
203
|
f"Unknown browser '{browser}'. Choose from: {', '.join(SUPPORTED_BROWSERS)}"
|
|
159
204
|
)
|
|
160
|
-
return
|
|
205
|
+
return fn
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _profile_cookie_files(browser: str, profile: str | None) -> list[tuple[str, Path]]:
|
|
209
|
+
"""Discover (profile_name, Cookies-db) pairs for a chromium-family browser."""
|
|
210
|
+
found: list[tuple[str, Path]] = []
|
|
211
|
+
for rel in _CHROMIUM_ROOTS.get(browser, []):
|
|
212
|
+
root = Path.home() / rel
|
|
213
|
+
if not root.exists():
|
|
214
|
+
continue
|
|
215
|
+
for prof in sorted(p for p in root.iterdir() if p.is_dir()):
|
|
216
|
+
if profile and prof.name != profile:
|
|
217
|
+
continue
|
|
218
|
+
for db in (prof / "Cookies", prof / "Network" / "Cookies"):
|
|
219
|
+
if db.exists():
|
|
220
|
+
found.append((prof.name, db))
|
|
221
|
+
break
|
|
222
|
+
return found
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _candidate_cookie_sets(
|
|
226
|
+
browser: str, profile: str | None
|
|
227
|
+
) -> list[tuple[str, list[dict[str, Any]]]]:
|
|
228
|
+
"""Return [(profile_name, avios_cookies)] to try, most specific first."""
|
|
229
|
+
bc3 = _bc3()
|
|
230
|
+
fn = _browser_fn(bc3, browser)
|
|
231
|
+
candidates: list[tuple[str, list[dict[str, Any]]]] = []
|
|
232
|
+
for name, db in _profile_cookie_files(browser, profile):
|
|
233
|
+
try:
|
|
234
|
+
raw = fn(domain_name="avios.com", cookie_file=str(db))
|
|
235
|
+
except Exception:
|
|
236
|
+
continue
|
|
237
|
+
candidates.append((name, _only_avios(_to_cookie_dicts(raw))))
|
|
238
|
+
if not profile:
|
|
239
|
+
# Fallback to browser_cookie3's own default lookup.
|
|
240
|
+
with contextlib.suppress(Exception):
|
|
241
|
+
raw = fn(domain_name="avios.com")
|
|
242
|
+
candidates.append(("default", _only_avios(_to_cookie_dicts(raw))))
|
|
243
|
+
return candidates
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _cookies_authenticate(settings: Settings, cookies: list[dict[str, Any]]) -> bool:
|
|
247
|
+
"""True if these cookies yield a 200 from the auth endpoint."""
|
|
248
|
+
header = cookie_header_from(cookies)
|
|
249
|
+
if not header:
|
|
250
|
+
return False
|
|
251
|
+
try:
|
|
252
|
+
with httpx.Client(
|
|
253
|
+
base_url=settings.base_url,
|
|
254
|
+
timeout=15.0,
|
|
255
|
+
follow_redirects=False,
|
|
256
|
+
headers={
|
|
257
|
+
"accept": "application/json",
|
|
258
|
+
"user-agent": settings.user_agent,
|
|
259
|
+
"cookie": header,
|
|
260
|
+
},
|
|
261
|
+
) as client:
|
|
262
|
+
return client.get(endpoints.ACCOUNTS).status_code == 200
|
|
263
|
+
except httpx.HTTPError:
|
|
264
|
+
return False
|
|
161
265
|
|
|
162
266
|
|
|
163
267
|
def import_from_browser(
|
|
164
268
|
session: Session | None = None,
|
|
165
269
|
browser: str = "chrome",
|
|
166
270
|
*,
|
|
271
|
+
profile: str | None = None,
|
|
167
272
|
loader: Callable[[], Iterable[_RawCookie]] | None = None,
|
|
168
|
-
|
|
169
|
-
|
|
273
|
+
authenticator: Callable[[list[dict[str, Any]]], bool] | None = None,
|
|
274
|
+
) -> ImportResult:
|
|
275
|
+
"""Import the avios.com session cookie from a browser profile.
|
|
170
276
|
|
|
171
|
-
|
|
277
|
+
Scans the browser's profiles, prefers the one whose cookies actually
|
|
278
|
+
authenticate, and saves them. ``loader``/``authenticator`` are test seams.
|
|
172
279
|
"""
|
|
173
280
|
session = session or Session()
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
if not
|
|
281
|
+
authenticate = authenticator or (lambda c: _cookies_authenticate(session.settings, c))
|
|
282
|
+
|
|
283
|
+
if loader is not None:
|
|
284
|
+
candidates: list[tuple[str, list[dict[str, Any]]]] = [
|
|
285
|
+
("browser", _only_avios(_to_cookie_dicts(loader())))
|
|
286
|
+
]
|
|
287
|
+
else:
|
|
288
|
+
candidates = _candidate_cookie_sets(browser, profile)
|
|
289
|
+
|
|
290
|
+
working: tuple[str, list[dict[str, Any]]] | None = None
|
|
291
|
+
fallback: tuple[str, list[dict[str, Any]]] | None = None
|
|
292
|
+
for name, cookies in candidates:
|
|
293
|
+
if not cookies:
|
|
294
|
+
continue
|
|
295
|
+
if fallback is None:
|
|
296
|
+
fallback = (name, cookies)
|
|
297
|
+
if authenticate(cookies):
|
|
298
|
+
working = (name, cookies)
|
|
299
|
+
break
|
|
300
|
+
|
|
301
|
+
chosen = working or fallback
|
|
302
|
+
if chosen is None:
|
|
303
|
+
where = f"profile '{profile}'" if profile else browser
|
|
177
304
|
raise LoginError(
|
|
178
|
-
f"No avios.com cookies found in {
|
|
305
|
+
f"No avios.com cookies found in {where}. Log into avios.com in that "
|
|
306
|
+
"browser first (and check the profile with --profile)."
|
|
179
307
|
)
|
|
308
|
+
name, cookies = chosen
|
|
180
309
|
session.save_cookies(cookies)
|
|
181
|
-
return len(cookies)
|
|
310
|
+
return ImportResult(count=len(cookies), profile=name, authenticated=working is not None)
|
|
@@ -98,6 +98,9 @@ def login(
|
|
|
98
98
|
False, "--from-browser", help="Import the cookie from a running browser (no popup)."
|
|
99
99
|
),
|
|
100
100
|
browser: str = typer.Option("chrome", help="Browser to import from (with --from-browser)."),
|
|
101
|
+
profile: str | None = typer.Option(
|
|
102
|
+
None, help="Browser profile name for --from-browser (e.g. 'Default', 'Profile 1')."
|
|
103
|
+
),
|
|
101
104
|
headless: bool = typer.Option(
|
|
102
105
|
False, help="Run the login browser headless (only works without captcha/MFA)."
|
|
103
106
|
),
|
|
@@ -106,7 +109,19 @@ def login(
|
|
|
106
109
|
session = Session()
|
|
107
110
|
try:
|
|
108
111
|
if from_browser:
|
|
109
|
-
|
|
112
|
+
result = import_from_browser(session, browser, profile=profile)
|
|
113
|
+
where = f" from {browser} profile '{result.profile}'" if result.profile else ""
|
|
114
|
+
if not result.authenticated:
|
|
115
|
+
console.print(
|
|
116
|
+
f"[yellow]Imported {result.count} cookie(s){where}, but they're not a "
|
|
117
|
+
"logged-in avios session.[/]"
|
|
118
|
+
)
|
|
119
|
+
console.print(
|
|
120
|
+
"Log into avios.com in that profile, pick another with "
|
|
121
|
+
"[bold]--profile[/], or use [bold]avios login[/] (browser)."
|
|
122
|
+
)
|
|
123
|
+
raise typer.Exit(2)
|
|
124
|
+
console.print(f"[green]Logged in{where}.[/] Saved {result.count} cookie(s).")
|
|
110
125
|
else:
|
|
111
126
|
console.print(
|
|
112
127
|
"Opening a browser — log in normally (password, captcha, SMS code). "
|
|
@@ -114,11 +129,11 @@ def login(
|
|
|
114
129
|
"captures your session automatically."
|
|
115
130
|
)
|
|
116
131
|
count = login_via_browser(session, headless=headless)
|
|
132
|
+
console.print(f"[green]Logged in.[/] Saved {count} avios cookie(s).")
|
|
117
133
|
except LoginError as exc:
|
|
118
134
|
console.print(f"[red]{escape(str(exc))}[/]")
|
|
119
135
|
raise typer.Exit(1) from exc
|
|
120
136
|
|
|
121
|
-
console.print(f"[green]Logged in.[/] Saved {count} avios cookie(s).")
|
|
122
137
|
try:
|
|
123
138
|
balance = AviosClient(session).get_balance()
|
|
124
139
|
console.print(
|
|
@@ -150,8 +165,10 @@ def balance(json_out: bool = JSON_OPTION) -> None:
|
|
|
150
165
|
def _render_balance(result: Balance) -> None:
|
|
151
166
|
table = Table(show_header=False, box=None)
|
|
152
167
|
table.add_row("[bold]Avios[/]", f"[bold cyan]{result.balance:,}[/]")
|
|
153
|
-
if result.
|
|
154
|
-
table.add_row("
|
|
168
|
+
if result.individual is not None:
|
|
169
|
+
table.add_row("Individual", f"{result.individual:,}")
|
|
170
|
+
if result.household is not None:
|
|
171
|
+
table.add_row("Household", f"{result.household:,}")
|
|
155
172
|
console.print(table)
|
|
156
173
|
|
|
157
174
|
|
|
@@ -160,7 +177,7 @@ def transactions(
|
|
|
160
177
|
limit: int = typer.Option(20, help="Number of transactions to show."),
|
|
161
178
|
json_out: bool = JSON_OPTION,
|
|
162
179
|
) -> None:
|
|
163
|
-
"""List recent Avios transactions."""
|
|
180
|
+
"""List recent Avios transactions (experimental; needs the manage-avios session)."""
|
|
164
181
|
with _handle_errors():
|
|
165
182
|
items = _client().get_transactions(limit=limit)
|
|
166
183
|
records = [item.as_dict() for item in items]
|
|
@@ -172,7 +189,7 @@ def transactions(
|
|
|
172
189
|
|
|
173
190
|
@app.command()
|
|
174
191
|
def pending(json_out: bool = JSON_OPTION) -> None:
|
|
175
|
-
"""List pending Avios transactions."""
|
|
192
|
+
"""List pending Avios transactions (experimental; needs the manage-avios session)."""
|
|
176
193
|
with _handle_errors():
|
|
177
194
|
items = _client().get_pending_transactions()
|
|
178
195
|
records = [item.as_dict() for item in items]
|
|
@@ -182,32 +199,41 @@ def pending(json_out: bool = JSON_OPTION) -> None:
|
|
|
182
199
|
_render_records(records, "Pending")
|
|
183
200
|
|
|
184
201
|
|
|
185
|
-
@app.command()
|
|
186
|
-
def accounts(json_out: bool = JSON_OPTION) -> None:
|
|
187
|
-
"""List linked loyalty accounts."""
|
|
188
|
-
with _handle_errors():
|
|
189
|
-
items = _client().get_accounts()
|
|
190
|
-
records = [item.as_dict() for item in items]
|
|
191
|
-
if json_out:
|
|
192
|
-
_print_json(records)
|
|
193
|
-
return
|
|
194
|
-
_render_records(records, "Accounts")
|
|
195
|
-
|
|
196
|
-
|
|
197
202
|
@app.command()
|
|
198
203
|
def overview() -> None:
|
|
199
|
-
"""Show the dashboard overview (
|
|
204
|
+
"""Show the dashboard overview (experimental; needs the manage-avios session)."""
|
|
200
205
|
with _handle_errors():
|
|
201
206
|
result = _client().get_overview()
|
|
202
207
|
_print_json(result.as_dict())
|
|
203
208
|
|
|
204
209
|
|
|
205
210
|
@app.command()
|
|
206
|
-
def whoami() -> None:
|
|
207
|
-
"""Show your profile (
|
|
211
|
+
def whoami(json_out: bool = JSON_OPTION) -> None:
|
|
212
|
+
"""Show your profile (name, tier, membership)."""
|
|
208
213
|
with _handle_errors():
|
|
209
|
-
|
|
210
|
-
|
|
214
|
+
data = _client().get_profile().as_dict()
|
|
215
|
+
if json_out:
|
|
216
|
+
_print_json(data)
|
|
217
|
+
return
|
|
218
|
+
claims = data.get("tokenContent", {})
|
|
219
|
+
|
|
220
|
+
def claim(key: str) -> str:
|
|
221
|
+
return str(claims.get(f"https://avios.com/{key}", "") or "")
|
|
222
|
+
|
|
223
|
+
table = Table(show_header=False, box=None)
|
|
224
|
+
name = claims.get("name") or f"{claims.get('given_name', '')} {claims.get('family_name', '')}"
|
|
225
|
+
for label, value in (
|
|
226
|
+
("Name", str(name).strip()),
|
|
227
|
+
("Tier", claim("customer_tier_name")),
|
|
228
|
+
("Membership", claim("membership_id")),
|
|
229
|
+
("Email", str(claims.get("email", "") or "")),
|
|
230
|
+
):
|
|
231
|
+
if value:
|
|
232
|
+
table.add_row(f"[bold]{label}[/]", value)
|
|
233
|
+
if table.row_count:
|
|
234
|
+
console.print(table)
|
|
235
|
+
else:
|
|
236
|
+
_print_json(data)
|
|
211
237
|
|
|
212
238
|
|
|
213
239
|
@app.command()
|
|
@@ -10,7 +10,7 @@ from __future__ import annotations
|
|
|
10
10
|
from typing import Any
|
|
11
11
|
|
|
12
12
|
from avios import endpoints
|
|
13
|
-
from avios.models import
|
|
13
|
+
from avios.models import Balance, Overview, Profile, Transaction
|
|
14
14
|
from avios.session import Session
|
|
15
15
|
|
|
16
16
|
|
|
@@ -36,18 +36,17 @@ class AviosClient:
|
|
|
36
36
|
self.session = session or Session()
|
|
37
37
|
|
|
38
38
|
def get_balance(self) -> Balance:
|
|
39
|
-
|
|
39
|
+
# /shell/api/users/current/accounts authenticates with the SSO session and
|
|
40
|
+
# returns {balance, individual, household}.
|
|
41
|
+
return Balance.model_validate(self.session.get_json(endpoints.ACCOUNTS))
|
|
40
42
|
|
|
41
43
|
def get_profile(self) -> Profile:
|
|
42
|
-
|
|
44
|
+
# /auth-gateway/user returns the SSO user (idToken + tokenContent claims).
|
|
45
|
+
return Profile.model_validate(self.session.get_json(endpoints.AUTH_USER))
|
|
43
46
|
|
|
44
47
|
def get_overview(self) -> Overview:
|
|
45
48
|
return Overview.model_validate(self.session.get_json(endpoints.OVERVIEW))
|
|
46
49
|
|
|
47
|
-
def get_accounts(self) -> list[Account]:
|
|
48
|
-
payload = self.session.get_json(endpoints.ACCOUNTS)
|
|
49
|
-
return [Account.model_validate(item) for item in _extract_list(payload)]
|
|
50
|
-
|
|
51
50
|
def get_transactions(self, limit: int | None = None) -> list[Transaction]:
|
|
52
51
|
payload = self.session.get_json(endpoints.TRANSACTIONS)
|
|
53
52
|
items = _extract_list(payload)
|
|
@@ -11,7 +11,7 @@ from __future__ import annotations
|
|
|
11
11
|
|
|
12
12
|
from typing import Any
|
|
13
13
|
|
|
14
|
-
from pydantic import BaseModel, ConfigDict
|
|
14
|
+
from pydantic import BaseModel, ConfigDict
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
class AviosModel(BaseModel):
|
|
@@ -25,10 +25,14 @@ class AviosModel(BaseModel):
|
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
class Balance(AviosModel):
|
|
28
|
-
"""Avios balance (from ``/
|
|
28
|
+
"""Avios balance (from ``/shell/api/users/current/accounts``).
|
|
29
|
+
|
|
30
|
+
Shape: ``{"balance": N, "individual": N, "household": N}``.
|
|
31
|
+
"""
|
|
29
32
|
|
|
30
33
|
balance: int
|
|
31
|
-
|
|
34
|
+
individual: int | None = None
|
|
35
|
+
household: int | None = None
|
|
32
36
|
|
|
33
37
|
|
|
34
38
|
class Transaction(AviosModel):
|
|
@@ -61,9 +61,6 @@ class AviosApp(App[None]):
|
|
|
61
61
|
async def _load(self) -> None:
|
|
62
62
|
try:
|
|
63
63
|
balance = await asyncio.to_thread(self._client.get_balance)
|
|
64
|
-
transactions = await asyncio.to_thread(
|
|
65
|
-
self._client.get_transactions, TRANSACTIONS_TO_SHOW
|
|
66
|
-
)
|
|
67
64
|
except (NotAuthenticated, SessionExpired) as exc:
|
|
68
65
|
self._show_error(f"{exc} — run `avios login`")
|
|
69
66
|
return
|
|
@@ -71,6 +68,15 @@ class AviosApp(App[None]):
|
|
|
71
68
|
self._show_error(f"Request failed: {exc}")
|
|
72
69
|
return
|
|
73
70
|
self.query_one("#balance", BalanceDisplay).update_balance(balance)
|
|
71
|
+
|
|
72
|
+
# Transactions are experimental (manage-avios session); degrade gracefully
|
|
73
|
+
# so the balance still shows if they're unavailable.
|
|
74
|
+
try:
|
|
75
|
+
transactions = await asyncio.to_thread(
|
|
76
|
+
self._client.get_transactions, TRANSACTIONS_TO_SHOW
|
|
77
|
+
)
|
|
78
|
+
except (NotAuthenticated, SessionExpired, httpx.HTTPError):
|
|
79
|
+
transactions = []
|
|
74
80
|
self._populate_transactions(transactions)
|
|
75
81
|
|
|
76
82
|
def _show_error(self, message: str) -> None:
|
|
@@ -20,8 +20,8 @@ class BalanceDisplay(Static):
|
|
|
20
20
|
text = Text()
|
|
21
21
|
text.append("Avios ", style="bold")
|
|
22
22
|
text.append(f"{balance.balance:,}", style="bold cyan")
|
|
23
|
-
if balance.
|
|
24
|
-
text.append(f" Household {balance.
|
|
23
|
+
if balance.household is not None:
|
|
24
|
+
text.append(f" Household {balance.household:,}", style="dim")
|
|
25
25
|
self.last_text = text.plain
|
|
26
26
|
self.update(text)
|
|
27
27
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"balance": 75751, "individual": 75751, "household": 112430}
|
|
@@ -14,8 +14,9 @@ import types
|
|
|
14
14
|
import pytest
|
|
15
15
|
|
|
16
16
|
from avios.auth import (
|
|
17
|
+
ImportResult,
|
|
17
18
|
LoginError,
|
|
18
|
-
|
|
19
|
+
_browser_fn,
|
|
19
20
|
_only_avios,
|
|
20
21
|
_open_login_context,
|
|
21
22
|
_to_cookie_dicts,
|
|
@@ -52,52 +53,49 @@ def test_only_avios_filters_foreign_domains() -> None:
|
|
|
52
53
|
assert _only_avios(cookies) == [cookies[0]]
|
|
53
54
|
|
|
54
55
|
|
|
55
|
-
def
|
|
56
|
+
def test_import_from_browser_uses_authenticated_cookies(settings: Settings) -> None:
|
|
56
57
|
session = _session(settings)
|
|
57
|
-
|
|
58
|
+
result = import_from_browser(
|
|
58
59
|
session,
|
|
59
60
|
loader=lambda: [
|
|
60
61
|
FakeCookie("appSession", "abc", "www.avios.com"),
|
|
61
62
|
FakeCookie("_ga", "junk", ".google.com"),
|
|
62
63
|
],
|
|
64
|
+
authenticator=lambda cookies: True,
|
|
63
65
|
)
|
|
64
|
-
assert
|
|
65
|
-
assert
|
|
66
|
+
assert isinstance(result, ImportResult)
|
|
67
|
+
assert result.count == 1 # only the avios cookie
|
|
68
|
+
assert result.authenticated is True
|
|
66
69
|
assert session.load_cookies() == [
|
|
67
70
|
{"name": "appSession", "value": "abc", "domain": "www.avios.com"}
|
|
68
71
|
]
|
|
69
72
|
|
|
70
73
|
|
|
71
|
-
def
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
monkeypatch.setitem(sys.modules, "browser_cookie3", module)
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
def test_default_loader_passes_domain(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
84
|
-
seen: dict[str, str] = {}
|
|
74
|
+
def test_import_from_browser_reports_unauthenticated(settings: Settings) -> None:
|
|
75
|
+
session = _session(settings)
|
|
76
|
+
result = import_from_browser(
|
|
77
|
+
session,
|
|
78
|
+
loader=lambda: [FakeCookie("appSession", "stale", "www.avios.com")],
|
|
79
|
+
authenticator=lambda cookies: False, # cookies exist but don't authenticate
|
|
80
|
+
)
|
|
81
|
+
assert result.count == 1
|
|
82
|
+
assert result.authenticated is False # saved as best-effort, flagged as not working
|
|
85
83
|
|
|
86
|
-
def chrome(**kwargs: str) -> list[FakeCookie]:
|
|
87
|
-
seen.update(kwargs)
|
|
88
|
-
return [FakeCookie("appSession", "x", "www.avios.com")]
|
|
89
84
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
85
|
+
def test_import_from_browser_no_cookies_raises(settings: Settings) -> None:
|
|
86
|
+
with pytest.raises(LoginError, match="No avios.com cookies"):
|
|
87
|
+
import_from_browser(
|
|
88
|
+
_session(settings),
|
|
89
|
+
loader=lambda: [FakeCookie("_ga", "j", ".x.com")],
|
|
90
|
+
authenticator=lambda cookies: True,
|
|
91
|
+
)
|
|
95
92
|
|
|
96
93
|
|
|
97
|
-
def
|
|
98
|
-
|
|
94
|
+
def test_browser_fn_unknown_browser() -> None:
|
|
95
|
+
bc3 = types.ModuleType("browser_cookie3")
|
|
96
|
+
bc3.chrome = lambda **k: [] # type: ignore[attr-defined]
|
|
99
97
|
with pytest.raises(LoginError, match="Unknown browser"):
|
|
100
|
-
|
|
98
|
+
_browser_fn(bc3, "nope")
|
|
101
99
|
|
|
102
100
|
|
|
103
101
|
def test_login_via_browser_without_playwright(
|
|
@@ -13,9 +13,9 @@ import pytest
|
|
|
13
13
|
from typer.testing import CliRunner
|
|
14
14
|
|
|
15
15
|
from avios import __version__
|
|
16
|
-
from avios.auth import LoginError
|
|
16
|
+
from avios.auth import ImportResult, LoginError
|
|
17
17
|
from avios.cli import app
|
|
18
|
-
from avios.models import
|
|
18
|
+
from avios.models import Balance, Overview, Profile, Transaction
|
|
19
19
|
from avios.session import NotAuthenticated, SessionExpired
|
|
20
20
|
|
|
21
21
|
runner = CliRunner()
|
|
@@ -28,7 +28,7 @@ class FakeClient:
|
|
|
28
28
|
pass
|
|
29
29
|
|
|
30
30
|
def get_balance(self) -> Balance:
|
|
31
|
-
return Balance(balance=100,
|
|
31
|
+
return Balance(balance=100, individual=100, household=200)
|
|
32
32
|
|
|
33
33
|
def get_transactions(self, limit: int | None = None) -> list[Transaction]:
|
|
34
34
|
items = [
|
|
@@ -40,14 +40,20 @@ class FakeClient:
|
|
|
40
40
|
def get_pending_transactions(self) -> list[Transaction]:
|
|
41
41
|
return []
|
|
42
42
|
|
|
43
|
-
def get_accounts(self) -> list[Account]:
|
|
44
|
-
return [Account.model_validate({"programme": "BAEC", "balance": 100})]
|
|
45
|
-
|
|
46
43
|
def get_overview(self) -> Overview:
|
|
47
44
|
return Overview.model_validate({"tier": "Blue"})
|
|
48
45
|
|
|
49
46
|
def get_profile(self) -> Profile:
|
|
50
|
-
return Profile.model_validate(
|
|
47
|
+
return Profile.model_validate(
|
|
48
|
+
{
|
|
49
|
+
"idToken": "jwt",
|
|
50
|
+
"tokenContent": {
|
|
51
|
+
"name": "Alex Choi",
|
|
52
|
+
"https://avios.com/customer_tier_name": "Gold",
|
|
53
|
+
"https://avios.com/membership_id": "BA123",
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
)
|
|
51
57
|
|
|
52
58
|
def raw(self, path: str) -> dict[str, str]:
|
|
53
59
|
return {"path": path}
|
|
@@ -81,7 +87,7 @@ def test_balance_json(fake_client: None) -> None:
|
|
|
81
87
|
result = runner.invoke(app, ["balance", "--json"])
|
|
82
88
|
assert result.exit_code == 0
|
|
83
89
|
assert '"balance"' in result.stdout
|
|
84
|
-
assert '"
|
|
90
|
+
assert '"household"' in result.stdout
|
|
85
91
|
|
|
86
92
|
|
|
87
93
|
def test_transactions_respects_limit(fake_client: None) -> None:
|
|
@@ -97,10 +103,12 @@ def test_transactions_json(fake_client: None) -> None:
|
|
|
97
103
|
assert '"avios"' in result.stdout
|
|
98
104
|
|
|
99
105
|
|
|
100
|
-
def
|
|
101
|
-
result = runner.invoke(app, ["
|
|
106
|
+
def test_whoami(fake_client: None) -> None:
|
|
107
|
+
result = runner.invoke(app, ["whoami"])
|
|
102
108
|
assert result.exit_code == 0
|
|
103
|
-
|
|
109
|
+
out = " ".join(result.stdout.split())
|
|
110
|
+
assert "Alex Choi" in out
|
|
111
|
+
assert "Gold" in out
|
|
104
112
|
|
|
105
113
|
|
|
106
114
|
def test_raw(fake_client: None) -> None:
|
|
@@ -134,16 +142,31 @@ def test_session_expired(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
134
142
|
|
|
135
143
|
# -- login / logout ----------------------------------------------------------
|
|
136
144
|
def test_login_from_browser(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
137
|
-
monkeypatch.setattr(
|
|
145
|
+
monkeypatch.setattr(
|
|
146
|
+
"avios.cli.import_from_browser",
|
|
147
|
+
lambda session, browser, profile=None: ImportResult(2, "Default", True),
|
|
148
|
+
)
|
|
138
149
|
monkeypatch.setattr("avios.cli.AviosClient", FakeClient)
|
|
139
150
|
result = runner.invoke(app, ["login", "--from-browser"])
|
|
140
151
|
assert result.exit_code == 0
|
|
141
152
|
assert "Logged in" in result.stdout
|
|
153
|
+
assert "Default" in result.stdout
|
|
142
154
|
assert "Balance" in result.stdout
|
|
143
155
|
|
|
144
156
|
|
|
157
|
+
def test_login_from_browser_unauthenticated(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
158
|
+
monkeypatch.setattr(
|
|
159
|
+
"avios.cli.import_from_browser",
|
|
160
|
+
lambda session, browser, profile=None: ImportResult(20, "Default", False),
|
|
161
|
+
)
|
|
162
|
+
result = runner.invoke(app, ["login", "--from-browser"])
|
|
163
|
+
assert result.exit_code == 2
|
|
164
|
+
normalized = " ".join(result.stdout.split()) # Rich wraps lines
|
|
165
|
+
assert "not a logged-in avios session" in normalized
|
|
166
|
+
|
|
167
|
+
|
|
145
168
|
def test_login_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
146
|
-
def boom(session: Any, browser: str) ->
|
|
169
|
+
def boom(session: Any, browser: str, profile: str | None = None) -> ImportResult:
|
|
147
170
|
raise LoginError("nope")
|
|
148
171
|
|
|
149
172
|
monkeypatch.setattr("avios.cli.import_from_browser", boom)
|
|
@@ -34,10 +34,10 @@ def _client(settings: Settings, routes: dict[str, Any]) -> AviosClient:
|
|
|
34
34
|
|
|
35
35
|
|
|
36
36
|
def test_get_balance(settings: Settings, load_fixture: Callable[[str], Any]) -> None:
|
|
37
|
-
client = _client(settings, {endpoints.
|
|
37
|
+
client = _client(settings, {endpoints.ACCOUNTS: load_fixture("balance.json")})
|
|
38
38
|
balance = client.get_balance()
|
|
39
39
|
assert balance.balance == 75751
|
|
40
|
-
assert balance.
|
|
40
|
+
assert balance.household == 112430
|
|
41
41
|
|
|
42
42
|
|
|
43
43
|
def test_get_transactions_from_bare_list(
|
|
@@ -64,11 +64,11 @@ def test_get_transactions_from_wrapped_object(settings: Settings) -> None:
|
|
|
64
64
|
assert txns[0].as_dict()["avios"] == 100
|
|
65
65
|
|
|
66
66
|
|
|
67
|
-
def
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
assert
|
|
67
|
+
def test_get_profile(settings: Settings) -> None:
|
|
68
|
+
payload = {"idToken": "jwt", "tokenContent": {"name": "Alex"}}
|
|
69
|
+
client = _client(settings, {endpoints.AUTH_USER: payload})
|
|
70
|
+
profile = client.get_profile()
|
|
71
|
+
assert profile.as_dict()["tokenContent"]["name"] == "Alex"
|
|
72
72
|
|
|
73
73
|
|
|
74
74
|
def test_raw_normalises_leading_slash(settings: Settings) -> None:
|
|
@@ -77,7 +77,7 @@ def test_raw_normalises_leading_slash(settings: Settings) -> None:
|
|
|
77
77
|
|
|
78
78
|
|
|
79
79
|
def test_expired_session_propagates(settings: Settings) -> None:
|
|
80
|
-
client = _client(settings, {endpoints.
|
|
80
|
+
client = _client(settings, {endpoints.ACCOUNTS: 302})
|
|
81
81
|
with pytest.raises(SessionExpired):
|
|
82
82
|
client.get_balance()
|
|
83
83
|
|
|
@@ -8,20 +8,22 @@ from typing import Any
|
|
|
8
8
|
from avios.models import Balance, Transaction
|
|
9
9
|
|
|
10
10
|
|
|
11
|
-
def
|
|
11
|
+
def test_balance_parses_shell_shape(load_fixture: Callable[[str], Any]) -> None:
|
|
12
12
|
balance = Balance.model_validate(load_fixture("balance.json"))
|
|
13
13
|
assert balance.balance == 75751
|
|
14
|
-
assert balance.
|
|
14
|
+
assert balance.individual == 75751
|
|
15
|
+
assert balance.household == 112430
|
|
15
16
|
|
|
16
17
|
|
|
17
|
-
def
|
|
18
|
-
balance = Balance(balance=100,
|
|
19
|
-
assert balance.as_dict() == {"balance": 100, "
|
|
18
|
+
def test_balance_round_trips() -> None:
|
|
19
|
+
balance = Balance(balance=100, individual=100, household=200)
|
|
20
|
+
assert balance.as_dict() == {"balance": 100, "individual": 100, "household": 200}
|
|
20
21
|
|
|
21
22
|
|
|
22
|
-
def
|
|
23
|
+
def test_balance_extras_are_optional() -> None:
|
|
23
24
|
balance = Balance.model_validate({"balance": 42})
|
|
24
|
-
assert balance.
|
|
25
|
+
assert balance.individual is None
|
|
26
|
+
assert balance.household is None
|
|
25
27
|
|
|
26
28
|
|
|
27
29
|
def test_extra_fields_are_preserved() -> None:
|
|
@@ -15,7 +15,7 @@ from avios.tui.widgets import BalanceDisplay
|
|
|
15
15
|
|
|
16
16
|
class FakeClient:
|
|
17
17
|
def get_balance(self) -> Balance:
|
|
18
|
-
return Balance(balance=100,
|
|
18
|
+
return Balance(balance=100, individual=100, household=200)
|
|
19
19
|
|
|
20
20
|
def get_transactions(self, limit: int | None = None) -> list[Transaction]:
|
|
21
21
|
return [
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"balance": 75751, "householdAviosBalance": 75752}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|