fix: persist product archive cover and provenance

This commit is contained in:
2026-09-12 13:28:11 +08:00
parent 1c51a9bc2b
commit 87a3d44359
6 changed files with 262 additions and 36 deletions
+95
View File
@@ -55,6 +55,7 @@ def product_archive_client(tmp_path, monkeypatch):
monkeypatch.setattr(service_app, "product_archive_store", store, raising=False)
client = TestClient(service_app.app)
client.archive_root = archive_root
client.product_store = store
return client
@@ -89,6 +90,15 @@ def prepared_wordcloud_job(tmp_path, monkeypatch):
)
def archive_product_version(client, product_id, document, preview=PNG_BYTES):
return client.post(
f"/api/products/{product_id}/versions",
data={"document_json": json.dumps(document)},
files={"preview": ("design-preview.png", preview, "image/png")},
headers=orders_auth_header(),
)
def test_scanner_keeps_only_visible_wordcloud_assets_and_deduplicates():
document = {
"layers": [{"id": "shown", "visible": True}, {"id": "hidden", "visible": False}],
@@ -252,3 +262,88 @@ def test_archive_version_handles_document_without_wordcloud_sources(product_arch
assert response.status_code == 201
assert response.json()["wordcloud_count"] == 0
def test_archive_version_rejects_invalid_png_bytes_with_png_mime(product_archive_client):
product = create_product(product_archive_client)
response = archive_product_version(product_archive_client, product["product_id"], {"elements": []}, b"\x89PNG\r\n\x1a\nnot-an-image")
assert response.status_code == 400
def test_archive_version_persists_each_multi_source_provenance(product_archive_client, prepared_wordcloud_job, monkeypatch):
second_db = prepared_wordcloud_job.workspace / "output" / "second.sqlite"
with sqlite3.connect(second_db) as connection:
connection.execute("CREATE TABLE word_locations (id INTEGER PRIMARY KEY, name TEXT)")
second_asset = "asset_second"
second_dir = service_app._asset_dir(second_asset)
second_dir.mkdir(parents=True)
(second_dir / "meta.json").write_text(json.dumps({"type": "wordcloud", "job_id": "job-second"}), encoding="utf-8")
monkeypatch.setattr(
service_app, "_resolve_job_status",
lambda job_id: SimpleNamespace(status="success", artifacts={"db": str(prepared_wordcloud_job.db_path if job_id == "job-success" else second_db)}),
)
product = create_product(product_archive_client)
response = archive_product_version(product_archive_client, product["product_id"], {
"elements": [
{"type": "sticker", "assetId": prepared_wordcloud_job.asset_id},
{"type": "sticker", "assetId": second_asset},
]
})
assert response.status_code == 201
archives = {item["source_job_id"]: item for item in response.json()["wordcloud_archives"]}
assert archives["job-success"]["source_asset_id"] == prepared_wordcloud_job.asset_id
assert archives["job-second"]["source_asset_id"] == second_asset
assert archives["job-success"]["db_checksum"] == hashlib.sha256(Path(archives["job-success"]["db_path"]).read_bytes()).hexdigest()
assert archives["job-second"]["db_checksum"] == hashlib.sha256(Path(archives["job-second"]["db_path"]).read_bytes()).hexdigest()
def test_later_archive_version_becomes_current_cover_in_product_contract(product_archive_client):
product = create_product(product_archive_client)
first = archive_product_version(product_archive_client, product["product_id"], {"elements": []})
first_cover = product_archive_client.get(f"/api/products/{product['product_id']}", headers=orders_auth_header()).json()["cover_image_id"]
second = archive_product_version(product_archive_client, product["product_id"], {"elements": []})
detail = product_archive_client.get(f"/api/products/{product['product_id']}", headers=orders_auth_header()).json()
listed = product_archive_client.get("/api/products", headers=orders_auth_header()).json()[0]
assert first.status_code == second.status_code == 201
assert detail["cover_image_id"] == listed["cover_image_id"]
assert detail["cover_image_id"] != first_cover
def test_product_routes_require_orders_auth(product_archive_client):
product = create_product(product_archive_client)
requests = [
product_archive_client.post("/api/products", json={"name": "未授权", "source": "manual"}),
product_archive_client.get("/api/products"),
product_archive_client.get(f"/api/products/{product['product_id']}"),
product_archive_client.post(f"/api/products/{product['product_id']}/versions"),
product_archive_client.delete(f"/api/products/{product['product_id']}"),
product_archive_client.post(f"/api/products/{product['product_id']}/restore"),
]
assert [response.status_code for response in requests] == [403] * 6
def test_archive_failure_during_final_move_removes_staging_files(product_archive_client, prepared_wordcloud_job, monkeypatch):
product = create_product(product_archive_client)
service = service_app._product_archive_service()
monkeypatch.setattr(service, "_move_staging", lambda *_: (_ for _ in ()).throw(RuntimeError("move failed")))
with pytest.raises(RuntimeError, match="move failed"):
service.archive_version(product["product_id"], prepared_wordcloud_job.document, PNG_BYTES)
assert not list((product_archive_client.archive_root / product["product_id"]).iterdir())
def test_archive_failure_during_metadata_transaction_removes_final_files(product_archive_client, prepared_wordcloud_job, monkeypatch):
product = create_product(product_archive_client)
monkeypatch.setattr(product_archive_client.product_store, "_before_archive_commit", lambda: (_ for _ in ()).throw(RuntimeError("metadata failed")))
with pytest.raises(RuntimeError, match="metadata failed"):
service_app._product_archive_service().archive_version(product["product_id"], prepared_wordcloud_job.document, PNG_BYTES)
assert not list((product_archive_client.archive_root / product["product_id"]).iterdir())
assert product_archive_client.product_store._fetchall("SELECT * FROM product_versions") == []