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:
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
274
tests/test_api.py
Normal file
274
tests/test_api.py
Normal 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
122
tests/test_parsers.py
Normal 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)
|
||||
Reference in New Issue
Block a user