From ec39182bbd260402de6d70a91ec4d3a85e0423f2 Mon Sep 17 00:00:00 2001 From: nick Date: Sun, 7 Jun 2026 10:15:33 -0600 Subject: [PATCH] Add HTTP API contract fixtures --- run_tests.py | 1 + simulator/requirements.txt | 1 + tests/fixtures/http_api/config_response.json | 31 +++ tests/fixtures/http_api/error_response.json | 4 + .../fixtures/http_api/relay_set_request.json | 4 + .../fixtures/http_api/relay_set_response.json | 6 + tests/fixtures/http_api/status_response.json | 112 ++++++++++ .../http_api/temp_assign_request.json | 4 + .../http_api/temp_assign_response.json | 18 ++ .../fixtures/http_api/temp_clear_request.json | 3 + .../http_api/temp_clear_response.json | 18 ++ .../fixtures/http_api/temp_scan_response.json | 10 + .../http_api/wifi_config_request.json | 9 + .../http_api/wifi_config_response.json | 21 ++ tests/test_http_api_contract.py | 206 ++++++++++++++++++ 15 files changed, 448 insertions(+) create mode 100644 tests/fixtures/http_api/config_response.json create mode 100644 tests/fixtures/http_api/error_response.json create mode 100644 tests/fixtures/http_api/relay_set_request.json create mode 100644 tests/fixtures/http_api/relay_set_response.json create mode 100644 tests/fixtures/http_api/status_response.json create mode 100644 tests/fixtures/http_api/temp_assign_request.json create mode 100644 tests/fixtures/http_api/temp_assign_response.json create mode 100644 tests/fixtures/http_api/temp_clear_request.json create mode 100644 tests/fixtures/http_api/temp_clear_response.json create mode 100644 tests/fixtures/http_api/temp_scan_response.json create mode 100644 tests/fixtures/http_api/wifi_config_request.json create mode 100644 tests/fixtures/http_api/wifi_config_response.json create mode 100644 tests/test_http_api_contract.py diff --git a/run_tests.py b/run_tests.py index 23b83fd..6d5e532 100644 --- a/run_tests.py +++ b/run_tests.py @@ -23,6 +23,7 @@ COMMANDS = [ "pico-dashboard/hardware/touch.py", "pico-dashboard/hardware/buzzer.py"], [sys.executable, "-m", "pytest", "tests/test_pico_core.py"], + [sys.executable, "-m", "pytest", "tests/test_http_api_contract.py"], ] diff --git a/simulator/requirements.txt b/simulator/requirements.txt index 7e10602..4649a3c 100644 --- a/simulator/requirements.txt +++ b/simulator/requirements.txt @@ -1 +1,2 @@ flask +pytest diff --git a/tests/fixtures/http_api/config_response.json b/tests/fixtures/http_api/config_response.json new file mode 100644 index 0000000..141b23f --- /dev/null +++ b/tests/fixtures/http_api/config_response.json @@ -0,0 +1,31 @@ +{ + "device_name": "Cargo Controller", + "relays": [ + { + "id": "relay_1", + "name": "Aux Power", + "pin": 16, + "enabled": true + }, + { + "id": "relay_2", + "name": "Fridge", + "pin": 17, + "enabled": true + } + ], + "bms": { + "enabled": true, + "name": "House Battery", + "address": "aa:bb:cc:dd:ee:ff", + "address_type": "public" + }, + "temperature_sensors": [ + { + "id": "temp_1", + "name": "Cabin", + "address": "28ff001122334455", + "enabled": true + } + ] +} diff --git a/tests/fixtures/http_api/error_response.json b/tests/fixtures/http_api/error_response.json new file mode 100644 index 0000000..5765099 --- /dev/null +++ b/tests/fixtures/http_api/error_response.json @@ -0,0 +1,4 @@ +{ + "ok": false, + "error": "invalid_json" +} diff --git a/tests/fixtures/http_api/relay_set_request.json b/tests/fixtures/http_api/relay_set_request.json new file mode 100644 index 0000000..1ef5a26 --- /dev/null +++ b/tests/fixtures/http_api/relay_set_request.json @@ -0,0 +1,4 @@ +{ + "id": "relay_1", + "state": true +} diff --git a/tests/fixtures/http_api/relay_set_response.json b/tests/fixtures/http_api/relay_set_response.json new file mode 100644 index 0000000..ae23df6 --- /dev/null +++ b/tests/fixtures/http_api/relay_set_response.json @@ -0,0 +1,6 @@ +{ + "type": "relay_response", + "ok": true, + "id": "relay_1", + "state": true +} diff --git a/tests/fixtures/http_api/status_response.json b/tests/fixtures/http_api/status_response.json new file mode 100644 index 0000000..97391eb --- /dev/null +++ b/tests/fixtures/http_api/status_response.json @@ -0,0 +1,112 @@ +{ + "type": "status_response", + "timestamp": 123456, + "battery": { + "source": "jbd_bms", + "connected": true, + "soc": 70, + "voltage": 13.34, + "current": -5.2, + "remaining_ah": 104.8, + "capacity_ah": 150.0, + "runtime_hours": 20.15, + "temperature_f": 76.5, + "cycle_count": 3, + "cell_count": 4, + "ntc_count": 2, + "cell_voltages": [3.334, 3.333, 3.334, 3.333], + "cell_min_voltage": 3.333, + "cell_max_voltage": 3.334, + "cell_delta_mv": 1, + "cells_valid": true + }, + "temps": [ + { + "id": "temp_1", + "name": "Cabin", + "enabled": true, + "weather": false, + "online": true, + "temperature_f": 72.4 + } + ], + "relays": [ + { + "id": "relay_1", + "name": "Aux Power", + "pin": 16, + "enabled": true, + "state": false + }, + { + "id": "relay_2", + "name": "Fridge", + "pin": 17, + "enabled": true, + "state": true + } + ], + "vehicle": { + "ignition_on": false + }, + "network": { + "wifi_enabled": true, + "uart_connected": true, + "ap_enabled": true, + "ap_ip": "192.168.4.1", + "sta_enabled": true, + "sta_connected": false, + "sta_ssid": "", + "sta_ip": "", + "saved_network_count": 1, + "saved_networks": [ + { + "index": 1, + "ssid": "Starlink", + "priority": 1, + "active": false + } + ] + }, + "alarms": { + "low_soc": false, + "critical_soc": false, + "low_voltage": false, + "high_battery_temp": false, + "cell_imbalance": false, + "bms_disconnected": false + }, + "system": { + "firmware_name": "overland-controller", + "firmware_version": "0.4.0", + "build_date": "Jun 07 2026", + "build_time": "12:00:00", + "uptime_seconds": 42 + }, + "config": { + "device_name": "Cargo Controller", + "relays": [ + { + "id": "relay_1", + "name": "Aux Power", + "pin": 16, + "enabled": true + } + ], + "bms": { + "enabled": true, + "name": "House Battery", + "address": "aa:bb:cc:dd:ee:ff", + "address_type": "public" + }, + "temperature_sensors": [ + { + "id": "temp_1", + "name": "Cabin", + "address": "28ff001122334455", + "enabled": true, + "weather": false + } + ] + } +} diff --git a/tests/fixtures/http_api/temp_assign_request.json b/tests/fixtures/http_api/temp_assign_request.json new file mode 100644 index 0000000..a0eb785 --- /dev/null +++ b/tests/fixtures/http_api/temp_assign_request.json @@ -0,0 +1,4 @@ +{ + "id": "temp_1", + "index": 1 +} diff --git a/tests/fixtures/http_api/temp_assign_response.json b/tests/fixtures/http_api/temp_assign_response.json new file mode 100644 index 0000000..221b1f9 --- /dev/null +++ b/tests/fixtures/http_api/temp_assign_response.json @@ -0,0 +1,18 @@ +{ + "device_name": "Cargo Controller", + "relays": [], + "bms": { + "enabled": true, + "name": "House Battery", + "address": "aa:bb:cc:dd:ee:ff", + "address_type": "public" + }, + "temperature_sensors": [ + { + "id": "temp_1", + "name": "Cabin", + "address": "28ff001122334455", + "enabled": true + } + ] +} diff --git a/tests/fixtures/http_api/temp_clear_request.json b/tests/fixtures/http_api/temp_clear_request.json new file mode 100644 index 0000000..e3116d4 --- /dev/null +++ b/tests/fixtures/http_api/temp_clear_request.json @@ -0,0 +1,3 @@ +{ + "id": "temp_1" +} diff --git a/tests/fixtures/http_api/temp_clear_response.json b/tests/fixtures/http_api/temp_clear_response.json new file mode 100644 index 0000000..a42c549 --- /dev/null +++ b/tests/fixtures/http_api/temp_clear_response.json @@ -0,0 +1,18 @@ +{ + "device_name": "Cargo Controller", + "relays": [], + "bms": { + "enabled": true, + "name": "House Battery", + "address": "aa:bb:cc:dd:ee:ff", + "address_type": "public" + }, + "temperature_sensors": [ + { + "id": "temp_1", + "name": "Cabin", + "address": "", + "enabled": false + } + ] +} diff --git a/tests/fixtures/http_api/temp_scan_response.json b/tests/fixtures/http_api/temp_scan_response.json new file mode 100644 index 0000000..7b9503d --- /dev/null +++ b/tests/fixtures/http_api/temp_scan_response.json @@ -0,0 +1,10 @@ +{ + "type": "temp_scan_response", + "ok": true, + "devices": [ + { + "index": 1, + "address": "28ff001122334455" + } + ] +} diff --git a/tests/fixtures/http_api/wifi_config_request.json b/tests/fixtures/http_api/wifi_config_request.json new file mode 100644 index 0000000..46b22d8 --- /dev/null +++ b/tests/fixtures/http_api/wifi_config_request.json @@ -0,0 +1,9 @@ +{ + "networks": [ + { + "ssid": "Starlink", + "password": "example-password", + "priority": 1 + } + ] +} diff --git a/tests/fixtures/http_api/wifi_config_response.json b/tests/fixtures/http_api/wifi_config_response.json new file mode 100644 index 0000000..2714330 --- /dev/null +++ b/tests/fixtures/http_api/wifi_config_response.json @@ -0,0 +1,21 @@ +{ + "type": "wifi_config_response", + "ok": true, + "wifi": { + "ap_enabled": true, + "sta_enabled": true, + "network_count": 1, + "active_ssid": "", + "sta_connected": false, + "ap_ip": "192.168.4.1", + "sta_ip": "", + "networks": [ + { + "index": 1, + "ssid": "Starlink", + "priority": 1, + "password_set": true + } + ] + } +} diff --git a/tests/test_http_api_contract.py b/tests/test_http_api_contract.py new file mode 100644 index 0000000..095074c --- /dev/null +++ b/tests/test_http_api_contract.py @@ -0,0 +1,206 @@ +import json +import re +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +FIXTURES = ROOT / "tests" / "fixtures" / "http_api" +FIRMWARE = ROOT / "firmware" / "esp32" / "overland-controller" / "overland-controller.ino" + + +def load_fixture(name): + return json.loads((FIXTURES / name).read_text()) + + +def firmware_source(): + return FIRMWARE.read_text() + + +def assert_keys(payload, keys): + missing = set(keys) - set(payload) + assert not missing, f"missing keys: {sorted(missing)}" + + +def registered_routes(): + pattern = re.compile(r'server\.on\("([^"]+)"(?:,\s*(HTTP_[A-Z]+))?') + return {(match.group(1), match.group(2) or "ANY") for match in pattern.finditer(firmware_source())} + + +def test_firmware_registers_current_http_contract_routes(): + routes = registered_routes() + + expected_routes = { + ("/status", "ANY"), + ("/config", "HTTP_GET"), + ("/relay/set", "HTTP_POST"), + ("/config/wifi", "HTTP_GET"), + ("/config/wifi", "HTTP_POST"), + ("/wifi/connect", "HTTP_POST"), + ("/wifi/clear", "HTTP_POST"), + ("/temps/scan", "HTTP_POST"), + ("/temps/assign", "HTTP_POST"), + ("/temps/clear", "HTTP_POST"), + } + + assert expected_routes <= routes + + +def test_status_fixture_matches_dashboard_contract_shape(): + payload = load_fixture("status_response.json") + + assert_keys(payload, [ + "type", + "timestamp", + "battery", + "temps", + "relays", + "vehicle", + "network", + "alarms", + "system", + "config", + ]) + assert payload["type"] == "status_response" + + assert_keys(payload["battery"], [ + "source", + "connected", + "soc", + "voltage", + "current", + "remaining_ah", + "capacity_ah", + "runtime_hours", + "temperature_f", + "cycle_count", + "cell_count", + "ntc_count", + "cell_voltages", + "cell_min_voltage", + "cell_max_voltage", + "cell_delta_mv", + "cells_valid", + ]) + assert_keys(payload["temps"][0], [ + "id", + "name", + "enabled", + "weather", + "online", + "temperature_f", + ]) + assert_keys(payload["relays"][0], ["id", "name", "pin", "enabled", "state"]) + assert_keys(payload["network"], [ + "wifi_enabled", + "uart_connected", + "ap_enabled", + "ap_ip", + "sta_enabled", + "sta_connected", + "sta_ssid", + "sta_ip", + "saved_network_count", + "saved_networks", + ]) + assert_keys(payload["system"], [ + "firmware_name", + "firmware_version", + "build_date", + "build_time", + "uptime_seconds", + ]) + + +def test_config_fixture_matches_current_config_response_shape(): + payload = load_fixture("config_response.json") + + assert_keys(payload, ["device_name", "relays", "bms", "temperature_sensors"]) + assert_keys(payload["relays"][0], ["id", "name", "pin", "enabled"]) + assert_keys(payload["bms"], ["enabled", "name", "address", "address_type"]) + assert_keys(payload["temperature_sensors"][0], ["id", "name", "address", "enabled"]) + + +def test_relay_set_contract_fixtures_and_firmware_errors(): + request = load_fixture("relay_set_request.json") + response = load_fixture("relay_set_response.json") + + assert_keys(request, ["id", "state"]) + assert_keys(response, ["type", "ok", "id", "state"]) + assert response["type"] == "relay_response" + assert response["ok"] is True + + source = firmware_source() + assert '"invalid_json"' in source + assert '"unknown_relay"' in source + + +def test_temp_scan_assign_clear_contract_fixtures(): + scan = load_fixture("temp_scan_response.json") + assign_request = load_fixture("temp_assign_request.json") + assign_response = load_fixture("temp_assign_response.json") + clear_request = load_fixture("temp_clear_request.json") + clear_response = load_fixture("temp_clear_response.json") + + assert_keys(scan, ["type", "ok", "devices"]) + assert scan["type"] == "temp_scan_response" + assert_keys(scan["devices"][0], ["index", "address"]) + + assert_keys(assign_request, ["id", "index"]) + assert_keys(assign_response, ["device_name", "relays", "bms", "temperature_sensors"]) + assert assign_response["temperature_sensors"][0]["address"] + assert assign_response["temperature_sensors"][0]["enabled"] is True + + assert_keys(clear_request, ["id"]) + assert_keys(clear_response, ["device_name", "relays", "bms", "temperature_sensors"]) + assert clear_response["temperature_sensors"][0]["address"] == "" + assert clear_response["temperature_sensors"][0]["enabled"] is False + + source = firmware_source() + assert '"unknown_temp_sensor"' in source + assert '"invalid_temp_selection"' in source + + +def test_wifi_config_contract_fixtures(): + request = load_fixture("wifi_config_request.json") + response = load_fixture("wifi_config_response.json") + + assert_keys(request, ["networks"]) + assert_keys(request["networks"][0], ["ssid", "password", "priority"]) + assert_keys(response, ["type", "ok", "wifi"]) + assert response["type"] == "wifi_config_response" + assert response["ok"] is True + assert_keys(response["wifi"], [ + "ap_enabled", + "sta_enabled", + "network_count", + "active_ssid", + "sta_connected", + "ap_ip", + "sta_ip", + "networks", + ]) + assert_keys(response["wifi"]["networks"][0], [ + "index", + "ssid", + "priority", + "password_set", + ]) + + +def test_http_error_fixture_and_known_error_codes_are_stable(): + payload = load_fixture("error_response.json") + source = firmware_source() + + assert payload == {"ok": False, "error": "invalid_json"} + + for error_code in [ + "invalid_json", + "unknown_relay", + "unknown_temp_sensor", + "invalid_temp_selection", + "invalid_bms_selection", + "invalid_relay_route", + "missing_relay_action", + "invalid_relay_action", + ]: + assert error_code in source