258 lines
7.8 KiB
Python
258 lines
7.8 KiB
Python
|
|
"""Issue F — binary asset store + artifact entity + wiki rendering."""
|
||
|
|
|
||
|
|
from io import BytesIO
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
from fastapi.testclient import TestClient
|
||
|
|
from PIL import Image
|
||
|
|
|
||
|
|
from atocore.assets import (
|
||
|
|
AssetTooLarge,
|
||
|
|
AssetTypeNotAllowed,
|
||
|
|
get_asset,
|
||
|
|
get_asset_binary,
|
||
|
|
get_thumbnail,
|
||
|
|
invalidate_asset,
|
||
|
|
list_orphan_assets,
|
||
|
|
store_asset,
|
||
|
|
)
|
||
|
|
from atocore.engineering.service import (
|
||
|
|
ENTITY_TYPES,
|
||
|
|
create_entity,
|
||
|
|
create_relationship,
|
||
|
|
init_engineering_schema,
|
||
|
|
)
|
||
|
|
from atocore.main import app
|
||
|
|
from atocore.models.database import init_db
|
||
|
|
|
||
|
|
|
||
|
|
def _png_bytes(color=(255, 0, 0), size=(64, 48)) -> bytes:
|
||
|
|
buf = BytesIO()
|
||
|
|
Image.new("RGB", size, color).save(buf, format="PNG")
|
||
|
|
return buf.getvalue()
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def assets_env(tmp_data_dir, tmp_path, monkeypatch):
|
||
|
|
registry_path = tmp_path / "test-registry.json"
|
||
|
|
registry_path.write_text('{"projects": []}', encoding="utf-8")
|
||
|
|
monkeypatch.setenv("ATOCORE_PROJECT_REGISTRY_PATH", str(registry_path))
|
||
|
|
from atocore import config
|
||
|
|
config.settings = config.Settings()
|
||
|
|
|
||
|
|
init_db()
|
||
|
|
init_engineering_schema()
|
||
|
|
yield tmp_data_dir
|
||
|
|
|
||
|
|
|
||
|
|
def test_artifact_is_in_entity_types():
|
||
|
|
assert "artifact" in ENTITY_TYPES
|
||
|
|
|
||
|
|
|
||
|
|
def test_store_asset_happy_path(assets_env):
|
||
|
|
data = _png_bytes()
|
||
|
|
asset = store_asset(data=data, mime_type="image/png", caption="red square")
|
||
|
|
assert asset.hash_sha256
|
||
|
|
assert asset.size_bytes == len(data)
|
||
|
|
assert asset.width == 64
|
||
|
|
assert asset.height == 48
|
||
|
|
assert asset.mime_type == "image/png"
|
||
|
|
from pathlib import Path
|
||
|
|
assert Path(asset.stored_path).exists()
|
||
|
|
|
||
|
|
|
||
|
|
def test_store_asset_is_idempotent_on_hash(assets_env):
|
||
|
|
data = _png_bytes()
|
||
|
|
a = store_asset(data=data, mime_type="image/png")
|
||
|
|
b = store_asset(data=data, mime_type="image/png", caption="different caption")
|
||
|
|
assert a.id == b.id, "same content should dedup to the same asset id"
|
||
|
|
|
||
|
|
|
||
|
|
def test_store_asset_rejects_unknown_mime(assets_env):
|
||
|
|
with pytest.raises(AssetTypeNotAllowed):
|
||
|
|
store_asset(data=b"hello", mime_type="text/plain")
|
||
|
|
|
||
|
|
|
||
|
|
def test_store_asset_rejects_oversize(assets_env, monkeypatch):
|
||
|
|
monkeypatch.setattr(
|
||
|
|
"atocore.config.settings.assets_max_upload_bytes",
|
||
|
|
10,
|
||
|
|
raising=False,
|
||
|
|
)
|
||
|
|
with pytest.raises(AssetTooLarge):
|
||
|
|
store_asset(data=_png_bytes(), mime_type="image/png")
|
||
|
|
|
||
|
|
|
||
|
|
def test_get_asset_binary_roundtrip(assets_env):
|
||
|
|
data = _png_bytes(color=(0, 255, 0))
|
||
|
|
asset = store_asset(data=data, mime_type="image/png")
|
||
|
|
_, roundtrip = get_asset_binary(asset.id)
|
||
|
|
assert roundtrip == data
|
||
|
|
|
||
|
|
|
||
|
|
def test_thumbnail_generates_and_caches(assets_env):
|
||
|
|
data = _png_bytes(size=(800, 600))
|
||
|
|
asset = store_asset(data=data, mime_type="image/png")
|
||
|
|
_, thumb1 = get_thumbnail(asset.id, size=120)
|
||
|
|
_, thumb2 = get_thumbnail(asset.id, size=120)
|
||
|
|
assert thumb1 == thumb2
|
||
|
|
# Must be a valid JPEG and smaller than the source
|
||
|
|
assert thumb1[:3] == b"\xff\xd8\xff"
|
||
|
|
assert len(thumb1) < len(data)
|
||
|
|
|
||
|
|
|
||
|
|
def test_orphan_list_excludes_referenced(assets_env):
|
||
|
|
referenced = store_asset(data=_png_bytes((1, 1, 1)), mime_type="image/png")
|
||
|
|
lonely = store_asset(data=_png_bytes((2, 2, 2)), mime_type="image/png")
|
||
|
|
create_entity(
|
||
|
|
entity_type="artifact",
|
||
|
|
name="ref-test",
|
||
|
|
properties={"kind": "image", "asset_id": referenced.id},
|
||
|
|
)
|
||
|
|
orphan_ids = {o.id for o in list_orphan_assets()}
|
||
|
|
assert lonely.id in orphan_ids
|
||
|
|
assert referenced.id not in orphan_ids
|
||
|
|
|
||
|
|
|
||
|
|
def test_invalidate_refuses_referenced_asset(assets_env):
|
||
|
|
asset = store_asset(data=_png_bytes((3, 3, 3)), mime_type="image/png")
|
||
|
|
create_entity(
|
||
|
|
entity_type="artifact",
|
||
|
|
name="pinned",
|
||
|
|
properties={"kind": "image", "asset_id": asset.id},
|
||
|
|
)
|
||
|
|
assert invalidate_asset(asset.id) is False
|
||
|
|
assert get_asset(asset.id).status == "active"
|
||
|
|
|
||
|
|
|
||
|
|
def test_invalidate_orphan_succeeds(assets_env):
|
||
|
|
asset = store_asset(data=_png_bytes((4, 4, 4)), mime_type="image/png")
|
||
|
|
assert invalidate_asset(asset.id) is True
|
||
|
|
assert get_asset(asset.id).status == "invalid"
|
||
|
|
|
||
|
|
|
||
|
|
def test_api_upload_and_fetch(assets_env):
|
||
|
|
client = TestClient(app)
|
||
|
|
png = _png_bytes((7, 7, 7))
|
||
|
|
r = client.post(
|
||
|
|
"/assets",
|
||
|
|
files={"file": ("red.png", png, "image/png")},
|
||
|
|
data={"project": "p05", "caption": "unit test upload"},
|
||
|
|
)
|
||
|
|
assert r.status_code == 200, r.text
|
||
|
|
body = r.json()
|
||
|
|
assert body["mime_type"] == "image/png"
|
||
|
|
assert body["caption"] == "unit test upload"
|
||
|
|
asset_id = body["id"]
|
||
|
|
|
||
|
|
r2 = client.get(f"/assets/{asset_id}")
|
||
|
|
assert r2.status_code == 200
|
||
|
|
assert r2.headers["content-type"].startswith("image/png")
|
||
|
|
assert r2.content == png
|
||
|
|
|
||
|
|
r3 = client.get(f"/assets/{asset_id}/thumbnail?size=100")
|
||
|
|
assert r3.status_code == 200
|
||
|
|
assert r3.headers["content-type"].startswith("image/jpeg")
|
||
|
|
|
||
|
|
r4 = client.get(f"/assets/{asset_id}/meta")
|
||
|
|
assert r4.status_code == 200
|
||
|
|
assert r4.json()["id"] == asset_id
|
||
|
|
|
||
|
|
|
||
|
|
def test_api_upload_rejects_bad_mime(assets_env):
|
||
|
|
client = TestClient(app)
|
||
|
|
r = client.post(
|
||
|
|
"/assets",
|
||
|
|
files={"file": ("notes.txt", b"hello", "text/plain")},
|
||
|
|
)
|
||
|
|
assert r.status_code == 415
|
||
|
|
|
||
|
|
|
||
|
|
def test_api_get_entity_evidence_returns_artifacts(assets_env):
|
||
|
|
asset = store_asset(data=_png_bytes((9, 9, 9)), mime_type="image/png")
|
||
|
|
artifact = create_entity(
|
||
|
|
entity_type="artifact",
|
||
|
|
name="cap-001",
|
||
|
|
properties={
|
||
|
|
"kind": "image",
|
||
|
|
"asset_id": asset.id,
|
||
|
|
"caption": "tower base",
|
||
|
|
},
|
||
|
|
)
|
||
|
|
tower = create_entity(entity_type="component", name="tower")
|
||
|
|
create_relationship(
|
||
|
|
source_entity_id=tower.id,
|
||
|
|
target_entity_id=artifact.id,
|
||
|
|
relationship_type="evidenced_by",
|
||
|
|
)
|
||
|
|
|
||
|
|
client = TestClient(app)
|
||
|
|
r = client.get(f"/entities/{tower.id}/evidence")
|
||
|
|
assert r.status_code == 200
|
||
|
|
body = r.json()
|
||
|
|
assert body["count"] == 1
|
||
|
|
ev = body["evidence"][0]
|
||
|
|
assert ev["kind"] == "image"
|
||
|
|
assert ev["caption"] == "tower base"
|
||
|
|
assert ev["asset"]["id"] == asset.id
|
||
|
|
|
||
|
|
|
||
|
|
def test_v1_assets_aliases_present(assets_env):
|
||
|
|
client = TestClient(app)
|
||
|
|
spec = client.get("/openapi.json").json()
|
||
|
|
paths = spec["paths"]
|
||
|
|
for p in (
|
||
|
|
"/v1/assets",
|
||
|
|
"/v1/assets/{asset_id}",
|
||
|
|
"/v1/assets/{asset_id}/thumbnail",
|
||
|
|
"/v1/assets/{asset_id}/meta",
|
||
|
|
"/v1/entities/{entity_id}/evidence",
|
||
|
|
):
|
||
|
|
assert p in paths, f"{p} missing from /v1 alias set"
|
||
|
|
|
||
|
|
|
||
|
|
def test_wiki_renders_evidence_strip(assets_env):
|
||
|
|
from atocore.engineering.wiki import render_entity
|
||
|
|
|
||
|
|
asset = store_asset(data=_png_bytes((10, 10, 10)), mime_type="image/png")
|
||
|
|
artifact = create_entity(
|
||
|
|
entity_type="artifact",
|
||
|
|
name="cap-ev-01",
|
||
|
|
properties={
|
||
|
|
"kind": "image",
|
||
|
|
"asset_id": asset.id,
|
||
|
|
"caption": "viewport",
|
||
|
|
},
|
||
|
|
)
|
||
|
|
tower = create_entity(entity_type="component", name="tower-wiki")
|
||
|
|
create_relationship(
|
||
|
|
source_entity_id=tower.id,
|
||
|
|
target_entity_id=artifact.id,
|
||
|
|
relationship_type="evidenced_by",
|
||
|
|
)
|
||
|
|
|
||
|
|
html = render_entity(tower.id)
|
||
|
|
assert "Visual evidence" in html
|
||
|
|
assert f"/assets/{asset.id}/thumbnail" in html
|
||
|
|
assert "viewport" in html
|
||
|
|
|
||
|
|
|
||
|
|
def test_wiki_renders_artifact_full_image(assets_env):
|
||
|
|
from atocore.engineering.wiki import render_entity
|
||
|
|
|
||
|
|
asset = store_asset(data=_png_bytes((11, 11, 11)), mime_type="image/png")
|
||
|
|
artifact = create_entity(
|
||
|
|
entity_type="artifact",
|
||
|
|
name="cap-full-01",
|
||
|
|
properties={
|
||
|
|
"kind": "image",
|
||
|
|
"asset_id": asset.id,
|
||
|
|
"caption": "detail shot",
|
||
|
|
"capture_context": "narrator: here's the base plate close-up",
|
||
|
|
},
|
||
|
|
)
|
||
|
|
html = render_entity(artifact.id)
|
||
|
|
assert f"/assets/{asset.id}/thumbnail?size=1024" in html
|
||
|
|
assert "Capture context" in html
|
||
|
|
assert "narrator" in html
|