Repository: bimmerconnected/bimmer_connected
Pull request #489 (2022-10-01 10:31:56)

Store fingerprint responses in memory instead of writing files
## Proposed change
<!--
  Describe the big picture of your changes here to communicate to the
  maintainers why we should accept this pull request. If it fixes a bug
  or resolves a feature request, be sure to link to that issue in the
  additional information section.
-->
Fingerprints are by default stored in memory, as suggested in https://github.com/home-assistant/core/pull/74871#discussion_r979521676.
At maximum, 10 responses are stored to generate unlimited memory usage. When retrieving the responses, the current store is cleared.

Fingerprint functionality in CLI still works as before (although file names have been adjusted).

## Type of change
- [ ] Bugfix (non-breaking change which fixes an issue)
- [ ] New feature (which adds functionality to this library)
- [ ] Breaking change (fix/feature causing existing functionality to break)
- [x] Code quality improvements to existing code or addition of tests

## Additional information
<!--
  Details are important, and help maintainers processing your PR.
  Please be sure to fill out additional details, if applicable.
-->

- This PR fixes or closes issue: fixes #
- This PR is related to issue: https://github.com/home-assistant/core/pull/74871
## Checklist
<!--
  Put an `x` in the boxes that apply. You can also fill these out after
  creating the PR. If you're unsure about any of them, don't hesitate to ask.
  We're here to help! This is simply a reminder of what we are going to look
  for before merging your code.
-->

- [x] The code change is tested and works locally.
- [x] Tests have been added to verify that the new code works.


codecov-commenter: # [Codecov](https://codecov.io/gh/bimmerconnected/bimmer_connected/pull/489?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bimmerconnected) Report
Base: **100.00**% // Head: **100.00**% // No change to project coverage :thumbsup:
> Coverage data is based on head [(`4f42b04`)](https://codecov.io/gh/bimmerconnected/bimmer_connected/pull/489?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bimmerconnected) compared to base [(`ed289df`)](https://codecov.io/gh/bimmerconnected/bimmer_connected/commit/ed289dfc3932a790c686187ce096c66d43833c31?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bimmerconnected).
> Patch coverage: 100.00% of modified lines in pull request are covered.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff            @@
##            master      #489   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           16        16           
  Lines         1244      1236    -8     
=========================================
- Hits          1244      1236    -8     
```

| Flag | Coverage Δ | |
|---|---|---|
| 3.10.x | `100.00% <100.00%> (ø)` | |
| 3.9 | `100.00% <100.00%> (?)` | |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bimmerconnected#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://codecov.io/gh/bimmerconnected/bimmer_connected/pull/489?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bimmerconnected) | Coverage Δ | |
|---|---|---|
| [bimmer\_connected/account.py](https://codecov.io/gh/bimmerconnected/bimmer_connected/pull/489/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bimmerconnected#diff-YmltbWVyX2Nvbm5lY3RlZC9hY2NvdW50LnB5) | `100.00% <100.00%> (ø)` | |
| [bimmer\_connected/api/client.py](https://codecov.io/gh/bimmerconnected/bimmer_connected/pull/489/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bimmerconnected#diff-YmltbWVyX2Nvbm5lY3RlZC9hcGkvY2xpZW50LnB5) | `100.00% <100.00%> (ø)` | |
| [bimmer\_connected/api/utils.py](https://codecov.io/gh/bimmerconnected/bimmer_connected/pull/489/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bimmerconnected#diff-YmltbWVyX2Nvbm5lY3RlZC9hcGkvdXRpbHMucHk=) | `100.00% <100.00%> (ø)` | |
| [bimmer\_connected/models.py](https://codecov.io/gh/bimmerconnected/bimmer_connected/pull/489/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bimmerconnected#diff-YmltbWVyX2Nvbm5lY3RlZC9tb2RlbHMucHk=) | `100.00% <100.00%> (ø)` | |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bimmerconnected). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bimmerconnected)

</details>

[:umbrella: View full report at Codecov](https://codecov.io/gh/bimmerconnected/bimmer_connected/pull/489?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bimmerconnected).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bimmerconnected).


Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 05889dc..621af83 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,6 +1,6 @@
 repos:
   - repo: https://github.com/psf/black
-    rev: 22.3.0
+    rev: 22.8.0
     hooks:
       - id: black
         args:
@@ -8,7 +8,7 @@ repos:
           - --quiet
         files: ^((bimmer_connected|test)/.+)?[^/]+\.py$
   - repo: https://github.com/codespell-project/codespell
-    rev: v2.1.0
+    rev: v2.2.1
     hooks:
       - id: codespell
         args:
@@ -18,17 +18,17 @@ repos:
         exclude_types: [csv, json]
         exclude: ^test/responses/
   - repo: https://github.com/PyCQA/flake8
-    rev: 4.0.1
+    rev: 5.0.4
     hooks:
       - id: flake8
         additional_dependencies:
-          - pycodestyle==2.8.0
-          - pyflakes==2.4.0
+          - pycodestyle==2.9.1
+          - pyflakes==2.5.0
           - flake8-docstrings==1.6.0
           - pydocstyle==6.1.1
-          - flake8-comprehensions==3.7.0
-          - flake8-noqa==1.2.1
-          - mccabe==0.6.1
+          - flake8-comprehensions==3.10.0
+          - flake8-noqa==1.2.9
+          - mccabe==0.7.0
         files: ^(bimmer_connected|test)/.+\.py$
   - repo: https://github.com/PyCQA/isort
     rev: 5.10.1
@@ -36,7 +36,7 @@ repos:
       - id: isort
         files: ^(bimmer_connected|test)/.+\.py$
   - repo: https://github.com/pre-commit/mirrors-mypy
-    rev: v0.950
+    rev: v0.981
     hooks: 
       - id: mypy
         name: mypy
diff --git a/bimmer_connected/account.py b/bimmer_connected/account.py
index 0a10808..3e56529 100644
--- a/bimmer_connected/account.py
+++ b/bimmer_connected/account.py
@@ -2,17 +2,16 @@
 
 import datetime
 import logging
-import pathlib
 from dataclasses import InitVar, dataclass, field
 from typing import List, Optional
 
 import httpx
 
 from bimmer_connected.api.authentication import MyBMWAuthentication
-from bimmer_connected.api.client import MyBMWClient, MyBMWClientConfiguration
+from bimmer_connected.api.client import RESPONSE_STORE, MyBMWClient, MyBMWClientConfiguration
 from bimmer_connected.api.regions import Regions
 from bimmer_connected.const import VEHICLE_STATE_URL, VEHICLES_URL, CarBrands
-from bimmer_connected.models import GPSPosition
+from bimmer_connected.models import AnonymizedResponse, GPSPosition
 from bimmer_connected.utils import deprecated
 from bimmer_connected.vehicle import MyBMWVehicle
 
@@ -37,7 +36,7 @@ class MyBMWAccount:  # pylint: disable=too-many-instance-attributes
     config: MyBMWClientConfiguration = None  # type: ignore[assignment]
     """Optional. If provided, username/password/region are ignored."""
 
-    log_responses: InitVar[pathlib.Path] = None
+    log_responses: InitVar[bool] = False
     """Optional. If set, all responses from the server will be logged to this directory."""
 
     observer_position: InitVar[GPSPosition] = None
@@ -52,7 +51,7 @@ class MyBMWAccount:  # pylint: disable=too-many-instance-attributes
         if self.config is None:
             self.config = MyBMWClientConfiguration(
                 MyBMWAuthentication(self.username, password, self.region),
-                log_response_path=log_responses,
+                log_responses=log_responses,
                 observer_position=observer_position,
                 use_metric_units=use_metric_units,
             )
@@ -136,6 +135,13 @@ class MyBMWAccount:  # pylint: disable=too-many-instance-attributes
         """Change between using metric units (km, l) if True or imperial units (mi, gal) if False."""
         self.config.use_metric_units = use_metric_units
 
+    @staticmethod
+    def get_stored_responses() -> List[AnonymizedResponse]:
+        """Return responses stored if log_responses was set to True."""
+        responses = list(RESPONSE_STORE)
+        RESPONSE_STORE.clear()
+        return responses
+
     @property
     def timezone(self):
         """Returns the current tzinfo."""
diff --git a/bimmer_connected/api/client.py b/bimmer_connected/api/client.py
index 23e5325..141ea38 100644
--- a/bimmer_connected/api/client.py
+++ b/bimmer_connected/api/client.py
@@ -1,18 +1,18 @@
 """Generic API management."""
 
-import pathlib
-import re
-from collections import defaultdict
+from collections import defaultdict, deque
 from dataclasses import dataclass
-from typing import Dict, Optional
+from typing import Deque, Dict, Optional
 
 import httpx
 
 from bimmer_connected.api.authentication import MyBMWAuthentication
 from bimmer_connected.api.regions import get_app_version, get_server_url
-from bimmer_connected.api.utils import get_correlation_id, log_to_to_file
+from bimmer_connected.api.utils import anonymize_response, get_correlation_id
 from bimmer_connected.const import HTTPX_TIMEOUT, USER_AGENT, X_USER_AGENT, CarBrands
-from bimmer_connected.models import GPSPosition
+from bimmer_connected.models import AnonymizedResponse, GPSPosition
+
+RESPONSE_STORE: Deque[AnonymizedResponse] = deque(maxlen=10)
 
 
 @dataclass
@@ -20,10 +20,16 @@ class MyBMWClientConfiguration:
     """Stores global settings for MyBMWClient."""
 
     authentication: MyBMWAuthentication
-    log_response_path: Optional[pathlib.Path] = None
+    log_responses: Optional[bool] = False
     observer_position: Optional[GPSPosition] = None
     use_metric_units: Optional[bool] = True
 
+    def set_log_responses(self, log_responses: bool) -> None:
+        """Set if responses are logged and clear response store."""
+
+        self.log_responses = log_responses
+        RESPONSE_STORE.clear()
+
 
 class MyBMWClient(httpx.AsyncClient):
     """Async HTTP client based on `httpx.AsyncClient` with automated OAuth token refresh."""
@@ -44,15 +50,12 @@ class MyBMWClient(httpx.AsyncClient):
         # Register event hooks
         kwargs["event_hooks"] = defaultdict(list, **kwargs.get("event_hooks", {}))
 
-        # Event hook for logging content to file
+        # Event hook for logging content
         async def log_response(response: httpx.Response):
-            content = await response.aread()
-            brand = [x for x in [b.value for b in CarBrands] if x in response.request.headers.get("x-user-agent", "")]
-            base_file_name = "_".join([response.url.path.split("/")[-1]] + brand)
-            base_file_name = re.sub(r"\d", "0", base_file_name)
-            await log_to_to_file(content, config.log_response_path, base_file_name)  # type: ignore[arg-type]
+            await response.aread()
+            RESPONSE_STORE.append(anonymize_response(response))
 
-        if config.log_response_path:
+        if config.log_responses:
             kwargs["event_hooks"]["response"].append(log_response)
 
         # Event hook which calls raise_for_status on all requests
diff --git a/bimmer_connected/api/utils.py b/bimmer_connected/api/utils.py
index d210e3d..1afa30b 100644
--- a/bimmer_connected/api/utils.py
+++ b/bimmer_connected/api/utils.py
@@ -4,20 +4,20 @@ import base64
 import hashlib
 import json
 import logging
-import pathlib
+import mimetypes
 import random
+import re
 import string
-from tempfile import TemporaryDirectory
-from typing import TYPE_CHECKING, Any, Dict, List, Union
+from typing import Dict, List, Match, Union
 from uuid import uuid4
 
 import httpx
-from aiofile import async_open
 
-if TYPE_CHECKING:
-    from bimmer_connected.account import MyBMWAccount
+from bimmer_connected.models import AnonymizedResponse
 
 UNICODE_CHARACTER_SET = string.ascii_letters + string.digits + "-._~"
+RE_VIN = re.compile(r"(?P<vin>WB[a-zA-Z0-9]{15})")
+ANONYMIZED_VINS: Dict[str, str] = {}
 
 
 def generate_token(length: int = 30, chars: str = UNICODE_CHARACTER_SET) -> str:
@@ -66,7 +66,6 @@ def anonymize_data(json_data: Union[List, Dict]) -> Union[List, Dict]:
         "lon": 34.5678,
         "longitude": 34.5678,
         "heading": 123,
-        "vin": "some_vin",
         "licensePlate": "some_license_plate",
         "name": "some_name",
         "city": "some_city",
@@ -84,52 +83,36 @@ def anonymize_data(json_data: Union[List, Dict]) -> Union[List, Dict]:
         for key, value in json_data.items():
             if key in replacements:
                 json_data[key] = replacements[key]
+            elif isinstance(value, str):
+                json_data[key] = RE_VIN.sub(anonymize_vin, json_data[key])
             else:
                 json_data[key] = anonymize_data(value)
 
     return json_data
 
 
-async def log_to_to_file(content: Union[str, bytes, List, Dict], logfile_path: pathlib.Path, logfile_name: str) -> None:
-    """If a log path is set, log all responses to a file."""
-    if logfile_path is None or logfile_name is None:
-        return
+def anonymize_vin(match: Match):
+    """Anonymize VINs but keep assignment."""
+    vin = match.groupdict()["vin"]
+    if vin not in ANONYMIZED_VINS:
+        ANONYMIZED_VINS[vin] = f"{vin[:3]}0FINGERPRINT{str(len(ANONYMIZED_VINS)+1).zfill(2)}"
+    return ANONYMIZED_VINS[vin]
 
-    try:
-        parsed_content = json.loads(content)  # type: ignore[arg-type]
-        anonymized_data = json.dumps(anonymize_data(parsed_content), indent=2, sort_keys=True)  # type: ignore[arg-type]
-        file_extension = "json"
-    except json.JSONDecodeError:
-        anonymized_data = content.decode("UTF-8") if isinstance(content, bytes) else content  # type: ignore[assignment]
-        file_extension = "txt"
-
-    output_path = None
-    count = 0
 
-    while output_path is None or output_path.exists():
-        output_path = logfile_path / f"{logfile_name}_{count}.{file_extension}"
-        count += 1
+def anonymize_response(response: httpx.Response) -> AnonymizedResponse:
+    """Anonymize a responses URL and content."""
+    brand = response.request.headers.get("x-user-agent", ";").split(";")[1]
+    brand = f"{brand}-" if brand else ""
 
-    async with async_open(output_path, "w", encoding="UTF-8") as logfile:
-        await logfile.write(anonymized_data)
+    url_path = "_".join(response.url.path.split("/")[3:] + [str(response.request.url.params)])
+    url_path = RE_VIN.sub(anonymize_vin, url_path)
 
+    try:
+        content: Union[List, Dict, str]
+        content = anonymize_data(response.json())
+    except json.JSONDecodeError:
+        content = response.text
 
-async def get_fingerprints(account: "MyBMWAccount") -> Dict[str, Any]:
-    """Retrieve vehicle data from BMW servers and return original responses as JSON."""
-    original_log_response_path = account.config.log_response_path
-    fingerprints = {}
+    file_extension = mimetypes.guess_extension(response.headers.get("content-type") or "") or ".txt"
 
-    try:
-        # Use a temporary directory to just get the files from one call to get_vehicles()
-        with TemporaryDirectory() as tempdir:
-            tempdir_path = pathlib.Path(tempdir)
-            account.config.log_response_path = tempdir_path
-            await account.get_vehicles()
-            for logfile in tempdir_path.iterdir():
-                async with async_open(logfile, "rb") as pointer:
-                    fingerprints[logfile.name] = json.loads(await pointer.read())
-    finally:
-        # Make sure that log_response_path is always set to the original value afterwards
-        account.config.log_response_path = original_log_response_path
-
-    return fingerprints
+    return AnonymizedResponse(f"{brand}{url_path}{file_extension}", content)
diff --git a/bimmer_connected/cli.py b/bimmer_connected/cli.py
index 963788e..eb6d12d 100644
--- a/bimmer_connected/cli.py
+++ b/bimmer_connected/cli.py
@@ -11,12 +11,11 @@ from datetime import datetime
 from pathlib import Path
 
 import httpx
-from aiofile import async_open
 
 from bimmer_connected.account import MyBMWAccount
 from bimmer_connected.api.client import MyBMWClient
 from bimmer_connected.api.regions import get_region_from_name, valid_regions
-from bimmer_connected.utils import MyBMWJSONEncoder
+from bimmer_connected.utils import MyBMWJSONEncoder, log_response_store_to_file
 from bimmer_connected.vehicle import MyBMWVehicle, VehicleViewDirection
 
 TEXT_VIN = "Vehicle Identification Number"
@@ -135,7 +134,7 @@ async def fingerprint(args) -> None:
         args.username,
         args.password,
         get_region_from_name(args.region),
-        log_responses=time_dir,
+        log_responses=True,
         use_metric_units=(not args.imperial),
     )
     if args.lat and args.lng:
@@ -159,6 +158,7 @@ async def fingerprint(args) -> None:
             except httpx.HTTPStatusError:
                 pass
 
+    log_response_store_to_file(account.get_stored_responses(), time_dir)
     print(f"fingerprint of the vehicles written to {time_dir}")
 
 
@@ -196,9 +196,9 @@ async def image(args) -> None:
 
     for viewdirection in VehicleViewDirection:
         filename = str(viewdirection.name).lower() + ".png"
-        async with async_open(filename, "wb") as output_file:
+        with open(filename, "wb") as output_file:
             image_data = await vehicle.get_vehicle_image(viewdirection)
-            await output_file.write(image_data)
+            output_file.write(image_data)
         print(f"vehicle image saved to {filename}")
 
 
diff --git a/bimmer_connected/models.py b/bimmer_connected/models.py
index 9b86834..fb92e66 100644
--- a/bimmer_connected/models.py
+++ b/bimmer_connected/models.py
@@ -126,7 +126,10 @@ class ValueWithUnit(NamedTuple):
     value: Optional[Union[int, float]]
     unit: Optional[str]
 
-    # def __add__(self, other: "ValueWithUnit"):
-    #     if self.unit != other.unit or not other:
-    #         raise ValueError("Both values must have the same unit!")
-    #     return ValueWithUnit(self.value + other.value, self.unit)
+
+@dataclass
+class AnonymizedResponse:
+    """An anonymized response."""
+
+    filename: str
+    content: Optional[Union[List, Dict, str]] = None
diff --git a/bimmer_connected/utils.py b/bimmer_connected/utils.py
index 56e9d64..2c1a27f 100644
--- a/bimmer_connected/utils.py
+++ b/bimmer_connected/utils.py
@@ -4,10 +4,13 @@ import datetime
 import inspect
 import json
 import logging
+import pathlib
 import time
 import traceback
 from enum import Enum
-from typing import TYPE_CHECKING, Dict, Optional
+from typing import TYPE_CHECKING, Dict, List, Optional
+
+from bimmer_connected.models import AnonymizedResponse
 
 if TYPE_CHECKING:
     from typing import Callable, TypeVar
@@ -102,3 +105,17 @@ def to_camel_case(input_str: str) -> str:
         retval = retval + (curr.upper() if flag_upper else curr)
         flag_upper = False
     return retval
+
+
+def log_response_store_to_file(response_store: List[AnonymizedResponse], logfile_path: pathlib.Path) -> None:
+    """Log all responses to files."""
+
+    for response in response_store:
+        output_path = logfile_path / response.filename
+        content = response.content
+
+        with open(output_path, "w", encoding="UTF-8") as logfile:
+            if output_path.suffix == ".json" or not isinstance(content, str):
+                json.dump(content or [], logfile, indent=4, sort_keys=True)
+            else:
+                logfile.write((content or "NO CONTENT"))
diff --git a/requirements.txt b/requirements.txt
index 6b6bb12..2a7c91d 100755
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,4 +1,3 @@
 httpx
 pycryptodome>=3.4
-pyjwt>=2.1.0
-aiofile
\ No newline at end of file
+pyjwt>=2.1.0
\ No newline at end of file
diff --git a/setup.py b/setup.py
index c3bab25..5f2e7a1 100644
--- a/setup.py
+++ b/setup.py
@@ -8,6 +8,5 @@ setup(
         "httpx",
         "pycryptodome>=3.4",
         "pyjwt>=2.1.0",
-        "aiofile",
     ],
 )


Test patch:
diff --git a/test/test_account.py b/test/test_account.py
index e48e1e1..8b45fa5 100644
--- a/test/test_account.py
+++ b/test/test_account.py
@@ -2,6 +2,7 @@
 
 import datetime
 import logging
+from pathlib import Path
 
 try:
     from unittest import mock
@@ -20,7 +21,6 @@ from bimmer_connected.account import ConnectedDriveAccount, MyBMWAccount
 from bimmer_connected.api.authentication import MyBMWAuthentication, MyBMWLoginRetry
 from bimmer_connected.api.client import MyBMWClient
 from bimmer_connected.api.regions import get_region_from_name
-from bimmer_connected.api.utils import get_fingerprints
 from bimmer_connected.const import Regions
 from bimmer_connected.models import GPSPosition
 
@@ -357,10 +357,10 @@ async def test_vehicle_search_case():
 
 
 @pytest.mark.asyncio
-async def test_storing_fingerprints(tmp_path):
-    """Test the login flow."""
+async def test_get_fingerprints():
+    """Test getting fingerprints."""
     with account_mock() as mock_api:
-        account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION, log_responses=tmp_path)
+        account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION, log_responses=True)
         await account.get_vehicles()
 
         mock_api.get(path__regex=r"^/eadrax-vcs/v2/vehicles/(?P<vin>\w+)/state").respond(
@@ -369,9 +369,9 @@ async def test_storing_fingerprints(tmp_path):
         with pytest.raises(httpx.HTTPStatusError):
             await account.get_vehicles()
 
-    files = list(tmp_path.iterdir())
-    json_files = [f for f in files if f.suffix == ".json"]
-    txt_files = [f for f in files if f.suffix == ".txt"]
+    filenames = [Path(f.filename) for f in account.get_stored_responses()]
+    json_files = [f for f in filenames if f.suffix == ".json"]
+    txt_files = [f for f in filenames if f.suffix == ".txt"]
 
     assert len(json_files) == (get_fingerprint_count() + 2)
     assert len(txt_files) == 1
@@ -572,17 +572,3 @@ async def test_client_async_only():
     with httpx.Client(auth=MyBMWLoginRetry()) as client:
         with pytest.raises(RuntimeError):
             client.get("/eadrax-ucs/v1/presentation/oauth/config")
-
-
-@pytest.mark.asyncio
-async def test_get_fingerprints():
-    """Test get_fingerprints to JSON."""
-    with account_mock():
-        account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION)
-
-        fingerprints = await get_fingerprints(account)
-
-        assert len([f for f in fingerprints if f.startswith("vehicles")]) == 2
-        assert len([f for f in fingerprints if f.startswith("state_bmw")]) == len(ALL_FINGERPRINTS.get("bmw", {}))
-        assert len([f for f in fingerprints if f.startswith("state_mini")]) == len(ALL_FINGERPRINTS.get("mini", {}))
-        assert len([f for f in fingerprints if f.startswith("state")]) == get_fingerprint_count()
diff --git a/test/test_api.py b/test/test_api.py
index c856b25..4ab6f13 100644
--- a/test/test_api.py
+++ b/test/test_api.py
@@ -1,10 +1,16 @@
 """Tests for API that are not covered by other tests."""
 import json
 
+import httpx
 import pytest
 
+from bimmer_connected.account import MyBMWAccount
 from bimmer_connected.api.regions import get_region_from_name, valid_regions
-from bimmer_connected.api.utils import anonymize_data, log_to_to_file
+from bimmer_connected.api.utils import anonymize_data
+from bimmer_connected.utils import log_response_store_to_file
+
+from . import RESPONSE_DIR, TEST_PASSWORD, TEST_REGION, TEST_USERNAME, get_fingerprint_count, load_response
+from .test_account import account_mock
 
 
 def test_valid_regions():
@@ -21,7 +27,7 @@ def test_unknown_region():
 def test_anonymize_data():
     """Test anonymization function."""
     test_dict = {
-        "vin": "secret",
+        "vin": "WBA000000SECRET01",
         "a sub-dict": {
             "lat": 666,
             "lon": 666,
@@ -30,7 +36,7 @@ def test_anonymize_data():
         "licensePlate": "secret",
         "public": "public_data",
         "a_list": [
-            {"vin": "secret"},
+            {"vin": "WBA000000SECRET01"},
             {
                 "lon": 666,
                 "public": "more_public_data",
@@ -40,6 +46,7 @@ def test_anonymize_data():
         "empty_list": [],
     }
     anon_text = json.dumps(anonymize_data(test_dict))
+    assert "SECRET" not in anon_text
     assert "secret" not in anon_text
     assert "666" not in anon_text
     assert "public_data" in anon_text
@@ -47,7 +54,49 @@ def test_anonymize_data():
 
 
 @pytest.mark.asyncio
-async def test_log_to_file_without_file_name(tmp_path):
-    """Test not logging to file if no file name is given."""
-    assert await log_to_to_file(content=[], logfile_path=tmp_path, logfile_name=None) is None
-    assert len(list(tmp_path.iterdir())) == 0
+async def test_storing_fingerprints(tmp_path):
+    """Test storing fingerprints to file."""
+    with account_mock() as mock_api:
+        account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION, log_responses=True)
+        await account.get_vehicles()
+
+        mock_api.get(path__regex=r"^/eadrax-vcs/v2/vehicles/(?P<vin>\w+)/state").respond(
+            500, text=load_response(RESPONSE_DIR / "auth" / "auth_error_internal_error.txt")
+        )
+        with pytest.raises(httpx.HTTPStatusError):
+            await account.get_vehicles()
+
+    log_response_store_to_file(account.get_stored_responses(), tmp_path)
+
+    files = list(tmp_path.iterdir())
+    json_files = [f for f in files if f.suffix == ".json"]
+    txt_files = [f for f in files if f.suffix == ".txt"]
+
+    assert len(json_files) == (get_fingerprint_count() + 2)
+    assert len(txt_files) == 1
+
+
+@pytest.mark.asyncio
+async def test_fingerprint_deque():
+    """Test storing fingerprints to file."""
+    with account_mock() as mock_api:
+        account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION, log_responses=True)
+        await account.get_vehicles()
+        await account.get_vehicles()
+
+        # More than 10 calls were made, but only last 10 are stored
+        assert len([c for c in mock_api.calls if c.request.url.path.startswith("/eadrax-vcs")]) > 10
+        assert len(account.get_stored_responses()) == min(2 + get_fingerprint_count() * 2, 10)
+
+        # Stored responses are reset
+        account.config.set_log_responses(False)
+        assert len(account.get_stored_responses()) == 0
+
+        # No new responses are getting added
+        await account.get_vehicles()
+        assert len(account.get_stored_responses()) == 0
+
+        # Get responses again
+        account.config.set_log_responses(True)
+        await account.get_vehicles()
+        assert len(account.get_stored_responses()) == min(get_fingerprint_count(), 10)