pycellarion 0.1.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.
@@ -0,0 +1,11 @@
1
+ # Keeps the pinned actions current.
2
+ version: 2
3
+ updates:
4
+ - package-ecosystem: github-actions
5
+ directory: /
6
+ schedule:
7
+ interval: monthly
8
+ - package-ecosystem: pip
9
+ directory: /
10
+ schedule:
11
+ interval: monthly
@@ -0,0 +1,58 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ workflow_dispatch:
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ concurrency:
13
+ group: ${{ github.workflow }}-${{ github.ref }}
14
+ cancel-in-progress: true
15
+
16
+ jobs:
17
+ lint:
18
+ runs-on: ubuntu-latest
19
+ timeout-minutes: 10
20
+ steps:
21
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
22
+ - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
23
+ with:
24
+ python-version: "3.13"
25
+ cache: pip
26
+ - run: pip install -e ".[test]"
27
+ - run: ruff check .
28
+ - run: ruff format --check .
29
+ - run: mypy src
30
+
31
+ tests:
32
+ name: tests (py${{ matrix.python }})
33
+ runs-on: ubuntu-latest
34
+ timeout-minutes: 10
35
+ strategy:
36
+ fail-fast: false
37
+ matrix:
38
+ python: ["3.12", "3.13"]
39
+ steps:
40
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
41
+ - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
42
+ with:
43
+ python-version: ${{ matrix.python }}
44
+ cache: pip
45
+ - run: pip install -e ".[test]"
46
+ - run: pytest -q --cov --cov-report=term-missing
47
+
48
+ build:
49
+ runs-on: ubuntu-latest
50
+ timeout-minutes: 10
51
+ steps:
52
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
53
+ - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
54
+ with:
55
+ python-version: "3.13"
56
+ - run: pip install build twine
57
+ - run: python -m build
58
+ - run: twine check dist/*
@@ -0,0 +1,41 @@
1
+ name: Publish to PyPI
2
+
3
+ # Runs when a GitHub release is published. Uses PyPI trusted publishing
4
+ # (OpenID Connect): no API token is stored anywhere. The PyPI project must
5
+ # list this repository + workflow as a trusted publisher first.
6
+ on:
7
+ release:
8
+ types: [published]
9
+
10
+ permissions:
11
+ contents: read
12
+
13
+ jobs:
14
+ build:
15
+ runs-on: ubuntu-latest
16
+ timeout-minutes: 10
17
+ steps:
18
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
19
+ - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
20
+ with:
21
+ python-version: "3.13"
22
+ - run: pip install build
23
+ - run: python -m build
24
+ - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
25
+ with:
26
+ name: dist
27
+ path: dist/
28
+
29
+ publish:
30
+ needs: build
31
+ runs-on: ubuntu-latest
32
+ timeout-minutes: 10
33
+ environment: pypi
34
+ permissions:
35
+ id-token: write
36
+ steps:
37
+ - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
38
+ with:
39
+ name: dist
40
+ path: dist/
41
+ - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1, 2026-09
@@ -0,0 +1,10 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .pytest_cache/
4
+ .mypy_cache/
5
+ .ruff_cache/
6
+ .coverage
7
+ dist/
8
+ build/
9
+ *.egg-info/
10
+ .venv/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 jagduvi1
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,117 @@
1
+ Metadata-Version: 2.5
2
+ Name: pycellarion
3
+ Version: 0.1.0
4
+ Summary: Async client for the Cellarion wine-cellar API
5
+ Project-URL: Homepage, https://github.com/jagduvi1/pycellarion
6
+ Project-URL: Issues, https://github.com/jagduvi1/pycellarion/issues
7
+ Project-URL: Cellarion, https://cellarion.app
8
+ Author: Johan Eklund
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: asyncio,cellar,cellarion,home-assistant,wine
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Framework :: AsyncIO
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Home Automation
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.12
22
+ Requires-Dist: aiohttp>=3.9
23
+ Provides-Extra: test
24
+ Requires-Dist: mypy>=1.11; extra == 'test'
25
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'test'
26
+ Requires-Dist: pytest-cov>=5; extra == 'test'
27
+ Requires-Dist: pytest>=8; extra == 'test'
28
+ Requires-Dist: ruff>=0.6; extra == 'test'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # pycellarion
32
+
33
+ [![PyPI](https://img.shields.io/pypi/v/pycellarion)](https://pypi.org/project/pycellarion/)
34
+ [![CI](https://github.com/jagduvi1/pycellarion/actions/workflows/ci.yml/badge.svg)](https://github.com/jagduvi1/pycellarion/actions/workflows/ci.yml)
35
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
36
+
37
+ Async Python client for the [Cellarion](https://cellarion.app) wine-cellar
38
+ API. It is the library behind the
39
+ [Home Assistant integration](https://github.com/jagduvi1/ha-cellarion), and
40
+ usable on its own by anything that wants to read a cellar or mark a bottle
41
+ as consumed.
42
+
43
+ - **Async, typed, small.** One dependency (`aiohttp`), `py.typed`, no I/O
44
+ outside the calls you make.
45
+ - **Two credentials.** A personal API token (`cel_…`, created in Cellarion
46
+ under *Settings → API tokens*), or email and password used once to mint
47
+ such a token. Passwords are never kept beyond the login they were given for.
48
+ - **Push events.** Parses the server's `text/event-stream` and yields event
49
+ names, so callers can refresh on change instead of polling.
50
+ - **Honest errors.** Status codes map to a small exception hierarchy:
51
+ `CellarionAuthError` (re-authenticate), `CellarionScopeError` (the token
52
+ lacks a scope), `CellarionTokensNotSupported` and
53
+ `CellarionPushNotSupported` (older servers), `CellarionPushForbidden`,
54
+ and `CellarionApiError` for everything else.
55
+
56
+ ## Install
57
+
58
+ ```bash
59
+ pip install pycellarion
60
+ ```
61
+
62
+ ## Use
63
+
64
+ ```python
65
+ import aiohttp
66
+ from pycellarion import CellarionClient
67
+
68
+ async with aiohttp.ClientSession() as session:
69
+ client = CellarionClient(session, "https://cellarion.app", token="cel_…")
70
+
71
+ stats = await client.get_stats_overview()
72
+ print(stats["stats"]["overview"]["totalBottles"])
73
+
74
+ async for event in client.events_stream():
75
+ if event == "_connected":
76
+ continue
77
+ print("something changed:", event)
78
+ ```
79
+
80
+ Minting a token from email and password:
81
+
82
+ ```python
83
+ client = CellarionClient(session, "https://cellarion.app", "me@example.com", "secret")
84
+ await client.authenticate()
85
+ token = await client.async_create_api_token("My script", ["read", "consume"])
86
+ # keep `token`; the password is not needed again
87
+ ```
88
+
89
+ ## API
90
+
91
+ | Method | Purpose |
92
+ |---|---|
93
+ | `authenticate()` | Log in with email and password (session JWT); raises `CellarionAuthError` on bad credentials |
94
+ | `async_create_api_token(name, scopes)` | Mint a personal API token (password login required) |
95
+ | `get_stats_overview()` / `get_cellars()` / `get_notifications()` / `get_peak_bottles(limit)` / `get_health()` | Reads |
96
+ | `get_account_id()` | Stable account id from `/api/auth/whoami`, or `None` on servers without it |
97
+ | `consume_bottle(bottle_id, reason, rating, note)` | Mark a bottle as consumed |
98
+ | `events_stream()` | Async iterator of push event names; yields `"_connected"` first |
99
+ | `revoke_own_token()` | Ask the server to revoke the client's own API token (Cellarion 1.220+) |
100
+
101
+ Responses are returned as plain `dict`s (`JsonDict`); bodies over
102
+ `MAX_BODY_BYTES` (4 MiB) are refused.
103
+
104
+ ## Development
105
+
106
+ ```bash
107
+ pip install -e ".[test]"
108
+ ruff check . && ruff format --check . && mypy src
109
+ pytest --cov
110
+ ```
111
+
112
+ Releases are published to PyPI from GitHub releases through
113
+ [trusted publishing](https://docs.pypi.org/trusted-publishers/).
114
+
115
+ ## License
116
+
117
+ MIT
@@ -0,0 +1,87 @@
1
+ # pycellarion
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/pycellarion)](https://pypi.org/project/pycellarion/)
4
+ [![CI](https://github.com/jagduvi1/pycellarion/actions/workflows/ci.yml/badge.svg)](https://github.com/jagduvi1/pycellarion/actions/workflows/ci.yml)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
6
+
7
+ Async Python client for the [Cellarion](https://cellarion.app) wine-cellar
8
+ API. It is the library behind the
9
+ [Home Assistant integration](https://github.com/jagduvi1/ha-cellarion), and
10
+ usable on its own by anything that wants to read a cellar or mark a bottle
11
+ as consumed.
12
+
13
+ - **Async, typed, small.** One dependency (`aiohttp`), `py.typed`, no I/O
14
+ outside the calls you make.
15
+ - **Two credentials.** A personal API token (`cel_…`, created in Cellarion
16
+ under *Settings → API tokens*), or email and password used once to mint
17
+ such a token. Passwords are never kept beyond the login they were given for.
18
+ - **Push events.** Parses the server's `text/event-stream` and yields event
19
+ names, so callers can refresh on change instead of polling.
20
+ - **Honest errors.** Status codes map to a small exception hierarchy:
21
+ `CellarionAuthError` (re-authenticate), `CellarionScopeError` (the token
22
+ lacks a scope), `CellarionTokensNotSupported` and
23
+ `CellarionPushNotSupported` (older servers), `CellarionPushForbidden`,
24
+ and `CellarionApiError` for everything else.
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ pip install pycellarion
30
+ ```
31
+
32
+ ## Use
33
+
34
+ ```python
35
+ import aiohttp
36
+ from pycellarion import CellarionClient
37
+
38
+ async with aiohttp.ClientSession() as session:
39
+ client = CellarionClient(session, "https://cellarion.app", token="cel_…")
40
+
41
+ stats = await client.get_stats_overview()
42
+ print(stats["stats"]["overview"]["totalBottles"])
43
+
44
+ async for event in client.events_stream():
45
+ if event == "_connected":
46
+ continue
47
+ print("something changed:", event)
48
+ ```
49
+
50
+ Minting a token from email and password:
51
+
52
+ ```python
53
+ client = CellarionClient(session, "https://cellarion.app", "me@example.com", "secret")
54
+ await client.authenticate()
55
+ token = await client.async_create_api_token("My script", ["read", "consume"])
56
+ # keep `token`; the password is not needed again
57
+ ```
58
+
59
+ ## API
60
+
61
+ | Method | Purpose |
62
+ |---|---|
63
+ | `authenticate()` | Log in with email and password (session JWT); raises `CellarionAuthError` on bad credentials |
64
+ | `async_create_api_token(name, scopes)` | Mint a personal API token (password login required) |
65
+ | `get_stats_overview()` / `get_cellars()` / `get_notifications()` / `get_peak_bottles(limit)` / `get_health()` | Reads |
66
+ | `get_account_id()` | Stable account id from `/api/auth/whoami`, or `None` on servers without it |
67
+ | `consume_bottle(bottle_id, reason, rating, note)` | Mark a bottle as consumed |
68
+ | `events_stream()` | Async iterator of push event names; yields `"_connected"` first |
69
+ | `revoke_own_token()` | Ask the server to revoke the client's own API token (Cellarion 1.220+) |
70
+
71
+ Responses are returned as plain `dict`s (`JsonDict`); bodies over
72
+ `MAX_BODY_BYTES` (4 MiB) are refused.
73
+
74
+ ## Development
75
+
76
+ ```bash
77
+ pip install -e ".[test]"
78
+ ruff check . && ruff format --check . && mypy src
79
+ pytest --cov
80
+ ```
81
+
82
+ Releases are published to PyPI from GitHub releases through
83
+ [trusted publishing](https://docs.pypi.org/trusted-publishers/).
84
+
85
+ ## License
86
+
87
+ MIT
@@ -0,0 +1,64 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pycellarion"
7
+ version = "0.1.0"
8
+ description = "Async client for the Cellarion wine-cellar API"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.12"
13
+ authors = [{ name = "Johan Eklund" }]
14
+ keywords = ["cellarion", "wine", "cellar", "home-assistant", "asyncio"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Framework :: AsyncIO",
18
+ "Intended Audience :: Developers",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Topic :: Home Automation",
24
+ "Typing :: Typed",
25
+ ]
26
+ dependencies = ["aiohttp>=3.9"]
27
+
28
+ [project.urls]
29
+ Homepage = "https://github.com/jagduvi1/pycellarion"
30
+ Issues = "https://github.com/jagduvi1/pycellarion/issues"
31
+ Cellarion = "https://cellarion.app"
32
+
33
+ [project.optional-dependencies]
34
+ test = ["pytest>=8", "pytest-asyncio>=0.24", "pytest-cov>=5", "mypy>=1.11", "ruff>=0.6"]
35
+
36
+ [tool.hatch.build.targets.wheel]
37
+ packages = ["src/pycellarion"]
38
+
39
+ [tool.pytest.ini_options]
40
+ testpaths = ["tests"]
41
+ asyncio_mode = "auto"
42
+
43
+ [tool.coverage.run]
44
+ source = ["pycellarion"]
45
+
46
+ [tool.coverage.report]
47
+ fail_under = 95
48
+ show_missing = true
49
+
50
+ [tool.ruff]
51
+ target-version = "py312"
52
+ line-length = 100
53
+
54
+ [tool.ruff.lint]
55
+ select = ["E", "F", "W", "I", "UP", "B", "SIM", "RUF", "ASYNC", "D"]
56
+ ignore = ["E501", "D203", "D213", "D105", "D107"]
57
+
58
+ [tool.ruff.lint.per-file-ignores]
59
+ "tests/*" = ["D"]
60
+
61
+ [tool.mypy]
62
+ python_version = "3.12"
63
+ strict = true
64
+ warn_unreachable = true
@@ -0,0 +1,26 @@
1
+ """pycellarion — async client for the Cellarion wine-cellar API."""
2
+
3
+ from .client import MAX_BODY_BYTES, CellarionClient, JsonDict
4
+ from .exceptions import (
5
+ CellarionApiError,
6
+ CellarionAuthError,
7
+ CellarionPushForbidden,
8
+ CellarionPushNotSupported,
9
+ CellarionScopeError,
10
+ CellarionTokensNotSupported,
11
+ )
12
+
13
+ __version__ = "0.1.0"
14
+
15
+ __all__ = [
16
+ "MAX_BODY_BYTES",
17
+ "CellarionApiError",
18
+ "CellarionAuthError",
19
+ "CellarionClient",
20
+ "CellarionPushForbidden",
21
+ "CellarionPushNotSupported",
22
+ "CellarionScopeError",
23
+ "CellarionTokensNotSupported",
24
+ "JsonDict",
25
+ "__version__",
26
+ ]
@@ -0,0 +1,390 @@
1
+ """Async client for the Cellarion wine-cellar API.
2
+
3
+ The client is deliberately thin: it maps HTTP status codes to a small
4
+ exception hierarchy, retries one expired session once, parses the
5
+ server-sent event stream, and returns JSON objects as plain dicts.
6
+ It never stores a password beyond the login it was created for.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from collections.abc import AsyncIterator
13
+ from typing import Any
14
+ from urllib.parse import quote
15
+
16
+ import aiohttp
17
+
18
+ from .exceptions import (
19
+ CellarionApiError,
20
+ CellarionAuthError,
21
+ CellarionPushForbidden,
22
+ CellarionPushNotSupported,
23
+ CellarionScopeError,
24
+ CellarionTokensNotSupported,
25
+ )
26
+
27
+ _LOGGER = logging.getLogger(__name__)
28
+
29
+ JsonDict = dict[str, Any]
30
+
31
+ # Largest response body the client will read. The stats and bottle lists the
32
+ # integration asks for are a few kilobytes; anything past this is a proxy
33
+ # error page or a misbehaving server, not data worth holding in memory.
34
+ MAX_BODY_BYTES = 4 * 1024 * 1024
35
+
36
+
37
+ async def _read_json(resp: aiohttp.ClientResponse, what: str) -> JsonDict:
38
+ """Decode a JSON object body, mapping a non-JSON answer to an API error.
39
+
40
+ A reverse proxy or SPA fallback can answer a 200 with an HTML page; that
41
+ must surface as a normal (retryable) API error, not an unhandled
42
+ exception that marks the whole update as "unexpected".
43
+ """
44
+ try:
45
+ data = await resp.json()
46
+ except (aiohttp.ClientError, ValueError) as err:
47
+ raise CellarionApiError(f"{what} response was not JSON") from err
48
+ return data if isinstance(data, dict) else {}
49
+
50
+
51
+ class CellarionClient:
52
+ """Async API client for Cellarion."""
53
+
54
+ def __init__(
55
+ self,
56
+ session: aiohttp.ClientSession,
57
+ url: str,
58
+ email: str | None = None,
59
+ password: str | None = None,
60
+ token: str | None = None,
61
+ ) -> None:
62
+ self._session = session
63
+ self._url = url.rstrip("/")
64
+ self._email = email
65
+ self._password = password
66
+ # Personal API token (cel_...) — static, never refreshed via login
67
+ self._api_token = token
68
+ self._token: str | None = token
69
+
70
+ async def authenticate(self) -> bool:
71
+ """Authenticate and store JWT token."""
72
+ if self._api_token:
73
+ # Static API token: there is nothing to refresh. Reaching this
74
+ # means the server rejected it — revoked or invalid.
75
+ raise CellarionAuthError("API token rejected (revoked or invalid)")
76
+
77
+ login_url = f"{self._url}/api/auth/login"
78
+ _LOGGER.debug("Authenticating to %s", login_url)
79
+ try:
80
+ resp = await self._session.post(
81
+ login_url,
82
+ # The Cellarion login endpoint takes "username", which matches
83
+ # against both username and email.
84
+ json={"username": self._email, "password": self._password},
85
+ timeout=aiohttp.ClientTimeout(total=15),
86
+ # Never re-send the password body to a redirect target
87
+ allow_redirects=False,
88
+ )
89
+ except (aiohttp.ClientError, TimeoutError) as err:
90
+ _LOGGER.error("Connection to %s failed: %s", login_url, err)
91
+ raise CellarionApiError(f"Connection failed: {err}") from err
92
+
93
+ if 300 <= resp.status < 400:
94
+ raise CellarionApiError(
95
+ f"Login was redirected (status {resp.status}); configure the final instance URL"
96
+ )
97
+ if resp.status in (400, 401):
98
+ raise CellarionAuthError("Invalid email or password")
99
+ if resp.status == 403:
100
+ raise CellarionAuthError("Account not verified or access denied")
101
+ if resp.status == 429:
102
+ raise CellarionApiError("Rate limited by Cellarion, try again later")
103
+ if resp.status != 200:
104
+ raise CellarionApiError(f"Login failed with status {resp.status}")
105
+
106
+ data = await _read_json(resp, "Login")
107
+ self._token = data.get("token")
108
+ if not self._token:
109
+ raise CellarionApiError("No token in login response")
110
+ return True
111
+
112
+ async def _send(self, method: str, path: str, json: JsonDict | None) -> aiohttp.ClientResponse:
113
+ """Issue one request, mapping transport errors to CellarionApiError."""
114
+ try:
115
+ return await self._session.request(
116
+ method,
117
+ f"{self._url}{path}",
118
+ headers={"Authorization": f"Bearer {self._token}"},
119
+ json=json,
120
+ timeout=aiohttp.ClientTimeout(total=30),
121
+ )
122
+ except (aiohttp.ClientError, TimeoutError) as err:
123
+ raise CellarionApiError(f"Request failed: {err}") from err
124
+
125
+ async def _request(self, method: str, path: str, json: JsonDict | None = None) -> JsonDict:
126
+ """Make an authenticated API request with auto-retry on 401."""
127
+ if not self._token:
128
+ await self.authenticate()
129
+
130
+ resp = await self._send(method, path, json)
131
+ # Always release the connection back to the pool, even on error paths.
132
+ try:
133
+ if resp.status == 401:
134
+ # Token expired — re-authenticate and retry once
135
+ _LOGGER.debug("Token expired, re-authenticating")
136
+ resp.close()
137
+ await self.authenticate()
138
+ resp = await self._send(method, path, json)
139
+ if resp.status == 401:
140
+ raise CellarionAuthError("Authentication rejected after retry")
141
+
142
+ if resp.status == 403 and self._api_token:
143
+ # Valid token, missing scope — a config error; re-login won't help
144
+ raise CellarionScopeError(f"API token lacks the scope for {method} {path}")
145
+
146
+ # Accept the 2xx success range: action endpoints (e.g. consume) may
147
+ # answer 201/204 rather than 200.
148
+ if not 200 <= resp.status < 300:
149
+ detail = ""
150
+ try:
151
+ detail = (await resp.json()).get("error", "")
152
+ except Exception: # best-effort error body
153
+ detail = ""
154
+ raise CellarionApiError(
155
+ f"{method} {path} returned status {resp.status}"
156
+ + (f": {detail}" if detail else "")
157
+ )
158
+
159
+ # getattr keeps this working against test doubles that don't
160
+ # implement content_length; real aiohttp always provides it.
161
+ length = getattr(resp, "content_length", None)
162
+ if resp.status == 204 or length == 0:
163
+ return {}
164
+ if length is not None and length > MAX_BODY_BYTES:
165
+ raise CellarionApiError(
166
+ f"{method} {path} answered with {length} bytes; refusing to read it"
167
+ )
168
+ try:
169
+ data = await resp.json()
170
+ except (aiohttp.ClientError, ValueError):
171
+ # No/!JSON body on a success status — nothing to return
172
+ return {}
173
+ return data if isinstance(data, dict) else {}
174
+ finally:
175
+ resp.close()
176
+
177
+ async def get_stats_overview(self) -> JsonDict:
178
+ """Fetch collection statistics."""
179
+ return await self._request("GET", "/api/stats/overview")
180
+
181
+ async def get_cellars(self) -> JsonDict:
182
+ """Fetch user's cellars."""
183
+ return await self._request("GET", "/api/cellars")
184
+
185
+ async def get_account_id(self) -> str | None:
186
+ """Return a stable account id for the credential, or None.
187
+
188
+ Reads the scoped identity endpoint /api/auth/whoami, which returns
189
+ just {"id": ...}. Returns None when the server or credential can't
190
+ provide one — an older server without the endpoint (404), or a token
191
+ whose scope doesn't include it (403). Callers treat None as "unknown"
192
+ and skip the check, never as an error. The nested "user" fallback keeps
193
+ it working against the fuller /api/auth/me shape too.
194
+ """
195
+ try:
196
+ data = await self._request("GET", "/api/auth/whoami")
197
+ except CellarionApiError:
198
+ return None
199
+ user = data.get("user")
200
+ user = user if isinstance(user, dict) else {}
201
+ account_id = data.get("id") or user.get("id") or user.get("_id")
202
+ return str(account_id) if account_id else None
203
+
204
+ async def get_notifications(self) -> JsonDict:
205
+ """Fetch notifications with unread count."""
206
+ return await self._request("GET", "/api/notifications")
207
+
208
+ async def async_create_api_token(self, name: str, scopes: list[str]) -> str:
209
+ """Mint a personal API token. Requires password-based login.
210
+
211
+ The Cellarion endpoint requires the account password in the body as
212
+ confirmation; the caller stores only the returned token.
213
+ """
214
+ if not self._password:
215
+ raise CellarionApiError("Password login required to mint a token")
216
+ if not self._token:
217
+ await self.authenticate()
218
+
219
+ try:
220
+ resp = await self._session.post(
221
+ f"{self._url}/api/tokens",
222
+ headers={"Authorization": f"Bearer {self._token}"},
223
+ json={
224
+ "name": name,
225
+ "scopes": scopes,
226
+ "password": self._password,
227
+ },
228
+ timeout=aiohttp.ClientTimeout(total=15),
229
+ allow_redirects=False,
230
+ )
231
+ except (aiohttp.ClientError, TimeoutError) as err:
232
+ raise CellarionApiError(f"Token creation failed: {err}") from err
233
+
234
+ if 300 <= resp.status < 400:
235
+ raise CellarionApiError(
236
+ f"Token creation was redirected (status {resp.status}); "
237
+ "configure the final instance URL"
238
+ )
239
+ if resp.status in (404, 405, 501):
240
+ raise CellarionTokensNotSupported("Server does not support API tokens")
241
+ if resp.status in (401, 403):
242
+ raise CellarionAuthError("Password confirmation rejected")
243
+ if resp.status == 429:
244
+ raise CellarionApiError("Rate limited by Cellarion, try again later")
245
+ if resp.status not in (200, 201):
246
+ raise CellarionApiError(f"Token creation returned status {resp.status}")
247
+
248
+ data = await _read_json(resp, "Token creation")
249
+ token = data.get("token")
250
+ if not token:
251
+ raise CellarionApiError("No token in creation response")
252
+ return str(token)
253
+
254
+ async def get_peak_bottles(self, limit: int = 10) -> JsonDict:
255
+ """Fetch bottles currently in their peak drink window."""
256
+ return await self._request("GET", f"/api/bottles?maturity=peak&limit={int(limit)}")
257
+
258
+ async def consume_bottle(
259
+ self,
260
+ bottle_id: str,
261
+ reason: str = "drank",
262
+ rating: float | None = None,
263
+ note: str | None = None,
264
+ ) -> JsonDict:
265
+ """Mark a bottle as consumed (drank/gifted/sold/other)."""
266
+ body: JsonDict = {"reason": reason}
267
+ if rating is not None:
268
+ body["rating"] = rating
269
+ if note:
270
+ body["note"] = note
271
+ # The id is validated upstream; quoting keeps a stray "/", "?" or "#"
272
+ # from steering the request to another path regardless.
273
+ return await self._request(
274
+ "POST", f"/api/bottles/{quote(bottle_id, safe='')}/consume", json=body
275
+ )
276
+
277
+ async def events_stream(self) -> AsyncIterator[str]:
278
+ """Yield push event names from the server's SSE stream.
279
+
280
+ Yields the sentinel "_connected" once the stream is open, then one
281
+ item per server-sent event. Raises CellarionPushNotSupported when
282
+ the server has no stream endpoint (integration falls back to
283
+ polling only).
284
+ """
285
+ if not self._token:
286
+ await self.authenticate()
287
+
288
+ url = f"{self._url}/api/events/stream"
289
+ headers = {
290
+ "Authorization": f"Bearer {self._token}",
291
+ "Accept": "text/event-stream",
292
+ }
293
+ # sock_read=90 → three missed 25s server heartbeats = dead stream
294
+ timeout = aiohttp.ClientTimeout(total=None, connect=15, sock_read=90)
295
+
296
+ try:
297
+ resp = await self._session.get(url, headers=headers, timeout=timeout)
298
+ except (aiohttp.ClientError, TimeoutError) as err:
299
+ raise CellarionApiError(f"Push stream connection failed: {err}") from err
300
+
301
+ if resp.status == 401:
302
+ resp.close()
303
+ await self.authenticate()
304
+ headers["Authorization"] = f"Bearer {self._token}"
305
+ try:
306
+ resp = await self._session.get(url, headers=headers, timeout=timeout)
307
+ except (aiohttp.ClientError, TimeoutError) as err:
308
+ raise CellarionApiError(f"Push stream retry failed: {err}") from err
309
+ if resp.status == 401:
310
+ resp.close()
311
+ raise CellarionAuthError("Push stream rejected credentials")
312
+
313
+ if resp.status == 403:
314
+ # API token without the 'read' scope — a configuration error the
315
+ # user must fix; retrying or falling back silently would hide it
316
+ resp.close()
317
+ raise CellarionPushForbidden("Credential lacks the 'read' scope for the event stream")
318
+ if resp.status in (404, 405, 501):
319
+ resp.close()
320
+ raise CellarionPushNotSupported("Server has no /api/events/stream")
321
+ if resp.status != 200:
322
+ status = resp.status
323
+ resp.close()
324
+ raise CellarionApiError(f"Push stream returned status {status}")
325
+ if not resp.content_type.startswith("text/event-stream"):
326
+ # A proxy or SPA fallback answered instead of the stream route
327
+ resp.close()
328
+ raise CellarionPushNotSupported(f"Expected text/event-stream, got {resp.content_type}")
329
+
330
+ try:
331
+ yield "_connected"
332
+ event_name: str | None = None
333
+ async for raw in resp.content:
334
+ line = raw.decode("utf-8", "ignore").rstrip("\r\n")
335
+ if not line:
336
+ if event_name:
337
+ yield event_name
338
+ event_name = None
339
+ continue
340
+ if line.startswith(":"): # heartbeat comment
341
+ continue
342
+ if line.startswith("event:"):
343
+ event_name = line[6:].strip()
344
+ elif line.startswith("data:") and event_name is None:
345
+ event_name = "message"
346
+ except (aiohttp.ClientError, TimeoutError) as err:
347
+ raise CellarionApiError(f"Push stream read failed: {err}") from err
348
+ except ValueError as err:
349
+ # aiohttp raises ValueError for a line over its 64 KiB limit —
350
+ # a proxy error page mid-stream, not a transport error
351
+ raise CellarionApiError(f"Push stream sent malformed data: {err}") from err
352
+ finally:
353
+ resp.close()
354
+
355
+ async def revoke_own_token(self) -> bool:
356
+ """Ask the server to revoke the API token this client authenticates with.
357
+
358
+ Best effort, never raises: this runs while the integration is being
359
+ deleted, and nothing the user can do at that point would change the
360
+ outcome. Returns True when the token is gone — a 200 (revoked now) or
361
+ a 401 (already revoked or invalid). Anything else means it may still
362
+ be valid: a 403 from a server older than Cellarion 1.220 that has no
363
+ self-revoke route, or an outage. Password-based clients have no token
364
+ of their own and return False.
365
+ """
366
+ if not self._api_token:
367
+ return False
368
+ try:
369
+ resp = await self._send("DELETE", "/api/tokens/self", None)
370
+ except CellarionApiError as err:
371
+ _LOGGER.debug("Token self-revocation failed: %s", err)
372
+ return False
373
+ try:
374
+ if resp.status in (200, 401):
375
+ return True
376
+ _LOGGER.debug("Token self-revocation answered %s", resp.status)
377
+ return False
378
+ finally:
379
+ resp.close()
380
+
381
+ async def get_health(self) -> JsonDict:
382
+ """Fetch service health (no auth required, but we use it anyway)."""
383
+ try:
384
+ resp = await self._session.get(
385
+ f"{self._url}/api/health",
386
+ timeout=aiohttp.ClientTimeout(total=10),
387
+ )
388
+ return await _read_json(resp, "Health")
389
+ except (aiohttp.ClientError, TimeoutError, CellarionApiError):
390
+ return {"status": "unreachable"}
@@ -0,0 +1,27 @@
1
+ """Exceptions raised by the Cellarion client."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class CellarionApiError(Exception):
7
+ """Base exception for Cellarion API errors."""
8
+
9
+
10
+ class CellarionAuthError(CellarionApiError):
11
+ """Authentication failed."""
12
+
13
+
14
+ class CellarionPushNotSupported(CellarionApiError):
15
+ """The server does not offer the push event stream."""
16
+
17
+
18
+ class CellarionPushForbidden(CellarionApiError):
19
+ """The credential lacks the scope required for the push stream."""
20
+
21
+
22
+ class CellarionScopeError(CellarionApiError):
23
+ """The API token is valid but lacks a required scope."""
24
+
25
+
26
+ class CellarionTokensNotSupported(CellarionApiError):
27
+ """The server does not support personal API tokens."""
File without changes
File without changes
@@ -0,0 +1,441 @@
1
+ """Tests for the client: stream parser, retries, error mapping, self-revocation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from typing import Any
7
+
8
+ import aiohttp
9
+ import pytest
10
+
11
+ from pycellarion import (
12
+ CellarionApiError,
13
+ CellarionAuthError,
14
+ CellarionClient,
15
+ CellarionPushForbidden,
16
+ CellarionPushNotSupported,
17
+ CellarionScopeError,
18
+ CellarionTokensNotSupported,
19
+ )
20
+
21
+ BASE_URL = "http://cellarion.local"
22
+ TEST_TOKEN = "cel_" + "a" * 64
23
+
24
+
25
+ class FakeResponse:
26
+ """Just enough of aiohttp.ClientResponse for the client."""
27
+
28
+ def __init__(
29
+ self,
30
+ status: int = 200,
31
+ json: Any = None,
32
+ *,
33
+ content_type: str = "application/json",
34
+ lines: Iterable[bytes] = (),
35
+ read_error: Exception | None = None,
36
+ content_length: int | None = None,
37
+ ) -> None:
38
+ self.status = status
39
+ self._json = json
40
+ self.content_type = content_type
41
+ self._lines = list(lines)
42
+ self._read_error = read_error
43
+ self.content_length = content_length
44
+ self.closed = False
45
+
46
+ async def json(self) -> Any:
47
+ if self._json is None:
48
+ raise ValueError("not json")
49
+ return self._json
50
+
51
+ def close(self) -> None:
52
+ self.closed = True
53
+
54
+ @property
55
+ def content(self) -> FakeResponse:
56
+ return self
57
+
58
+ def __aiter__(self) -> FakeResponse:
59
+ self._iter = iter(self._lines)
60
+ return self
61
+
62
+ async def __anext__(self) -> bytes:
63
+ try:
64
+ return next(self._iter)
65
+ except StopIteration:
66
+ if self._read_error:
67
+ err, self._read_error = self._read_error, None
68
+ raise err from None
69
+ raise StopAsyncIteration from None
70
+
71
+
72
+ class FakeSession:
73
+ """Hands out scripted responses in order and records every call."""
74
+
75
+ def __init__(self, responses: Iterable[FakeResponse | Exception]) -> None:
76
+ self._responses = list(responses)
77
+ self.calls: list[tuple[str, str, dict[str, Any]]] = []
78
+
79
+ def _next(self, method: str, url: str, kwargs: dict[str, Any]) -> FakeResponse:
80
+ self.calls.append((method, url, kwargs))
81
+ item = self._responses.pop(0)
82
+ if isinstance(item, Exception):
83
+ raise item
84
+ return item
85
+
86
+ async def request(self, method: str, url: str, **kwargs: Any) -> FakeResponse:
87
+ return self._next(method, url, kwargs)
88
+
89
+ async def get(self, url: str, **kwargs: Any) -> FakeResponse:
90
+ return self._next("GET", url, kwargs)
91
+
92
+ async def post(self, url: str, **kwargs: Any) -> FakeResponse:
93
+ return self._next("POST", url, kwargs)
94
+
95
+
96
+ def token_client(*responses: FakeResponse | Exception) -> tuple[CellarionClient, FakeSession]:
97
+ session = FakeSession(responses)
98
+ return CellarionClient(session, BASE_URL, token=TEST_TOKEN), session # type: ignore[arg-type]
99
+
100
+
101
+ def password_client(
102
+ *responses: FakeResponse | Exception,
103
+ ) -> tuple[CellarionClient, FakeSession]:
104
+ session = FakeSession(responses)
105
+ return CellarionClient(session, BASE_URL, "user@example.com", "pw"), session # type: ignore[arg-type]
106
+
107
+
108
+ SSE_BODY = [
109
+ b": heartbeat\n",
110
+ b"\n",
111
+ b"event: stats_changed\n",
112
+ b"data: {}\n",
113
+ b"\n",
114
+ b"data: bare-data-event\n",
115
+ b"\n",
116
+ b"event: ignored-without-blank-line\n",
117
+ ]
118
+
119
+
120
+ async def collect(client: CellarionClient) -> list[str]:
121
+ return [event async for event in client.events_stream()]
122
+
123
+
124
+ # ── SSE stream ───────────────────────────────────────────────────────
125
+
126
+
127
+ async def test_stream_parses_events_and_heartbeats() -> None:
128
+ """Named events, bare data lines and heartbeats map to the right yields."""
129
+ client, session = token_client(
130
+ FakeResponse(200, content_type="text/event-stream", lines=SSE_BODY)
131
+ )
132
+ assert await collect(client) == ["_connected", "stats_changed", "message"]
133
+ assert session.calls[0][2]["headers"]["Accept"] == "text/event-stream"
134
+
135
+
136
+ async def test_stream_401_relogs_once_for_password_clients() -> None:
137
+ """A rejected JWT triggers one login and a retry; a second 401 is fatal."""
138
+ client, session = password_client(
139
+ FakeResponse(200, {"token": "jwt-1"}), # initial login
140
+ FakeResponse(401), # stream rejects
141
+ FakeResponse(200, {"token": "jwt-2"}), # re-login
142
+ FakeResponse(200, content_type="text/event-stream", lines=[]),
143
+ )
144
+ assert await collect(client) == ["_connected"]
145
+ assert session.calls[-1][2]["headers"]["Authorization"] == "Bearer jwt-2"
146
+
147
+ client, _ = password_client(
148
+ FakeResponse(200, {"token": "jwt-1"}),
149
+ FakeResponse(401),
150
+ FakeResponse(200, {"token": "jwt-2"}),
151
+ FakeResponse(401),
152
+ )
153
+ with pytest.raises(CellarionAuthError):
154
+ await collect(client)
155
+
156
+
157
+ @pytest.mark.parametrize(
158
+ ("response", "expected"),
159
+ [
160
+ (FakeResponse(403), CellarionPushForbidden),
161
+ (FakeResponse(404), CellarionPushNotSupported),
162
+ (FakeResponse(501), CellarionPushNotSupported),
163
+ (FakeResponse(200, content_type="text/html"), CellarionPushNotSupported),
164
+ (FakeResponse(500), CellarionApiError),
165
+ (aiohttp.ClientError("down"), CellarionApiError),
166
+ ],
167
+ )
168
+ async def test_stream_status_mapping(response: Any, expected: type[Exception]) -> None:
169
+ """Every non-stream answer maps to the exception the listener expects."""
170
+ client, _ = token_client(response)
171
+ with pytest.raises(expected):
172
+ await collect(client)
173
+
174
+
175
+ async def test_stream_read_errors_become_api_errors() -> None:
176
+ """Transport errors and over-long lines both surface as CellarionApiError."""
177
+ for err in (aiohttp.ClientPayloadError("cut"), ValueError("Line is too long")):
178
+ client, _ = token_client(
179
+ FakeResponse(
180
+ 200,
181
+ content_type="text/event-stream",
182
+ lines=[b"event: a\n", b"\n"],
183
+ read_error=err,
184
+ )
185
+ )
186
+ got: list[str] = []
187
+ with pytest.raises(CellarionApiError):
188
+ async for event in client.events_stream():
189
+ got.append(event)
190
+ assert got == ["_connected", "a"]
191
+
192
+
193
+ # ── Authenticated requests ───────────────────────────────────────────
194
+
195
+
196
+ async def test_request_relogs_once_on_401_for_password_clients() -> None:
197
+ """An expired JWT is refreshed transparently, exactly once."""
198
+ client, session = password_client(
199
+ FakeResponse(200, {"token": "jwt-1"}),
200
+ FakeResponse(401),
201
+ FakeResponse(200, {"token": "jwt-2"}),
202
+ FakeResponse(200, {"stats": {}}),
203
+ )
204
+ assert await client.get_stats_overview() == {"stats": {}}
205
+ assert [c[0] for c in session.calls] == ["POST", "GET", "POST", "GET"]
206
+ assert session.calls[-1][2]["headers"]["Authorization"] == "Bearer jwt-2"
207
+
208
+
209
+ async def test_request_error_body_detail_is_included() -> None:
210
+ client, _ = token_client(FakeResponse(500, {"error": "boom"}))
211
+ with pytest.raises(CellarionApiError, match="status 500: boom"):
212
+ await client.get_cellars()
213
+
214
+
215
+ @pytest.mark.parametrize(
216
+ "response",
217
+ [
218
+ FakeResponse(204),
219
+ FakeResponse(200, content_length=0),
220
+ FakeResponse(200, content_type="text/plain"), # non-JSON success body
221
+ FakeResponse(200, ["not", "a", "dict"]),
222
+ ],
223
+ )
224
+ async def test_request_empty_or_odd_success_bodies_give_empty_dict(response: FakeResponse) -> None:
225
+ client, _ = token_client(response)
226
+ assert await client.consume_bottle("6a50805b785f507654afdc51") == {}
227
+ assert response.closed
228
+
229
+
230
+ async def test_scope_error_only_for_api_tokens() -> None:
231
+ """403 is a scope error with a token, a plain API error with a password."""
232
+ client, _ = token_client(FakeResponse(403, {"error": "scope"}))
233
+ with pytest.raises(CellarionScopeError):
234
+ await client.get_cellars()
235
+
236
+ client, _ = password_client(
237
+ FakeResponse(200, {"token": "jwt"}), FakeResponse(403, {"error": "no"})
238
+ )
239
+ with pytest.raises(CellarionApiError) as excinfo:
240
+ await client.get_cellars()
241
+ assert not isinstance(excinfo.value, CellarionScopeError)
242
+
243
+
244
+ @pytest.mark.parametrize(
245
+ ("body", "expected"),
246
+ [
247
+ ({"id": "A"}, "A"),
248
+ ({"user": {"id": "B"}}, "B"),
249
+ ({"user": {"_id": "C"}}, "C"),
250
+ ({"user": "not-a-dict"}, None),
251
+ ({}, None),
252
+ ],
253
+ )
254
+ async def test_get_account_id_shapes(body: dict[str, Any], expected: str | None) -> None:
255
+ client, _ = token_client(FakeResponse(200, body))
256
+ assert await client.get_account_id() == expected
257
+
258
+
259
+ # ── Login and token minting ──────────────────────────────────────────
260
+
261
+
262
+ @pytest.mark.parametrize(
263
+ ("status", "expected"),
264
+ [
265
+ (400, CellarionAuthError),
266
+ (403, CellarionAuthError),
267
+ (429, CellarionApiError),
268
+ (502, CellarionApiError),
269
+ ],
270
+ )
271
+ async def test_login_status_mapping(status: int, expected: type[Exception]) -> None:
272
+ client, _ = password_client(FakeResponse(status, {"error": "x"}))
273
+ with pytest.raises(expected):
274
+ await client.authenticate()
275
+
276
+
277
+ async def test_login_without_token_in_body() -> None:
278
+ client, _ = password_client(FakeResponse(200, {"user": {}}))
279
+ with pytest.raises(CellarionApiError, match="No token"):
280
+ await client.authenticate()
281
+
282
+
283
+ async def test_login_connection_error() -> None:
284
+ client, _ = password_client(aiohttp.ClientError("refused"))
285
+ with pytest.raises(CellarionApiError, match="Connection failed"):
286
+ await client.authenticate()
287
+
288
+
289
+ @pytest.mark.parametrize(
290
+ ("response", "expected"),
291
+ [
292
+ (FakeResponse(401), CellarionAuthError),
293
+ (FakeResponse(403), CellarionAuthError),
294
+ (FakeResponse(405), CellarionTokensNotSupported),
295
+ (FakeResponse(429), CellarionApiError),
296
+ (FakeResponse(500), CellarionApiError),
297
+ (FakeResponse(307), CellarionApiError),
298
+ (FakeResponse(201, {"id": "t1"}), CellarionApiError), # no token in body
299
+ (FakeResponse(201, content_type="text/html"), CellarionApiError),
300
+ (aiohttp.ClientError("down"), CellarionApiError),
301
+ ],
302
+ )
303
+ async def test_create_token_error_mapping(response: Any, expected: type[Exception]) -> None:
304
+ client, session = password_client(FakeResponse(200, {"token": "jwt"}), response)
305
+ with pytest.raises(expected):
306
+ await client.async_create_api_token("HA", ["read"])
307
+ if session.calls[1:]:
308
+ # The credential-bearing POST never follows a redirect
309
+ assert session.calls[1][2]["allow_redirects"] is False
310
+
311
+
312
+ async def test_create_token_needs_a_password() -> None:
313
+ client, _ = token_client()
314
+ with pytest.raises(CellarionApiError, match="Password login required"):
315
+ await client.async_create_api_token("HA", ["read"])
316
+
317
+
318
+ async def test_create_token_success_returns_string() -> None:
319
+ client, session = password_client(
320
+ FakeResponse(200, {"token": "jwt"}), FakeResponse(201, {"token": "cel_new"})
321
+ )
322
+ assert await client.async_create_api_token("HA", ["read", "consume"]) == "cel_new"
323
+ assert session.calls[1][2]["json"]["scopes"] == ["read", "consume"]
324
+
325
+
326
+ async def test_health_non_json_is_unreachable() -> None:
327
+ client, _ = token_client(FakeResponse(200, content_type="text/html"))
328
+ assert await client.get_health() == {"status": "unreachable"}
329
+
330
+
331
+ # ── Self-revocation ──────────────────────────────────────────────────
332
+
333
+
334
+ @pytest.mark.parametrize("status", [200, 401])
335
+ async def test_revoke_own_token_done_on_200_or_401(status: int) -> None:
336
+ """200 = revoked now, 401 = already revoked; both mean the token is gone."""
337
+ client, session = token_client(FakeResponse(status, {"message": "Token revoked"}))
338
+ assert await client.revoke_own_token() is True
339
+ method, url, kwargs = session.calls[0]
340
+ assert (method, url) == ("DELETE", f"{BASE_URL}/api/tokens/self")
341
+ assert kwargs["headers"]["Authorization"] == f"Bearer {TEST_TOKEN}"
342
+
343
+
344
+ @pytest.mark.parametrize(
345
+ "response",
346
+ [
347
+ FakeResponse(403, {"error": "scope"}),
348
+ FakeResponse(404),
349
+ FakeResponse(500),
350
+ aiohttp.ClientError("down"),
351
+ ],
352
+ )
353
+ async def test_revoke_own_token_is_best_effort(response: Any) -> None:
354
+ """A pre-1.220 server (403), odd answers and outages mean 'maybe still valid'."""
355
+ client, _ = token_client(response)
356
+ assert await client.revoke_own_token() is False
357
+
358
+
359
+ async def test_revoke_own_token_needs_an_api_token() -> None:
360
+ """A password-based client has no token of its own and sends nothing."""
361
+ client, session = password_client()
362
+ assert await client.revoke_own_token() is False
363
+ assert session.calls == []
364
+
365
+
366
+ async def test_oversized_body_is_refused() -> None:
367
+ """A response far larger than any real payload is an error, not a read."""
368
+ client, _ = token_client(FakeResponse(200, {"stats": {}}, content_length=50_000_000))
369
+ with pytest.raises(CellarionApiError, match="refusing to read"):
370
+ await client.get_stats_overview()
371
+
372
+
373
+ # ── Remaining branches ───────────────────────────────────────────────
374
+
375
+
376
+ async def test_token_client_401_is_fatal_without_retry() -> None:
377
+ """A static API token cannot be refreshed: one 401 is a revoked token."""
378
+ client, session = token_client(FakeResponse(401))
379
+ with pytest.raises(CellarionAuthError, match="revoked or invalid"):
380
+ await client.get_cellars()
381
+ assert len(session.calls) == 1
382
+
383
+
384
+ async def test_login_redirect_is_refused() -> None:
385
+ client, session = password_client(FakeResponse(302))
386
+ with pytest.raises(CellarionApiError, match="redirected"):
387
+ await client.authenticate()
388
+ assert session.calls[0][2]["allow_redirects"] is False
389
+
390
+
391
+ async def test_password_client_second_401_is_fatal() -> None:
392
+ client, _ = password_client(
393
+ FakeResponse(200, {"token": "jwt-1"}),
394
+ FakeResponse(401),
395
+ FakeResponse(200, {"token": "jwt-2"}),
396
+ FakeResponse(401),
397
+ )
398
+ with pytest.raises(CellarionAuthError, match="after retry"):
399
+ await client.get_cellars()
400
+
401
+
402
+ async def test_error_without_json_body_still_reports_status() -> None:
403
+ client, _ = token_client(FakeResponse(502, content_type="text/html"))
404
+ with pytest.raises(CellarionApiError, match=r"status 502$"):
405
+ await client.get_cellars()
406
+
407
+
408
+ async def test_get_account_id_on_api_error_is_none() -> None:
409
+ client, _ = token_client(FakeResponse(500, {"error": "x"}))
410
+ assert await client.get_account_id() is None
411
+
412
+
413
+ async def test_read_helpers_hit_their_paths() -> None:
414
+ client, session = token_client(
415
+ FakeResponse(200, {"notifications": [], "unreadCount": 0}),
416
+ FakeResponse(200, {"bottles": {"items": []}}),
417
+ )
418
+ await client.get_notifications()
419
+ await client.get_peak_bottles(limit=3)
420
+ assert session.calls[0][1].endswith("/api/notifications")
421
+ assert session.calls[1][1].endswith("/api/bottles?maturity=peak&limit=3")
422
+
423
+
424
+ async def test_consume_body_carries_rating_and_note() -> None:
425
+ client, session = token_client(FakeResponse(200, {"ok": True}))
426
+ await client.consume_bottle(
427
+ "6a50805b785f507654afdc51", reason="gifted", rating=92, note="lovely"
428
+ )
429
+ assert session.calls[0][2]["json"] == {"reason": "gifted", "rating": 92, "note": "lovely"}
430
+
431
+
432
+ async def test_stream_retry_connection_error() -> None:
433
+ """A transport error on the post-login retry is a normal API error."""
434
+ client, _ = password_client(
435
+ FakeResponse(200, {"token": "jwt-1"}),
436
+ FakeResponse(401),
437
+ FakeResponse(200, {"token": "jwt-2"}),
438
+ aiohttp.ClientError("gone"),
439
+ )
440
+ with pytest.raises(CellarionApiError, match="retry failed"):
441
+ await collect(client)