avios-cli 0.1.5__tar.gz → 0.2.0__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.5 → avios_cli-0.2.0}/CHANGELOG.md +36 -1
- {avios_cli-0.1.5 → avios_cli-0.2.0}/PKG-INFO +8 -9
- {avios_cli-0.1.5 → avios_cli-0.2.0}/README.md +7 -8
- {avios_cli-0.1.5 → avios_cli-0.2.0}/src/avios/__init__.py +1 -1
- {avios_cli-0.1.5 → avios_cli-0.2.0}/src/avios/auth.py +4 -2
- {avios_cli-0.1.5 → avios_cli-0.2.0}/src/avios/cli.py +54 -39
- {avios_cli-0.1.5 → avios_cli-0.2.0}/src/avios/client.py +15 -11
- {avios_cli-0.1.5 → avios_cli-0.2.0}/src/avios/config.py +3 -0
- {avios_cli-0.1.5 → avios_cli-0.2.0}/src/avios/models.py +23 -7
- {avios_cli-0.1.5 → avios_cli-0.2.0}/src/avios/session.py +2 -0
- {avios_cli-0.1.5 → avios_cli-0.2.0}/src/avios/tui/app.py +17 -10
- {avios_cli-0.1.5 → avios_cli-0.2.0}/src/avios/tui/widgets.py +2 -2
- avios_cli-0.2.0/tests/fixtures/balance.json +1 -0
- avios_cli-0.2.0/tests/fixtures/transactions.json +25 -0
- {avios_cli-0.1.5 → avios_cli-0.2.0}/tests/test_cli.py +38 -15
- avios_cli-0.2.0/tests/test_client.py +81 -0
- avios_cli-0.2.0/tests/test_models.py +53 -0
- {avios_cli-0.1.5 → avios_cli-0.2.0}/tests/test_session.py +1 -0
- {avios_cli-0.1.5 → avios_cli-0.2.0}/tests/test_tui.py +8 -4
- avios_cli-0.1.5/tests/fixtures/accounts.json +0 -3
- avios_cli-0.1.5/tests/fixtures/balance.json +0 -1
- avios_cli-0.1.5/tests/fixtures/transactions.json +0 -5
- avios_cli-0.1.5/tests/test_client.py +0 -89
- avios_cli-0.1.5/tests/test_models.py +0 -32
- {avios_cli-0.1.5 → avios_cli-0.2.0}/.editorconfig +0 -0
- {avios_cli-0.1.5 → avios_cli-0.2.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {avios_cli-0.1.5 → avios_cli-0.2.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {avios_cli-0.1.5 → avios_cli-0.2.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {avios_cli-0.1.5 → avios_cli-0.2.0}/.github/dependabot.yml +0 -0
- {avios_cli-0.1.5 → avios_cli-0.2.0}/.github/workflows/ci.yml +0 -0
- {avios_cli-0.1.5 → avios_cli-0.2.0}/.github/workflows/publish.yml +0 -0
- {avios_cli-0.1.5 → avios_cli-0.2.0}/.gitignore +0 -0
- {avios_cli-0.1.5 → avios_cli-0.2.0}/.pre-commit-config.yaml +0 -0
- {avios_cli-0.1.5 → avios_cli-0.2.0}/.python-version +0 -0
- {avios_cli-0.1.5 → avios_cli-0.2.0}/CONTRIBUTING.md +0 -0
- {avios_cli-0.1.5 → avios_cli-0.2.0}/LICENSE +0 -0
- {avios_cli-0.1.5 → avios_cli-0.2.0}/SECURITY.md +0 -0
- {avios_cli-0.1.5 → avios_cli-0.2.0}/docs/dashboard.svg +0 -0
- {avios_cli-0.1.5 → avios_cli-0.2.0}/pyproject.toml +0 -0
- {avios_cli-0.1.5 → avios_cli-0.2.0}/scripts/screenshot.py +0 -0
- {avios_cli-0.1.5 → avios_cli-0.2.0}/src/avios/endpoints.py +0 -0
- {avios_cli-0.1.5 → avios_cli-0.2.0}/src/avios/tui/__init__.py +0 -0
- {avios_cli-0.1.5 → avios_cli-0.2.0}/src/avios/tui/art.py +0 -0
- {avios_cli-0.1.5 → avios_cli-0.2.0}/src/avios/tui/styles.tcss +0 -0
- {avios_cli-0.1.5 → avios_cli-0.2.0}/tests/conftest.py +0 -0
- {avios_cli-0.1.5 → avios_cli-0.2.0}/tests/test_auth.py +0 -0
- {avios_cli-0.1.5 → avios_cli-0.2.0}/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.2.0] - 2026-07-24
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- **`transactions`, `pending` and `overview` now work.** The manage-avios API
|
|
13
|
+
only needed an `x-avios-opco` header (not a separate session, as previously
|
|
14
|
+
thought) — it's now sent automatically. `transactions` renders date /
|
|
15
|
+
description / Avios (coloured +/-) / type and respects `--limit`; the TUI shows
|
|
16
|
+
real transactions too.
|
|
17
|
+
- Typed `Transaction` model (identifier, dateProcessed, description, type,
|
|
18
|
+
partner, amount, categories) and a `NamedRef` helper.
|
|
19
|
+
- `AVIOS_OPCO` setting (default `BAEC` = British Airways Executive Club) for other
|
|
20
|
+
Avios programmes.
|
|
21
|
+
|
|
22
|
+
## [0.1.6] - 2026-07-24
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
- **`balance` finally works after login.** It (and `whoami`) were querying the
|
|
26
|
+
`manage-avios`/`spend-avios` micro-app APIs, which need a separate per-app
|
|
27
|
+
session and returned 401 ("Session expired") even when you were logged in.
|
|
28
|
+
Probing a real session showed the balance is served by
|
|
29
|
+
`/shell/api/users/current/accounts` (`{balance, individual, household}`) and the
|
|
30
|
+
profile by `/auth-gateway/user` — both of which authenticate with the browser
|
|
31
|
+
session. `balance`/`whoami` now use those.
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
- `balance` shows total / individual / household; `whoami` shows name, tier,
|
|
35
|
+
membership and email.
|
|
36
|
+
- The TUI shows your balance even if transactions are unavailable.
|
|
37
|
+
- `transactions`, `pending` and `overview` are marked **experimental** — they need
|
|
38
|
+
the `manage-avios` app session, which the cookie/SSO login can't yet establish.
|
|
39
|
+
- Removed the `accounts` command (its endpoint is the balance source, now shown by
|
|
40
|
+
`balance`).
|
|
41
|
+
|
|
9
42
|
## [0.1.5] - 2026-07-24
|
|
10
43
|
|
|
11
44
|
### Fixed
|
|
@@ -87,7 +120,9 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
87
120
|
- Reward-flight **availability** search is not yet implemented (needs a British
|
|
88
121
|
Airways capture).
|
|
89
122
|
|
|
90
|
-
[Unreleased]: https://github.com/alexechoi/avios-cli/compare/v0.
|
|
123
|
+
[Unreleased]: https://github.com/alexechoi/avios-cli/compare/v0.2.0...HEAD
|
|
124
|
+
[0.2.0]: https://github.com/alexechoi/avios-cli/compare/v0.1.6...v0.2.0
|
|
125
|
+
[0.1.6]: https://github.com/alexechoi/avios-cli/compare/v0.1.5...v0.1.6
|
|
91
126
|
[0.1.5]: https://github.com/alexechoi/avios-cli/compare/v0.1.4...v0.1.5
|
|
92
127
|
[0.1.4]: https://github.com/alexechoi/avios-cli/compare/v0.1.3...v0.1.4
|
|
93
128
|
[0.1.3]: https://github.com/alexechoi/avios-cli/compare/v0.1.2...v0.1.3
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: avios-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
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
|
|
@@ -119,17 +119,16 @@ expires after ~a day; just run `avios login` again. Run `avios logout` to remove
|
|
|
119
119
|
## Usage
|
|
120
120
|
|
|
121
121
|
```bash
|
|
122
|
-
avios balance #
|
|
123
|
-
avios transactions --limit 20 # recent transactions
|
|
122
|
+
avios balance # total / individual / household Avios
|
|
123
|
+
avios transactions --limit 20 # recent transactions (date, description, Avios, type)
|
|
124
124
|
avios pending # pending Avios
|
|
125
|
-
avios
|
|
126
|
-
avios
|
|
127
|
-
avios
|
|
128
|
-
avios raw /manage-avios/api/user/current # hit any endpoint directly
|
|
125
|
+
avios overview # dashboard summary
|
|
126
|
+
avios whoami # name, tier, membership, email
|
|
127
|
+
avios raw /shell/api/users/current/accounts # hit any endpoint directly
|
|
129
128
|
```
|
|
130
129
|
|
|
131
|
-
Add `--json` to `balance`, `transactions`, `pending` or `
|
|
132
|
-
output.
|
|
130
|
+
Add `--json` to `balance`, `transactions`, `pending` or `whoami` for scriptable
|
|
131
|
+
output. Non-BA Avios programmes can set `AVIOS_OPCO` (default `BAEC`).
|
|
133
132
|
|
|
134
133
|
## TUI
|
|
135
134
|
|
|
@@ -84,17 +84,16 @@ expires after ~a day; just run `avios login` again. Run `avios logout` to remove
|
|
|
84
84
|
## Usage
|
|
85
85
|
|
|
86
86
|
```bash
|
|
87
|
-
avios balance #
|
|
88
|
-
avios transactions --limit 20 # recent transactions
|
|
87
|
+
avios balance # total / individual / household Avios
|
|
88
|
+
avios transactions --limit 20 # recent transactions (date, description, Avios, type)
|
|
89
89
|
avios pending # pending Avios
|
|
90
|
-
avios
|
|
91
|
-
avios
|
|
92
|
-
avios
|
|
93
|
-
avios raw /manage-avios/api/user/current # hit any endpoint directly
|
|
90
|
+
avios overview # dashboard summary
|
|
91
|
+
avios whoami # name, tier, membership, email
|
|
92
|
+
avios raw /shell/api/users/current/accounts # hit any endpoint directly
|
|
94
93
|
```
|
|
95
94
|
|
|
96
|
-
Add `--json` to `balance`, `transactions`, `pending` or `
|
|
97
|
-
output.
|
|
95
|
+
Add `--json` to `balance`, `transactions`, `pending` or `whoami` for scriptable
|
|
96
|
+
output. Non-BA Avios programmes can set `AVIOS_OPCO` (default `BAEC`).
|
|
98
97
|
|
|
99
98
|
## TUI
|
|
100
99
|
|
|
@@ -149,7 +149,9 @@ def _wait_for_auth(ctx: Any, page: Any, base_url: str, timeout_ms: int) -> bool:
|
|
|
149
149
|
|
|
150
150
|
def _is_authenticated(ctx: Any, base_url: str) -> bool:
|
|
151
151
|
try:
|
|
152
|
-
|
|
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)
|
|
153
155
|
except Exception:
|
|
154
156
|
return False
|
|
155
157
|
|
|
@@ -257,7 +259,7 @@ def _cookies_authenticate(settings: Settings, cookies: list[dict[str, Any]]) ->
|
|
|
257
259
|
"cookie": header,
|
|
258
260
|
},
|
|
259
261
|
) as client:
|
|
260
|
-
return client.get(endpoints.
|
|
262
|
+
return client.get(endpoints.ACCOUNTS).status_code == 200
|
|
261
263
|
except httpx.HTTPError:
|
|
262
264
|
return False
|
|
263
265
|
|
|
@@ -20,7 +20,7 @@ from rich.table import Table
|
|
|
20
20
|
from avios import __version__
|
|
21
21
|
from avios.auth import LoginError, import_from_browser, login_via_browser
|
|
22
22
|
from avios.client import AviosClient
|
|
23
|
-
from avios.models import Balance
|
|
23
|
+
from avios.models import Balance, Transaction
|
|
24
24
|
from avios.session import NotAuthenticated, Session, SessionExpired
|
|
25
25
|
|
|
26
26
|
app = typer.Typer(
|
|
@@ -78,19 +78,6 @@ def _print_json(data: Any) -> None:
|
|
|
78
78
|
console.print_json(data=data)
|
|
79
79
|
|
|
80
80
|
|
|
81
|
-
def _render_records(records: list[dict[str, Any]], title: str) -> None:
|
|
82
|
-
if not records:
|
|
83
|
-
console.print(f"[dim]No {title.lower()}.[/]")
|
|
84
|
-
return
|
|
85
|
-
keys = [k for k, v in records[0].items() if not isinstance(v, dict | list)][:6]
|
|
86
|
-
table = Table(title=title)
|
|
87
|
-
for key in keys:
|
|
88
|
-
table.add_column(key)
|
|
89
|
-
for record in records:
|
|
90
|
-
table.add_row(*[str(record.get(key, "")) for key in keys])
|
|
91
|
-
console.print(table)
|
|
92
|
-
|
|
93
|
-
|
|
94
81
|
# -- auth commands -----------------------------------------------------------
|
|
95
82
|
@app.command()
|
|
96
83
|
def login(
|
|
@@ -165,8 +152,29 @@ def balance(json_out: bool = JSON_OPTION) -> None:
|
|
|
165
152
|
def _render_balance(result: Balance) -> None:
|
|
166
153
|
table = Table(show_header=False, box=None)
|
|
167
154
|
table.add_row("[bold]Avios[/]", f"[bold cyan]{result.balance:,}[/]")
|
|
168
|
-
if result.
|
|
169
|
-
table.add_row("
|
|
155
|
+
if result.individual is not None:
|
|
156
|
+
table.add_row("Individual", f"{result.individual:,}")
|
|
157
|
+
if result.household is not None:
|
|
158
|
+
table.add_row("Household", f"{result.household:,}")
|
|
159
|
+
console.print(table)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _render_transactions(items: list[Transaction], title: str) -> None:
|
|
163
|
+
if not items:
|
|
164
|
+
console.print(f"[dim]No {title.lower()}.[/]")
|
|
165
|
+
return
|
|
166
|
+
table = Table(title=title)
|
|
167
|
+
table.add_column("Date")
|
|
168
|
+
table.add_column("Description")
|
|
169
|
+
table.add_column("Avios", justify="right")
|
|
170
|
+
table.add_column("Type")
|
|
171
|
+
for txn in items:
|
|
172
|
+
date = (txn.date_processed or "")[:10]
|
|
173
|
+
desc = (txn.description or "").splitlines()[0][:44] if txn.description else ""
|
|
174
|
+
amount = f"{txn.amount:+,}" if txn.amount is not None else ""
|
|
175
|
+
colour = "green" if (txn.amount or 0) >= 0 else "red"
|
|
176
|
+
kind = txn.type.value if txn.type else ""
|
|
177
|
+
table.add_row(date, desc, f"[{colour}]{amount}[/]", kind or "")
|
|
170
178
|
console.print(table)
|
|
171
179
|
|
|
172
180
|
|
|
@@ -178,11 +186,10 @@ def transactions(
|
|
|
178
186
|
"""List recent Avios transactions."""
|
|
179
187
|
with _handle_errors():
|
|
180
188
|
items = _client().get_transactions(limit=limit)
|
|
181
|
-
records = [item.as_dict() for item in items]
|
|
182
189
|
if json_out:
|
|
183
|
-
_print_json(
|
|
190
|
+
_print_json([item.as_dict() for item in items])
|
|
184
191
|
return
|
|
185
|
-
|
|
192
|
+
_render_transactions(items, "Transactions")
|
|
186
193
|
|
|
187
194
|
|
|
188
195
|
@app.command()
|
|
@@ -190,39 +197,47 @@ def pending(json_out: bool = JSON_OPTION) -> None:
|
|
|
190
197
|
"""List pending Avios transactions."""
|
|
191
198
|
with _handle_errors():
|
|
192
199
|
items = _client().get_pending_transactions()
|
|
193
|
-
records = [item.as_dict() for item in items]
|
|
194
200
|
if json_out:
|
|
195
|
-
_print_json(
|
|
201
|
+
_print_json([item.as_dict() for item in items])
|
|
196
202
|
return
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
@app.command()
|
|
201
|
-
def accounts(json_out: bool = JSON_OPTION) -> None:
|
|
202
|
-
"""List linked loyalty accounts."""
|
|
203
|
-
with _handle_errors():
|
|
204
|
-
items = _client().get_accounts()
|
|
205
|
-
records = [item.as_dict() for item in items]
|
|
206
|
-
if json_out:
|
|
207
|
-
_print_json(records)
|
|
208
|
-
return
|
|
209
|
-
_render_records(records, "Accounts")
|
|
203
|
+
_render_transactions(items, "Pending")
|
|
210
204
|
|
|
211
205
|
|
|
212
206
|
@app.command()
|
|
213
207
|
def overview() -> None:
|
|
214
|
-
"""Show the dashboard overview (raw JSON
|
|
208
|
+
"""Show the dashboard overview (raw JSON)."""
|
|
215
209
|
with _handle_errors():
|
|
216
210
|
result = _client().get_overview()
|
|
217
211
|
_print_json(result.as_dict())
|
|
218
212
|
|
|
219
213
|
|
|
220
214
|
@app.command()
|
|
221
|
-
def whoami() -> None:
|
|
222
|
-
"""Show your profile (
|
|
215
|
+
def whoami(json_out: bool = JSON_OPTION) -> None:
|
|
216
|
+
"""Show your profile (name, tier, membership)."""
|
|
223
217
|
with _handle_errors():
|
|
224
|
-
|
|
225
|
-
|
|
218
|
+
data = _client().get_profile().as_dict()
|
|
219
|
+
if json_out:
|
|
220
|
+
_print_json(data)
|
|
221
|
+
return
|
|
222
|
+
claims = data.get("tokenContent", {})
|
|
223
|
+
|
|
224
|
+
def claim(key: str) -> str:
|
|
225
|
+
return str(claims.get(f"https://avios.com/{key}", "") or "")
|
|
226
|
+
|
|
227
|
+
table = Table(show_header=False, box=None)
|
|
228
|
+
name = claims.get("name") or f"{claims.get('given_name', '')} {claims.get('family_name', '')}"
|
|
229
|
+
for label, value in (
|
|
230
|
+
("Name", str(name).strip()),
|
|
231
|
+
("Tier", claim("customer_tier_name")),
|
|
232
|
+
("Membership", claim("membership_id")),
|
|
233
|
+
("Email", str(claims.get("email", "") or "")),
|
|
234
|
+
):
|
|
235
|
+
if value:
|
|
236
|
+
table.add_row(f"[bold]{label}[/]", value)
|
|
237
|
+
if table.row_count:
|
|
238
|
+
console.print(table)
|
|
239
|
+
else:
|
|
240
|
+
_print_json(data)
|
|
226
241
|
|
|
227
242
|
|
|
228
243
|
@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,23 +36,27 @@ 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
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
50
|
+
def get_transactions(self, limit: int = 50) -> list[Transaction]:
|
|
51
|
+
# `offset` is a server-side page hint; slice to `limit` for an exact count.
|
|
52
|
+
offset = limit if limit and limit > 0 else 1000
|
|
53
|
+
path = f"{endpoints.TRANSACTIONS}?startRecord=1&offset={offset}&status=completed"
|
|
54
|
+
payload = self.session.get_json(path)
|
|
55
|
+
items = (
|
|
56
|
+
payload.get("transactions", []) if isinstance(payload, dict) else _extract_list(payload)
|
|
57
|
+
)
|
|
54
58
|
transactions = [Transaction.model_validate(item) for item in items]
|
|
55
|
-
return transactions[:limit] if limit
|
|
59
|
+
return transactions[:limit] if limit and limit > 0 else transactions
|
|
56
60
|
|
|
57
61
|
def get_pending_transactions(self) -> list[Transaction]:
|
|
58
62
|
payload = self.session.get_json(endpoints.TRANSACTIONS_PENDING)
|
|
@@ -39,6 +39,9 @@ class Settings(BaseSettings):
|
|
|
39
39
|
user_agent: str = DEFAULT_USER_AGENT
|
|
40
40
|
config_dir: Path = Field(default_factory=_default_config_dir)
|
|
41
41
|
request_timeout: float = 20.0
|
|
42
|
+
# Operating company code sent as `x-avios-opco`; required by the manage-avios
|
|
43
|
+
# API. "BAEC" = British Airways Executive Club (override with AVIOS_OPCO).
|
|
44
|
+
opco: str = "BAEC"
|
|
42
45
|
|
|
43
46
|
@property
|
|
44
47
|
def state_path(self) -> Path:
|
|
@@ -25,18 +25,34 @@ 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
|
-
class
|
|
35
|
-
"""A
|
|
38
|
+
class NamedRef(AviosModel):
|
|
39
|
+
"""A ``{value, id}`` reference (transaction type, partner, ...)."""
|
|
36
40
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
41
|
+
value: str | None = None
|
|
42
|
+
id: str | None = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Transaction(AviosModel):
|
|
46
|
+
"""A single Avios statement entry (from the manage-avios transactions API)."""
|
|
47
|
+
|
|
48
|
+
identifier: str | None = None
|
|
49
|
+
date_processed: str | None = Field(default=None, alias="dateProcessed")
|
|
50
|
+
description: str | None = None
|
|
51
|
+
type: NamedRef | None = None
|
|
52
|
+
partner: NamedRef | None = None
|
|
53
|
+
amount: int | None = None
|
|
54
|
+
categories: list[str] = Field(default_factory=list)
|
|
55
|
+
record_index: int | None = Field(default=None, alias="recordIndex")
|
|
40
56
|
|
|
41
57
|
|
|
42
58
|
class Account(AviosModel):
|
|
@@ -95,6 +95,8 @@ class Session:
|
|
|
95
95
|
"accept": "application/json, text/plain, */*",
|
|
96
96
|
"user-agent": self.settings.user_agent,
|
|
97
97
|
"referer": f"{self.settings.base_url}/manage-avios/dashboard",
|
|
98
|
+
# Required by the manage-avios API (401 without it).
|
|
99
|
+
"x-avios-opco": self.settings.opco,
|
|
98
100
|
"cookie": cookie,
|
|
99
101
|
}
|
|
100
102
|
return httpx.Client(
|
|
@@ -21,7 +21,6 @@ from avios.tui.art import banner_text
|
|
|
21
21
|
from avios.tui.widgets import BalanceDisplay
|
|
22
22
|
|
|
23
23
|
TRANSACTIONS_TO_SHOW = 50
|
|
24
|
-
MAX_COLUMNS = 6
|
|
25
24
|
|
|
26
25
|
|
|
27
26
|
class AviosApp(App[None]):
|
|
@@ -61,9 +60,6 @@ class AviosApp(App[None]):
|
|
|
61
60
|
async def _load(self) -> None:
|
|
62
61
|
try:
|
|
63
62
|
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
63
|
except (NotAuthenticated, SessionExpired) as exc:
|
|
68
64
|
self._show_error(f"{exc} — run `avios login`")
|
|
69
65
|
return
|
|
@@ -71,6 +67,15 @@ class AviosApp(App[None]):
|
|
|
71
67
|
self._show_error(f"Request failed: {exc}")
|
|
72
68
|
return
|
|
73
69
|
self.query_one("#balance", BalanceDisplay).update_balance(balance)
|
|
70
|
+
|
|
71
|
+
# Transactions are experimental (manage-avios session); degrade gracefully
|
|
72
|
+
# so the balance still shows if they're unavailable.
|
|
73
|
+
try:
|
|
74
|
+
transactions = await asyncio.to_thread(
|
|
75
|
+
self._client.get_transactions, TRANSACTIONS_TO_SHOW
|
|
76
|
+
)
|
|
77
|
+
except (NotAuthenticated, SessionExpired, httpx.HTTPError):
|
|
78
|
+
transactions = []
|
|
74
79
|
self._populate_transactions(transactions)
|
|
75
80
|
|
|
76
81
|
def _show_error(self, message: str) -> None:
|
|
@@ -81,15 +86,17 @@ class AviosApp(App[None]):
|
|
|
81
86
|
table = self.query_one("#transactions", DataTable)
|
|
82
87
|
table.loading = False
|
|
83
88
|
table.clear(columns=True)
|
|
84
|
-
|
|
85
|
-
if not records:
|
|
89
|
+
if not transactions:
|
|
86
90
|
table.add_column("info")
|
|
87
91
|
table.add_row("No transactions")
|
|
88
92
|
return
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
+
table.add_columns("Date", "Description", "Avios", "Type")
|
|
94
|
+
for txn in transactions:
|
|
95
|
+
date = (txn.date_processed or "")[:10]
|
|
96
|
+
desc = (txn.description or "").splitlines()[0][:44] if txn.description else ""
|
|
97
|
+
amount = f"{txn.amount:+,}" if txn.amount is not None else ""
|
|
98
|
+
kind = txn.type.value if txn.type else ""
|
|
99
|
+
table.add_row(date, desc, amount, kind)
|
|
93
100
|
|
|
94
101
|
|
|
95
102
|
def run(client: AviosClient | None = None) -> 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}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"totalRecords": 2,
|
|
3
|
+
"transactions": [
|
|
4
|
+
{
|
|
5
|
+
"identifier": "2139440378",
|
|
6
|
+
"dateProcessed": "2026-07-21T11:37:35.000Z",
|
|
7
|
+
"description": "UBER",
|
|
8
|
+
"type": {"value": "Collection", "id": "DV_COL"},
|
|
9
|
+
"partner": {"value": "UBER", "id": "UBR"},
|
|
10
|
+
"amount": 50,
|
|
11
|
+
"categories": ["collectingAvios"],
|
|
12
|
+
"recordIndex": 1
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"identifier": "2023965591",
|
|
16
|
+
"dateProcessed": "2025-12-10T21:37:04.000Z",
|
|
17
|
+
"description": "Tokyo Haneda to London Heathrow\nClass: Club World\nBA0008",
|
|
18
|
+
"type": {"value": "Reward Flight", "id": "FLT_RWD"},
|
|
19
|
+
"partner": {"value": "British Airways", "id": "BA"},
|
|
20
|
+
"amount": -52000,
|
|
21
|
+
"categories": ["flights"],
|
|
22
|
+
"recordIndex": 2
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}
|
|
@@ -15,7 +15,7 @@ from typer.testing import CliRunner
|
|
|
15
15
|
from avios import __version__
|
|
16
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,26 +28,46 @@ 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
|
-
def get_transactions(self, limit: int
|
|
33
|
+
def get_transactions(self, limit: int = 50) -> list[Transaction]:
|
|
34
34
|
items = [
|
|
35
|
-
Transaction.model_validate(
|
|
36
|
-
|
|
35
|
+
Transaction.model_validate(
|
|
36
|
+
{
|
|
37
|
+
"dateProcessed": "2026-07-01T09:00:00.000Z",
|
|
38
|
+
"description": "UBER",
|
|
39
|
+
"type": {"value": "Collection", "id": "DV_COL"},
|
|
40
|
+
"amount": 100,
|
|
41
|
+
}
|
|
42
|
+
),
|
|
43
|
+
Transaction.model_validate(
|
|
44
|
+
{
|
|
45
|
+
"dateProcessed": "2026-06-01T09:00:00.000Z",
|
|
46
|
+
"description": "Deliveroo UK",
|
|
47
|
+
"type": {"value": "BA Avios eStore", "id": "BA_AV_ESTORE"},
|
|
48
|
+
"amount": 50,
|
|
49
|
+
}
|
|
50
|
+
),
|
|
37
51
|
]
|
|
38
|
-
return items[:limit]
|
|
52
|
+
return items[:limit]
|
|
39
53
|
|
|
40
54
|
def get_pending_transactions(self) -> list[Transaction]:
|
|
41
55
|
return []
|
|
42
56
|
|
|
43
|
-
def get_accounts(self) -> list[Account]:
|
|
44
|
-
return [Account.model_validate({"programme": "BAEC", "balance": 100})]
|
|
45
|
-
|
|
46
57
|
def get_overview(self) -> Overview:
|
|
47
58
|
return Overview.model_validate({"tier": "Blue"})
|
|
48
59
|
|
|
49
60
|
def get_profile(self) -> Profile:
|
|
50
|
-
return Profile.model_validate(
|
|
61
|
+
return Profile.model_validate(
|
|
62
|
+
{
|
|
63
|
+
"idToken": "jwt",
|
|
64
|
+
"tokenContent": {
|
|
65
|
+
"name": "Alex Choi",
|
|
66
|
+
"https://avios.com/customer_tier_name": "Gold",
|
|
67
|
+
"https://avios.com/membership_id": "BA123",
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
)
|
|
51
71
|
|
|
52
72
|
def raw(self, path: str) -> dict[str, str]:
|
|
53
73
|
return {"path": path}
|
|
@@ -81,7 +101,7 @@ def test_balance_json(fake_client: None) -> None:
|
|
|
81
101
|
result = runner.invoke(app, ["balance", "--json"])
|
|
82
102
|
assert result.exit_code == 0
|
|
83
103
|
assert '"balance"' in result.stdout
|
|
84
|
-
assert '"
|
|
104
|
+
assert '"household"' in result.stdout
|
|
85
105
|
|
|
86
106
|
|
|
87
107
|
def test_transactions_respects_limit(fake_client: None) -> None:
|
|
@@ -94,13 +114,16 @@ def test_transactions_respects_limit(fake_client: None) -> None:
|
|
|
94
114
|
def test_transactions_json(fake_client: None) -> None:
|
|
95
115
|
result = runner.invoke(app, ["transactions", "--json"])
|
|
96
116
|
assert result.exit_code == 0
|
|
97
|
-
assert '"
|
|
117
|
+
assert '"amount"' in result.stdout
|
|
118
|
+
assert '"dateProcessed"' in result.stdout
|
|
98
119
|
|
|
99
120
|
|
|
100
|
-
def
|
|
101
|
-
result = runner.invoke(app, ["
|
|
121
|
+
def test_whoami(fake_client: None) -> None:
|
|
122
|
+
result = runner.invoke(app, ["whoami"])
|
|
102
123
|
assert result.exit_code == 0
|
|
103
|
-
|
|
124
|
+
out = " ".join(result.stdout.split())
|
|
125
|
+
assert "Alex Choi" in out
|
|
126
|
+
assert "Gold" in out
|
|
104
127
|
|
|
105
128
|
|
|
106
129
|
def test_raw(fake_client: None) -> None:
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Tests for the typed API client (balance, transactions, profile, ...)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from avios import endpoints
|
|
12
|
+
from avios.client import AviosClient, _extract_list
|
|
13
|
+
from avios.config import Settings
|
|
14
|
+
from avios.session import Session, SessionExpired
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _client(
|
|
18
|
+
settings: Settings, routes: dict[str, Any], captured: list[httpx.Request] | None = None
|
|
19
|
+
) -> AviosClient:
|
|
20
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
21
|
+
if captured is not None:
|
|
22
|
+
captured.append(request)
|
|
23
|
+
if request.url.path not in routes:
|
|
24
|
+
return httpx.Response(404, json={})
|
|
25
|
+
body = routes[request.url.path]
|
|
26
|
+
if isinstance(body, int): # treat an int as a status code (e.g. 302)
|
|
27
|
+
return httpx.Response(body, headers={"location": "/auth-gateway/login"})
|
|
28
|
+
return httpx.Response(200, json=body)
|
|
29
|
+
|
|
30
|
+
session = Session(settings, transport=httpx.MockTransport(handler))
|
|
31
|
+
session.save_cookies([{"name": "appSession", "value": "x", "domain": "www.avios.com"}])
|
|
32
|
+
return AviosClient(session)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_get_balance(settings: Settings, load_fixture: Callable[[str], Any]) -> None:
|
|
36
|
+
client = _client(settings, {endpoints.ACCOUNTS: load_fixture("balance.json")})
|
|
37
|
+
balance = client.get_balance()
|
|
38
|
+
assert balance.balance == 75751
|
|
39
|
+
assert balance.household == 112430
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_get_transactions(settings: Settings, load_fixture: Callable[[str], Any]) -> None:
|
|
43
|
+
captured: list[httpx.Request] = []
|
|
44
|
+
client = _client(
|
|
45
|
+
settings, {endpoints.TRANSACTIONS: load_fixture("transactions.json")}, captured
|
|
46
|
+
)
|
|
47
|
+
txns = client.get_transactions(limit=2)
|
|
48
|
+
assert len(txns) == 2
|
|
49
|
+
assert txns[0].type is not None and txns[0].type.value == "Collection"
|
|
50
|
+
assert txns[0].amount == 50
|
|
51
|
+
assert txns[1].amount == -52000
|
|
52
|
+
# Sends the required opco header and the limit as offset.
|
|
53
|
+
req = captured[-1]
|
|
54
|
+
assert req.headers["x-avios-opco"] == "BAEC"
|
|
55
|
+
assert "offset=2" in str(req.url)
|
|
56
|
+
assert "status=completed" in str(req.url)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_get_profile(settings: Settings) -> None:
|
|
60
|
+
payload = {"idToken": "jwt", "tokenContent": {"name": "Alex"}}
|
|
61
|
+
client = _client(settings, {endpoints.AUTH_USER: payload})
|
|
62
|
+
profile = client.get_profile()
|
|
63
|
+
assert profile.as_dict()["tokenContent"]["name"] == "Alex"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_raw_normalises_leading_slash(settings: Settings) -> None:
|
|
67
|
+
client = _client(settings, {"/manage-avios/api/user/current": {"id": 1}})
|
|
68
|
+
assert client.raw("manage-avios/api/user/current") == {"id": 1}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_expired_session_propagates(settings: Settings) -> None:
|
|
72
|
+
client = _client(settings, {endpoints.ACCOUNTS: 302})
|
|
73
|
+
with pytest.raises(SessionExpired):
|
|
74
|
+
client.get_balance()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_extract_list_variants() -> None:
|
|
78
|
+
assert _extract_list([1, 2]) == [1, 2]
|
|
79
|
+
assert _extract_list({"items": [1]}) == [1]
|
|
80
|
+
assert _extract_list({"nope": 1}) == []
|
|
81
|
+
assert _extract_list("string") == []
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Tests for the pydantic models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from avios.models import Balance, Transaction
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_balance_parses_shell_shape(load_fixture: Callable[[str], Any]) -> None:
|
|
12
|
+
balance = Balance.model_validate(load_fixture("balance.json"))
|
|
13
|
+
assert balance.balance == 75751
|
|
14
|
+
assert balance.individual == 75751
|
|
15
|
+
assert balance.household == 112430
|
|
16
|
+
|
|
17
|
+
|
|
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}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_balance_extras_are_optional() -> None:
|
|
24
|
+
balance = Balance.model_validate({"balance": 42})
|
|
25
|
+
assert balance.individual is None
|
|
26
|
+
assert balance.household is None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_extra_fields_are_preserved() -> None:
|
|
30
|
+
txn = Transaction.model_validate({"date": "2026-07-01", "avios": 500, "unknown": "x"})
|
|
31
|
+
dumped = txn.as_dict()
|
|
32
|
+
assert dumped["unknown"] == "x"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_transaction_parses_real_shape() -> None:
|
|
36
|
+
txn = Transaction.model_validate(
|
|
37
|
+
{
|
|
38
|
+
"identifier": "2139440378",
|
|
39
|
+
"dateProcessed": "2026-07-21T11:37:35.000Z",
|
|
40
|
+
"description": "UBER",
|
|
41
|
+
"type": {"value": "Collection", "id": "DV_COL"},
|
|
42
|
+
"partner": {"value": "UBER", "id": "UBR"},
|
|
43
|
+
"amount": 50,
|
|
44
|
+
"categories": ["collectingAvios"],
|
|
45
|
+
"recordIndex": 1,
|
|
46
|
+
}
|
|
47
|
+
)
|
|
48
|
+
assert txn.identifier == "2139440378"
|
|
49
|
+
assert txn.date_processed == "2026-07-21T11:37:35.000Z"
|
|
50
|
+
assert txn.type is not None and txn.type.value == "Collection"
|
|
51
|
+
assert txn.partner is not None and txn.partner.id == "UBR"
|
|
52
|
+
assert txn.amount == 50
|
|
53
|
+
assert txn.categories == ["collectingAvios"]
|
|
@@ -67,6 +67,7 @@ def test_client_requires_authentication(settings: Settings) -> None:
|
|
|
67
67
|
def test_get_json_returns_parsed_body(settings: Settings) -> None:
|
|
68
68
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
69
69
|
assert request.headers["cookie"] == "appSession=abc"
|
|
70
|
+
assert request.headers["x-avios-opco"] == "BAEC"
|
|
70
71
|
return httpx.Response(200, json={"balance": 75751})
|
|
71
72
|
|
|
72
73
|
session = _session(settings, httpx.MockTransport(handler))
|
|
@@ -15,12 +15,16 @@ 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
|
-
def get_transactions(self, limit: int
|
|
20
|
+
def get_transactions(self, limit: int = 50) -> list[Transaction]:
|
|
21
21
|
return [
|
|
22
|
-
Transaction.model_validate(
|
|
23
|
-
|
|
22
|
+
Transaction.model_validate(
|
|
23
|
+
{"dateProcessed": "2026-07-01T09:00:00Z", "description": "UBER", "amount": 100}
|
|
24
|
+
),
|
|
25
|
+
Transaction.model_validate(
|
|
26
|
+
{"dateProcessed": "2026-06-01T09:00:00Z", "description": "Flight", "amount": -5000}
|
|
27
|
+
),
|
|
24
28
|
]
|
|
25
29
|
|
|
26
30
|
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"balance": 75751, "householdAviosBalance": 75752}
|
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
"""Tests for the typed API client.
|
|
2
|
-
|
|
3
|
-
The transactions/accounts fixtures are representative, not confirmed shapes, so
|
|
4
|
-
these tests assert on counts and field pass-through (via ``extra='allow'``) rather
|
|
5
|
-
than a rigid schema. The balance shape is confirmed.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
from __future__ import annotations
|
|
9
|
-
|
|
10
|
-
from collections.abc import Callable
|
|
11
|
-
from typing import Any
|
|
12
|
-
|
|
13
|
-
import httpx
|
|
14
|
-
import pytest
|
|
15
|
-
|
|
16
|
-
from avios import endpoints
|
|
17
|
-
from avios.client import AviosClient, _extract_list
|
|
18
|
-
from avios.config import Settings
|
|
19
|
-
from avios.session import Session, SessionExpired
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def _client(settings: Settings, routes: dict[str, Any]) -> AviosClient:
|
|
23
|
-
def handler(request: httpx.Request) -> httpx.Response:
|
|
24
|
-
if request.url.path not in routes:
|
|
25
|
-
return httpx.Response(404, json={})
|
|
26
|
-
body = routes[request.url.path]
|
|
27
|
-
if isinstance(body, int): # treat an int as a status code (e.g. 302)
|
|
28
|
-
return httpx.Response(body, headers={"location": "/auth-gateway/login"})
|
|
29
|
-
return httpx.Response(200, json=body)
|
|
30
|
-
|
|
31
|
-
session = Session(settings, transport=httpx.MockTransport(handler))
|
|
32
|
-
session.save_cookies([{"name": "appSession", "value": "x", "domain": "www.avios.com"}])
|
|
33
|
-
return AviosClient(session)
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def test_get_balance(settings: Settings, load_fixture: Callable[[str], Any]) -> None:
|
|
37
|
-
client = _client(settings, {endpoints.BALANCE: load_fixture("balance.json")})
|
|
38
|
-
balance = client.get_balance()
|
|
39
|
-
assert balance.balance == 75751
|
|
40
|
-
assert balance.household_avios_balance == 75752
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def test_get_transactions_from_bare_list(
|
|
44
|
-
settings: Settings, load_fixture: Callable[[str], Any]
|
|
45
|
-
) -> None:
|
|
46
|
-
client = _client(settings, {endpoints.TRANSACTIONS: load_fixture("transactions.json")})
|
|
47
|
-
txns = client.get_transactions()
|
|
48
|
-
assert len(txns) == 3
|
|
49
|
-
assert txns[0].as_dict()["description"] == "Flight BA286 SFO-LHR"
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def test_get_transactions_respects_limit(
|
|
53
|
-
settings: Settings, load_fixture: Callable[[str], Any]
|
|
54
|
-
) -> None:
|
|
55
|
-
client = _client(settings, {endpoints.TRANSACTIONS: load_fixture("transactions.json")})
|
|
56
|
-
assert len(client.get_transactions(limit=2)) == 2
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def test_get_transactions_from_wrapped_object(settings: Settings) -> None:
|
|
60
|
-
payload = {"total": 1, "transactions": [{"date": "2026-07-01", "avios": 100}]}
|
|
61
|
-
client = _client(settings, {endpoints.TRANSACTIONS: payload})
|
|
62
|
-
txns = client.get_transactions()
|
|
63
|
-
assert len(txns) == 1
|
|
64
|
-
assert txns[0].as_dict()["avios"] == 100
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
def test_get_accounts(settings: Settings, load_fixture: Callable[[str], Any]) -> None:
|
|
68
|
-
client = _client(settings, {endpoints.ACCOUNTS: load_fixture("accounts.json")})
|
|
69
|
-
accounts = client.get_accounts()
|
|
70
|
-
assert len(accounts) == 1
|
|
71
|
-
assert accounts[0].as_dict()["programme"] == "British Airways Executive Club"
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
def test_raw_normalises_leading_slash(settings: Settings) -> None:
|
|
75
|
-
client = _client(settings, {"/manage-avios/api/user/current": {"id": 1}})
|
|
76
|
-
assert client.raw("manage-avios/api/user/current") == {"id": 1}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def test_expired_session_propagates(settings: Settings) -> None:
|
|
80
|
-
client = _client(settings, {endpoints.BALANCE: 302})
|
|
81
|
-
with pytest.raises(SessionExpired):
|
|
82
|
-
client.get_balance()
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
def test_extract_list_variants() -> None:
|
|
86
|
-
assert _extract_list([1, 2]) == [1, 2]
|
|
87
|
-
assert _extract_list({"items": [1]}) == [1]
|
|
88
|
-
assert _extract_list({"nope": 1}) == []
|
|
89
|
-
assert _extract_list("string") == []
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
"""Tests for the pydantic models."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from collections.abc import Callable
|
|
6
|
-
from typing import Any
|
|
7
|
-
|
|
8
|
-
from avios.models import Balance, Transaction
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def test_balance_parses_confirmed_shape(load_fixture: Callable[[str], Any]) -> None:
|
|
12
|
-
balance = Balance.model_validate(load_fixture("balance.json"))
|
|
13
|
-
assert balance.balance == 75751
|
|
14
|
-
assert balance.household_avios_balance == 75752
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def test_balance_alias_round_trips() -> None:
|
|
18
|
-
balance = Balance(balance=100, household_avios_balance=200)
|
|
19
|
-
assert balance.as_dict() == {"balance": 100, "householdAviosBalance": 200}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def test_balance_household_is_optional() -> None:
|
|
23
|
-
balance = Balance.model_validate({"balance": 42})
|
|
24
|
-
assert balance.household_avios_balance is None
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def test_extra_fields_are_preserved() -> None:
|
|
28
|
-
txn = Transaction.model_validate({"date": "2026-07-01", "avios": 500, "unknown": "x"})
|
|
29
|
-
dumped = txn.as_dict()
|
|
30
|
-
assert dumped["date"] == "2026-07-01"
|
|
31
|
-
assert dumped["avios"] == 500
|
|
32
|
-
assert dumped["unknown"] == "x"
|
|
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
|