From cca21434827400ed043b79f184cbbf09fc582b31 Mon Sep 17 00:00:00 2001 From: MillerPatrick214 <149214352+MillerPatrick214@users.noreply.github.com> Date: Fri, 20 Mar 2026 14:03:16 -0700 Subject: [PATCH 1/2] feat: add CI with example contract validation against OpenAPI schemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GitHub Actions workflow: runs unit tests + example contract tests on Python 3.8, 3.10, 3.12 for every PR and main push - Contract test mocks HTTP layer, validates all SDK request payloads against JSON schemas from /schemas, returns schema-valid mock responses - Scans for hardcoded sur_live_ keys in examples and client source - Vendored JSON schemas from surmado-api-public into /schemas - Fixed version assertions in test_client.py (0.3.0 → 0.3.1) - Fixed README: Signal/Scan examples use url=, brand methods use website= - Added CI badge to README SUR-1267 Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 46 +++++ README.md | 7 +- client/tests/test_client.py | 6 +- client/tests/test_examples.py | 276 ++++++++++++++++++++++++++ schemas/error_response.json | 79 ++++++++ schemas/report_creation_response.json | 100 ++++++++++ schemas/report_status_response.json | 143 +++++++++++++ schemas/scan_request.json | 62 ++++++ schemas/signal_request.json | 104 ++++++++++ schemas/solutions_request.json | 89 +++++++++ schemas/webhook_payload.json | 102 ++++++++++ 11 files changed, 1008 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 client/tests/test_examples.py create mode 100644 schemas/error_response.json create mode 100644 schemas/report_creation_response.json create mode 100644 schemas/report_status_response.json create mode 100644 schemas/scan_request.json create mode 100644 schemas/signal_request.json create mode 100644 schemas/solutions_request.json create mode 100644 schemas/webhook_payload.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8b530c7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,46 @@ +# CI for surmado-python SDK +# Runs unit tests + example contract validation on every PR and main push. +# Examples are validated against JSON schemas in /schemas — no live API calls. +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.8", "3.10", "3.12"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e . + pip install jsonschema + + - name: Run unit tests + run: python -m pytest client/tests/test_client.py -v + + - name: Run example contract tests + run: python -m pytest client/tests/test_examples.py -v + + - name: Check for hardcoded API keys + run: | + # Fail if any real API key pattern appears outside of comments/docs + if grep -rn "sur_live_[A-Za-z0-9_]\{20,\}" examples/ client/ --include="*.py" | grep -v "^.*#.*sur_live_" | grep -v "docstring"; then + echo "::error::Found hardcoded live API key in source files" + exit 1 + fi + echo "No hardcoded keys found" diff --git a/README.md b/README.md index 0df6519..8593efe 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ # Surmado Python SDK +[![CI](https://github.com/surmado/surmado-python/actions/workflows/ci.yml/badge.svg)](https://github.com/surmado/surmado-python/actions/workflows/ci.yml) [![PyPI version](https://img.shields.io/pypi/v/surmado.svg)](https://pypi.org/project/surmado/) [![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/) @@ -73,7 +74,7 @@ README.md ← You are here ```python result = client.signal( - website="https://acme.com", + url="https://acme.com", brand_name="Acme Corp", # max 100 chars email="you@acme.com", industry="B2B SaaS", # max 200 chars @@ -89,7 +90,7 @@ result = client.signal( ```python result = client.scan( - website="https://acme.com", + url="https://acme.com", brand_name="Acme Corp", email="you@acme.com", competitor_urls=["https://competitor1.com", "https://competitor2.com"] @@ -183,7 +184,7 @@ brand = client.create_brand( # Create or get existing brand (never fails with conflict) brand = client.ensure_brand( brand_name="Acme Corp", - url="https://acme.com" + website="https://acme.com" ) ``` diff --git a/client/tests/test_client.py b/client/tests/test_client.py index f66e32f..bd00144 100644 --- a/client/tests/test_client.py +++ b/client/tests/test_client.py @@ -195,7 +195,7 @@ def test_headers_user_agent_version(self): def test_headers_user_agent_matches_version(self): client = Surmado(api_key="sur_test_x") headers = client._headers() - self.assertIn("0.3.0", headers["User-Agent"]) + self.assertIn("0.3.1", headers["User-Agent"]) def test_headers_has_three_keys(self): client = Surmado(api_key="sur_test_x") @@ -1342,11 +1342,11 @@ class TestVersion(unittest.TestCase): """Test version is accessible and correct.""" def test_version_string(self): - self.assertEqual(__version__, "0.3.0") + self.assertEqual(__version__, "0.3.1") def test_version_from_client_module(self): from surmado.client import __version__ as client_version - self.assertEqual(client_version, "0.3.0") + self.assertEqual(client_version, "0.3.1") def test_version_matches(self): from surmado.client import __version__ as client_version diff --git a/client/tests/test_examples.py b/client/tests/test_examples.py new file mode 100644 index 0000000..2e7c9ee --- /dev/null +++ b/client/tests/test_examples.py @@ -0,0 +1,276 @@ +"""Contract tests for /examples — validates SDK requests against OpenAPI schemas. + +Runs each example script with mocked HTTP, validating that: +1. Every request payload matches the corresponding request schema +2. Mock responses conform to response schemas +3. No hardcoded API keys (sur_live_*, sur_test_*) exist in example source +4. Examples run without error against the mock contract +""" +import json +import os +import re +import unittest +from pathlib import Path +from unittest.mock import patch, MagicMock + +from jsonschema import validate, ValidationError as JsonSchemaError + +# --------------------------------------------------------------------------- +# Schema loading +# --------------------------------------------------------------------------- + +REPO_ROOT = Path(__file__).resolve().parents[2] +SCHEMAS_DIR = REPO_ROOT / "schemas" +EXAMPLES_DIR = REPO_ROOT / "examples" + + +def load_schema(name: str) -> dict: + with open(SCHEMAS_DIR / name) as f: + return json.load(f) + + +REQUEST_SCHEMAS = { + "/reports/signal": load_schema("signal_request.json"), + "/reports/scan": load_schema("scan_request.json"), + "/reports/solutions": load_schema("solutions_request.json"), +} + +CREATION_RESPONSE_SCHEMA = load_schema("report_creation_response.json") +STATUS_RESPONSE_SCHEMA = load_schema("report_status_response.json") + +# --------------------------------------------------------------------------- +# Mock response factories — return schema-valid responses +# --------------------------------------------------------------------------- + +MOCK_AUTH_RESPONSE = { + "authenticated": True, + "org_id": "org_test123", + "org_name": "Test Org", + "credits": 100, + "message": "Authentication successful! Your API key is working correctly.", +} + +MOCK_SCAN_RESPONSE = { + "report_id": "rpt_scanTest001", + "org_id": "org_test123", + "product": "scan", + "status": "queued", + "brand_slug": "example_brand", + "brand_name": "Example Brand", + "brand_created": True, + "credits_used": 1, + "request_id": "req_abc123", + "created_at": "2026-01-15T10:30:00Z", + "note": "brand_slug created - prevents duplicates from name variations", +} + +MOCK_SIGNAL_RESPONSE = { + "report_id": "rpt_sigTest002", + "token": "SIG-2026-01-TEST1", + "org_id": "org_test123", + "product": "signal", + "status": "queued", + "brand_slug": "example_brand", + "brand_name": "Example Brand", + "credits_used": 1, + "request_id": "req_def456", + "created_at": "2026-01-15T10:31:00Z", +} + +MOCK_REPORT_STATUS = { + "report_id": "rpt_scanTest001", + "org_id": "org_test123", + "product": "scan", + "status": "processing", + "brand_slug": "example_brand", + "brand_name": "Example Brand", + "token": None, + "credits_used": 1, + "created_at": "2026-01-15T10:30:00Z", + "updated_at": "2026-01-15T10:32:00Z", +} + + +def _validate_schema(instance: dict, schema: dict, label: str): + """Validate instance against JSON schema, raising AssertionError on failure.""" + try: + validate(instance=instance, schema=schema) + except JsonSchemaError as exc: + raise AssertionError(f"Schema validation failed for {label}: {exc.message}") + + +# Pre-validate mock responses at import time so broken mocks fail fast +_validate_schema(MOCK_SCAN_RESPONSE, CREATION_RESPONSE_SCHEMA, "mock scan creation") +_validate_schema(MOCK_SIGNAL_RESPONSE, CREATION_RESPONSE_SCHEMA, "mock signal creation") +_validate_schema(MOCK_REPORT_STATUS, STATUS_RESPONSE_SCHEMA, "mock report status") + + +# --------------------------------------------------------------------------- +# Contract test +# --------------------------------------------------------------------------- + + +class TestQuickstartContract(unittest.TestCase): + """Run quickstart.py against mocked HTTP and validate schemas.""" + + def setUp(self): + self.validated_requests = [] + + def _mock_response(self, status_code: int, json_data: dict) -> MagicMock: + resp = MagicMock() + resp.status_code = status_code + resp.json.return_value = json_data + resp.text = json.dumps(json_data) + return resp + + def _route_post(self, url: str, **kwargs) -> MagicMock: + """Route POST requests, validate payloads against schemas.""" + payload = kwargs.get("json", {}) + + if "/reports/scan" in url: + schema = REQUEST_SCHEMAS["/reports/scan"] + _validate_schema(payload, schema, f"POST {url}") + self.validated_requests.append(("POST", "/reports/scan", payload)) + return self._mock_response(202, MOCK_SCAN_RESPONSE) + + elif "/reports/signal" in url: + schema = REQUEST_SCHEMAS["/reports/signal"] + _validate_schema(payload, schema, f"POST {url}") + self.validated_requests.append(("POST", "/reports/signal", payload)) + return self._mock_response(202, MOCK_SIGNAL_RESPONSE) + + elif "/reports/solutions" in url: + schema = REQUEST_SCHEMAS["/reports/solutions"] + _validate_schema(payload, schema, f"POST {url}") + self.validated_requests.append(("POST", "/reports/solutions", payload)) + return self._mock_response(202, MOCK_SCAN_RESPONSE) + + # Default: return 200 with empty body + return self._mock_response(200, {}) + + def _route_get(self, url: str, **kwargs) -> MagicMock: + """Route GET requests with schema-valid mock responses.""" + if "/test-auth" in url: + return self._mock_response(200, MOCK_AUTH_RESPONSE) + + elif "/reports/" in url: + return self._mock_response(200, MOCK_REPORT_STATUS) + + return self._mock_response(200, {}) + + @patch.dict(os.environ, {"SURMADO_API_KEY": "sur_test_ci_placeholder_key"}) + @patch("requests.get") + @patch("requests.post") + def test_quickstart_runs_against_contract(self, mock_post, mock_get): + """quickstart.py runs to completion and all payloads match schemas.""" + mock_post.side_effect = self._route_post + mock_get.side_effect = self._route_get + + # Import and run quickstart + import importlib.util + spec = importlib.util.spec_from_file_location( + "quickstart", EXAMPLES_DIR / "quickstart.py" + ) + module = importlib.util.find_module = spec + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + module.main() + + # Verify we actually validated requests (not silently skipped) + self.assertTrue( + len(self.validated_requests) >= 2, + f"Expected at least 2 validated requests, got {len(self.validated_requests)}: " + f"{[r[1] for r in self.validated_requests]}" + ) + + @patch.dict(os.environ, {"SURMADO_API_KEY": "sur_test_ci_placeholder_key"}) + @patch("requests.get") + @patch("requests.post") + def test_quickstart_scan_payload_matches_schema(self, mock_post, mock_get): + """Scan request payload has all required fields and correct types.""" + mock_post.side_effect = self._route_post + mock_get.side_effect = self._route_get + + import importlib.util + spec = importlib.util.spec_from_file_location( + "quickstart", EXAMPLES_DIR / "quickstart.py" + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + module.main() + + scan_requests = [r for r in self.validated_requests if r[1] == "/reports/scan"] + self.assertEqual(len(scan_requests), 1, "Expected exactly 1 scan request") + + payload = scan_requests[0][2] + self.assertIn("url", payload) + self.assertIn("email", payload) + self.assertIn("brand_name", payload) + + @patch.dict(os.environ, {"SURMADO_API_KEY": "sur_test_ci_placeholder_key"}) + @patch("requests.get") + @patch("requests.post") + def test_quickstart_signal_payload_matches_schema(self, mock_post, mock_get): + """Signal request payload has all required fields and correct types.""" + mock_post.side_effect = self._route_post + mock_get.side_effect = self._route_get + + import importlib.util + spec = importlib.util.spec_from_file_location( + "quickstart", EXAMPLES_DIR / "quickstart.py" + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + module.main() + + signal_requests = [r for r in self.validated_requests if r[1] == "/reports/signal"] + self.assertEqual(len(signal_requests), 1, "Expected exactly 1 signal request") + + payload = signal_requests[0][2] + for field in ["url", "email", "brand_name", "industry", "location", + "persona", "pain_points", "brand_details", "direct_competitors"]: + self.assertIn(field, payload, f"Signal payload missing required field: {field}") + + +class TestNoHardcodedKeys(unittest.TestCase): + """Ensure no example files contain real API keys.""" + + REAL_KEY_PATTERN = re.compile(r"sur_live_[A-Za-z0-9_]{10,}") + + def test_no_live_keys_in_examples(self): + """No example file contains a real sur_live_ key.""" + for py_file in EXAMPLES_DIR.glob("**/*.py"): + content = py_file.read_text() + matches = self.REAL_KEY_PATTERN.findall(content) + self.assertEqual( + matches, [], + f"Found hardcoded live key(s) in {py_file.name}: {matches}" + ) + + def test_no_live_keys_in_client(self): + """No client source file contains a real sur_live_ key.""" + client_dir = REPO_ROOT / "client" / "surmado" + for py_file in client_dir.glob("**/*.py"): + content = py_file.read_text() + matches = self.REAL_KEY_PATTERN.findall(content) + self.assertEqual( + matches, [], + f"Found hardcoded live key(s) in {py_file.name}: {matches}" + ) + + +class TestMockResponsesConformToSchemas(unittest.TestCase): + """Verify our mock fixtures are valid per the response schemas.""" + + def test_scan_creation_response(self): + _validate_schema(MOCK_SCAN_RESPONSE, CREATION_RESPONSE_SCHEMA, "scan creation") + + def test_signal_creation_response(self): + _validate_schema(MOCK_SIGNAL_RESPONSE, CREATION_RESPONSE_SCHEMA, "signal creation") + + def test_report_status_response(self): + _validate_schema(MOCK_REPORT_STATUS, STATUS_RESPONSE_SCHEMA, "report status") + + +if __name__ == "__main__": + unittest.main() diff --git a/schemas/error_response.json b/schemas/error_response.json new file mode 100644 index 0000000..6f34cf6 --- /dev/null +++ b/schemas/error_response.json @@ -0,0 +1,79 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ErrorResponse", + "description": "Error response format for API errors (4xx and 5xx). Error shapes vary across endpoints -- always check for a detail field and read error and message defensively.", + "type": "object", + "required": ["detail"], + "properties": { + "detail": { + "type": "object", + "description": "Error detail object. Structure varies by endpoint.", + "required": ["error"], + "properties": { + "error": { + "description": "Error identifier. May be a string code or a nested object with code and message.", + "oneOf": [ + { + "type": "string", + "description": "Error code string (e.g. INSUFFICIENT_CREDITS, brand_not_found)" + }, + { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "string", + "description": "Machine-readable error code" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + } + } + } + ] + }, + "message": { + "type": "string", + "description": "Human-readable error message (present when error is a string)" + }, + "have": { + "type": "integer", + "description": "Credits available (insufficient credits errors)" + }, + "need": { + "type": "integer", + "description": "Credits required (insufficient credits errors)" + }, + "brand_slug": { + "type": "string", + "description": "Brand slug (brand not found errors)" + } + } + } + }, + "examples": [ + { + "detail": { + "error": "INSUFFICIENT_CREDITS", + "have": 0, + "need": 1, + "message": "Requires 1 credit but only 0 available." + } + }, + { + "detail": { + "error": "brand_not_found", + "brand_slug": "nonexistent_brand" + } + }, + { + "detail": { + "error": { + "code": "brand_reference_missing", + "message": "Either brand_slug or brand_name must be provided" + } + } + } + ] +} diff --git a/schemas/report_creation_response.json b/schemas/report_creation_response.json new file mode 100644 index 0000000..06e904c --- /dev/null +++ b/schemas/report_creation_response.json @@ -0,0 +1,100 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ReportCreationResponse", + "description": "Response from creating a Scan, Signal, or Solutions report (HTTP 202 Accepted)", + "type": "object", + "required": [ + "report_id", + "org_id", + "product", + "status", + "brand_slug", + "brand_name", + "credits_used", + "created_at" + ], + "properties": { + "report_id": { + "type": "string", + "description": "Unique report identifier", + "pattern": "^rpt_[a-zA-Z0-9]+$" + }, + "org_id": { + "type": "string", + "description": "Organization ID" + }, + "product": { + "type": "string", + "enum": ["scan", "signal", "solutions"], + "description": "Product type" + }, + "status": { + "type": "string", + "enum": ["queued"], + "description": "Initial status is always 'queued'" + }, + "brand_slug": { + "type": "string", + "description": "URL-safe brand identifier" + }, + "brand_name": { + "type": "string", + "description": "Display name of the brand" + }, + "brand_created": { + "type": "boolean", + "description": "True if brand was auto-created from brand_name" + }, + "credits_used": { + "type": "integer", + "minimum": 0, + "description": "Credits consumed for this report (1 credit per report)" + }, + "request_id": { + "type": "string", + "description": "Idempotency request identifier. Pass as query parameter on creation to make retries safe." + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp of report creation" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp of last update" + }, + "white_label": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "agency_name": { + "type": ["string", "null"] + } + } + }, + "tier_features": { + "type": "object", + "properties": { + "has_pptx": { + "type": "boolean", + "description": "Whether PPTX output is included" + }, + "has_enhanced_analysis": { + "type": "boolean", + "description": "Whether enhanced insights are included" + }, + "persona_variations": { + "type": "integer", + "description": "Number of persona variations" + } + } + }, + "note": { + "type": ["string", "null"], + "description": "Educational message (e.g. brand_slug created - prevents duplicates from name variations)" + } + } +} diff --git a/schemas/report_status_response.json b/schemas/report_status_response.json new file mode 100644 index 0000000..e0aca8d --- /dev/null +++ b/schemas/report_status_response.json @@ -0,0 +1,143 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ReportStatusResponse", + "description": "Response from GET /reports/{report_id} - includes full report details", + "type": "object", + "required": [ + "report_id", + "org_id", + "product", + "status", + "brand_slug", + "brand_name", + "created_at" + ], + "properties": { + "report_id": { + "type": "string", + "description": "Unique report identifier" + }, + "org_id": { + "type": "string", + "description": "Organization ID" + }, + "product": { + "type": "string", + "enum": ["scan", "signal", "solutions"], + "description": "Product type" + }, + "status": { + "type": "string", + "enum": ["queued", "processing", "completed", "failed", "cancelled", "waiting_on_signal", "waiting_on_scan", "waiting_on_dependencies"], + "description": "Current report status. Treat any status other than completed, failed, or cancelled as still in progress." + }, + "brand_slug": { + "type": "string", + "description": "URL-safe brand identifier" + }, + "brand_name": { + "type": "string", + "description": "Display name of the brand" + }, + "token": { + "type": ["string", "null"], + "description": "Report token (e.g. SIG-2025-11-A1B2C, SCN-..., SOL-...). Populated on completion, null while queued or processing. Use as signal_token or scan_token when creating Strategy reports." + }, + "credits_used": { + "type": "integer", + "minimum": 0, + "description": "Credits consumed for this report (1 credit per report)" + }, + "credits_refunded": { + "type": "boolean", + "description": "True if credits were refunded due to failure" + }, + "request_id": { + "type": "string", + "description": "Idempotency request identifier" + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp of report creation" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp of last update" + }, + "completed_at": { + "type": ["string", "null"], + "format": "date-time", + "description": "ISO 8601 timestamp of completion" + }, + "processing_time_seconds": { + "type": ["number", "null"], + "description": "Processing time in seconds" + }, + "download_url": { + "type": ["string", "null"], + "description": "Signed HTTPS URL for PDF download (expires in ~15 minutes)" + }, + "pptx_download_url": { + "type": ["string", "null"], + "description": "Signed HTTPS URL for PPTX download (expires in ~15 minutes, when available)" + }, + "public_intelligence": { + "type": ["object", "null"], + "description": "Structured report data for automation workflows. When present, contains parsed report data you can route into other systems without downloading files." + }, + "webhook_url": { + "type": ["string", "null"], + "description": "Webhook URL for completion notifications" + }, + "error": { + "type": ["string", "null"], + "description": "Human-readable error message (if failed)" + }, + "error_code": { + "type": ["string", "null"], + "enum": [ + null, + "VALIDATION_FAILED", + "BRAND_NOT_FOUND", + "CREDITS_INSUFFICIENT", + "IDEMPOTENT_REPLAY", + "PROVIDER_TIMEOUT", + "GENERATION_FAILED", + "STORAGE_FAILED", + "INTERNAL_ERROR" + ], + "description": "Machine-readable error code" + }, + "error_details": { + "type": ["object", "null"], + "description": "Structured error metadata" + }, + "white_label": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "agency_name": { + "type": ["string", "null"] + } + } + }, + "tier_features": { + "type": "object", + "properties": { + "has_pptx": { + "type": "boolean" + }, + "has_enhanced_analysis": { + "type": "boolean" + }, + "persona_variations": { + "type": "integer" + } + } + } + } +} diff --git a/schemas/scan_request.json b/schemas/scan_request.json new file mode 100644 index 0000000..d8dfc0f --- /dev/null +++ b/schemas/scan_request.json @@ -0,0 +1,62 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ScanRequest", + "description": "Request body for POST /reports/scan - SEO audit", + "type": "object", + "required": ["url", "email"], + "anyOf": [ + { "required": ["brand_slug"] }, + { "required": ["brand_name"] } + ], + "properties": { + "brand_slug": { + "type": "string", + "description": "Existing brand slug (use this if you already created the brand)" + }, + "brand_name": { + "type": "string", + "maxLength": 100, + "description": "Brand name (we'll auto-create brand_slug if not provided)" + }, + "url": { + "type": "string", + "format": "uri", + "description": "Website URL to audit" + }, + "email": { + "type": "string", + "format": "email", + "description": "Contact email for notifications" + }, + "report_style": { + "type": "string", + "maxLength": 50, + "default": "executive", + "description": "Report style (e.g., executive, technical, comprehensive)" + }, + "competitor_urls": { + "type": "array", + "items": { + "type": "string", + "format": "uri" + }, + "description": "Competitor URLs to compare against" + }, + "webhook_url": { + "type": "string", + "format": "uri", + "description": "URL to receive completion webhook (HTTPS required)" + }, + "is_agency_white_label": { + "type": "boolean", + "default": false, + "description": "Enable white-label mode for agencies" + }, + "agency_name": { + "type": "string", + "maxLength": 100, + "description": "Agency name for white-label outputs" + } + } +} + diff --git a/schemas/signal_request.json b/schemas/signal_request.json new file mode 100644 index 0000000..a7daeb4 --- /dev/null +++ b/schemas/signal_request.json @@ -0,0 +1,104 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "SignalRequest", + "description": "Request body for POST /reports/signal - AI visibility testing", + "type": "object", + "required": ["url", "email", "industry", "location", "persona", "pain_points", "brand_details", "direct_competitors"], + "anyOf": [ + { "required": ["brand_slug"] }, + { "required": ["brand_name"] } + ], + "properties": { + "brand_slug": { + "type": "string", + "description": "Existing brand slug (use this if you already created the brand)" + }, + "brand_name": { + "type": "string", + "maxLength": 100, + "description": "Brand name (we'll auto-create brand_slug if not provided)" + }, + "url": { + "type": "string", + "format": "uri", + "description": "Your website URL" + }, + "email": { + "type": "string", + "format": "email", + "description": "Contact email for notifications" + }, + "industry": { + "type": "string", + "maxLength": 200, + "description": "Your industry or sector" + }, + "business_scale": { + "type": "string", + "maxLength": 50, + "default": "medium", + "description": "Business scale (e.g., small, medium, large, national, enterprise)" + }, + "location": { + "type": "string", + "maxLength": 200, + "description": "Primary market location" + }, + "product": { + "type": "string", + "maxLength": 1000, + "description": "Product or service description" + }, + "persona": { + "type": "string", + "maxLength": 800, + "description": "Target customer persona" + }, + "pain_points": { + "type": "string", + "maxLength": 1000, + "description": "Problems your solution addresses (MUST be a string, not an array)" + }, + "brand_details": { + "type": "string", + "maxLength": 1200, + "description": "Brand positioning and unique value" + }, + "direct_competitors": { + "type": "string", + "maxLength": 500, + "description": "Direct competitors (comma-separated string, NOT an array)" + }, + "indirect_competitors": { + "type": "string", + "maxLength": 500, + "description": "Indirect competitors (comma-separated string)" + }, + "keywords": { + "type": "string", + "maxLength": 500, + "description": "Keywords to analyze (comma-separated string)" + }, + "generate_pptx": { + "type": "boolean", + "default": true, + "description": "Generate PowerPoint presentation" + }, + "webhook_url": { + "type": "string", + "format": "uri", + "description": "URL to receive completion webhook (HTTPS required)" + }, + "is_agency_white_label": { + "type": "boolean", + "default": false, + "description": "Enable white-label mode for agencies" + }, + "agency_name": { + "type": "string", + "maxLength": 100, + "description": "Agency name for white-label outputs" + } + } +} + diff --git a/schemas/solutions_request.json b/schemas/solutions_request.json new file mode 100644 index 0000000..72afe31 --- /dev/null +++ b/schemas/solutions_request.json @@ -0,0 +1,89 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "SolutionsRequest", + "description": "Request body for POST /reports/solutions - AI-powered strategic advisory. Supports two modes: (1) token mode -- pass signal_token (required) and optionally scan_token; brand is inherited, standalone fields become optional supplemental context. (2) standalone mode -- provide brand and all business context fields directly. scan_token alone does not constitute token mode.", + "type": "object", + "required": ["email"], + "anyOf": [ + { "required": ["brand_slug"] }, + { "required": ["brand_name"] }, + { "required": ["signal_token"] } + ], + "if": { + "not": { "required": ["signal_token"] } + }, + "then": { + "description": "Standalone mode: when signal_token is absent, brand and all context fields are required. scan_token may be included for supplemental SEO context.", + "anyOf": [ + { "required": ["brand_slug"] }, + { "required": ["brand_name"] } + ], + "required": ["business_story", "decision", "success", "timeline", "scale_indicator"] + }, + "properties": { + "brand_slug": { + "type": "string", + "description": "Existing brand slug (required for standalone mode)" + }, + "brand_name": { + "type": "string", + "maxLength": 100, + "description": "Brand name -- auto-creates brand_slug if not provided (required for standalone mode)" + }, + "email": { + "type": "string", + "format": "email", + "description": "Contact email for notifications" + }, + "signal_token": { + "type": "string", + "maxLength": 50, + "description": "Token from a completed Signal report (e.g. SIG-2025-11-XXXXX). When provided, brand is inherited and standalone fields become optional supplemental context." + }, + "scan_token": { + "type": "string", + "maxLength": 50, + "description": "Token from a completed Scan report (e.g. SCN-2025-11-XXXXX). Feeds SEO/technical context into the analysis." + }, + "business_story": { + "type": "string", + "maxLength": 2000, + "description": "Tell us about your business (required for standalone mode)" + }, + "decision": { + "type": "string", + "maxLength": 1500, + "description": "Key decision or challenge you're facing (required for standalone mode)" + }, + "success": { + "type": "string", + "maxLength": 1000, + "description": "What does success look like? (required for standalone mode)" + }, + "timeline": { + "type": "string", + "maxLength": 200, + "description": "Timeline for decision (required for standalone mode)" + }, + "scale_indicator": { + "type": "string", + "maxLength": 100, + "description": "Business scale indicator, e.g. regional (required for standalone mode)" + }, + "webhook_url": { + "type": "string", + "format": "uri", + "description": "URL to receive completion webhook (HTTPS required)" + }, + "is_agency_white_label": { + "type": "boolean", + "default": false, + "description": "Enable white-label mode for agencies" + }, + "agency_name": { + "type": "string", + "maxLength": 100, + "description": "Agency name for white-label outputs" + } + } +} diff --git a/schemas/webhook_payload.json b/schemas/webhook_payload.json new file mode 100644 index 0000000..c8d9016 --- /dev/null +++ b/schemas/webhook_payload.json @@ -0,0 +1,102 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "WebhookPayload", + "description": "Payload sent to webhook_url when a report completes or fails. Uses a nested envelope with event, timestamp, and report object.", + "type": "object", + "required": ["event", "timestamp", "report"], + "properties": { + "event": { + "type": "string", + "enum": ["report.completed", "report.failed"], + "description": "Event type" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp of the event" + }, + "report": { + "type": "object", + "required": ["id", "product", "status"], + "properties": { + "id": { + "type": "string", + "description": "Report identifier (same as report_id in REST responses)" + }, + "token": { + "type": "string", + "description": "Report token (e.g. SIG-2025-11-A1B2C). Empty string if failed." + }, + "product": { + "type": "string", + "enum": ["scan", "signal", "solutions"], + "description": "Product type" + }, + "status": { + "type": "string", + "enum": ["completed", "failed"], + "description": "Final report status" + }, + "tier": { + "type": "string", + "description": "Report tier (e.g. basic)" + }, + "data_url": { + "type": "string", + "format": "uri", + "description": "URL to the report status endpoint -- call with your API key to get signed download URLs" + }, + "pdf_url": { + "type": ["string", "null"], + "format": "uri", + "description": "Magic-link URL (valid ~30 days) that generates a fresh signed PDF on each access" + }, + "summary": { + "type": ["object", "null"], + "description": "Curated metrics extracted from the report (varies by product, may be absent)", + "properties": { + "business_name": { + "type": "string" + }, + "contact_email": { + "type": "string", + "format": "email" + }, + "presence_score": { + "type": ["number", "null"], + "description": "Signal reports: AI presence score" + }, + "authority_score": { + "type": ["number", "null"], + "description": "Signal reports: authority score" + }, + "competitive_rank": { + "type": ["number", "null"], + "description": "Signal reports: competitive rank" + }, + "seo_score": { + "type": ["number", "null"], + "description": "Scan reports: SEO score" + }, + "performance_score": { + "type": ["number", "null"], + "description": "Scan reports: performance score" + }, + "critical_issues": { + "type": ["number", "null"], + "description": "Scan reports: number of critical issues" + } + } + }, + "failure_reason": { + "type": ["string", "null"], + "description": "Human-readable failure reason (failed reports only)" + }, + "credits_refunded": { + "type": ["boolean", "null"], + "description": "True if credits were refunded (failed reports only)" + } + } + } + } +} From 0c2be9302f58eeeeb8ccc96b806aa2c696a2d6d0 Mon Sep 17 00:00:00 2001 From: MillerPatrick214 <149214352+MillerPatrick214@users.noreply.github.com> Date: Fri, 20 Mar 2026 14:08:45 -0700 Subject: [PATCH 2/2] fix: add pytest to CI deps, bump matrix to 3.9+ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pytest was missing from pip install. Python 3.8 is EOL and failing on ubuntu-latest — bumped minimum CI matrix to 3.9. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b530c7..e15c9f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.8", "3.10", "3.12"] + python-version: ["3.9", "3.10", "3.12"] steps: - uses: actions/checkout@v4 @@ -28,7 +28,7 @@ jobs: run: | python -m pip install --upgrade pip pip install -e . - pip install jsonschema + pip install pytest jsonschema - name: Run unit tests run: python -m pytest client/tests/test_client.py -v