""" 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'")