- 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
526 lines
22 KiB
Python
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'")
|