Wiederverwendbarer UI.noteMediaAttacher für beide Notiz-Stellen (UI.noteModal
+ Notizblock-Seite). note_media-Tabelle + POST/DELETE /api/notes/{id}/media
(vor der gierigen /{parent_type}/{parent_id}-Route). Audio per MediaRecorder,
serverseitig nach m4a/AAC transkodiert (ffmpeg) — iOS spielt Chrome-Opus-webm
nicht ab. UI.lightbox global eingeführt. Mikrofon-Policy microphone=(self) +
CSP media-src 'self' blob:, Datenschutz v6. Disk-Cleanup für note_media bei
Notiz-, Account- und Admin-User-Delete. Reine Medien-Notiz ohne Text erlaubt.
noteModal-Bug gefixt: notes.get() liefert Array -> existing[0] statt
existing?.id (verhinderte Bearbeiten, erzeugte Duplikate). 12 neue Tests.
admin.py enthält außerdem KI-Vision-Statusfelder aus paralleler Arbeit
(nicht sauber trennbar ohne interaktives Staging).
129 lines
4.5 KiB
Python
129 lines
4.5 KiB
Python
"""Tests für Notiz-Medien: Bild-/Audio-Upload, GET liefert media_items,
|
|
Löschen entfernt DB-Zeile + Datei, Notiz-Delete räumt Dateien, Validierung."""
|
|
|
|
import io
|
|
import os
|
|
|
|
import pytest
|
|
|
|
|
|
def _jpeg_bytes(color=(200, 100, 50)):
|
|
from PIL import Image
|
|
buf = io.BytesIO()
|
|
Image.new("RGB", (12, 12), color).save(buf, format="JPEG")
|
|
return buf.getvalue()
|
|
|
|
|
|
# Minimaler MP4/M4A-Header (ftyp-Box) — genügt für validate_audio(audio/mp4).
|
|
_M4A_BYTES = b"\x00\x00\x00\x18ftypM4A \x00\x00\x00\x00M4A mp42isom" + b"\x00" * 32
|
|
|
|
|
|
def _create_note(client, user, text="Notiz mit Medien"):
|
|
r = client.post("/api/notes/diary/1", headers=user["headers"],
|
|
json={"text": text, "parent_label": "Testobjekt"})
|
|
assert r.status_code == 201, r.text
|
|
return r.json()
|
|
|
|
|
|
def _media_path(url):
|
|
media_dir = os.getenv("MEDIA_DIR", "/data/media")
|
|
rel = url[len("/media/"):] if url.startswith("/media/") else url.lstrip("/")
|
|
return os.path.join(media_dir, rel)
|
|
|
|
|
|
def test_upload_image_to_note(client, user):
|
|
note = _create_note(client, user)
|
|
r = client.post(
|
|
f"/api/notes/{note['id']}/media",
|
|
headers=user["headers"],
|
|
files={"file": ("foto.jpg", _jpeg_bytes(), "image/jpeg")},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
m = r.json()
|
|
assert m["media_type"] == "image"
|
|
assert m["url"].startswith("/media/notes/")
|
|
assert os.path.exists(_media_path(m["url"]))
|
|
|
|
|
|
def test_note_get_includes_media_items(client, user):
|
|
note = _create_note(client, user)
|
|
client.post(f"/api/notes/{note['id']}/media", headers=user["headers"],
|
|
files={"file": ("a.jpg", _jpeg_bytes(), "image/jpeg")})
|
|
r = client.get("/api/notes/diary/1", headers=user["headers"])
|
|
assert r.status_code == 200
|
|
target = next(n for n in r.json() if n["id"] == note["id"])
|
|
assert len(target["media_items"]) == 1
|
|
assert target["media_items"][0]["media_type"] == "image"
|
|
|
|
|
|
def test_upload_audio_to_note(client, user):
|
|
# Reine Sprachnotiz: leerer Text ist erlaubt, das Medium trägt die Notiz.
|
|
note = _create_note(client, user, text="")
|
|
r = client.post(
|
|
f"/api/notes/{note['id']}/media",
|
|
headers=user["headers"],
|
|
files={"file": ("sprachnachricht.m4a", _M4A_BYTES, "audio/mp4")},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
m = r.json()
|
|
assert m["media_type"] == "audio"
|
|
assert m["url"].endswith(".m4a")
|
|
assert os.path.exists(_media_path(m["url"]))
|
|
|
|
|
|
def test_delete_media_removes_row_and_file(client, user):
|
|
note = _create_note(client, user)
|
|
up = client.post(f"/api/notes/{note['id']}/media", headers=user["headers"],
|
|
files={"file": ("x.jpg", _jpeg_bytes(), "image/jpeg")}).json()
|
|
path = _media_path(up["url"])
|
|
assert os.path.exists(path)
|
|
|
|
r = client.delete(f"/api/notes/{note['id']}/media/{up['id']}", headers=user["headers"])
|
|
assert r.status_code == 204
|
|
assert not os.path.exists(path)
|
|
|
|
g = client.get("/api/notes/diary/1", headers=user["headers"]).json()
|
|
target = next(n for n in g if n["id"] == note["id"])
|
|
assert target["media_items"] == []
|
|
|
|
|
|
def test_delete_note_removes_media_files(client, user):
|
|
note = _create_note(client, user)
|
|
up = client.post(f"/api/notes/{note['id']}/media", headers=user["headers"],
|
|
files={"file": ("y.jpg", _jpeg_bytes(), "image/jpeg")}).json()
|
|
path = _media_path(up["url"])
|
|
assert os.path.exists(path)
|
|
|
|
r = client.delete(f"/api/notes/{note['id']}", headers=user["headers"])
|
|
assert r.status_code == 204
|
|
assert not os.path.exists(path)
|
|
|
|
|
|
def test_upload_rejects_corrupt_image(client, user):
|
|
note = _create_note(client, user)
|
|
r = client.post(
|
|
f"/api/notes/{note['id']}/media",
|
|
headers=user["headers"],
|
|
files={"file": ("fake.jpg", b"this is not a jpeg", "image/jpeg")},
|
|
)
|
|
assert r.status_code == 415
|
|
|
|
|
|
def test_upload_media_requires_own_note(client, user):
|
|
r = client.post(
|
|
"/api/notes/999999/media",
|
|
headers=user["headers"],
|
|
files={"file": ("z.jpg", _jpeg_bytes(), "image/jpeg")},
|
|
)
|
|
assert r.status_code == 404
|
|
|
|
|
|
def test_audio_utils_unit():
|
|
"""to_m4a reicht bereits-AAC durch; validate_audio prüft Magic-Bytes."""
|
|
from media_utils import to_m4a, validate_audio
|
|
data, ext = to_m4a(_M4A_BYTES, ".m4a")
|
|
assert ext == ".m4a"
|
|
assert data == _M4A_BYTES # schon AAC → keine Transkodierung
|
|
validate_audio(_M4A_BYTES, "audio/mp4") # kein Raise
|
|
with pytest.raises(ValueError):
|
|
validate_audio(b"xxxx", "audio/mp4")
|