1ed013bde2
- Render floor plan inside React Flow ViewportPortal so it pans/zooms with nodes (was screen-fixed, desynced on pan/zoom); zoom-stable resize handles. - Move floor plan config from the left panel into the canvas (design) edit modal; attach per-design and fix cross-design bleed on load. - Store images via a new generic backend media endpoint (POST/GET/DELETE /api/v1/media) on disk under <data_dir>/uploads, not base64 in the canvas. - Disable floor plans in standalone mode (no backend to upload/serve); drop base64 localStorage persistence. See ADR-001 in CLAUDE.md. - Tests: backend media route, DesignModal floor plan + upload, store floorMap. ha-relevant: maybe
95 lines
3.1 KiB
Python
95 lines
3.1 KiB
Python
import re
|
|
|
|
import pytest
|
|
|
|
from app.api.routes import media
|
|
from app.core.config import settings
|
|
|
|
PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"0" * 32
|
|
|
|
|
|
@pytest.fixture
|
|
def media_dir(tmp_path, monkeypatch):
|
|
"""Point uploads at a temp folder for the duration of a test."""
|
|
monkeypatch.setattr(settings, "upload_dir", str(tmp_path))
|
|
return tmp_path
|
|
|
|
|
|
async def _upload(client, headers, name="plan.png", data=PNG_BYTES, content_type="image/png"):
|
|
return await client.post(
|
|
"/api/v1/media/upload",
|
|
files={"file": (name, data, content_type)},
|
|
headers=headers,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upload_requires_auth(client, media_dir):
|
|
res = await _upload(client, headers={})
|
|
assert res.status_code == 401
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upload_stores_file_and_returns_url(client, auth_headers, media_dir):
|
|
headers = await auth_headers()
|
|
res = await _upload(client, headers)
|
|
assert res.status_code == 200
|
|
body = res.json()
|
|
assert re.fullmatch(r"/api/v1/media/[0-9a-f]{32}\.png", body["url"])
|
|
# File written to disk with the server-generated name (client name ignored).
|
|
assert (media_dir / body["filename"]).read_bytes() == PNG_BYTES
|
|
assert body["filename"] != "plan.png"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upload_rejects_unsupported_type(client, auth_headers, media_dir):
|
|
headers = await auth_headers()
|
|
res = await _upload(client, headers, name="a.txt", data=b"hello", content_type="text/plain")
|
|
assert res.status_code == 415
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upload_rejects_content_type_magic_mismatch(client, auth_headers, media_dir):
|
|
headers = await auth_headers()
|
|
# Claims PNG but bytes are not a PNG.
|
|
res = await _upload(client, headers, data=b"not-a-real-png", content_type="image/png")
|
|
assert res.status_code == 415
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upload_rejects_oversize(client, auth_headers, media_dir, monkeypatch):
|
|
monkeypatch.setattr(media, "MAX_BYTES", 8)
|
|
headers = await auth_headers()
|
|
res = await _upload(client, headers, data=PNG_BYTES) # > 8 bytes
|
|
assert res.status_code == 413
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_serves_uploaded_file(client, auth_headers, media_dir):
|
|
headers = await auth_headers()
|
|
up = await _upload(client, headers)
|
|
res = await client.get(up.json()["url"]) # public, no auth
|
|
assert res.status_code == 200
|
|
assert res.content == PNG_BYTES
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_rejects_bad_filename(client, media_dir):
|
|
res = await client.get("/api/v1/media/..%2f..%2fetc%2fpasswd")
|
|
assert res.status_code == 404
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_requires_auth_and_removes_file(client, auth_headers, media_dir):
|
|
headers = await auth_headers()
|
|
up = await _upload(client, headers)
|
|
filename = up.json()["filename"]
|
|
|
|
assert (await client.delete(f"/api/v1/media/{filename}")).status_code == 401
|
|
assert (media_dir / filename).exists()
|
|
|
|
res = await client.delete(f"/api/v1/media/{filename}", headers=headers)
|
|
assert res.status_code == 204
|
|
assert not (media_dir / filename).exists()
|
|
assert (await client.get(up.json()["url"])).status_code == 404
|