Files
ThinkStorm/tests/test_image_intake.py
xAdmin 41e08611c9 Implement optional single-image intake, Signal ingestion, and multimodal vision analysis
- Add image intake service with format validation (JPEG, PNG, WebP) and EXIF/GPS stripping
- Enforce strict single-image rule across Web and Signal attachment channels
- Implement token-optimized vision downscaling and JPEG compression
- Add IMAGE_CONTEXT pipeline stage with OmniRoute vision routing and resilient failover
- Seed and manage versioned idea-image-interpreter prompt in catalog
- Update Web UI with responsive image picker, preview chip, and Visual Context tab
- Add comprehensive automated test suite in test_image_intake.py
- Update README and Labyricorn devlog
2026-08-23 01:40:22 -07:00

526 lines
22 KiB
Python

"""
Comprehensive Test Suite for Optional Single-Image Intake & Vision Processing
Covers:
1. Web Intake:
- Text-only submission (JSON & multipart)
- Text + valid JPEG, PNG, WebP
- Text + unsupported file type (PDF/ZIP/TXT) -> rejected with 400
- Spoofed extension/MIME -> rejected with 400
- Oversized image -> rejected with 400
- Malformed image -> rejected with 400
- Metadata stripping (EXIF, GPS, camera tags stripped)
- Serving reference image via GET /api/ideas/{id}/image
2. Signal Ingestion:
- Text-only message
- Text + one image
- Text + two images (first accepted, second ignored)
- Text + non-image attachment (ignored, text processed)
- Text + non-image + image (image accepted)
- Text + image + non-image + second image (only first accepted)
- Malformed first candidate image + valid second image (second accepted)
- Non-image attachments only
- Sender authorization validation preserved
3. Processing & OmniRoute Failover:
- IMAGE_CONTEXT processor executes only when image exists
- Token-optimized downscaling bounds max dimension
- OmniRoute error / decline failover recorded in provenance without failing idea
- Image Interpreter prompt admin visibility & versioning
"""
import io
import json
import uuid
import pytest
from PIL import Image
from starlette.testclient import TestClient
from unittest.mock import patch, MagicMock
from thinkstorm.main import app
from thinkstorm.database import init_db, get_db
from thinkstorm.config import config
from thinkstorm.services.image_handler import (
validate_and_sanitize_image,
save_image_artifact,
get_image_artifact_path,
create_token_optimized_vision_payload,
ImageValidationError,
ImageTooLargeError,
ImageFormatError
)
from thinkstorm.processors.pipeline import (
process_image_context,
execute_intake_pipeline
)
from thinkstorm.prompts.catalog import (
get_all_prompts,
get_prompt_version,
update_prompt
)
TEST_SECRET = "sgw_callback_secret_test_2026_unit_testing"
TEST_API_KEY = "sgw_apikey_test_2026_unit_testing"
@pytest.fixture(autouse=True)
def setup_test_environment(monkeypatch):
init_db()
monkeypatch.setattr(config.services, "signal_gateway_callback_secret", TEST_SECRET)
monkeypatch.setattr(config.services, "signal_gateway_api_key", TEST_API_KEY)
monkeypatch.setattr(config.services, "signal_gateway_base_url", "http://10.138.4.46:8000")
monkeypatch.setattr(config, "max_image_upload_bytes", 2 * 1024 * 1024) # 2 MB for testing
@pytest.fixture
def client():
return TestClient(app)
def create_test_image_bytes(format="JPEG", size=(200, 200), color="blue", with_exif=False) -> bytes:
"""Helper to generate valid image bytes in memory, optionally with EXIF metadata."""
img = Image.new("RGB", size, color=color)
buf = io.BytesIO()
if format.upper() == "JPEG" and with_exif:
exif = img.getexif()
exif[0x010e] = "Test Camera Model Description" # ImageDescription
exif[0x0131] = "ThinkStorm Test Suite" # Software
img.save(buf, format="JPEG", exif=exif)
elif format.upper() == "JPEG":
img.save(buf, format="JPEG")
elif format.upper() == "PNG":
img.save(buf, format="PNG")
elif format.upper() == "WEBP":
img.save(buf, format="WEBP")
return buf.getvalue()
# =============================================================================
# 1. Web Intake Endpoint Tests
# =============================================================================
def test_web_text_only_json_submission(client):
"""Verifies standard JSON submission remains fully functional with submission_image = null."""
resp = client.post("/api/ideas", json={"text": "Self-hosted telemetry broker for IoT swarms."})
assert resp.status_code == 201
data = resp.json()
assert data["id"].startswith("TS-")
assert data["lifecycle_state"] == "SUBMITTED"
assert data.get("submission_image") is None
with get_db() as conn:
row = conn.execute("SELECT * FROM ideas WHERE id = ?", (data["id"],)).fetchone()
assert row["original_text"] == "Self-hosted telemetry broker for IoT swarms."
assert row["submission_image"] is None
def test_web_text_only_multipart_submission(client):
"""Verifies multipart/form-data submission works cleanly without an image attached."""
resp = client.post("/api/ideas", data={"text": "Distributed queue monitor with zero external dependencies."})
assert resp.status_code == 201
data = resp.json()
assert data["id"].startswith("TS-")
assert data.get("submission_image") is None
def test_web_submission_with_jpeg_image(client):
"""Verifies submission with a valid JPEG image creates idea and stores sanitized artifact."""
img_bytes = create_test_image_bytes("JPEG", size=(800, 600), color="red", with_exif=True)
resp = client.post(
"/api/ideas",
data={"text": "Architecture sketch for local AI agent swarm."},
files={"image": ("architecture_diagram.jpg", img_bytes, "image/jpeg")}
)
assert resp.status_code == 201
data = resp.json()
idea_id = data["id"]
sub_img = data["submission_image"]
assert sub_img is not None
assert sub_img["present"] is True
assert sub_img["mime_type"] == "image/jpeg"
assert sub_img["width"] == 800
assert sub_img["height"] == 600
assert sub_img["source"] == "web"
assert sub_img["original_filename"] == "architecture_diagram.jpg"
assert len(sub_img["sha256"]) == 64
# Verify image serving endpoint
img_resp = client.get(f"/api/ideas/{idea_id}/image")
assert img_resp.status_code == 200
assert img_resp.headers["content-type"] == "image/jpeg"
assert len(img_resp.content) > 0
# Verify EXIF metadata was stripped
retrieved_img = Image.open(io.BytesIO(img_resp.content))
exif = retrieved_img.getexif()
assert 0x010e not in exif
def test_web_submission_with_png_image(client):
"""Verifies submission with a valid PNG image."""
img_bytes = create_test_image_bytes("PNG", size=(640, 480), color="green")
resp = client.post(
"/api/ideas",
data={"text": "UI mockup for dark-mode dashboard."},
files={"image": ("mockup.png", img_bytes, "image/png")}
)
assert resp.status_code == 201
data = resp.json()
assert data["submission_image"]["mime_type"] == "image/png"
assert data["submission_image"]["width"] == 640
assert data["submission_image"]["height"] == 480
def test_web_submission_with_webp_image(client):
"""Verifies submission with a valid WebP image."""
img_bytes = create_test_image_bytes("WEBP", size=(400, 300), color="purple")
resp = client.post(
"/api/ideas",
data={"text": "Topology blueprint for mesh gateway."},
files={"image": ("mesh.webp", img_bytes, "image/webp")}
)
assert resp.status_code == 201
data = resp.json()
assert data["submission_image"]["mime_type"] == "image/webp"
def test_web_submission_unsupported_file_type_rejected(client):
"""Verifies unsupported non-image uploads (e.g. PDF, TXT) are rejected with 400."""
pdf_bytes = b"%PDF-1.4 simulated pdf document stream"
resp = client.post(
"/api/ideas",
data={"text": "Idea with PDF attached."},
files={"image": ("spec.pdf", pdf_bytes, "application/pdf")}
)
assert resp.status_code == 400
assert "Image validation failed" in resp.json()["detail"]
def test_web_submission_spoofed_extension_rejected(client):
"""Verifies a text/binary file renamed to .jpg is rejected by content validation."""
fake_jpeg = b"This is plain text disguised as an image file."
resp = client.post(
"/api/ideas",
data={"text": "Idea with spoofed file."},
files={"image": ("malicious.jpg", fake_jpeg, "image/jpeg")}
)
assert resp.status_code == 400
assert "Image validation failed" in resp.json()["detail"]
def test_web_submission_oversized_image_rejected(client, monkeypatch):
"""Verifies oversized images exceeding maximum configured limit are rejected with 400."""
monkeypatch.setattr(config, "max_image_upload_bytes", 50000) # 50 KB limit
large_img = create_test_image_bytes("JPEG", size=(3000, 3000), color="yellow")
assert len(large_img) > 50000
resp = client.post(
"/api/ideas",
data={"text": "High-resolution diagram."},
files={"image": ("huge.jpg", large_img, "image/jpeg")}
)
assert resp.status_code == 400
assert "exceeds maximum limit" in resp.json()["detail"]
def test_web_submission_malformed_corrupt_image_rejected(client):
"""Verifies corrupt image header/stream is rejected with 400."""
corrupt_bytes = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00corrupt-truncated-data"
resp = client.post(
"/api/ideas",
data={"text": "Idea with truncated image."},
files={"image": ("broken.jpg", corrupt_bytes, "image/jpeg")}
)
assert resp.status_code == 400
assert "Image validation failed" in resp.json()["detail"]
# =============================================================================
# 2. Signal Ingestion Tests (One-Image Rule & Attachment Handling)
# =============================================================================
def test_signal_text_only_message(client):
"""Signal text-only message continues to create standard text idea without image."""
event_id = f"sig_event_{uuid.uuid4().hex[:12]}"
headers = {"Authorization": f"Bearer {TEST_SECRET}", "Idempotency-Key": event_id}
payload = {
"schema_version": 1,
"event_id": event_id,
"channel": "signal",
"sender_uuid": "b1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d",
"text": "Signal idea without attachments.",
"received_at": "2026-08-22T10:00:00Z"
}
resp = client.post("/api/integrations/signal/events", headers=headers, json=payload)
assert resp.status_code == 200
idea_id = resp.json()["idea_id"]
with get_db() as conn:
idea = conn.execute("SELECT * FROM ideas WHERE id = ?", (idea_id,)).fetchone()
assert idea["submission_image"] is None
def test_signal_text_with_single_image(client):
"""Signal text + single image attachment creates idea with accepted reference image."""
import base64
event_id = f"sig_img_1_{uuid.uuid4().hex[:12]}"
headers = {"Authorization": f"Bearer {TEST_SECRET}", "Idempotency-Key": event_id}
img_b64 = base64.b64encode(create_test_image_bytes("PNG", size=(300, 300), color="blue")).decode("utf-8")
payload = {
"schema_version": 1,
"event_id": event_id,
"channel": "signal",
"sender_uuid": "b1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d",
"text": "Signal submission with whiteboard photo.",
"attachments": [
{
"filename": "whiteboard.png",
"content_type": "image/png",
"data": img_b64
}
]
}
resp = client.post("/api/integrations/signal/events", headers=headers, json=payload)
assert resp.status_code == 200
idea_id = resp.json()["idea_id"]
with get_db() as conn:
idea = conn.execute("SELECT * FROM ideas WHERE id = ?", (idea_id,)).fetchone()
assert idea["submission_image"] is not None
meta = json.loads(idea["submission_image"])
assert meta["present"] is True
assert meta["mime_type"] == "image/png"
assert meta["source"] == "signal"
assert meta["width"] == 300
def test_signal_multiple_images_accepts_only_first(client):
"""Signal message with 2 images accepts only the first and silently ignores the second."""
import base64
event_id = f"sig_img_multi_{uuid.uuid4().hex[:12]}"
headers = {"Authorization": f"Bearer {TEST_SECRET}", "Idempotency-Key": event_id}
img1_b64 = base64.b64encode(create_test_image_bytes("JPEG", size=(500, 400), color="red")).decode("utf-8")
img2_b64 = base64.b64encode(create_test_image_bytes("PNG", size=(800, 800), color="blue")).decode("utf-8")
payload = {
"schema_version": 1,
"event_id": event_id,
"channel": "signal",
"sender_uuid": "b1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d",
"text": "Message with two photo attachments.",
"attachments": [
{"filename": "first_photo.jpg", "data": img1_b64},
{"filename": "second_photo.png", "data": img2_b64}
]
}
resp = client.post("/api/integrations/signal/events", headers=headers, json=payload)
assert resp.status_code == 200
idea_id = resp.json()["idea_id"]
with get_db() as conn:
idea = conn.execute("SELECT * FROM ideas WHERE id = ?", (idea_id,)).fetchone()
meta = json.loads(idea["submission_image"])
# Should match the first image (500x400 JPEG), not the second
assert meta["original_filename"] == "first_photo.jpg"
assert meta["width"] == 500
assert meta["height"] == 400
assert meta["mime_type"] == "image/jpeg"
def test_signal_non_image_attachment_ignored(client):
"""Signal message with non-image attachment (PDF) ignores attachment and processes text normally."""
import base64
event_id = f"sig_pdf_{uuid.uuid4().hex[:12]}"
headers = {"Authorization": f"Bearer {TEST_SECRET}", "Idempotency-Key": event_id}
pdf_b64 = base64.b64encode(b"%PDF-1.4 document").decode("utf-8")
payload = {
"schema_version": 1,
"event_id": event_id,
"channel": "signal",
"sender_uuid": "b1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d",
"text": "Signal message with attached document.",
"attachments": [
{"filename": "document.pdf", "data": pdf_b64}
]
}
resp = client.post("/api/integrations/signal/events", headers=headers, json=payload)
assert resp.status_code == 200
idea_id = resp.json()["idea_id"]
with get_db() as conn:
idea = conn.execute("SELECT * FROM ideas WHERE id = ?", (idea_id,)).fetchone()
assert idea["original_text"] == "Signal message with attached document."
assert idea["submission_image"] is None
def test_signal_mixed_attachments_non_image_then_valid_image(client):
"""Signal: PDF + valid WebP + ZIP -> accepts valid WebP."""
import base64
event_id = f"sig_mixed_{uuid.uuid4().hex[:12]}"
headers = {"Authorization": f"Bearer {TEST_SECRET}", "Idempotency-Key": event_id}
pdf_b64 = base64.b64encode(b"%PDF-1.4 file").decode("utf-8")
webp_b64 = base64.b64encode(create_test_image_bytes("WEBP", size=(350, 250), color="cyan")).decode("utf-8")
zip_b64 = base64.b64encode(b"PK\x03\x04zip archive").decode("utf-8")
payload = {
"schema_version": 1,
"event_id": event_id,
"channel": "signal",
"sender_uuid": "b1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d",
"text": "Idea with PDF, WebP and ZIP.",
"attachments": [
{"filename": "notes.pdf", "data": pdf_b64},
{"filename": "diagram.webp", "data": webp_b64},
{"filename": "archive.zip", "data": zip_b64}
]
}
resp = client.post("/api/integrations/signal/events", headers=headers, json=payload)
assert resp.status_code == 200
idea_id = resp.json()["idea_id"]
with get_db() as conn:
idea = conn.execute("SELECT * FROM ideas WHERE id = ?", (idea_id,)).fetchone()
meta = json.loads(idea["submission_image"])
assert meta["original_filename"] == "diagram.webp"
assert meta["mime_type"] == "image/webp"
assert meta["width"] == 350
def test_signal_malformed_first_image_then_valid_second_image(client):
"""Signal: malformed/corrupted image followed by valid image accepts the valid second image."""
import base64
event_id = f"sig_corrupt_then_valid_{uuid.uuid4().hex[:12]}"
headers = {"Authorization": f"Bearer {TEST_SECRET}", "Idempotency-Key": event_id}
bad_b64 = base64.b64encode(b"\xff\xd8\xffcorrupt_data").decode("utf-8")
good_b64 = base64.b64encode(create_test_image_bytes("PNG", size=(600, 400), color="magenta")).decode("utf-8")
payload = {
"schema_version": 1,
"event_id": event_id,
"channel": "signal",
"sender_uuid": "b1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d",
"text": "Idea with bad image then good image.",
"attachments": [
{"filename": "corrupt.jpg", "data": bad_b64},
{"filename": "good.png", "data": good_b64}
]
}
resp = client.post("/api/integrations/signal/events", headers=headers, json=payload)
assert resp.status_code == 200
idea_id = resp.json()["idea_id"]
with get_db() as conn:
idea = conn.execute("SELECT * FROM ideas WHERE id = ?", (idea_id,)).fetchone()
meta = json.loads(idea["submission_image"])
assert meta["original_filename"] == "good.png"
assert meta["mime_type"] == "image/png"
# =============================================================================
# 3. Vision Processing, Token Optimization & OmniRoute Failover Tests
# =============================================================================
def test_token_optimized_vision_payload_downscaling():
"""Verifies large high-res image (e.g. 4000x3000) is bounded to max dimension (1536px)."""
large_raw = create_test_image_bytes("JPEG", size=(4000, 3000), color="blue")
opt_bytes, opt_mime = create_token_optimized_vision_payload(large_raw, "image/jpeg")
opt_img = Image.open(io.BytesIO(opt_bytes))
w, h = opt_img.size
assert max(w, h) <= 1536
# Aspect ratio preserved (4000:3000 = 4:3 -> 1536:1152)
assert abs((w / h) - (4.0 / 3.0)) < 0.05
assert len(opt_bytes) < len(large_raw)
@pytest.mark.asyncio
async def test_image_context_processor_execution_and_provenance():
"""Verifies process_image_context executes, records provenance, and creates image-context.md."""
idea_id = "TS-TEST-0099"
img_bytes = create_test_image_bytes("PNG", size=(640, 480), color="green")
_, meta = validate_and_sanitize_image(img_bytes, "wireframe.png", source="web", idea_id=idea_id)
save_image_artifact(idea_id, img_bytes, meta)
# Insert test idea record
with get_db() as conn:
conn.execute(
"""
INSERT OR REPLACE INTO ideas (id, original_text, submitted_at, title, summary, submission_image, created_at, updated_at)
VALUES (?, ?, '2026-08-22T00:00:00Z', 'Test Idea', 'Summary', ?, '2026-08-22T00:00:00Z', '2026-08-22T00:00:00Z')
""",
(idea_id, "Visual layout for decentralized dashboard.", json.dumps(meta))
)
context_report, run_id = await process_image_context(
idea_id=idea_id,
original_text="Visual layout for decentralized dashboard.",
submission_image=meta
)
assert context_report is not None
assert "# Image Context" in context_report
assert "## Observed" in context_report
assert "## Relevant to the Idea" in context_report
# Verify provenance run
with get_db() as conn:
run = conn.execute("SELECT * FROM processor_runs WHERE id = ?", (run_id,)).fetchone()
assert run is not None
assert run["stage"] == "IMAGE_CONTEXT"
assert run["processor_name"] == "IdeaImageInterpreter"
assert run["prompt_id"] == "idea-image-interpreter"
assert run["status"] == "COMPLETED"
assert run["output_artifact"] == "image-context.md"
@pytest.mark.asyncio
async def test_omniroute_vision_failure_resilience():
"""Verifies that if OmniRoute raises an error/decline, the idea is not failed and provenance logs FAILED."""
idea_id = "TS-FAIL-001"
img_bytes = create_test_image_bytes("JPEG", size=(300, 300), color="red")
_, meta = validate_and_sanitize_image(img_bytes, "sketch.jpg", source="web", idea_id=idea_id)
save_image_artifact(idea_id, img_bytes, meta)
with get_db() as conn:
conn.execute(
"""
INSERT OR REPLACE INTO ideas (id, original_text, submitted_at, title, summary, submission_image, created_at, updated_at)
VALUES (?, ?, '2026-08-22T00:00:00Z', 'Test Idea', 'Summary', ?, '2026-08-22T00:00:00Z', '2026-08-22T00:00:00Z')
""",
(idea_id, "Idea with simulated failing vision provider.", json.dumps(meta))
)
with patch("thinkstorm.processors.pipeline.omniroute_svc.chat_completion", side_effect=Exception("OmniRoute 413: Vision payload rejected")):
context_report, run_id = await process_image_context(
idea_id=idea_id,
original_text="Idea with simulated failing vision provider.",
submission_image=meta
)
# Should return None without raising exception
assert context_report is None
assert run_id is not None
with get_db() as conn:
run = conn.execute("SELECT * FROM processor_runs WHERE id = ?", (run_id,)).fetchone()
assert run is not None
assert run["stage"] == "IMAGE_CONTEXT"
assert run["status"] == "FAILED"
assert "Vision payload rejected" in run["error_message"]
def test_prompt_catalog_image_interpreter_admin_visibility_and_versioning(client):
"""Verifies prompt permissions (masked for non-admin, visible to admin) and versioning."""
# 1. Non-admin prompt list -> masked
resp_unauth = client.get("/api/admin/prompts")
assert resp_unauth.status_code in (401, 403)
# 2. Admin access
login_adm = client.post("/api/auth/login", json={"username": "admin", "password": config.admin_bootstrap_key})
token = login_adm.json()["token"]
resp_adm = client.get("/api/admin/prompts", headers={"Authorization": f"Bearer {token}"})
assert resp_adm.status_code == 200
prompts = resp_adm.json()
img_prompts = [p for p in prompts if p["id"] == "idea-image-interpreter"]
assert len(img_prompts) == 1
assert "Visual Context" in img_prompts[0]["system_prompt"]
assert img_prompts[0]["stage"] == "IMAGE_CONTEXT"
# 3. Update prompt version
init_v = get_prompt_version("idea-image-interpreter", is_admin=True)["version"]
new_ver = update_prompt(
prompt_id="idea-image-interpreter",
system_prompt="Updated Visual Context Interpreter prompt v2.",
user_prompt_template="Analyze v2:\n{{submission_text}}",
updated_by="admin"
)
assert new_ver == init_v + 1
p_new = get_prompt_version("idea-image-interpreter", version=new_ver, is_admin=True)
assert p_new["version"] == new_ver
assert "Updated Visual Context" in p_new["system_prompt"]
# Reset back to canonical v1
with get_db() as conn:
conn.execute("DELETE FROM prompt_versions WHERE prompt_definition_id = 'idea-image-interpreter' AND version > 1")
conn.execute("UPDATE prompt_definitions SET current_version = 1 WHERE id = 'idea-image-interpreter'")