fix: close library upload flow and harden image storage
This commit is contained in:
@@ -781,3 +781,175 @@ def test_source_deletion_removes_unreferenced_archived_plans(
|
||||
service.db.one("SELECT id FROM library_plans WHERE source_id='family'") is None
|
||||
)
|
||||
assert client.get("/api/v1/generation-plans?source_id=family").status_code == 404
|
||||
|
||||
|
||||
def test_manual_upload_survives_reload_and_generates_bound_versions(client, service):
|
||||
data = BytesIO()
|
||||
Image.new("RGB", (700, 500), "blue").save(data, "PNG")
|
||||
path = "/api/v1/source-libraries/manual-uploads/upload"
|
||||
result = client.post(
|
||||
path, files={"file": ("original.png", data.getvalue(), "image/png")}
|
||||
)
|
||||
assert result.status_code == 200, result.text
|
||||
picture = result.json()
|
||||
with Image.open(service.assets.path(picture["id"])) as image:
|
||||
assert image.size == (700, 500)
|
||||
assert (
|
||||
client.post(path, files={"file": ("same.png", data.getvalue())}).json()["id"]
|
||||
== picture["id"]
|
||||
)
|
||||
listed = client.get("/api/v1/source-libraries/manual-uploads/images").json()
|
||||
assert listed["total"] == 1 and listed["items"][0]["id"] == picture["id"]
|
||||
plan = create_plan(client, "manual-uploads")
|
||||
bind(client, "watch", "manual-uploads", plan)
|
||||
generated = client.post(
|
||||
"/api/v1/source-images/" + picture["id"] + "/generate",
|
||||
json={"plan_id": plan["id"]},
|
||||
)
|
||||
assert generated.status_code == 200, generated.text
|
||||
assert generated.json()[0]["plan"]["width"] == 410
|
||||
run = client.post(
|
||||
"/api/v1/source-libraries/manual-uploads/run", json={"deliver": False}
|
||||
)
|
||||
assert run.status_code == 200, run.text
|
||||
assert (
|
||||
client.get("/api/v1/source-libraries/manual-uploads/images").json()["total"]
|
||||
== 1
|
||||
)
|
||||
assert client.get("/api/v1/library-terminals/watch/images").json()["total"] == 1
|
||||
|
||||
|
||||
def test_invalid_upload_does_not_create_source(client, service):
|
||||
response = client.post(
|
||||
"/api/v1/source-libraries/manual-uploads/upload",
|
||||
files={"file": ("bad.jpg", b"not an image")},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert service.photo_sources.list() == []
|
||||
|
||||
|
||||
def test_ingest_sql_failure_rolls_back_asset_and_file(
|
||||
client, service, library_source, monkeypatch
|
||||
):
|
||||
original = service.db.execute
|
||||
|
||||
def fail(query, args=()):
|
||||
if "INSERT INTO source_images" in query:
|
||||
raise RuntimeError("injected SQL failure")
|
||||
return original(query, args)
|
||||
|
||||
monkeypatch.setattr(service.db, "execute", fail)
|
||||
with pytest.raises(RuntimeError):
|
||||
service.libraries.import_picture("family", "photo.jpg")
|
||||
assert service.db.all("SELECT * FROM assets") == []
|
||||
assert service.db.all("SELECT * FROM source_images") == []
|
||||
assert list((service.settings.data_dir / "source-images").iterdir()) == []
|
||||
assert service.db.all("SELECT * FROM asset_file_journal") == []
|
||||
|
||||
|
||||
def test_generation_sql_failure_keeps_source_without_orphan(
|
||||
client, service, library_source, monkeypatch
|
||||
):
|
||||
picture = import_picture(client)
|
||||
plan = create_plan(client)
|
||||
original = service.db.execute
|
||||
|
||||
def fail(query, args=()):
|
||||
if "INSERT INTO library_versions" in query:
|
||||
raise RuntimeError("injected SQL failure")
|
||||
return original(query, args)
|
||||
|
||||
monkeypatch.setattr(service.db, "execute", fail)
|
||||
with pytest.raises(RuntimeError):
|
||||
service.libraries.generate(picture["id"], plan["id"])
|
||||
assert len(service.db.all("SELECT * FROM assets")) == 1
|
||||
assert list((service.settings.data_dir / "renditions").iterdir()) == []
|
||||
assert service.assets.path(picture["id"]).exists()
|
||||
|
||||
|
||||
def test_delete_sql_failure_preserves_files_and_relations(
|
||||
client, service, library_source, monkeypatch
|
||||
):
|
||||
picture = import_picture(client)
|
||||
plan = create_plan(client)
|
||||
version = service.libraries.generate(picture["id"], plan["id"])
|
||||
original = service.db.execute
|
||||
|
||||
def fail(query, args=()):
|
||||
if query == "DELETE FROM source_images WHERE id=?":
|
||||
raise RuntimeError("injected SQL failure")
|
||||
return original(query, args)
|
||||
|
||||
monkeypatch.setattr(service.db, "execute", fail)
|
||||
with pytest.raises(RuntimeError):
|
||||
service.libraries.delete_picture(picture["id"])
|
||||
assert service.assets.path(version["asset_id"]).exists()
|
||||
assert (
|
||||
service.libraries.picture(picture["id"])["versions"][0]["asset_id"]
|
||||
== version["asset_id"]
|
||||
)
|
||||
assert service.db.all("SELECT * FROM asset_file_journal") == []
|
||||
|
||||
|
||||
def test_delete_file_failure_retries_on_restart(
|
||||
client, service, library_source, monkeypatch
|
||||
):
|
||||
from pathlib import Path
|
||||
|
||||
from pigway_frame_flow.renderers.images import Assets
|
||||
|
||||
picture = import_picture(client)
|
||||
path = service.assets.path(picture["id"])
|
||||
unlink = Path.unlink
|
||||
|
||||
def fail(file, *args, **kwargs):
|
||||
if file == path:
|
||||
raise OSError("injected unlink failure")
|
||||
return unlink(file, *args, **kwargs)
|
||||
|
||||
with monkeypatch.context() as patch:
|
||||
patch.setattr(Path, "unlink", fail)
|
||||
service.libraries.delete_picture(picture["id"])
|
||||
assert service.db.all("SELECT * FROM source_images") == []
|
||||
assert (
|
||||
service.db.one("SELECT id FROM asset_file_journal")["id"] == picture["id"]
|
||||
)
|
||||
Assets(service.db, service.settings)
|
||||
assert not path.exists()
|
||||
assert service.db.all("SELECT * FROM asset_file_journal") == []
|
||||
|
||||
|
||||
def test_asset_disk_write_failure_leaves_no_partial_file(service, monkeypatch):
|
||||
import os
|
||||
|
||||
fsync = os.fsync
|
||||
calls = 0
|
||||
|
||||
def fail(fd):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
raise OSError("injected disk write failure")
|
||||
return fsync(fd)
|
||||
|
||||
monkeypatch.setattr(os, "fsync", fail)
|
||||
with pytest.raises(OSError):
|
||||
service.assets.put(b"data", "failed.jpg")
|
||||
assert service.db.all("SELECT * FROM assets") == []
|
||||
assert service.db.all("SELECT * FROM asset_file_journal") == []
|
||||
assert list((service.settings.data_dir / "images").iterdir()) == []
|
||||
|
||||
|
||||
def test_asset_startup_removes_interrupted_creation(service):
|
||||
from pigway_frame_flow.renderers.images import Assets
|
||||
|
||||
asset_id = "c" * 32
|
||||
root = service.settings.data_dir / "images"
|
||||
(root / (asset_id + ".jpg")).write_bytes(b"incomplete")
|
||||
(root / (asset_id + ".part")).write_bytes(b"partial")
|
||||
service.db.execute(
|
||||
"INSERT INTO asset_file_journal VALUES(?,?)", (asset_id, "images")
|
||||
)
|
||||
Assets(service.db, service.settings)
|
||||
assert not list(root.iterdir())
|
||||
assert service.db.all("SELECT * FROM asset_file_journal") == []
|
||||
|
||||
Reference in New Issue
Block a user