"""Tests for the glTF import pipeline.""" import json from pathlib import Path import pytest from fastapi.testclient import TestClient from sqlalchemy import select from app.config import settings from app.database import get_db, SessionLocal from app.models.asset import Asset, AssetType from app.models.user import User from app.main import app # --- Fixtures --- @pytest.fixture def db_session(): """Provide a test database session with rollback.""" async_session = SessionLocal() async def override_get_db(): return async_session app.dependency_overrides[get_db] = override_get_db yield async_session async def cleanup(): await async_session.rollback() await async_session.close() import asyncio asyncio.get_event_loop().run_until_complete(cleanup()) app.dependency_overrides.clear() @pytest.fixture def test_user(db_session): """Create a test user.""" user = User( username="test_gltf_user", email="gltf_test@example.com", hashed_password="$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36PQm3iV8jEGPym2fA1Wile", # pass is_active=True, ) db_session.add(user) import asyncio asyncio.get_event_loop().run_until_complete(db_session.commit()) asyncio.get_event_loop().run_until_complete(db_session.refresh(user)) return user @pytest.fixture def client(db_session, test_user): """Create a test client with auth.""" from jose import jwt from datetime import datetime, timedelta, timezone token = jwt.encode( { "sub": str(test_user.id), "exp": datetime.now(timezone.utc) + timedelta(minutes=30), }, settings.SECRET_KEY, algorithm=settings.ALGORITHM, ) from fastapi.testclient import TestClient client = TestClient(app) client.headers["Authorization"] = f"Bearer {token}" return client # --- Helpers for building test glTF files --- def make_gltf_json( scenes=True, meshes=True, nodes=False, materials=0, animations=0, vertex_count=0, has_bounds=True, ): """Build a minimal valid .gltf JSON structure.""" data = { "asset": {"version": "2.0", "generator": "test"}, } if scenes: data["scenes"] = [{"nodes": [0]}] if nodes: data["nodes"] = [{"name": "root"}] if meshes: accessors = [] if vertex_count > 0 and has_bounds: accessors.append( { "count": vertex_count, "componentType": 5126, "type": "VEC3", "min": [0.0, 0.0, 0.0], "max": [1.0, 1.0, 1.0], } ) elif vertex_count > 0: accessors.append( { "count": vertex_count, "componentType": 5126, "type": "VEC3", } ) meshes_list = [] for _ in range(1): prim = {"attributes": {"POSITION": 0}} if accessors else {} meshes_list.append({"primitives": [prim]}) data["meshes"] = meshes_list if accessors: data["accessors"] = accessors if materials > 0: data["materials"] = [{"name": f"mat_{i}"} for i in range(materials)] if animations > 0: data["animations"] = [{"name": f"anim_{i}"} for i in range(animations)] return data def make_glb(data_dict): """Build a minimal valid .glb binary file from a dict.""" json_bytes = json.dumps(data_dict).encode("utf-8") # Pad JSON to 4-byte boundary json_padding = (4 - len(json_bytes) % 4) % 4 json_bytes += b" " * json_padding json_chunk_length = len(json_bytes) json_chunk_type = b"JSON" # No binary chunks for simplicity total_length = 12 + 8 + json_chunk_length + 8 # header + json chunk header + data + BIN padding header = b"glTF" header += int.to_bytes(2, 4, "little") # version header += int.to_bytes(total_length, 4, "little") # total length chunk_header = int.to_bytes(json_chunk_length, 4, "little") chunk_header += json_chunk_type # Add a BIN chunk with zero length to pad bin_chunk_length = 0 bin_chunk_type = b"BIN" bin_padding = (4 - bin_chunk_length % 4) % 4 return ( header + chunk_header + json_bytes + bin_chunk_type + int.to_bytes(bin_chunk_length, 4, "little") + b" " * bin_padding ) # --- Tests: Extension validation --- class TestImportGltfExtensionValidation: """Test that only .gltf and .glb files are accepted.""" def test_rejects_non_gltf_extension(self, client): """Files without .gltf or .glb extension return 400.""" response = client.post( "/api/assets/import-gltf", files={"file": ("test.png", b"fake png data", "image/png")}, ) assert response.status_code == 400 assert ".gltf" in response.json()["detail"].lower() or ".glb" in response.json()["detail"].lower() def test_rejects_txt_extension(self, client): """A .txt file is rejected.""" response = client.post( "/api/assets/import-gltf", files={"file": ("model.txt", b"not a gltf", "text/plain")}, ) assert response.status_code == 400 # --- Tests: .gltf validation --- class TestImportGltfJsonValidation: """Test validation of .gltf (JSON) files.""" def test_valid_minimal_gltf(self, client): """A minimal valid .gltf is accepted.""" data = make_gltf_json(vertex_count=24, materials=2, animations=1) json_bytes = json.dumps(data).encode("utf-8") response = client.post( "/api/assets/import-gltf", files={"file": ("model.gltf", json_bytes, "model/gltf+json")}, ) assert response.status_code == 201 body = response.json() assert body["asset_type"] == "model_gltf" assert body["file_size"] == len(json_bytes) assert body["mime_type"] == "model/gltf+json" metadata = json.loads(body["metadata_json"]) assert metadata["vertex_count"] == 24 assert metadata["material_count"] == 2 assert metadata["has_animation"] is True def test_rejects_invalid_json(self, client): """Malformed JSON returns 400.""" response = client.post( "/api/assets/import-gltf", files={"file": ("bad.gltf", b"{not valid json", "model/gltf+json")}, ) assert response.status_code == 400 assert "Invalid JSON" in response.json()["detail"] def test_rejects_gltf_missing_asset(self, client): """glTF without 'asset' property returns 400.""" data = {"scenes": [{}]} json_bytes = json.dumps(data).encode("utf-8") response = client.post( "/api/assets/import-gltf", files={"file": ("no_asset.gltf", json_bytes, "model/gltf+json")}, ) assert response.status_code == 400 assert "Missing required 'asset'" in response.json()["detail"] def test_rejects_gltf_no_meshes_scenes_nodes(self, client): """glTF with neither scenes, meshes, nor nodes returns 400.""" data = {"asset": {"version": "2.0"}, "materials": []} json_bytes = json.dumps(data).encode("utf-8") response = client.post( "/api/assets/import-gltf", files={"file": ("empty.gltf", json_bytes, "model/gltf+json")}, ) assert response.status_code == 400 assert "scenes, meshes, or nodes" in response.json()["detail"] # --- Tests: .glb validation --- class TestImportGlbValidation: """Test validation of .glb (binary) files.""" def test_valid_minimal_glb(self, client): """A minimal valid .glb is accepted.""" data = make_gltf_json(vertex_count=12, materials=1) glb_bytes = make_glb(data) response = client.post( "/api/assets/import-gltf", files={"file": ("model.glb", glb_bytes, "model/gltf+binary")}, ) assert response.status_code == 201 body = response.json() assert body["asset_type"] == "model_gltf" assert body["file_size"] == len(glb_bytes) assert body["mime_type"] == "model/gltf+binary" metadata = json.loads(body["metadata_json"]) assert metadata["vertex_count"] == 12 assert metadata["material_count"] == 1 def test_rejects_invalid_magic_bytes(self, client): """File not starting with 'glTF' magic returns 400.""" bad_glb = b"xxxx" + int.to_bytes(2, 4, "little") + int.to_bytes(16, 4, "little") bad_glb += int.to_bytes(4, 4, "little") + b"JSON" + b"{}" + b"BIN" + int.to_bytes(0, 4, "little") response = client.post( "/api/assets/import-gltf", files={"file": ("bad.glb", bad_glb, "model/gltf+binary")}, ) assert response.status_code == 400 assert "magic" in response.json()["detail"].lower() def test_rejects_wrong_version(self, client): """glTF version 1 returns 400.""" data = {"asset": {"version": "1.0"}} json_bytes = json.dumps(data).encode("utf-8") json_padding = (4 - len(json_bytes) % 4) % 4 json_bytes += b" " * json_padding json_len = len(json_bytes) header = b"glTF" + int.to_bytes(1, 4, "little") + int.to_bytes(12 + 8 + json_len + 8, 4, "little") chunk = int.to_bytes(json_len, 4, "little") + b"JSON" bin_pad = b"BIN" + int.to_bytes(0, 4, "little") glb_bytes = header + chunk + json_bytes + bin_pad response = client.post( "/api/assets/import-gltf", files={"file": ("v1.glb", glb_bytes, "model/gltf+binary")}, ) assert response.status_code == 400 assert "Unsupported glTF version" in response.json()["detail"] def test_rejects_too_short(self, client): """A file shorter than 12 bytes returns 400.""" response = client.post( "/api/assets/import-gltf", files={"file": ("tiny.glb", b"glTF123", "model/gltf+binary")}, ) assert response.status_code == 400 assert "too short" in response.json()["detail"].lower() # --- Tests: File size validation --- class TestImportGltfFileSize: """Test file size limits.""" def test_rejects_oversized_file(self, client): """Files exceeding MAX_UPLOAD_SIZE_MB return 413.""" # Create a file larger than the default 100 MB limit large_content = b"X" * (settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024 + 1) response = client.post( "/api/assets/import-gltf", files={"file": ("big.gltf", large_content, "model/gltf+json")}, ) assert response.status_code == 413 assert "exceeds maximum" in response.json()["detail"].lower() # --- Tests: Metadata extraction --- class TestMetadataExtraction: """Test that metadata is correctly extracted from glTF data.""" def test_vertex_count_from_meshes(self, client): """Vertex count is extracted from mesh primitives.""" data = make_gltf_json(vertex_count=100, materials=0, animations=0) json_bytes = json.dumps(data).encode("utf-8") response = client.post( "/api/assets/import-gltf", files={"file": ("model.gltf", json_bytes, "model/gltf+json")}, ) assert response.status_code == 201 metadata = json.loads(response.json()["metadata_json"]) assert metadata["vertex_count"] == 100 def test_material_count(self, client): """Material count is extracted from materials array.""" data = make_gltf_json(materials=5, vertex_count=0) json_bytes = json.dumps(data).encode("utf-8") response = client.post( "/api/assets/import-gltf", files={"file": ("model.gltf", json_bytes, "model/gltf+json")}, ) assert response.status_code == 201 metadata = json.loads(response.json()["metadata_json"]) assert metadata["material_count"] == 5 def test_has_animation_flag(self, client): """has_animation is True when animations exist.""" data = make_gltf_json(animations=3, vertex_count=0) json_bytes = json.dumps(data).encode("utf-8") response = client.post( "/api/assets/import-gltf", files={"file": ("model.gltf", json_bytes, "model/gltf+json")}, ) assert response.status_code == 201 metadata = json.loads(response.json()["metadata_json"]) assert metadata["has_animation"] is True def test_no_animation_flag(self, client): """has_animation is False when no animations exist.""" data = make_gltf_json(animations=0, vertex_count=0) json_bytes = json.dumps(data).encode("utf-8") response = client.post( "/api/assets/import-gltf", files={"file": ("model.gltf", json_bytes, "model/gltf+json")}, ) assert response.status_code == 201 metadata = json.loads(response.json()["metadata_json"]) assert metadata["has_animation"] is False def test_bounding_box_extraction(self, client): """Bounding box is extracted from accessor min/max.""" data = make_gltf_json(vertex_count=8, has_bounds=True) json_bytes = json.dumps(data).encode("utf-8") response = client.post( "/api/assets/import-gltf", files={"file": ("model.gltf", json_bytes, "model/gltf+json")}, ) assert response.status_code == 201 metadata = json.loads(response.json()["metadata_json"]) assert metadata["bounding_box"] is not None assert "min" in metadata["bounding_box"] assert "max" in metadata["bounding_box"] def test_no_bounding_box_without_accessor_bounds(self, client): """Bounding box is None when accessor has no min/max.""" data = make_gltf_json(vertex_count=8, has_bounds=False) json_bytes = json.dumps(data).encode("utf-8") response = client.post( "/api/assets/import-gltf", files={"file": ("model.gltf", json_bytes, "model/gltf+json")}, ) assert response.status_code == 201 metadata = json.loads(response.json()["metadata_json"]) assert metadata["bounding_box"] is None # --- Tests: Database persistence --- class TestDatabasePersistence: """Test that assets are correctly stored in the database.""" def test_asset_created_in_db(self, client, db_session, test_user): """An asset record is created in the database.""" data = make_gltf_json(vertex_count=10) json_bytes = json.dumps(data).encode("utf-8") response = client.post( "/api/assets/import-gltf", files={"file": ("model.gltf", json_bytes, "model/gltf+json")}, ) assert response.status_code == 201 # Verify in database result = db_session.execute(select(Asset).where(Asset.id == response.json()["id"])) asset = result.scalar_one_or_none() assert asset is not None assert asset.asset_type == AssetType.MODEL_GLTF assert asset.owner_id == test_user.id assert asset.file_size == len(json_bytes) assert asset.metadata_json is not None def test_file_stored_on_disk(self, client, db_session, test_user): """The file is stored in the upload directory.""" data = make_gltf_json(vertex_count=10) json_bytes = json.dumps(data).encode("utf-8") response = client.post( "/api/assets/import-gltf", files={"file": ("model.gltf", json_bytes, "model/gltf+json")}, ) assert response.status_code == 201 asset_id = response.json()["id"] upload_dir = Path(settings.UPLOAD_DIR) # The stored file should exist found = False for f in upload_dir.iterdir(): if f.name.startswith(f"{asset_id}_"): found = True assert f.read_bytes() == json_bytes break assert found, f"File not found in {upload_dir}" # --- Tests: Filename sanitization --- class TestFilenameSanitization: """Test that filenames are properly sanitized.""" def test_sanitizes_special_chars(self, client): """Special characters in filename are stripped.""" data = make_gltf_json(vertex_count=10) json_bytes = json.dumps(data).encode("utf-8") response = client.post( "/api/assets/import-gltf", files={"file": ("my model (1).gltf", json_bytes, "model/gltf+json")}, ) assert response.status_code == 201 # The name should be the stem without special chars assert response.json()["name"] == "my model 1" def test_preserves_extension(self, client): """The .gltf extension is preserved in the stored file.""" data = make_gltf_json(vertex_count=10) json_bytes = json.dumps(data).encode("utf-8") response = client.post( "/api/assets/import-gltf", files={"file": ("model.gltf", json_bytes, "model/gltf+json")}, ) assert response.status_code == 201 upload_dir = Path(settings.UPLOAD_DIR) for f in upload_dir.iterdir(): if f.name.endswith(".gltf"): assert f.read_bytes() == json_bytes break