Files
ATOCore/tests/test_assets.py

258 lines
7.8 KiB
Python
Raw Normal View History

feat(assets): binary asset store + artifact entity + wiki evidence (Issue F) Wires visual evidence into the knowledge graph. Images, PDFs, and CAD exports can now be uploaded, deduped by SHA-256, thumbnailed, linked to entities via EVIDENCED_BY, and rendered inline on wiki pages. Unblocks AKC uploading voice-session screenshots alongside extracted entities. - assets/ module: store_asset (hash dedup + MIME allowlist + 20 MB cap), get_asset_binary, get_thumbnail (Pillow, on-disk cache under .thumbnails/<size>/), list_orphan_assets, invalidate_asset - models/database.py: new `assets` table + indexes - engineering/service.py: `artifact` added to ENTITY_TYPES - api/routes.py: POST /assets (multipart), GET /assets/{id}, /assets/{id}/thumbnail, /assets/{id}/meta, /admin/assets/orphans, DELETE /assets/{id} (409 if still referenced), GET /entities/{id}/evidence (EVIDENCED_BY artifacts with asset meta) - main.py: all new paths aliased under /v1 - engineering/wiki.py: entity pages render EVIDENCED_BY → artifact as a "Visual evidence" thumbnail strip; artifact pages render the full image + caption + capture_context - deploy/dalidou/docker-compose.yml: bind-mount ${ATOCORE_ASSETS_DIR} - config.py: assets_dir + assets_max_upload_bytes settings - requirements.txt + pyproject.toml: python-multipart, Pillow>=10.0.0 - tests/test_assets.py: 16 tests (dedup, cap, thumbnail cache, orphan detection, invalidate gating, API upload/fetch, evidence, v1 aliases, wiki rendering) - DEV-LEDGER.md: session log + cleanup note + test_count 478 -> 494 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 21:46:52 -04:00
"""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