fix: close library upload flow and harden image storage
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 8m9s

This commit is contained in:
渡口浪人
2026-10-24 00:38:00 +08:00
parent 0859871363
commit 1494904b12
39 changed files with 1471 additions and 240 deletions
+172
View File
@@ -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") == []