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:
2026-08-23 01:40:22 -07:00
parent 94ff1e4408
commit 41e08611c9
30 changed files with 3784 additions and 278 deletions
+525
View File
@@ -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'")
+450
View File
@@ -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
+113
View File
@@ -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"