diff --git a/backend/tests/test_gltf_import.py b/backend/tests/test_gltf_import.py new file mode 100644 index 0000000..8c73c09 --- /dev/null +++ b/backend/tests/test_gltf_import.py @@ -0,0 +1,493 @@ +"""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