feat(photos): S3 multipart BLOB storage + original/processed GET + 410 after purge + attach UI
- backend/photos.py: POST /slabs/{id}/photos (multipart original+processed ->
slab_photos BLOBs, re-upload replaces), GET original/processed (image/png, 410
once purged), GET list. Cites CAPTURE-95/105/130, PUBLISH-40.
- frontend: create/photos/PhotoAttach.svelte (slab id, original/processed files,
calibration flag, lineup) + dev toggle.
- tests: 5 photos tests (upload+get both variants, 410 after purge, 404 slab,
reupload replaces, list).
Verified: pytest 36 passed; gates exit 0; npm build ok; live QA upload->200,
get original/processed->200, purge->410.
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
"""S3 photos BLOB storage tests: multipart upload, original/processed GET, 410 after purge."""
|
||||
|
||||
import base64
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import backend.db as db_mod
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def photos_env():
|
||||
tmp = tempfile.mkdtemp(prefix="photos_")
|
||||
os.environ["SLAB_DB_PATH"] = os.path.join(tmp, "p.db")
|
||||
os.environ["SLAB_IMAGES_DIR"] = tmp
|
||||
os.environ["WOO_BASE_URL"] = "https://example.com"
|
||||
os.environ["SLAB_AES_KEY"] = base64.b64encode(b"0123456789abcdef0123456789abcdef").decode()
|
||||
db_mod._reset_for_tests()
|
||||
from backend.app import app
|
||||
|
||||
conn = db_mod.init_db()
|
||||
# seed a slab
|
||||
conn.execute("INSERT INTO slabs(id, sku, status, server_rev, created_at, updated_at) VALUES ('s1','SLAB-01','calibrated',1,'t','t')")
|
||||
conn.commit()
|
||||
yield TestClient(app), conn
|
||||
db_mod._reset_for_tests()
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
PNG_ORIG = b"\x89PNG-orig-bytes"
|
||||
PNG_PROC = b"\x89PNG-proc-bytes"
|
||||
|
||||
|
||||
def _multipart(client, slab_id, photo_id, original=PNG_ORIG, processed=PNG_PROC, is_calibration=0):
|
||||
files = {"original": ("orig.png", original, "image/png")}
|
||||
if processed is not None:
|
||||
files["processed"] = ("proc.png", processed, "image/png")
|
||||
return client.post(
|
||||
f"/api/v1/slabs/{slab_id}/photos",
|
||||
data={"photo_id": photo_id, "is_calibration": str(is_calibration)},
|
||||
files=files,
|
||||
)
|
||||
|
||||
|
||||
def test_upload_and_get_original_processed(photos_env):
|
||||
client, _ = photos_env
|
||||
r = _multipart(client, "s1", "p1")
|
||||
assert r.status_code == 201, r.text
|
||||
body = r.json()
|
||||
assert body["original_retained"] is True and body["processed_retained"] is True
|
||||
|
||||
r = client.get("/api/v1/slabs/s1/photos/p1/original")
|
||||
assert r.status_code == 200
|
||||
assert r.content == PNG_ORIG
|
||||
assert r.headers["content-type"] == "image/png"
|
||||
|
||||
r = client.get("/api/v1/slabs/s1/photos/p1/processed")
|
||||
assert r.status_code == 200
|
||||
assert r.content == PNG_PROC
|
||||
|
||||
|
||||
def test_processed_410_when_purged(photos_env):
|
||||
client, conn = photos_env
|
||||
_multipart(client, "s1", "p2")
|
||||
# simulate purge (publish clears blobs)
|
||||
conn.execute("UPDATE slab_photos SET original_blob=NULL, processed_blob=NULL WHERE id='p2'")
|
||||
conn.commit()
|
||||
assert client.get("/api/v1/slabs/s1/photos/p2/original").status_code == 410
|
||||
assert client.get("/api/v1/slabs/s1/photos/p2/processed").status_code == 410
|
||||
|
||||
|
||||
def test_upload_requires_slab(photos_env):
|
||||
client, _ = photos_env
|
||||
r = _multipart(client, "nope", "p1")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_reupload_replaces(photos_env):
|
||||
client, _ = photos_env
|
||||
_multipart(client, "s1", "p3", original=PNG_ORIG, processed=PNG_PROC)
|
||||
r = _multipart(client, "s1", "p3", original=b"NEW-ORIG", processed=None)
|
||||
assert r.status_code == 201
|
||||
assert r.json()["processed_retained"] is False
|
||||
assert client.get("/api/v1/slabs/s1/photos/p3/original").content == b"NEW-ORIG"
|
||||
assert client.get("/api/v1/slabs/s1/photos/p3/processed").status_code == 410
|
||||
|
||||
|
||||
def test_list_photos(photos_env):
|
||||
client, _ = photos_env
|
||||
_multipart(client, "s1", "pa", is_calibration=1)
|
||||
_multipart(client, "s1", "pb")
|
||||
r = client.get("/api/v1/slabs/s1/photos")
|
||||
assert r.status_code == 200
|
||||
ids = [row["id"] for row in r.json()]
|
||||
assert "pa" in ids and "pb" in ids
|
||||
Reference in New Issue
Block a user