"""Tests for ifcurl.bcf and the /bcf service endpoint.""" from __future__ import annotations import io import zipfile from unittest.mock import patch import pytest from fastapi.testclient import TestClient from ifcurl.bcf import bcf_viewpoint_to_ifc_url, build_bcf from ifcurl.service import _t2_cache, _t3_cache, app, configure_allowed_hosts from ifcurl.url import IfcUrl client = TestClient(app) FAKE_HEXSHA = "b" * 40 MUTABLE_URL = "ifc://example.com/org/repo@heads/main?path=model.ifc" CAMERA_URL = ( "ifc://example.com/org/repo@heads/main" "?path=model.ifc&camera=1,2,3,0,0,-1,0,1,0&fov=60" ) CLIP_URL = ( "ifc://example.com/org/repo@heads/main" "?path=model.ifc&camera=0,0,5,0,0,-1,0,1,0&fov=60" "&clip=0,0,2,0,0,-1" ) @pytest.fixture(autouse=True) def reset_service(tmp_path, monkeypatch): monkeypatch.setattr("ifcurl.service.user_cache_dir", lambda *a, **kw: str(tmp_path)) _t2_cache.clear() _t3_cache.clear() configure_allowed_hosts(None) yield _t2_cache.clear() _t3_cache.clear() configure_allowed_hosts(None) def _zip_names(data: bytes) -> set[str]: with zipfile.ZipFile(io.BytesIO(data)) as zf: return set(zf.namelist()) def _zip_read(data: bytes, name: str) -> str: with zipfile.ZipFile(io.BytesIO(data)) as zf: return zf.read(name).decode() def _zip_read_bytes(data: bytes, name: str) -> bytes: with zipfile.ZipFile(io.BytesIO(data)) as zf: return zf.read(name) # --------------------------------------------------------------------------- # build_bcf unit tests # --------------------------------------------------------------------------- class TestBuildBcf: def test_returns_valid_zip(self): data = build_bcf() assert zipfile.is_zipfile(io.BytesIO(data)) def test_contains_version_file(self): data = build_bcf() assert "bcf.version" in _zip_names(data) def test_contains_markup(self): data = build_bcf(title="Test topic") names = _zip_names(data) markup_files = [n for n in names if n.endswith("markup.bcf")] assert len(markup_files) == 1 content = _zip_read(data, markup_files[0]) assert "Test topic" in content def test_no_viewpoint_without_camera(self): data = build_bcf() names = _zip_names(data) assert not any(n.endswith("viewpoint.bcfv") for n in names) def test_viewpoint_present_with_camera(self): cam = (1.0, 2.0, 3.0, 0.0, 0.0, -1.0, 0.0, 1.0, 0.0) data = build_bcf(camera=cam, fov=60.0) names = _zip_names(data) assert any(n.endswith("viewpoint.bcfv") for n in names) def test_perspective_camera_in_viewpoint(self): cam = (1.0, 2.0, 3.0, 0.0, 0.0, -1.0, 0.0, 1.0, 0.0) data = build_bcf(camera=cam, fov=60.0) vp_file = next(n for n in _zip_names(data) if n.endswith("viewpoint.bcfv")) content = _zip_read(data, vp_file) assert "PerspectiveCamera" in content assert "60.0000" in content def test_orthographic_camera_in_viewpoint(self): cam = (0.0, 0.0, 10.0, 0.0, 0.0, -1.0, 0.0, 1.0, 0.0) data = build_bcf(camera=cam, scale=50.0) vp_file = next(n for n in _zip_names(data) if n.endswith("viewpoint.bcfv")) content = _zip_read(data, vp_file) assert "OrthogonalCamera" in content assert "ViewToWorldScale" in content def test_clipping_planes_in_viewpoint(self): cam = (0.0, 0.0, 5.0, 0.0, 0.0, -1.0, 0.0, 1.0, 0.0) data = build_bcf(camera=cam, fov=60.0, clips=[(0.0, 0.0, 2.0, 0.0, 0.0, -1.0)]) vp_file = next(n for n in _zip_names(data) if n.endswith("viewpoint.bcfv")) content = _zip_read(data, vp_file) assert "ClippingPlane" in content def test_comment_in_markup(self): cam = (0.0, 0.0, 5.0, 0.0, 0.0, -1.0, 0.0, 1.0, 0.0) data = build_bcf(camera=cam, fov=60.0, comment="Look at this crack") markup = next(n for n in _zip_names(data) if n.endswith("markup.bcf")) content = _zip_read(data, markup) assert "Look at this crack" in content def test_guids_in_viewpoint_selection(self): cam = (0.0, 0.0, 5.0, 0.0, 0.0, -1.0, 0.0, 1.0, 0.0) data = build_bcf(camera=cam, fov=60.0, guids=["abc123", "def456"]) vp_file = next(n for n in _zip_names(data) if n.endswith("viewpoint.bcfv")) content = _zip_read(data, vp_file) assert 'IfcGuid="abc123"' in content assert 'IfcGuid="def456"' in content def test_isolate_visibility_uses_exceptions(self): cam = (0.0, 0.0, 5.0, 0.0, 0.0, -1.0, 0.0, 1.0, 0.0) data = build_bcf(camera=cam, fov=60.0, guids=["abc123"], visibility="isolate") vp_file = next(n for n in _zip_names(data) if n.endswith("viewpoint.bcfv")) content = _zip_read(data, vp_file) assert 'DefaultVisibility="false"' in content assert "Exceptions" in content def test_description_in_markup(self): data = build_bcf(description="ifc://example.com/org/repo@heads/main?path=m.ifc") markup = next(n for n in _zip_names(data) if n.endswith("markup.bcf")) content = _zip_read(data, markup) assert "ifc://example.com" in content def test_no_description_element_when_empty(self): data = build_bcf() markup = next(n for n in _zip_names(data) if n.endswith("markup.bcf")) content = _zip_read(data, markup) assert "" not in content def test_snapshot_written_to_zip_and_referenced_in_markup(self): cam = (0.0, 0.0, 5.0, 0.0, 0.0, -1.0, 0.0, 1.0, 0.0) png_bytes = b"\x89PNG\r\n\x1a\nfake" data = build_bcf(camera=cam, fov=60.0, snapshot=png_bytes) names = _zip_names(data) snap_file = next((n for n in names if n.endswith("snapshot.png")), None) assert snap_file is not None assert _zip_read_bytes(data, snap_file) == png_bytes markup = next(n for n in names if n.endswith("markup.bcf")) assert "snapshot.png" in _zip_read(data, markup) def test_no_snapshot_when_not_provided(self): cam = (0.0, 0.0, 5.0, 0.0, 0.0, -1.0, 0.0, 1.0, 0.0) data = build_bcf(camera=cam, fov=60.0) names = _zip_names(data) assert not any(n.endswith("snapshot.png") for n in names) markup = next(n for n in names if n.endswith("markup.bcf")) assert "snapshot.png" not in _zip_read(data, markup) # --------------------------------------------------------------------------- # /bcf service endpoint tests # --------------------------------------------------------------------------- class TestBcfEndpoint: def test_returns_zip_bytes(self): r = client.post("/bcf", json={"url": CAMERA_URL}) assert r.status_code == 200 assert zipfile.is_zipfile(io.BytesIO(r.content)) def test_content_disposition_header(self): r = client.post("/bcf", json={"url": CAMERA_URL}) assert "view.bcf" in r.headers["content-disposition"] def test_contains_viewpoint_when_camera_in_url(self): r = client.post("/bcf", json={"url": CAMERA_URL}) names = _zip_names(r.content) assert any(n.endswith("viewpoint.bcfv") for n in names) def test_no_viewpoint_when_no_camera(self): r = client.post("/bcf", json={"url": MUTABLE_URL}) assert r.status_code == 200 names = _zip_names(r.content) assert not any(n.endswith("viewpoint.bcfv") for n in names) def test_title_and_comment_in_markup(self): r = client.post( "/bcf", json={"url": CAMERA_URL, "title": "My issue", "comment": "Fix this"} ) markup = next(n for n in _zip_names(r.content) if n.endswith("markup.bcf")) content = _zip_read(r.content, markup) assert "My issue" in content assert "Fix this" in content def test_source_url_in_description(self): r = client.post("/bcf", json={"url": CAMERA_URL}) markup = next(n for n in _zip_names(r.content) if n.endswith("markup.bcf")) content = _zip_read(r.content, markup) # & is XML-escaped to & in the description element assert CAMERA_URL.replace("&", "&") in content def test_clip_plane_in_viewpoint(self): r = client.post("/bcf", json={"url": CLIP_URL}) vp = next(n for n in _zip_names(r.content) if n.endswith("viewpoint.bcfv")) assert "ClippingPlane" in _zip_read(r.content, vp) def test_ssrf_local_transport_rejected(self): r = client.post("/bcf", json={"url": "ifc:///some/path@HEAD?path=m.ifc"}) assert r.status_code == 403 def test_ssrf_private_ip_rejected(self): r = client.post( "/bcf", json={"url": "ifc://192.168.1.1/org/repo@HEAD?path=m.ifc"} ) assert r.status_code == 403 def test_invalid_url_returns_400(self): r = client.post("/bcf", json={"url": "https://example.com/model.ifc"}) assert r.status_code == 400 def test_selector_resolves_guids(self, model_with_geometry): ifc_bytes = model_with_geometry.to_string().encode() def mock_fetch(ifc_url, token=None): return FAKE_HEXSHA, ifc_bytes, False selector_url = MUTABLE_URL.replace( "?path=model.ifc", "?path=model.ifc&selector=IfcWall" ) with patch("ifcurl.service.fetch_ifc", mock_fetch): r = client.post("/bcf", json={"url": selector_url}) assert r.status_code == 200 vp_files = [n for n in _zip_names(r.content) if n.endswith("viewpoint.bcfv")] # No camera → no viewpoint even with selector assert len(vp_files) == 0 # --------------------------------------------------------------------------- # bcf_viewpoint_to_ifc_url() unit tests # --------------------------------------------------------------------------- BASE = IfcUrl.parse("ifc://example.com/org/repo@heads/main?path=model.ifc") PERSPECTIVE_VP = { "perspective_camera": { "camera_view_point": {"x": 1.0, "y": 2.0, "z": 3.0}, "camera_direction": {"x": 0.0, "y": 0.0, "z": -1.0}, "camera_up_vector": {"x": 0.0, "y": 1.0, "z": 0.0}, "field_of_view": 60.0, } } ORTHO_VP = { "orthogonal_camera": { "camera_view_point": {"x": 0.0, "y": 0.0, "z": 10.0}, "camera_direction": {"x": 0.0, "y": 0.0, "z": -1.0}, "camera_up_vector": {"x": 0.0, "y": 1.0, "z": 0.0}, "view_to_world_scale": 50.0, } } class TestBcfViewpointToIfcUrl: def test_perspective_camera(self): result = IfcUrl.parse(bcf_viewpoint_to_ifc_url(BASE, PERSPECTIVE_VP)) assert result.camera == (1.0, 2.0, 3.0, 0.0, 0.0, -1.0, 0.0, 1.0, 0.0) assert result.fov == 60.0 assert result.scale is None def test_orthogonal_camera(self): result = IfcUrl.parse(bcf_viewpoint_to_ifc_url(BASE, ORTHO_VP)) assert result.scale == 50.0 assert result.fov is None def test_preserves_base_repo_context(self): result = IfcUrl.parse(bcf_viewpoint_to_ifc_url(BASE, PERSPECTIVE_VP)) assert result.host == "example.com" assert result.repo_path == "org/repo" assert result.ref == "heads/main" assert result.path == "model.ifc" def test_no_camera_gives_no_camera_params(self): result = IfcUrl.parse(bcf_viewpoint_to_ifc_url(BASE, {})) assert result.camera is None assert result.fov is None assert result.scale is None def test_clipping_planes(self): vp = { **PERSPECTIVE_VP, "clipping_planes": [ { "location": {"x": 0.0, "y": 0.0, "z": 2.0}, "direction": {"x": 0.0, "y": 0.0, "z": -1.0}, } ], } result = IfcUrl.parse(bcf_viewpoint_to_ifc_url(BASE, vp)) assert result.clips == [(0.0, 0.0, 2.0, 0.0, 0.0, -1.0)] def test_selection_becomes_selector(self): vp = { **PERSPECTIVE_VP, "components": { "selection": [ {"ifc_guid": "abc123"}, {"ifc_guid": "def456"}, ] }, } result = IfcUrl.parse(bcf_viewpoint_to_ifc_url(BASE, vp)) assert result.selector == "abc123+def456" assert result.visibility == "highlight" def test_isolate_from_default_hidden(self): vp = { **PERSPECTIVE_VP, "components": { "visibility": { "default_visibility": False, "exceptions": [{"ifc_guid": "abc123"}], } }, } result = IfcUrl.parse(bcf_viewpoint_to_ifc_url(BASE, vp)) assert result.selector == "abc123" assert result.visibility == "isolate" def test_no_components_gives_no_selector(self): result = IfcUrl.parse(bcf_viewpoint_to_ifc_url(BASE, PERSPECTIVE_VP)) assert result.selector is None assert result.visibility == "highlight"