Skip to content

Commit 0c30263

Browse files
tests: cover build_config helpers and builds config endpoint
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 39cd8a8 commit 0c30263

3 files changed

Lines changed: 290 additions & 3 deletions

File tree

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""Tests for shared build_config packaging helpers."""
2+
import tarfile
3+
from pathlib import Path
4+
5+
import pytest
6+
import yaml
7+
8+
from build_config import (
9+
CONFIG_FILENAME,
10+
CONFIG_VERSION,
11+
build_config_dict,
12+
config_dict_from_build_info,
13+
dump_config_yaml,
14+
validate_config_dict,
15+
write_config_yaml,
16+
)
17+
from build_manager import BuildInfo
18+
from metadata_manager import RemoteInfo
19+
20+
21+
def _valid_config(**overrides):
22+
base = build_config_dict(
23+
vehicle_id="copter",
24+
vehicle_name="Copter",
25+
version_id="ardupilot-Copter-4.5.0-abc",
26+
version_name="4.5.0",
27+
version_type="stable",
28+
remote_name="ardupilot",
29+
board_id="CubeOrange",
30+
board_name="CubeOrange",
31+
selected_features={"HAL_LOGGING_ENABLED"},
32+
)
33+
base.update(overrides)
34+
return base
35+
36+
37+
def test_validate_accepts_schema_compliant_config():
38+
validate_config_dict(_valid_config())
39+
40+
41+
def test_validate_rejects_missing_required_field():
42+
bad = _valid_config()
43+
del bad["board"]
44+
with pytest.raises(Exception):
45+
validate_config_dict(bad)
46+
47+
48+
def test_dump_and_write_roundtrip(tmp_path: Path):
49+
config = _valid_config()
50+
text = dump_config_yaml(config)
51+
parsed = yaml.safe_load(text)
52+
assert parsed["config_version"] == CONFIG_VERSION
53+
assert parsed["vehicle"]["id"] == "copter"
54+
55+
out = tmp_path / CONFIG_FILENAME
56+
write_config_yaml(out, config)
57+
assert out.is_file()
58+
assert yaml.safe_load(out.read_text())["board"]["id"] == "CubeOrange"
59+
60+
61+
def test_config_dict_from_build_info_uses_labels_and_names():
62+
info = BuildInfo(
63+
vehicle_id="copter",
64+
version_id="ver-1",
65+
remote_info=RemoteInfo(
66+
name="ardupilot",
67+
url="https://github.com/ArduPilot/ardupilot.git",
68+
),
69+
git_hash="abc123",
70+
board="MatekH743",
71+
selected_features={"HAL_LOGGING_ENABLED"},
72+
vehicle_name="Copter",
73+
board_name="MatekH743",
74+
version_name="4.5.0",
75+
version_type="stable",
76+
)
77+
config = config_dict_from_build_info(info)
78+
validate_config_dict(config)
79+
assert config["selected_features"] == ["HAL_LOGGING_ENABLED"]
80+
assert config["version"]["name"] == "4.5.0"
81+
assert config["version"]["remote_name"] == "ardupilot"
82+
83+
84+
def test_packaged_yaml_inside_tar(tmp_path: Path):
85+
"""Simulate Builder archive membership for custombuild.yaml."""
86+
config = _valid_config()
87+
config_path = tmp_path / CONFIG_FILENAME
88+
write_config_yaml(config_path, config)
89+
90+
archive = tmp_path / "copter-MatekH743-build1.tar.gz"
91+
folder_name = archive.name.removesuffix(".tar.gz")
92+
with tarfile.open(archive, "w:gz") as tar:
93+
tar.add(config_path, arcname=f"{folder_name}/{CONFIG_FILENAME}")
94+
95+
with tarfile.open(archive, "r:gz") as tar:
96+
names = [m.name for m in tar.getmembers()]
97+
assert f"{folder_name}/{CONFIG_FILENAME}" in names
98+
extracted = tar.extractfile(f"{folder_name}/{CONFIG_FILENAME}")
99+
assert extracted is not None
100+
loaded = yaml.safe_load(extracted.read())
101+
assert loaded["vehicle"]["id"] == "copter"

tests/web/test_builds_api.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,3 +444,34 @@ def test_get_artifact_method_not_allowed(self, client):
444444
for method in [client.post, client.put, client.patch, client.delete]:
445445
response = method("/api/v1/builds/build-abc123/artifact")
446446
assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
447+
448+
def test_get_config_returns_200_when_available(self, client):
449+
"""Returns YAML when the service provides packaged config."""
450+
mock_service = Mock()
451+
mock_service.get_build_config_yaml.return_value = (
452+
"config_version: \"0.0.1\"\nvehicle:\n id: copter\n name: Copter\n",
453+
"custombuild-copter-MatekH743-build-abc123.yaml",
454+
)
455+
with self.override_builds_service(client, mock_service):
456+
response = client.get("/api/v1/builds/build-abc123/config")
457+
458+
assert response.status_code == status.HTTP_200_OK
459+
assert "copter" in response.text
460+
assert "attachment" in response.headers.get("content-disposition", "")
461+
462+
def test_get_config_returns_404_when_not_available(self, client):
463+
mock_service = Mock()
464+
mock_service.get_build_config_yaml.return_value = None
465+
with self.override_builds_service(client, mock_service):
466+
response = client.get("/api/v1/builds/some-build-id/config")
467+
468+
assert response.status_code == status.HTTP_404_NOT_FOUND
469+
assert "some-build-id" in response.json()["detail"]
470+
471+
def test_get_config_service_called_with_correct_build_id(self, client):
472+
mock_service = Mock()
473+
mock_service.get_build_config_yaml.return_value = None
474+
with self.override_builds_service(client, mock_service):
475+
client.get("/api/v1/builds/target-build/config")
476+
477+
mock_service.get_build_config_yaml.assert_called_once_with("target-build")

tests/web/test_builds_service.py

Lines changed: 158 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@ def make_build_info(
7070
git_hash="abc123def456",
7171
board="MatekH743",
7272
selected_features=None,
73+
vehicle_name=None,
74+
board_name=None,
75+
version_name="4.5.0",
76+
version_type="stable",
7377
state=bm.BuildState.PENDING,
7478
percent=0,
7579
):
@@ -80,6 +84,10 @@ def make_build_info(
8084
git_hash=git_hash,
8185
board=board,
8286
selected_features=set(selected_features) if selected_features is not None else set(),
87+
vehicle_name=vehicle_name or vehicle_id.capitalize(),
88+
board_name=board_name or board,
89+
version_name=version_name,
90+
version_type=version_type,
8391
)
8492
info.progress = bm.BuildProgress(state=state, percent=percent)
8593
return info
@@ -161,6 +169,21 @@ def test_create_build_raises_value_error_for_missing_vehicle_id(self, service):
161169
with pytest.raises(ValueError, match="vehicle_id is required"):
162170
service.create_build(request)
163171

172+
def test_create_build_raises_value_error_for_invalid_vehicle(
173+
self, service, mock_vehicles_manager
174+
):
175+
"""ValueError is raised when vehicle_id is not a known vehicle."""
176+
mock_vehicles_manager.get_vehicle_by_id.return_value = None
177+
request = BuildRequest(
178+
vehicle_id="unknown-vehicle",
179+
board_id="MatekH743",
180+
version_id="copter-4.5.0-stable",
181+
selected_features=[],
182+
)
183+
184+
with pytest.raises(ValueError, match="Invalid vehicle_id"):
185+
service.create_build(request)
186+
164187
def test_create_build_raises_value_error_for_missing_board_id(
165188
self, service
166189
):
@@ -216,9 +239,9 @@ def test_create_build_raises_value_error_when_remote_not_found(
216239
"""ValueError is raised when the remote is not found."""
217240
mock_versions_manager.get_remote_info.return_value = None
218241
request = BuildRequest(
219-
vehicle_id="some-vehicle",
220-
board_id="some-board",
221-
version_id="some-version",
242+
vehicle_id="copter",
243+
board_id="MatekH743",
244+
version_id="copter-4.5.0-stable",
222245
selected_features=[],
223246
)
224247

@@ -725,3 +748,135 @@ def test_get_artifact_path_uses_correct_build_id(
725748
service.get_artifact_path("my-target-build")
726749

727750
mock_build_manager.build_exists.assert_called_once_with("my-target-build")
751+
752+
def test_get_build_includes_version_name_and_type(
753+
self,
754+
service,
755+
mock_build_manager,
756+
mock_versions_manager,
757+
):
758+
"""version.name and version.type come from versions_manager."""
759+
mock_build_manager.build_exists.return_value = True
760+
mock_build_manager.get_build_info.return_value = make_build_info()
761+
mock_versions_manager.get_version_info.return_value = make_version_info(
762+
release_type="stable",
763+
version_number="4.5.0",
764+
)
765+
766+
result = service.get_build("build-abc123")
767+
768+
assert result.version.name == "4.5.0"
769+
assert result.version.type == "stable"
770+
mock_versions_manager.get_version_info.assert_called_with(
771+
vehicle_id="copter",
772+
version_id="copter-4.5.0-stable",
773+
)
774+
775+
def test_get_build_maps_latest_version_name_to_master(
776+
self,
777+
service,
778+
mock_build_manager,
779+
mock_versions_manager,
780+
):
781+
"""latest release_type is exposed as display name 'master'."""
782+
mock_build_manager.build_exists.return_value = True
783+
mock_build_manager.get_build_info.return_value = make_build_info(
784+
version_id="copter-latest",
785+
)
786+
mock_versions_manager.get_version_info.return_value = make_version_info(
787+
release_type="latest",
788+
version_number="NA",
789+
)
790+
791+
result = service.get_build("build-abc123")
792+
793+
assert result.version.name == "master"
794+
assert result.version.type == "latest"
795+
796+
def test_get_build_falls_back_to_stored_version_when_unknown(
797+
self,
798+
service,
799+
mock_build_manager,
800+
mock_versions_manager,
801+
):
802+
"""Missing live version info uses names stored on BuildInfo."""
803+
mock_build_manager.build_exists.return_value = True
804+
mock_build_manager.get_build_info.return_value = make_build_info(
805+
version_name="4.5.0",
806+
version_type="stable",
807+
)
808+
mock_versions_manager.get_version_info.return_value = None
809+
810+
result = service.get_build("build-abc123")
811+
812+
assert result.version.name == "4.5.0"
813+
assert result.version.type == "stable"
814+
815+
def test_get_build_falls_back_to_stored_vehicle_name_when_unknown(
816+
self,
817+
service,
818+
mock_build_manager,
819+
mock_vehicles_manager,
820+
):
821+
"""Missing live vehicle uses display name stored on BuildInfo, not id."""
822+
mock_build_manager.build_exists.return_value = True
823+
mock_build_manager.get_build_info.return_value = make_build_info(
824+
vehicle_id="retired-vehicle",
825+
vehicle_name="Retired Vehicle",
826+
)
827+
mock_vehicles_manager.get_vehicle_by_id.side_effect = lambda _vid: None
828+
829+
result = service.get_build("build-abc123")
830+
831+
assert result.vehicle.id == "retired-vehicle"
832+
assert result.vehicle.name == "Retired Vehicle"
833+
834+
def test_get_build_config_yaml_generates_from_build_info(
835+
self,
836+
service,
837+
mock_build_manager,
838+
):
839+
mock_build_manager.build_exists.return_value = True
840+
mock_build_manager.get_build_info.return_value = make_build_info(
841+
state=bm.BuildState.SUCCESS,
842+
vehicle_id="copter",
843+
board="MatekH743",
844+
vehicle_name="Copter",
845+
selected_features=["HAL_LOGGING_ENABLED"],
846+
)
847+
848+
result = service.get_build_config_yaml("build-abc123")
849+
850+
assert result is not None
851+
yaml_text, filename = result
852+
assert "HAL_LOGGING_ENABLED" in yaml_text
853+
assert filename.endswith(".yaml")
854+
855+
def test_get_build_config_yaml_returns_none_when_build_missing(
856+
self, service, mock_build_manager
857+
):
858+
mock_build_manager.build_exists.return_value = False
859+
assert service.get_build_config_yaml("missing") is None
860+
861+
def test_get_build_config_yaml_returns_none_when_info_missing(
862+
self, service, mock_build_manager
863+
):
864+
mock_build_manager.build_exists.return_value = True
865+
mock_build_manager.get_build_info.return_value = None
866+
assert service.get_build_config_yaml("build-abc123") is None
867+
868+
def test_create_build_stores_display_metadata(
869+
self, service, mock_build_manager
870+
):
871+
service.create_build(
872+
BuildRequest(
873+
vehicle_id="copter",
874+
board_id="MatekH743",
875+
version_id="copter-4.5.0-stable",
876+
selected_features=["HAL_LOGGING_ENABLED"],
877+
)
878+
)
879+
submitted: bm.BuildInfo = mock_build_manager.submit_build.call_args[1]["build_info"]
880+
assert submitted.selected_features == {"HAL_LOGGING_ENABLED"}
881+
assert submitted.vehicle_name == "Copter"
882+
assert submitted.version_type == "stable"

0 commit comments

Comments
 (0)