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
This commit is contained in:
@@ -0,0 +1,525 @@
|
||||
"""
|
||||
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'")
|
||||
@@ -0,0 +1,450 @@
|
||||
"""
|
||||
Comprehensive Tests for ThinkStorm Signal Gateway Integration
|
||||
Covers:
|
||||
1. Valid callback-test request returns 2xx and causes no message-processing side effects.
|
||||
2. Valid Signal event with correct bearer secret is durably accepted.
|
||||
3. Missing bearer secret returns 401.
|
||||
4. Wrong bearer secret returns 401.
|
||||
5. Nullable sender_number, sender_name, group_id, and reply_to are accepted.
|
||||
6. Unsupported schema_version is rejected with 400.
|
||||
7. Missing or mismatched Idempotency-Key is rejected for actual events.
|
||||
8. Delivering the same event_id twice returns success but produces exactly one downstream action.
|
||||
9. Direct message maps to expected ThinkStorm conversation/metadata.
|
||||
10. Group message maps to expected ThinkStorm conversation/metadata.
|
||||
11. Callback-test payload is not parsed as a real Signal message.
|
||||
12. Outbound client sends correct URL, authorization header, and JSON payload.
|
||||
13. Outbound 202, 400, 401, 413, 503, and timeout behaviors are handled intentionally.
|
||||
14. Secrets are absent from error messages and captured logs.
|
||||
15. Service health checks.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import json
|
||||
import uuid
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from unittest.mock import patch, MagicMock
|
||||
from io import BytesIO
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from thinkstorm.main import app
|
||||
from thinkstorm.database import init_db, get_db
|
||||
from thinkstorm.config import config
|
||||
from thinkstorm.services.signal_gateway import (
|
||||
SignalGatewayAdapter,
|
||||
SignalGatewayAuthError,
|
||||
SignalGatewayClientError,
|
||||
SignalGatewayPayloadTooLargeError,
|
||||
SignalGatewayUnavailableError,
|
||||
SignalGatewayTimeoutError,
|
||||
mask_secret
|
||||
)
|
||||
|
||||
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")
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
return TestClient(app)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 1. Callback Test Request Tests
|
||||
# -----------------------------------------------------------------------------
|
||||
def test_callback_test_request_success(client):
|
||||
"""Test callback probe returns 2xx and creates no ideas or queue jobs."""
|
||||
headers = {
|
||||
"Authorization": f"Bearer {TEST_SECRET}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
payload = {
|
||||
"schema_version": 1,
|
||||
"event_type": "signal_gateway.callback_test"
|
||||
}
|
||||
|
||||
with get_db() as conn:
|
||||
ideas_before = conn.execute("SELECT COUNT(*) FROM ideas").fetchone()[0]
|
||||
events_before = conn.execute("SELECT COUNT(*) FROM signal_inbound_events").fetchone()[0]
|
||||
|
||||
resp = client.post("/api/integrations/signal/events", headers=headers, json=payload)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["accepted"] is True
|
||||
assert data["event_type"] == "signal_gateway.callback_test"
|
||||
|
||||
with get_db() as conn:
|
||||
ideas_after = conn.execute("SELECT COUNT(*) FROM ideas").fetchone()[0]
|
||||
events_after = conn.execute("SELECT COUNT(*) FROM signal_inbound_events").fetchone()[0]
|
||||
|
||||
assert ideas_after == ideas_before
|
||||
assert events_after == events_before
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 2. Callback Authentication Tests
|
||||
# -----------------------------------------------------------------------------
|
||||
def test_callback_missing_bearer_secret(client):
|
||||
"""Missing bearer secret returns 401."""
|
||||
payload = {
|
||||
"schema_version": 1,
|
||||
"event_type": "signal_gateway.callback_test"
|
||||
}
|
||||
resp = client.post("/api/integrations/signal/events", json=payload)
|
||||
assert resp.status_code == 401
|
||||
assert "detail" in resp.json()
|
||||
|
||||
def test_callback_wrong_bearer_secret(client):
|
||||
"""Wrong bearer secret returns 401."""
|
||||
headers = {"Authorization": "Bearer wrong_secret_token_value"}
|
||||
payload = {
|
||||
"schema_version": 1,
|
||||
"event_type": "signal_gateway.callback_test"
|
||||
}
|
||||
resp = client.post("/api/integrations/signal/events", headers=headers, json=payload)
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_callback_malformed_auth_header(client):
|
||||
"""Malformed auth header (e.g. Basic or token only) returns 401."""
|
||||
headers = {"Authorization": f"Basic {TEST_SECRET}"}
|
||||
payload = {"schema_version": 1, "event_type": "signal_gateway.callback_test"}
|
||||
resp = client.post("/api/integrations/signal/events", headers=headers, json=payload)
|
||||
assert resp.status_code == 401
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 3. Schema & Idempotency Key Validation
|
||||
# -----------------------------------------------------------------------------
|
||||
def test_callback_unsupported_schema_version(client):
|
||||
"""Unsupported schema_version is rejected with 400."""
|
||||
headers = {
|
||||
"Authorization": f"Bearer {TEST_SECRET}",
|
||||
"Idempotency-Key": "sig_test_ver"
|
||||
}
|
||||
payload = {
|
||||
"schema_version": 2,
|
||||
"event_id": "sig_test_ver",
|
||||
"channel": "signal",
|
||||
"sender_uuid": "12345678-1234-1234-1234-123456789abc",
|
||||
"text": "Hello"
|
||||
}
|
||||
resp = client.post("/api/integrations/signal/events", headers=headers, json=payload)
|
||||
assert resp.status_code == 400
|
||||
assert "schema_version" in resp.json()["detail"]
|
||||
|
||||
def test_callback_missing_or_mismatched_idempotency_key(client):
|
||||
"""Missing or mismatched Idempotency-Key header is rejected for actual events."""
|
||||
event_id = "sig_idemp_001"
|
||||
payload = {
|
||||
"schema_version": 1,
|
||||
"event_id": event_id,
|
||||
"channel": "signal",
|
||||
"sender_uuid": "12345678-1234-1234-1234-123456789abc",
|
||||
"text": "An idea about autonomous systems."
|
||||
}
|
||||
|
||||
# Missing header
|
||||
resp_missing = client.post(
|
||||
"/api/integrations/signal/events",
|
||||
headers={"Authorization": f"Bearer {TEST_SECRET}"},
|
||||
json=payload
|
||||
)
|
||||
assert resp_missing.status_code == 400
|
||||
assert "Idempotency-Key" in resp_missing.json()["detail"]
|
||||
|
||||
# Mismatched header
|
||||
resp_mismatch = client.post(
|
||||
"/api/integrations/signal/events",
|
||||
headers={
|
||||
"Authorization": f"Bearer {TEST_SECRET}",
|
||||
"Idempotency-Key": "different_id_123"
|
||||
},
|
||||
json=payload
|
||||
)
|
||||
assert resp_mismatch.status_code == 400
|
||||
assert "Idempotency-Key" in resp_mismatch.json()["detail"]
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 4. Actual Signal Event Ingestion & Durable Idempotency
|
||||
# -----------------------------------------------------------------------------
|
||||
def test_valid_signal_event_ingestion_and_idempotency(client):
|
||||
"""Delivering a valid Signal event creates idea and inbound record; duplicate is harmless."""
|
||||
event_id = f"sig_event_valid_{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": "a1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d",
|
||||
"sender_number": "+15551234567",
|
||||
"sender_name": "Alice Developer",
|
||||
"group_id": None,
|
||||
"message_id": "1787300000001",
|
||||
"reply_to": None,
|
||||
"text": "Automated pipeline for tracking real-time API contract drifts.",
|
||||
"received_at": "2026-08-21T20:15:00+00:00"
|
||||
}
|
||||
|
||||
# 1. First delivery -> durably accepted and enqueued
|
||||
resp1 = client.post("/api/integrations/signal/events", headers=headers, json=payload)
|
||||
assert resp1.status_code == 200
|
||||
data1 = resp1.json()
|
||||
assert data1["accepted"] is True
|
||||
assert data1["event_id"] == event_id
|
||||
idea_id = data1["idea_id"]
|
||||
assert idea_id.startswith("TS-")
|
||||
assert data1.get("duplicate") is not True
|
||||
|
||||
# Verify DB state
|
||||
with get_db() as conn:
|
||||
ev_row = conn.execute("SELECT * FROM signal_inbound_events WHERE event_id = ?", (event_id,)).fetchone()
|
||||
assert ev_row is not None
|
||||
assert ev_row["sender_uuid"] == "a1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d"
|
||||
assert ev_row["sender_name"] == "Alice Developer"
|
||||
assert ev_row["sender_number"] == "+15551234567"
|
||||
assert ev_row["group_id"] is None
|
||||
assert ev_row["idea_id"] == idea_id
|
||||
|
||||
idea_row = conn.execute("SELECT * FROM ideas WHERE id = ?", (idea_id,)).fetchone()
|
||||
assert idea_row is not None
|
||||
assert idea_row["original_text"] == "Automated pipeline for tracking real-time API contract drifts."
|
||||
assert idea_row["source_channel"] == "signal"
|
||||
assert idea_row["source_sender_uuid"] == "a1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d"
|
||||
assert idea_row["source_event_id"] == event_id
|
||||
assert idea_row["lifecycle_state"] == "SUBMITTED"
|
||||
assert idea_row["processing_state"] == "QUEUED"
|
||||
|
||||
# 2. Duplicate delivery -> returns 200 success without creating duplicate idea
|
||||
with get_db() as conn:
|
||||
ideas_before_dup = conn.execute("SELECT COUNT(*) FROM ideas").fetchone()[0]
|
||||
|
||||
resp2 = client.post("/api/integrations/signal/events", headers=headers, json=payload)
|
||||
assert resp2.status_code == 200
|
||||
data2 = resp2.json()
|
||||
assert data2["accepted"] is True
|
||||
assert data2["event_id"] == event_id
|
||||
assert data2.get("duplicate") is True
|
||||
assert data2["idea_id"] == idea_id
|
||||
|
||||
with get_db() as conn:
|
||||
ideas_after_dup = conn.execute("SELECT COUNT(*) FROM ideas").fetchone()[0]
|
||||
assert ideas_after_dup == ideas_before_dup
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 5. Direct vs Group Message Mapping & Nullable Fields
|
||||
# -----------------------------------------------------------------------------
|
||||
def test_direct_message_mapping_with_null_fields(client):
|
||||
"""Direct message with null sender_number, sender_name, group_id, reply_to is accepted."""
|
||||
event_id = f"sig_event_direct_nulls_{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": "bbbbbbbb-1111-2222-3333-444444444444",
|
||||
"sender_number": None,
|
||||
"sender_name": None,
|
||||
"group_id": None,
|
||||
"message_id": "1787300000002",
|
||||
"reply_to": None,
|
||||
"text": "Direct message with completely null optional metadata.",
|
||||
"received_at": "2026-08-21T20:20:00+00:00"
|
||||
}
|
||||
|
||||
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["source_channel"] == "signal"
|
||||
assert idea["source_sender_uuid"] == "bbbbbbbb-1111-2222-3333-444444444444"
|
||||
assert idea["source_group_id"] is None
|
||||
|
||||
def test_group_message_mapping(client):
|
||||
"""Group message sets source_group_id and source_sender_uuid appropriately."""
|
||||
event_id = f"sig_event_group_{uuid.uuid4().hex[:12]}"
|
||||
group_id = "EdG5w+Xq1eAbcDeFgHiJkLmNoPqRsTuVwXyZ1234567="
|
||||
headers = {
|
||||
"Authorization": f"Bearer {TEST_SECRET}",
|
||||
"Idempotency-Key": event_id
|
||||
}
|
||||
payload = {
|
||||
"schema_version": 1,
|
||||
"event_id": event_id,
|
||||
"channel": "signal",
|
||||
"sender_uuid": "cccccccc-2222-3333-4444-555555555555",
|
||||
"sender_number": "+15559876543",
|
||||
"sender_name": "Bob TeamLead",
|
||||
"group_id": group_id,
|
||||
"message_id": "1787300000003",
|
||||
"reply_to": None,
|
||||
"text": "Team brainstorming: Distributed event sourcing with sqlite-vss.",
|
||||
"received_at": "2026-08-21T20:25:00+00:00"
|
||||
}
|
||||
|
||||
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["source_channel"] == "signal"
|
||||
assert idea["source_sender_uuid"] == "cccccccc-2222-3333-4444-555555555555"
|
||||
assert idea["source_group_id"] == group_id
|
||||
|
||||
ev = conn.execute("SELECT * FROM signal_inbound_events WHERE event_id = ?", (event_id,)).fetchone()
|
||||
assert ev["group_id"] == group_id
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 6. Outbound Signal Gateway Client Tests
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_outbound_client_success_202():
|
||||
"""Outbound client sends valid payload and handles 202 Accepted."""
|
||||
adapter = SignalGatewayAdapter(endpoint="http://10.138.4.46:8000", api_key=TEST_API_KEY)
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status = 202
|
||||
mock_resp.__enter__.return_value = mock_resp
|
||||
mock_resp.read.return_value = json.dumps({
|
||||
"accepted": True,
|
||||
"application_id": "thinkstorm",
|
||||
"message_id": "sgw_1787300000000_0",
|
||||
"status": "queued"
|
||||
}).encode("utf-8")
|
||||
|
||||
with patch("urllib.request.urlopen", return_value=mock_resp) as mock_urlopen:
|
||||
res = await adapter.send_message(
|
||||
recipient="12345678-1234-1234-1234-123456789abc",
|
||||
text="ThinkStorm response idea processed"
|
||||
)
|
||||
assert res["accepted"] is True
|
||||
assert res["status"] == "queued"
|
||||
assert res["message_id"] == "sgw_1787300000000_0"
|
||||
|
||||
# Verify request details
|
||||
req = mock_urlopen.call_args[0][0]
|
||||
assert req.full_url == "http://10.138.4.46:8000/api/v1/messages"
|
||||
assert req.headers["Authorization"] == f"Bearer {TEST_API_KEY}"
|
||||
assert req.headers["Content-type"] == "application/json"
|
||||
body = json.loads(req.data.decode("utf-8"))
|
||||
assert body["recipient"] == "12345678-1234-1234-1234-123456789abc"
|
||||
assert body["text"] == "ThinkStorm response idea processed"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_outbound_client_invalid_recipient_or_text():
|
||||
"""Outbound client validates recipient and text length constraints before sending."""
|
||||
adapter = SignalGatewayAdapter(endpoint="http://10.138.4.46:8000", api_key=TEST_API_KEY)
|
||||
|
||||
# Empty recipient
|
||||
with pytest.raises(SignalGatewayClientError):
|
||||
await adapter.send_message(recipient="", text="Hello")
|
||||
|
||||
# Recipient > 256 bytes
|
||||
with pytest.raises(SignalGatewayClientError):
|
||||
await adapter.send_message(recipient="a" * 257, text="Hello")
|
||||
|
||||
# Empty text
|
||||
with pytest.raises(SignalGatewayClientError):
|
||||
await adapter.send_message(recipient="uuid-123", text="")
|
||||
|
||||
# Text > 16000 bytes
|
||||
with pytest.raises(SignalGatewayPayloadTooLargeError):
|
||||
await adapter.send_message(recipient="uuid-123", text="x" * 16001)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_outbound_client_error_handling():
|
||||
"""Outbound client maps HTTP status codes 400, 401, 413, 503 and timeouts."""
|
||||
adapter = SignalGatewayAdapter(endpoint="http://10.138.4.46:8000", api_key=TEST_API_KEY)
|
||||
|
||||
# 400 Bad Request
|
||||
err_400 = urllib.error.HTTPError(
|
||||
url="http://10.138.4.46:8000/api/v1/messages",
|
||||
code=400,
|
||||
msg="Bad Request",
|
||||
hdrs={},
|
||||
fp=BytesIO(json.dumps({"accepted": False, "error": {"code": "bad_request", "message": "Invalid recipient format"}}).encode("utf-8"))
|
||||
)
|
||||
with patch("urllib.request.urlopen", side_effect=err_400):
|
||||
with pytest.raises(SignalGatewayClientError) as exc:
|
||||
await adapter.send_message(recipient="bad-recipient", text="Test")
|
||||
assert "400" in str(exc.value)
|
||||
|
||||
# 401 Unauthorized
|
||||
err_401 = urllib.error.HTTPError(
|
||||
url="http://10.138.4.46:8000/api/v1/messages",
|
||||
code=401,
|
||||
msg="Unauthorized",
|
||||
hdrs={},
|
||||
fp=BytesIO(json.dumps({"accepted": False, "error": {"code": "unauthorized", "message": "Invalid key"}}).encode("utf-8"))
|
||||
)
|
||||
with patch("urllib.request.urlopen", side_effect=err_401):
|
||||
with pytest.raises(SignalGatewayAuthError):
|
||||
await adapter.send_message(recipient="uuid-123", text="Test")
|
||||
|
||||
# 413 Payload Too Large
|
||||
err_413 = urllib.error.HTTPError(
|
||||
url="http://10.138.4.46:8000/api/v1/messages",
|
||||
code=413,
|
||||
msg="Payload Too Large",
|
||||
hdrs={},
|
||||
fp=BytesIO(json.dumps({"accepted": False, "error": {"code": "payload_too_large", "message": "Too large"}}).encode("utf-8"))
|
||||
)
|
||||
with patch("urllib.request.urlopen", side_effect=err_413):
|
||||
with pytest.raises(SignalGatewayPayloadTooLargeError):
|
||||
await adapter.send_message(recipient="uuid-123", text="Test")
|
||||
|
||||
# 503 Service Unavailable (retries and fails)
|
||||
err_503 = urllib.error.HTTPError(
|
||||
url="http://10.138.4.46:8000/api/v1/messages",
|
||||
code=503,
|
||||
msg="Service Unavailable",
|
||||
hdrs={},
|
||||
fp=BytesIO(json.dumps({"accepted": False, "error": {"code": "unavailable", "message": "Queue full"}}).encode("utf-8"))
|
||||
)
|
||||
with patch("urllib.request.urlopen", side_effect=err_503):
|
||||
with pytest.raises(SignalGatewayUnavailableError):
|
||||
await adapter.send_message(recipient="uuid-123", text="Test", max_retries=1)
|
||||
|
||||
# Timeout Error
|
||||
timeout_err = urllib.error.URLError(reason="Connection timed out")
|
||||
with patch("urllib.request.urlopen", side_effect=timeout_err):
|
||||
with pytest.raises(SignalGatewayTimeoutError):
|
||||
await adapter.send_message(recipient="uuid-123", text="Test")
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 7. Secret Redaction / Safety Tests
|
||||
# -----------------------------------------------------------------------------
|
||||
def test_secrets_redacted_in_errors_and_masking():
|
||||
"""Secrets are properly masked and not leaked."""
|
||||
masked = mask_secret("sgw_callback_secret_123456789")
|
||||
assert "sgw_" in masked
|
||||
assert "6789" in masked
|
||||
assert "callback_secret" not in masked
|
||||
assert mask_secret("") == ""
|
||||
assert mask_secret("short") == "***"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 8. Admin Service Health Test
|
||||
# -----------------------------------------------------------------------------
|
||||
def test_admin_signal_gateway_health_test(client):
|
||||
"""Admin can trigger health probe for signal_gateway."""
|
||||
# Login as admin
|
||||
login_resp = client.post("/api/auth/login", json={"username": "admin", "password": config.admin_bootstrap_key})
|
||||
token = login_resp.json()["token"]
|
||||
|
||||
resp = client.post("/api/admin/services/signal_gateway/test", headers={"Authorization": f"Bearer {token}"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["service_id"] == "signal_gateway"
|
||||
assert "healthy" in data
|
||||
@@ -0,0 +1,113 @@
|
||||
import asyncio
|
||||
|
||||
from thinkstorm import database
|
||||
from thinkstorm.processors import pipeline
|
||||
from thinkstorm.processors.pipeline import _extract_delimited_section
|
||||
|
||||
|
||||
def test_extracts_youtube_artifacts_from_generated_package():
|
||||
package = """
|
||||
<!-- OUTLINE_START -->
|
||||
# Video Outline
|
||||
|
||||
- Hook
|
||||
<!-- OUTLINE_END -->
|
||||
<!-- SCRIPT_START -->
|
||||
# Video Script
|
||||
|
||||
Welcome to the video.
|
||||
<!-- SCRIPT_END -->
|
||||
<!-- PROMOTION_START -->
|
||||
# Promotion Plan
|
||||
|
||||
- Share with the primary audience.
|
||||
<!-- PROMOTION_END -->
|
||||
"""
|
||||
|
||||
assert _extract_delimited_section(package, "OUTLINE").startswith("# Video Outline")
|
||||
assert _extract_delimited_section(package, "SCRIPT").startswith("# Video Script")
|
||||
assert _extract_delimited_section(package, "PROMOTION").startswith("# Promotion Plan")
|
||||
|
||||
|
||||
def test_missing_delimiters_preserve_generated_content():
|
||||
content = "# YouTube Production Package\n\nComplete fallback content."
|
||||
|
||||
assert _extract_delimited_section(content, "OUTLINE") == content
|
||||
|
||||
|
||||
def test_youtube_work_track_generates_three_versioned_artifacts(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(database, "DB_PATH", str(tmp_path / "youtube-track.db"))
|
||||
database.init_db()
|
||||
|
||||
now = database.get_utc_now()
|
||||
with database.get_db() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO ideas
|
||||
(id, original_text, submitted_at, title, summary, lifecycle_state,
|
||||
processing_state, claimed_by, created_at, updated_at)
|
||||
VALUES
|
||||
('TS-YT01', 'Explain the idea on video.', ?, 'A Better Video Idea',
|
||||
'A researched concept for a focused audience.', 'CLAIMED', 'IDLE',
|
||||
'creator', ?, ?)
|
||||
""",
|
||||
(now, now, now),
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO work_tracks
|
||||
(id, idea_id, work_type_id, name, state, workflow_id, created_at)
|
||||
VALUES
|
||||
('WT-YT01', 'TS-YT01', 'YOUTUBE_VIDEO', 'Launch Video', 'PLANNED',
|
||||
'youtube-video-v1', ?)
|
||||
""",
|
||||
(now,),
|
||||
)
|
||||
|
||||
async def fake_completion(**_kwargs):
|
||||
return {
|
||||
"text": """
|
||||
<!-- OUTLINE_START --># Outline\n\nAudience and chapter plan.<!-- OUTLINE_END -->
|
||||
<!-- SCRIPT_START --># Script\n\nNarration and visual cues.<!-- SCRIPT_END -->
|
||||
<!-- PROMOTION_START --># Promotion Plan\n\nDistribution and success metrics.<!-- PROMOTION_END -->
|
||||
""",
|
||||
"resolved_provider": "test",
|
||||
"resolved_model": "test/youtube",
|
||||
"input_tokens": 10,
|
||||
"output_tokens": 30,
|
||||
"total_tokens": 40,
|
||||
"duration_ms": 1,
|
||||
}
|
||||
|
||||
async def fake_persist(*_args, **_kwargs):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(pipeline.omniroute_svc, "chat_completion", fake_completion)
|
||||
monkeypatch.setattr(pipeline.gitea_svc, "persist_work_track_outputs", fake_persist)
|
||||
monkeypatch.setattr(pipeline.opengist_svc, "persist_work_track_outputs", fake_persist)
|
||||
|
||||
asyncio.run(pipeline.execute_work_track_workflow("WT-YT01"))
|
||||
|
||||
with database.get_db() as conn:
|
||||
outputs = conn.execute(
|
||||
"""
|
||||
SELECT name, content, version, is_current
|
||||
FROM work_track_outputs
|
||||
WHERE work_track_id = 'WT-YT01'
|
||||
ORDER BY name
|
||||
"""
|
||||
).fetchall()
|
||||
state = conn.execute(
|
||||
"SELECT state FROM work_tracks WHERE id = 'WT-YT01'"
|
||||
).fetchone()["state"]
|
||||
|
||||
assert [row["name"] for row in outputs] == [
|
||||
"promotion-plan.md",
|
||||
"video-outline.md",
|
||||
"video-script.md",
|
||||
]
|
||||
assert all(row["version"] == 1 and row["is_current"] == 1 for row in outputs)
|
||||
assert outputs[0]["content"].startswith("# Promotion Plan")
|
||||
assert outputs[1]["content"].startswith("# Outline")
|
||||
assert outputs[2]["content"].startswith("# Script")
|
||||
assert state == "COMPLETED"
|
||||
Reference in New Issue
Block a user