"""Tests for the REST API of the device profile store (/v1/profile).""" import json from pathlib import Path from typing import Iterator import pytest from fastapi import FastAPI from fastapi.testclient import TestClient from akkudoktoreos.devices.profile.batteryprofile import BatteryProfileSettings from akkudoktoreos.devices.profile.profilestore import ProfileStore, get_profile_store from akkudoktoreos.server.rest.error import register_problem_handlers from akkudoktoreos.server.rest.profile import router @pytest.fixture def client(config_eos) -> Iterator[TestClient]: """Client of an app with only the profile router, on a fresh store.""" ProfileStore.reset_instance() app = FastAPI() register_problem_handlers(app) app.include_router(router) with TestClient(app) as test_client: yield test_client ProfileStore.reset_instance() @pytest.fixture def generic_lfp() -> dict: """The document of the packaged generic LFP battery profile.""" store = get_profile_store() path = store.package_dir(BatteryProfileSettings) / "generic-lfp.json" return json.loads(path.read_text(encoding="utf-8")) def user_path(name: str) -> Path: return get_profile_store().user_dir(BatteryProfileSettings) / f"{name}.json" class TestRead: def test_kinds(self, client): response = client.get("/v1/profile") assert response.status_code == 200 assert "battery" in response.json() def test_list_contains_the_packaged_profile(self, client): response = client.get("/v1/profile/battery") assert response.status_code == 200 assert {"name": "generic-lfp", "source": "package", "packaged": True} in response.json() def test_schema(self, client): response = client.get("/v1/profile/battery/schema") assert response.status_code == 200 assert "charge_limit_crate" in response.json()["properties"] def test_get_document(self, client): response = client.get("/v1/profile/battery/generic-lfp") assert response.status_code == 200 assert response.json()["chemistry"] == "LFP" BatteryProfileSettings.model_validate(response.json()) def test_errors_are_problem_details(self, client): response = client.get("/v1/profile/nope") assert response.headers["content-type"] == "application/problem+json" assert response.json() == { "type": "about:blank", "title": "Unknown profile kind", "status": 404, "detail": "Unknown profile kind 'nope'.", "instance": "/v1/profile/nope", } def test_unknown_kind_is_404(self, client): assert client.get("/v1/profile/nope").status_code == 404 assert client.get("/v1/profile/nope/x").status_code == 404 assert client.put("/v1/profile/nope/x", json={}).status_code == 404 def test_missing_profile_is_404(self, client): response = client.get("/v1/profile/battery/does-not-exist") assert response.status_code == 404 assert "does-not-exist" in response.json()["detail"] class TestWrite: def test_put_saves_a_user_profile_and_returns_it(self, client, generic_lfp): document = {**generic_lfp, "battery_model": "My battery", "nominal_capacity_wh": 10240.0} response = client.put("/v1/profile/battery/my-battery", json=document) assert response.status_code == 200 assert response.json()["nominal_capacity_wh"] == 10240.0 assert user_path("my-battery").is_file() assert client.get("/v1/profile/battery/my-battery").json()["battery_model"] == "My battery" assert {"name": "my-battery", "source": "user", "packaged": False} in client.get( "/v1/profile/battery" ).json() def test_put_takes_effect_for_compiled_profile_users(self, client, generic_lfp): store = get_profile_store() before = store.get(BatteryProfileSettings, "generic-lfp") assert before.nominal_capacity_wh is None response = client.put( "/v1/profile/battery/generic-lfp", json={**generic_lfp, "nominal_capacity_wh": 5000.0} ) assert response.status_code == 200 after = store.get(BatteryProfileSettings, "generic-lfp") assert after is not before assert after.nominal_capacity_wh == 5000.0 def test_put_of_an_invalid_document_is_422_and_saves_nothing(self, client, generic_lfp): response = client.put( "/v1/profile/battery/broken", json={**generic_lfp, "nominal_capacity_wh": -1} ) assert response.status_code == 422 assert "nominal_capacity_wh" in response.json()["detail"] assert not user_path("broken").exists() def test_put_with_an_unsafe_name_is_422(self, client, generic_lfp): response = client.put("/v1/profile/battery/..%2F..%2Fescape", json=generic_lfp) assert response.status_code in (404, 422) # rejected by the router or the store data_dir = get_profile_store().user_dir(BatteryProfileSettings).parent.parent assert not (data_dir / "escape.json").exists() def test_names_with_subdirectories_are_not_routed(self, client, generic_lfp): """Profile names are flat; a path below the name matches no route.""" assert client.put("/v1/profile/battery/vendor/model-a", json=generic_lfp).status_code == 404 assert not user_path("vendor/model-a").exists() @pytest.mark.parametrize("name", ["C:evil", ".hidden"]) def test_invalid_names_are_rejected(self, client, generic_lfp, name): response = client.put(f"/v1/profile/battery/{name}", json=generic_lfp) assert response.status_code == 422 assert "Invalid battery profile name" in response.json()["detail"] def test_reserved_name_is_rejected(self, client, generic_lfp): """PUT .../schema would save a profile that GET can never return.""" response = client.put("/v1/profile/battery/schema", json=generic_lfp) assert response.status_code == 422 assert "reserved" in response.json()["detail"] class TestDelete: def test_delete_of_a_directory_is_an_eos_problem(self, client): user_path("model-a").mkdir(parents=True) response = client.delete("/v1/profile/battery/model-a") assert response.status_code == 422 assert "is not a file" in response.json()["detail"] def test_delete_user_profile(self, client, generic_lfp): client.put("/v1/profile/battery/my-battery", json=generic_lfp) response = client.delete("/v1/profile/battery/my-battery") assert response.status_code == 200 assert response.json() is None # no packaged profile of that name assert client.get("/v1/profile/battery/my-battery").status_code == 404 def test_delete_of_an_override_restores_the_packaged_profile(self, client, generic_lfp): client.put( "/v1/profile/battery/generic-lfp", json={**generic_lfp, "nominal_capacity_wh": 5000.0} ) response = client.delete("/v1/profile/battery/generic-lfp") assert response.status_code == 200 assert response.json() == {"name": "generic-lfp", "source": "package", "packaged": True} assert client.get("/v1/profile/battery/generic-lfp").json()["nominal_capacity_wh"] is None def test_delete_of_a_packaged_profile_is_409(self, client): response = client.delete("/v1/profile/battery/generic-lfp") assert response.status_code == 409 assert client.get("/v1/profile/battery/generic-lfp").status_code == 200 def test_delete_of_a_missing_profile_is_404(self, client): assert client.delete("/v1/profile/battery/does-not-exist").status_code == 404 def test_eos_app_serves_the_profile_api(config_eos): from akkudoktoreos.server.eos import app paths = app.openapi()["paths"] assert "/v1/profile/{kind}/{name}" in paths assert set(paths["/v1/profile/{kind}/{name}"]) == {"get", "put", "delete"}