diff --git a/src/openhound/core/models/entries.py b/src/openhound/core/models/entries.py index f7e9658e..50485532 100644 --- a/src/openhound/core/models/entries.py +++ b/src/openhound/core/models/entries.py @@ -78,7 +78,7 @@ class EdgeProperties(BaseModel): model_config = ConfigDict(extra="allow") composed: bool = False - traversable: bool = False + traversable: bool | None = None class Edge(BaseModel): diff --git a/src/openhound/core/models/entries_dataclass.py b/src/openhound/core/models/entries_dataclass.py index 78d8c2be..6f5f1b90 100644 --- a/src/openhound/core/models/entries_dataclass.py +++ b/src/openhound/core/models/entries_dataclass.py @@ -76,7 +76,11 @@ class EdgeProperties: """Core model specifying required fields for an OpenGraph edge properties.""" composed: bool = field(default=False, kw_only=True) - traversable: bool = field(default=False, kw_only=True) + traversable: bool | None = field(default=None, kw_only=True) + + def __post_init__(self): + """Accept but discard traversable properties. None will be discarded during conversion.""" + self.traversable = None @dataclass diff --git a/tests/test_bhe_job_scheduling.py b/tests/test_bhe_job_scheduling.py index 7e3746f3..b5287f84 100644 --- a/tests/test_bhe_job_scheduling.py +++ b/tests/test_bhe_job_scheduling.py @@ -65,6 +65,7 @@ def mock_bloodhound_api(): app.state.uploaded_parts = [] app.state.artifact_completed = False app.state.ingested_nodes = 0 + app.state.ingested_edge_properties = [] @app.get("/api/v2/jobs/available") async def jobs_available(): @@ -92,6 +93,9 @@ async def end_job(body: dict): async def ingest(request: Request): body = await request.body() decompressed = gzip.decompress(body) + app.state.ingested_edge_properties.extend( + edge["properties"] for edge in json.loads(decompressed)["graph"]["edges"] + ) validate_graph = Graph.model_validate_json(decompressed) app.state.ingested_nodes += len(validate_graph.graph.nodes) app.state.ingested_edges += len(validate_graph.graph.edges) @@ -153,7 +157,7 @@ async def create_artifact_upload(body: dict): @app.post("/api/v2/clients/management/artifacts/{artifact_id}/parts/{part_number}") async def upload_artifact_part( - artifact_id: str, part_number: int, request: Request + artifact_id: str, part_number: int, request: Request ): content = await request.body() checksum = base64.b64encode(hashlib.sha256(content).digest()).decode("ascii") @@ -287,7 +291,7 @@ def capture_request(*args, **kwargs): def test_client_update_uses_unknown_when_hostname_lookup_fails( - mock_service, mock_bloodhound_api, monkeypatch + mock_service, mock_bloodhound_api, monkeypatch ): def raise_error(): raise OSError("hostname unavailable") @@ -304,7 +308,7 @@ def raise_error(): def test_client_update_uses_unknown_when_ip_lookup_fails( - mock_service, mock_bloodhound_api, monkeypatch + mock_service, mock_bloodhound_api, monkeypatch ): monkeypatch.setattr( bloodhound_enterprise.socket, "gethostname", lambda: "test-host" @@ -429,7 +433,7 @@ def test_poll_recovers_from_broken_process_pool(mock_service, mock_bloodhound_ap def test_start_job_recovers_when_submit_raises_broken_pool( - mock_service, mock_bloodhound_api, monkeypatch + mock_service, mock_bloodhound_api, monkeypatch ): """If executor.submit raises BrokenProcessPool after the BHE job was started, the job should be ended FAILED, state cleared, and the executor rebuilt.""" @@ -524,6 +528,10 @@ def test_scheduler_ingest_opengraph(mock_service, mock_bloodhound_api, monkeypat assert result.job_id == 123 assert mock_bloodhound_api.app.state.ingested_nodes == 1000 assert mock_bloodhound_api.app.state.ingested_edges == 10000 + assert all( + "traversable" not in properties + for properties in mock_bloodhound_api.app.state.ingested_edge_properties + ) def _support_bundle_operation() -> dict: @@ -533,7 +541,7 @@ def _support_bundle_operation() -> dict: def test_check_management_returns_support_bundle_operation( - mock_service, mock_bloodhound_api + mock_service, mock_bloodhound_api ): mock_bloodhound_api.app.state.management_operations = [_support_bundle_operation()] @@ -544,7 +552,7 @@ def test_check_management_returns_support_bundle_operation( def test_check_management_ignores_non_queued_operations( - mock_service, mock_bloodhound_api + mock_service, mock_bloodhound_api ): operation = _support_bundle_operation() operation["status"] = ManagementOperationStatus.RUNNING.value @@ -554,7 +562,7 @@ def test_check_management_ignores_non_queued_operations( def test_poll_prioritizes_management_over_a_new_job( - mock_service, mock_bloodhound_api, monkeypatch + mock_service, mock_bloodhound_api, monkeypatch ): mock_bloodhound_api.app.state.management_operations = [_support_bundle_operation()] sent = [] @@ -567,7 +575,7 @@ def test_poll_prioritizes_management_over_a_new_job( def test_poll_starts_job_when_no_management_work( - mock_service, mock_bloodhound_api, monkeypatch + mock_service, mock_bloodhound_api, monkeypatch ): submitted = Future() monkeypatch.setattr(mock_service.executor, "submit", lambda *args: submitted) @@ -578,7 +586,7 @@ def test_poll_starts_job_when_no_management_work( def test_poll_still_checks_jobs_when_management_endpoint_fails( - mock_service, mock_bloodhound_api, monkeypatch + mock_service, mock_bloodhound_api, monkeypatch ): submitted = Future() monkeypatch.setattr(mock_service, "check_management", lambda: 1 / 0) @@ -590,7 +598,7 @@ def test_poll_still_checks_jobs_when_management_endpoint_fails( def test_poll_does_not_start_a_job_when_management_work_fails( - mock_service, mock_bloodhound_api, monkeypatch + mock_service, mock_bloodhound_api, monkeypatch ): mock_bloodhound_api.app.state.management_operations = [_support_bundle_operation()] @@ -605,7 +613,7 @@ def fail(operation): def test_send_support_bundle_claims_uploads_completes_and_cleans_up( - mock_service, mock_bloodhound_api, tmp_path, monkeypatch + mock_service, mock_bloodhound_api, tmp_path, monkeypatch ): log = tmp_path / "openhound.log" log.write_text("support log") @@ -630,8 +638,8 @@ def capture_bundle(*args): "operation_id": operation.id } assert ( - mock_bloodhound_api.app.state.artifact_create_payload["operation_id"] - == operation.id + mock_bloodhound_api.app.state.artifact_create_payload["operation_id"] + == operation.id ) assert mock_bloodhound_api.app.state.uploaded_parts assert mock_bloodhound_api.app.state.artifact_completed is True @@ -645,7 +653,7 @@ def capture_bundle(*args): def test_create_artifact_upload_preserves_entire_create_response( - mock_service, tmp_path + mock_service, tmp_path ): bundle = tmp_path / "support-bundle.zip" bundle.write_bytes(b"support bundle") @@ -672,7 +680,7 @@ def test_create_artifact_upload_preserves_entire_create_response( ], ) def test_send_support_bundle_marks_operation_failed_for_each_lifecycle_failure( - mock_service, mock_bloodhound_api, monkeypatch, caplog, failure_point + mock_service, mock_bloodhound_api, monkeypatch, caplog, failure_point ): operation = ManagementOperation.model_validate(_support_bundle_operation()) @@ -695,7 +703,7 @@ def fail(*args, **kwargs): def test_send_support_bundle_retries_transient_part_upload_failure( - mock_service, monkeypatch, tmp_path + mock_service, monkeypatch, tmp_path ): log = tmp_path / "openhound.log" log.write_text("support log") @@ -723,7 +731,7 @@ def flaky_request(method, path, **kwargs): def test_send_support_bundle_fails_after_transient_retries_are_exhausted( - mock_service, mock_bloodhound_api, monkeypatch, tmp_path + mock_service, mock_bloodhound_api, monkeypatch, tmp_path ): log = tmp_path / "openhound.log" log.write_text("support log") diff --git a/tests/test_edge_properties.py b/tests/test_edge_properties.py new file mode 100644 index 00000000..2b30a7dd --- /dev/null +++ b/tests/test_edge_properties.py @@ -0,0 +1,10 @@ +import pytest + +from openhound.core.models.entries_dataclass import EdgeProperties + + +@pytest.mark.parametrize("traversable", [True, False]) +def test_edge_properties_accept_and_discard_legacy_traversable(traversable): + properties = EdgeProperties(traversable=traversable) + + assert properties.traversable is None