feat: implementar 12 mejoras, tests, docs y optimizaciones

- Fase A: license templates, search history, cost estimator
- Fase B: import URL, bulk ZIP, batch download
- Fase C: comparison mode, mesh validation, measurement tool
- Fase D: cross-section clipping, overhang heatmap, layer animation
- Refactor Pydantic/SQLAlchemy warnings
- 24 tests pytest
- README actualizado
- WebP thumbnails, lazy loading, cache headers
This commit is contained in:
Consultoria AS
2026-04-27 09:14:58 +00:00
commit 14b307110d
31 changed files with 5386 additions and 0 deletions

0
tests/__init__.py Normal file
View File

274
tests/test_api.py Normal file
View File

@@ -0,0 +1,274 @@
import os
import tempfile
import zipfile
from unittest.mock import patch, AsyncMock
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.database import Base, get_db
from app.main import app
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def override_get_db():
db = TestingSessionLocal()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
Base.metadata.create_all(bind=engine)
client = TestClient(app)
TEST_DIR = os.path.dirname(os.path.abspath(__file__))
PROJECT_DIR = os.path.dirname(TEST_DIR)
_upload_counter = 0
def _get_unique_stl():
global _upload_counter
_upload_counter += 1
fd, path = tempfile.mkstemp(suffix='.stl')
with os.fdopen(fd, 'w') as f:
f.write(f"""solid test{_upload_counter}
facet normal 0 0 1
outer loop
vertex 0 0 0
vertex {_upload_counter} 0 0
vertex {_upload_counter} {_upload_counter} 0
endloop
endfacet
endsolid test{_upload_counter}
""")
return path
def test_list_empty_models():
response = client.get("/api/models/")
assert response.status_code == 200
assert response.json() == []
def test_list_tags_empty():
response = client.get("/api/models/tags")
assert response.status_code == 200
assert response.json() == []
def _upload():
stl_path = _get_unique_stl()
with open(stl_path, 'rb') as f:
response = client.post(
"/api/models/",
data={
"title": "Test Cube",
"description": "A test cube",
"author": "Tester",
"tags": "test, cube",
"category": "Piezas"
},
files={"files": (os.path.basename(stl_path), f, "application/octet-stream")}
)
assert response.status_code == 200
return response.json()['id']
def test_upload_model():
model_id = _upload()
assert isinstance(model_id, int)
def test_get_model():
model_id = _upload()
response = client.get(f"/api/models/{model_id}")
assert response.status_code == 200
data = response.json()
assert data['id'] == model_id
assert data['title'] == "Test Cube"
def test_update_model():
model_id = _upload()
response = client.put(
f"/api/models/{model_id}",
json={"title": "Updated Cube", "tag_names": ["updated"]}
)
assert response.status_code == 200
data = response.json()
assert data['title'] == "Updated Cube"
assert len(data['tags']) == 1
def test_download_model():
model_id = _upload()
response = client.get(f"/api/models/{model_id}/download")
assert response.status_code == 200
def test_thumbnail():
model_id = _upload()
response = client.get(f"/api/models/{model_id}/thumbnail")
assert response.status_code == 200
def test_qr_endpoint():
model_id = _upload()
response = client.get(f"/api/models/{model_id}/qr")
assert response.status_code == 200
def test_pagination():
response = client.get("/api/models/?skip=0&limit=10")
assert response.status_code == 200
def test_search_filter():
response = client.get("/api/models/?search=Cube")
assert response.status_code == 200
def test_delete_model():
model_id = _upload()
response = client.delete(f"/api/models/{model_id}")
assert response.status_code == 200
response = client.get(f"/api/models/{model_id}")
assert response.status_code == 404
def test_backup_endpoint():
response = client.get("/api/models/system/backup")
assert response.status_code == 200
# Phase 5: Social features
def test_create_rating():
model_id = _upload()
response = client.post(f"/api/models/{model_id}/ratings?stars=5")
assert response.status_code == 200
data = response.json()
assert data['stars'] == 5
response = client.get(f"/api/models/{model_id}")
assert response.json()['avg_rating'] == 5.0
def test_create_comment():
model_id = _upload()
response = client.post(f"/api/models/{model_id}/comments?text=Great%20model&author_name=User1")
assert response.status_code == 200
data = response.json()
assert data['text'] == "Great model"
assert data['author_name'] == "User1"
def test_list_comments():
model_id = _upload()
client.post(f"/api/models/{model_id}/comments?text=Comment1")
client.post(f"/api/models/{model_id}/comments?text=Comment2")
response = client.get(f"/api/models/{model_id}/comments")
assert response.status_code == 200
assert len(response.json()) == 2
def _create_collection():
response = client.post("/api/models/collections", json={"name": "My Collection", "description": "Test"})
assert response.status_code == 200
data = response.json()
assert data['name'] == "My Collection"
return data['id']
def test_add_to_collection():
model_id = _upload()
coll_id = _create_collection()
response = client.post(f"/api/models/collections/{coll_id}/add/{model_id}")
assert response.status_code == 200
response = client.get(f"/api/models/collections/{coll_id}")
assert response.status_code == 200
assert len(response.json()['models']) == 1
# Phase 6: Import / Bulk / Batch / Validation / Estimate
def test_import_from_url():
stl_path = _get_unique_stl()
with open(stl_path, 'rb') as f:
stl_bytes = f.read()
from unittest.mock import Mock
mock_response = Mock()
mock_response.content = stl_bytes
mock_response.raise_for_status = Mock()
async_mock_client = AsyncMock()
async_mock_client.get = AsyncMock(return_value=mock_response)
with patch('app.routers.models.httpx.AsyncClient', return_value=async_mock_client):
async_mock_client.__aenter__ = AsyncMock(return_value=async_mock_client)
async_mock_client.__aexit__ = AsyncMock(return_value=False)
response = client.post(
"/api/models/import-url",
data={"url": "http://example.com/model.stl", "title": "Imported Model"}
)
assert response.status_code == 200
data = response.json()
assert data['title'] == "Imported Model"
def test_bulk_zip_upload():
stl_path = _get_unique_stl()
zip_path = tempfile.mktemp(suffix='.zip')
with zipfile.ZipFile(zip_path, 'w') as zf:
zf.write(stl_path, os.path.basename(stl_path))
with open(zip_path, 'rb') as f:
response = client.post(
"/api/models/bulk-zip",
data={"author": "BulkTester"},
files={"zip_file": ("models.zip", f, "application/zip")}
)
assert response.status_code == 200
data = response.json()
assert len(data) >= 1
os.remove(zip_path)
def test_batch_download():
model_id1 = _upload()
model_id2 = _upload()
response = client.post(
"/api/models/batch-download",
json=[model_id1, model_id2]
)
assert response.status_code == 200
assert response.headers['content-type'] == 'application/zip'
def test_validate_mesh():
model_id = _upload()
response = client.get(f"/api/models/{model_id}/validate")
assert response.status_code == 200
data = response.json()
assert 'is_watertight' in data
assert 'volume_cm3' in data
def test_estimate_print():
model_id = _upload()
response = client.get(f"/api/models/{model_id}/estimate")
assert response.status_code == 200
data = response.json()
assert 'volume_cm3' in data
assert 'cost' in data
assert 'grams' in data

122
tests/test_parsers.py Normal file
View File

@@ -0,0 +1,122 @@
import os
import numpy as np
from app.parsers import parse_stl_file, parse_3mf_file, generate_thumbnail, generate_generic_thumbnail
TEST_DIR = os.path.dirname(os.path.abspath(__file__))
PROJECT_DIR = os.path.dirname(TEST_DIR)
def test_parse_ascii_stl():
path = os.path.join(PROJECT_DIR, 'test_cube.stl')
if not os.path.exists(path):
# Create a simple ASCII STL for testing
with open(path, 'w') as f:
f.write("""solid cube
facet normal 0 0 -1
outer loop
vertex 0 0 0
vertex 10 0 0
vertex 10 10 0
endloop
endfacet
facet normal 0 0 -1
outer loop
vertex 0 0 0
vertex 10 10 0
vertex 0 10 0
endloop
endfacet
facet normal 0 0 1
outer loop
vertex 0 0 10
vertex 10 10 10
vertex 10 0 10
endloop
endfacet
facet normal 0 0 1
outer loop
vertex 0 0 10
vertex 0 10 10
vertex 10 10 10
endloop
endfacet
facet normal -1 0 0
outer loop
vertex 0 0 0
vertex 0 10 10
vertex 0 0 10
endloop
endfacet
facet normal -1 0 0
outer loop
vertex 0 0 0
vertex 0 10 0
vertex 0 10 10
endloop
endfacet
facet normal 1 0 0
outer loop
vertex 10 0 0
vertex 10 0 10
vertex 10 10 10
endloop
endfacet
facet normal 1 0 0
outer loop
vertex 10 0 0
vertex 10 10 10
vertex 10 10 0
endloop
endfacet
facet normal 0 -1 0
outer loop
vertex 0 0 0
vertex 0 0 10
vertex 10 0 10
endloop
endfacet
facet normal 0 -1 0
outer loop
vertex 0 0 0
vertex 10 0 10
vertex 10 0 0
endloop
endfacet
facet normal 0 1 0
outer loop
vertex 0 10 0
vertex 10 10 0
vertex 10 10 10
endloop
endfacet
facet normal 0 1 0
outer loop
vertex 0 10 0
vertex 10 10 10
vertex 0 10 10
endloop
endfacet
endsolid cube
""")
result = parse_stl_file(path)
assert result['faces'] == 12
assert abs(result['width'] - 10.0) < 0.1
assert abs(result['height'] - 10.0) < 0.1
assert abs(result['depth'] - 10.0) < 0.1
assert len(result['vertices']) == 36 # 12 triangles * 3 vertices
def test_generate_thumbnail():
path = os.path.join(PROJECT_DIR, 'test_cube.stl')
result = parse_stl_file(path)
out_path = os.path.join(PROJECT_DIR, 'test_thumb.png')
generate_thumbnail(result['vertices'], out_path, size=128)
assert os.path.exists(out_path)
os.remove(out_path)
def test_generate_generic_thumbnail():
out_path = os.path.join(PROJECT_DIR, 'test_generic.png')
generate_generic_thumbnail(out_path, size=128, label='3MF')
assert os.path.exists(out_path)
os.remove(out_path)