Files
EOS/tests/test_serverrestprofile.py
T

232 lines
10 KiB
Python
Raw Normal View History

2026-11-09 19:11:15 +00:00
"""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
2026-11-10 17:56:13 +00:00
from akkudoktoreos.devices.profile.inverterprofile import InverterProfileSettings
2026-11-09 19:11:15 +00:00
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())
2026-11-10 17:56:13 +00:00
def test_inverter_profiles(self, client):
assert "inverter" in client.get("/v1/profile").json()
listed = client.get("/v1/profile/inverter").json()
assert {"name": "generic-hybrid", "source": "package", "packaged": True} in listed
schema = client.get("/v1/profile/inverter/schema").json()
assert "power_derating" in schema["properties"]
response = client.get("/v1/profile/inverter/generic-hybrid")
assert response.status_code == 200
InverterProfileSettings.model_validate(response.json())
2026-11-09 19:11:15 +00:00
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
2026-11-10 17:56:13 +00:00
@pytest.mark.parametrize("devices", ["batteries", "electric_vehicles"])
def test_delete_of_a_battery_profile_in_use_is_409(
self, client, config_eos, generic_lfp, devices
):
client.put("/v1/profile/battery/my-battery", json=generic_lfp)
config_eos.merge_settings_from_dict(
{"devices": {devices: {"unit1": {"device_id": "unit1", "battery_profile": "my-battery"}}}}
)
response = client.delete("/v1/profile/battery/my-battery")
assert response.status_code == 409
assert response.json()["title"] == "Profile in use"
assert "unit1" in response.json()["detail"]
assert client.get("/v1/profile/battery/my-battery").status_code == 200
def test_delete_of_an_inverter_profile_in_use_is_409(self, client, config_eos):
generic_hybrid = client.get("/v1/profile/inverter/generic-hybrid").json()
client.put("/v1/profile/inverter/my-inverter", json=generic_hybrid)
config_eos.merge_settings_from_dict(
{
"devices": {
"inverters": {
"inverter1": {"device_id": "inverter1", "inverter_profile": "my-inverter"}
}
}
}
)
response = client.delete("/v1/profile/inverter/my-inverter")
assert response.status_code == 409
assert "inverter1" in response.json()["detail"]
# A battery profile of the same name is not in use.
assert client.delete("/v1/profile/battery/my-inverter").status_code == 404
def test_delete_of_an_override_in_use_reverts_to_the_packaged_profile(
self, client, config_eos, generic_lfp
):
client.put("/v1/profile/battery/generic-lfp", json=generic_lfp)
config_eos.merge_settings_from_dict(
{"devices": {"batteries": {"battery1": {"device_id": "battery1"}}}}
)
assert config_eos.devices.batteries["battery1"].battery_profile == "generic-lfp"
assert client.delete("/v1/profile/battery/generic-lfp").status_code == 200
2026-11-09 19:11:15 +00:00
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"}