From 6de77be8db952562b594a8276f4914c1606f71b5 Mon Sep 17 00:00:00 2001 From: Shubhank <72601061+Sagsgit@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:24:00 +1200 Subject: [PATCH 01/12] feat(salesforce): add Salesforce CRM integration with SDK 2.0.0 - 7 actions: search_records, get_record, update_record, list_tasks, list_events, get_task_summary, get_event_summary - OAuth 2.0 platform auth, instance_url resolved from context.metadata - 56 pytest unit tests, zero credentials required - Real Salesforce logo icon (512x512) - README with auth setup, actions table, troubleshooting --- README.md | 4 + salesforce/README.md | 55 +++ salesforce/__init__.py | 3 + salesforce/config.json | 247 ++++++++++ salesforce/icon.png | Bin 0 -> 35864 bytes salesforce/requirements.txt | 1 + salesforce/salesforce.py | 288 ++++++++++++ salesforce/tests/__init__.py | 0 salesforce/tests/conftest.py | 4 + salesforce/tests/context.py | 8 + salesforce/tests/test_salesforce.py | 101 +++++ salesforce/tests/test_salesforce_unit.py | 549 +++++++++++++++++++++++ 12 files changed, 1260 insertions(+) create mode 100644 salesforce/README.md create mode 100644 salesforce/__init__.py create mode 100644 salesforce/config.json create mode 100644 salesforce/icon.png create mode 100644 salesforce/requirements.txt create mode 100644 salesforce/salesforce.py create mode 100644 salesforce/tests/__init__.py create mode 100644 salesforce/tests/conftest.py create mode 100644 salesforce/tests/context.py create mode 100644 salesforce/tests/test_salesforce.py create mode 100644 salesforce/tests/test_salesforce_unit.py diff --git a/README.md b/README.md index 3421bfc5..117a288d 100644 --- a/README.md +++ b/README.md @@ -251,6 +251,10 @@ Supports basic HTTP authentication and Bearer token authentication via the SDK. [float](float): Comprehensive resource management and project scheduling integration with Float API for team capacity planning, time tracking, and project coordination. Supports full CRUD operations for team members (people) with roles, departments, rates, and availability management. Includes complete project lifecycle management with client associations, budgets, timelines, and team assignments. Features task/allocation scheduling across team members, time off management with leave types, logged time tracking with billable hours, and client relationship management. Provides access to organizational structure (departments, roles), account settings, project stages, phases, milestones, and expenses. Includes comprehensive reporting capabilities (people utilization, project analytics) with date range filtering. Features 60 actions covering all Float API v3 endpoints, custom API key authentication with required User-Agent header, connected account information display, pagination support (up to 200 items per page), rate limiting awareness (200 GET/min, 100 non-GET/min), field filtering, sorting, modified-since sync capabilities, and ActionResult return type for cost tracking. Ideal for resource planning, capacity management, project scheduling, time tracking workflows, and team utilization analysis. +### Salesforce + +[salesforce](salesforce): Salesforce is the world's leading CRM platform for managing sales pipelines, customer relationships, and activity tracking. This integration provides 7 focused actions covering record search via SOQL, single-record retrieval and update, task and event listing with filters, and human-readable summaries of task and event records. Supports OAuth 2.0 (platform) authentication. Ideal for sales automation, CRM data updates, and surfacing task and event activity within workflows. + ### Shopify Admin [shopify-admin](shopify-admin): Integrates with the Shopify Admin API for backend store management. Currently enables comprehensive customer lifecycle management including searching, creating, updating, and deleting customer records via the GraphQL API. diff --git a/salesforce/README.md b/salesforce/README.md new file mode 100644 index 00000000..f4a1b668 --- /dev/null +++ b/salesforce/README.md @@ -0,0 +1,55 @@ +# Salesforce + +Salesforce is the world's leading CRM platform, used to manage sales pipelines, customer relationships, tasks, events, and more. This integration provides 7 focused actions for searching and updating records, and for retrieving summaries of task and event activity. + +## Auth Setup + +This integration uses **OAuth 2.0** via a Salesforce Connected App. + +1. Log in to Salesforce and go to **Setup → App Manager → New Connected App**. +2. Enable **OAuth Settings** and set a callback URL. +3. Add the **`api`** scope under Selected OAuth Scopes. +4. Save and copy the **Consumer Key** (Client ID) and **Consumer Secret**. +5. Use those credentials to connect via the Autohive platform OAuth flow. + +## Actions + +| Action | Description | Key Inputs | Key Outputs | +|--------|-------------|------------|-------------| +| `search_records` | Run a SOQL query against any object | `soql` | `records`, `total_size` | +| `get_record` | Fetch a single record by ID | `object_type`, `record_id` | `record` | +| `update_record` | Update fields on a record | `object_type`, `record_id`, `fields` | `result`, `record_id` | +| `list_tasks` | List Task records with optional filters | `status`, `due_date_from`, `due_date_to`, `limit` | `tasks`, `total_size` | +| `list_events` | List Event records with optional date filters | `start_date_from`, `start_date_to`, `limit` | `events`, `total_size` | +| `get_task_summary` | Get a readable summary of a Task | `task_id` | `summary`, `task` | +| `get_event_summary` | Get a readable summary of an Event | `event_id` | `summary`, `event` | + +## API Info + +- **Base URL:** `https://{instance_url}/services/data/v62.0/` +- **Docs:** https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/ +- **Rate limits:** Typically 15,000 API calls per 24 hours (varies by Salesforce edition) +- **Query endpoint:** `GET /query?q={SOQL}` +- **Record endpoint:** `GET /sobjects/{ObjectType}/{Id}` + +## Running Tests + +```bash +cd salesforce/tests +export SALESFORCE_TOKEN=your_access_token +export SALESFORCE_INSTANCE_URL=https://yourinstance.salesforce.com +# Optional: set record IDs to test get/update actions +export SALESFORCE_RECORD_ID=003XXXXXXXXXXXXXXX +export SALESFORCE_TASK_ID=00TXXXXXXXXXXXXXXX +export SALESFORCE_EVENT_ID=00UXXXXXXXXXXXXXXX +python test_salesforce.py +``` + +## Troubleshooting + +| Error | Cause | Fix | +|-------|-------|-----| +| `401 Unauthorized` | Expired or invalid access token | Re-authenticate via Autohive OAuth flow | +| `400 MALFORMED_QUERY` | Invalid SOQL syntax | Check field names, quote strings with single quotes | +| `404 NOT_FOUND` | Record ID doesn't exist or wrong object type | Verify ID and object type match | +| `REQUEST_LIMIT_EXCEEDED` | Daily API call limit hit | Wait until the 24-hour window resets | diff --git a/salesforce/__init__.py b/salesforce/__init__.py new file mode 100644 index 00000000..511c4e4d --- /dev/null +++ b/salesforce/__init__.py @@ -0,0 +1,3 @@ +from .salesforce import salesforce + +__all__ = ["salesforce"] diff --git a/salesforce/config.json b/salesforce/config.json new file mode 100644 index 00000000..465ab3a5 --- /dev/null +++ b/salesforce/config.json @@ -0,0 +1,247 @@ +{ + "name": "Salesforce", + "display_name": "Salesforce", + "version": "1.0.0", + "description": "Salesforce CRM integration for searching and updating records, and summarising task and event activity.", + "entry_point": "salesforce.py", + "auth": { + "type": "platform", + "provider": "salesforce", + "scopes": ["api"] + }, + "actions": { + "search_records": { + "display_name": "Search Records", + "description": "Run a SOQL query to search any Salesforce object (e.g. Contact, Lead, Opportunity, Account). Returns matching records.", + "input_schema": { + "type": "object", + "properties": { + "soql": { + "type": "string", + "description": "A valid SOQL query string, e.g. SELECT Id, Name, Email FROM Contact WHERE LastName = 'Smith' LIMIT 10" + } + }, + "required": ["soql"] + }, + "output_schema": { + "type": "object", + "properties": { + "result": { "type": "boolean" }, + "records": { + "type": "array", + "items": { "type": "object" }, + "description": "List of matching Salesforce records" + }, + "total_size": { + "type": "integer", + "description": "Total number of records matched" + }, + "done": { + "type": "boolean", + "description": "Whether all results have been returned" + } + } + } + }, + "get_record": { + "display_name": "Get Record", + "description": "Retrieve a single Salesforce record by its ID and object type (e.g. Contact, Lead, Opportunity).", + "input_schema": { + "type": "object", + "properties": { + "object_type": { + "type": "string", + "description": "Salesforce object type, e.g. Contact, Lead, Account, Opportunity" + }, + "record_id": { + "type": "string", + "description": "The Salesforce record ID (15 or 18 character)" + }, + "fields": { + "type": "string", + "description": "Comma-separated list of fields to return. If omitted, all fields are returned." + } + }, + "required": ["object_type", "record_id"] + }, + "output_schema": { + "type": "object", + "properties": { + "result": { "type": "boolean" }, + "record": { + "type": "object", + "description": "The Salesforce record fields" + } + } + } + }, + "update_record": { + "display_name": "Update Record", + "description": "Update one or more fields on an existing Salesforce record by ID and object type.", + "input_schema": { + "type": "object", + "properties": { + "object_type": { + "type": "string", + "description": "Salesforce object type, e.g. Contact, Lead, Account, Opportunity" + }, + "record_id": { + "type": "string", + "description": "The Salesforce record ID to update" + }, + "fields": { + "type": "object", + "description": "Key-value pairs of fields to update, e.g. {\"Phone\": \"0400000000\", \"Title\": \"Manager\"}" + } + }, + "required": ["object_type", "record_id", "fields"] + }, + "output_schema": { + "type": "object", + "properties": { + "result": { "type": "boolean" }, + "record_id": { "type": "string" }, + "object_type": { "type": "string" } + } + } + }, + "list_tasks": { + "display_name": "List Tasks", + "description": "List Salesforce Task records with optional filters for status, due date, and assigned user.", + "input_schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "description": "Filter by task status, e.g. Not Started, In Progress, Completed, Waiting on someone else, Deferred" + }, + "assigned_to_id": { + "type": "string", + "description": "Filter by assigned user ID (OwnerId)" + }, + "due_date_from": { + "type": "string", + "description": "Filter tasks due on or after this date (YYYY-MM-DD)" + }, + "due_date_to": { + "type": "string", + "description": "Filter tasks due on or before this date (YYYY-MM-DD)" + }, + "limit": { + "type": "integer", + "description": "Maximum number of tasks to return (default 25, max 200)", + "default": 25 + } + }, + "required": [] + }, + "output_schema": { + "type": "object", + "properties": { + "result": { "type": "boolean" }, + "tasks": { + "type": "array", + "items": { "type": "object" }, + "description": "List of Task records" + }, + "total_size": { "type": "integer" } + } + } + }, + "list_events": { + "display_name": "List Events", + "description": "List Salesforce Event (calendar) records with optional date range filters.", + "input_schema": { + "type": "object", + "properties": { + "start_date_from": { + "type": "string", + "description": "Return events starting on or after this date (YYYY-MM-DD)" + }, + "start_date_to": { + "type": "string", + "description": "Return events starting on or before this date (YYYY-MM-DD)" + }, + "assigned_to_id": { + "type": "string", + "description": "Filter by assigned user ID (OwnerId)" + }, + "limit": { + "type": "integer", + "description": "Maximum number of events to return (default 25, max 200)", + "default": 25 + } + }, + "required": [] + }, + "output_schema": { + "type": "object", + "properties": { + "result": { "type": "boolean" }, + "events": { + "type": "array", + "items": { "type": "object" }, + "description": "List of Event records" + }, + "total_size": { "type": "integer" } + } + } + }, + "get_task_summary": { + "display_name": "Get Task Summary", + "description": "Retrieve a single Salesforce Task record by ID and return a human-readable summary of its details.", + "input_schema": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "The Salesforce Task record ID" + } + }, + "required": ["task_id"] + }, + "output_schema": { + "type": "object", + "properties": { + "result": { "type": "boolean" }, + "summary": { + "type": "string", + "description": "Human-readable summary of the task" + }, + "task": { + "type": "object", + "description": "Raw task record fields" + } + } + } + }, + "get_event_summary": { + "display_name": "Get Event Summary", + "description": "Retrieve a single Salesforce Event record by ID and return a human-readable summary of its details.", + "input_schema": { + "type": "object", + "properties": { + "event_id": { + "type": "string", + "description": "The Salesforce Event record ID" + } + }, + "required": ["event_id"] + }, + "output_schema": { + "type": "object", + "properties": { + "result": { "type": "boolean" }, + "summary": { + "type": "string", + "description": "Human-readable summary of the event" + }, + "event": { + "type": "object", + "description": "Raw event record fields" + } + } + } + } + } +} diff --git a/salesforce/icon.png b/salesforce/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..9a6cb7c433d74be8f15d5a0df6b46a8d3415ce4d GIT binary patch literal 35864 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 mT-d)N+y7JlSd|ZH5w3A*| str: + return f"{instance_url.rstrip('/')}/services/data/{API_VERSION}" + + +def _headers(token: str) -> Dict[str, str]: + return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + + +def _get_token_and_instance(context: ExecutionContext): + credentials = context.auth.get("credentials", {}) + token = credentials.get("access_token", "") + instance_url = ( + credentials.get("instance_url") + or context.metadata.get("instance_url") + or os.environ.get("SALESFORCE_INSTANCE_URL", "") + ) + if not instance_url: + raise ValueError("Salesforce instance_url not found in credentials or metadata. Please reconnect.") + return token, instance_url + + +@salesforce.action("search_records") +class SearchRecordsAction(ActionHandler): + async def execute(self, inputs: Dict[str, Any], context: ExecutionContext) -> ActionResult: + try: + token, instance_url = _get_token_and_instance(context) + response = await context.fetch( + f"{_base_url(instance_url)}/query", + method="GET", + headers=_headers(token), + params={"q": inputs["soql"]}, + ) + return ActionResult( + data={ + "result": True, + "records": response.data.get("records", []), + "total_size": response.data.get("totalSize", 0), + "done": response.data.get("done", True), + }, + cost_usd=0.0, + ) + except Exception as e: + return ActionResult(data={"result": False, "error": str(e)}, cost_usd=0.0) + + +@salesforce.action("get_record") +class GetRecordAction(ActionHandler): + async def execute(self, inputs: Dict[str, Any], context: ExecutionContext) -> ActionResult: + try: + token, instance_url = _get_token_and_instance(context) + object_type = inputs["object_type"] + record_id = inputs["record_id"] + url = f"{_base_url(instance_url)}/sobjects/{object_type}/{record_id}" + + params = {} + if inputs.get("fields"): + params["fields"] = inputs["fields"] + + response = await context.fetch(url, method="GET", headers=_headers(token), params=params) + return ActionResult(data={"result": True, "record": response.data}, cost_usd=0.0) + except Exception as e: + return ActionResult(data={"result": False, "error": str(e)}, cost_usd=0.0) + + +@salesforce.action("update_record") +class UpdateRecordAction(ActionHandler): + async def execute(self, inputs: Dict[str, Any], context: ExecutionContext) -> ActionResult: + try: + token, instance_url = _get_token_and_instance(context) + object_type = inputs["object_type"] + record_id = inputs["record_id"] + url = f"{_base_url(instance_url)}/sobjects/{object_type}/{record_id}" + + await context.fetch(url, method="PATCH", headers=_headers(token), json=inputs["fields"]) + return ActionResult( + data={ + "result": True, + "record_id": record_id, + "object_type": object_type, + }, + cost_usd=0.0, + ) + except Exception as e: + return ActionResult(data={"result": False, "error": str(e)}, cost_usd=0.0) + + +def _build_task_query( # nosec B608 + status=None, + assigned_to_id=None, + due_date_from=None, + due_date_to=None, + limit=25, +) -> str: + limit = min(int(limit), 200) + conditions = [] + if status: + safe_status = status.replace("'", "\\'") + conditions.append(f"Status = '{safe_status}'") + if assigned_to_id: + conditions.append(f"OwnerId = '{assigned_to_id}'") + if due_date_from: + conditions.append(f"ActivityDate >= {due_date_from}") + if due_date_to: + conditions.append(f"ActivityDate <= {due_date_to}") + + where = f" WHERE {' AND '.join(conditions)}" if conditions else "" + fields = ( + "Id, Subject, Status, Priority, ActivityDate, Description, " + "OwnerId, WhoId, WhatId, CreatedDate, LastModifiedDate" + ) + return f"SELECT {fields} FROM Task{where} ORDER BY ActivityDate DESC LIMIT {limit}" # nosec B608 + + +def _build_event_query( # nosec B608 + start_date_from=None, + start_date_to=None, + assigned_to_id=None, + limit=25, +) -> str: + limit = min(int(limit), 200) + conditions = [] + if start_date_from: + conditions.append(f"StartDateTime >= {start_date_from}T00:00:00Z") + if start_date_to: + conditions.append(f"StartDateTime <= {start_date_to}T23:59:59Z") + if assigned_to_id: + conditions.append(f"OwnerId = '{assigned_to_id}'") + + where = f" WHERE {' AND '.join(conditions)}" if conditions else "" + fields = ( + "Id, Subject, StartDateTime, EndDateTime, Location, Description, " + "OwnerId, WhoId, WhatId, IsAllDayEvent, CreatedDate" + ) + return f"SELECT {fields} FROM Event{where} ORDER BY StartDateTime DESC LIMIT {limit}" # nosec B608 + + +@salesforce.action("list_tasks") +class ListTasksAction(ActionHandler): + async def execute(self, inputs: Dict[str, Any], context: ExecutionContext) -> ActionResult: + try: + token, instance_url = _get_token_and_instance(context) + soql = _build_task_query( + status=inputs.get("status"), + assigned_to_id=inputs.get("assigned_to_id"), + due_date_from=inputs.get("due_date_from"), + due_date_to=inputs.get("due_date_to"), + limit=inputs.get("limit", 25), + ) + response = await context.fetch( + f"{_base_url(instance_url)}/query", + method="GET", + headers=_headers(token), + params={"q": soql}, + ) + return ActionResult( + data={ + "result": True, + "tasks": response.data.get("records", []), + "total_size": response.data.get("totalSize", 0), + }, + cost_usd=0.0, + ) + except Exception as e: + return ActionResult(data={"result": False, "error": str(e)}, cost_usd=0.0) + + +@salesforce.action("list_events") +class ListEventsAction(ActionHandler): + async def execute(self, inputs: Dict[str, Any], context: ExecutionContext) -> ActionResult: + try: + token, instance_url = _get_token_and_instance(context) + soql = _build_event_query( + start_date_from=inputs.get("start_date_from"), + start_date_to=inputs.get("start_date_to"), + assigned_to_id=inputs.get("assigned_to_id"), + limit=inputs.get("limit", 25), + ) + response = await context.fetch( + f"{_base_url(instance_url)}/query", + method="GET", + headers=_headers(token), + params={"q": soql}, + ) + return ActionResult( + data={ + "result": True, + "events": response.data.get("records", []), + "total_size": response.data.get("totalSize", 0), + }, + cost_usd=0.0, + ) + except Exception as e: + return ActionResult(data={"result": False, "error": str(e)}, cost_usd=0.0) + + +def _summarise_task(task: Dict[str, Any]) -> str: + subject = task.get("Subject") or "No subject" + status = task.get("Status") or "Unknown" + priority = task.get("Priority") or "Normal" + due = task.get("ActivityDate") or "No due date" + description = task.get("Description") or "No description" + return f"Task: {subject}\nStatus: {status} | Priority: {priority} | Due: {due}\nDescription: {description}" + + +def _summarise_event(event: Dict[str, Any]) -> str: + subject = event.get("Subject") or "No subject" + start = event.get("StartDateTime") or "Unknown start" + end = event.get("EndDateTime") or "Unknown end" + location = event.get("Location") or "No location" + description = event.get("Description") or "No description" + all_day = " (All day)" if event.get("IsAllDayEvent") else "" + return f"Event: {subject}{all_day}\nStart: {start} | End: {end} | Location: {location}\nDescription: {description}" + + +@salesforce.action("get_task_summary") +class GetTaskSummaryAction(ActionHandler): + async def execute(self, inputs: Dict[str, Any], context: ExecutionContext) -> ActionResult: + try: + token, instance_url = _get_token_and_instance(context) + task_id = inputs["task_id"] + fields = ( + "Id, Subject, Status, Priority, ActivityDate, Description, " + "OwnerId, WhoId, WhatId, CreatedDate, LastModifiedDate" + ) + soql = f"SELECT {fields} FROM Task WHERE Id = '{task_id}' LIMIT 1" # nosec B608 + response = await context.fetch( + f"{_base_url(instance_url)}/query", + method="GET", + headers=_headers(token), + params={"q": soql}, + ) + records = response.data.get("records", []) + if not records: + return ActionResult(data={"result": False, "error": "Task not found"}, cost_usd=0.0) + task = records[0] + return ActionResult( + data={"result": True, "summary": _summarise_task(task), "task": task}, + cost_usd=0.0, + ) + except Exception as e: + return ActionResult(data={"result": False, "error": str(e)}, cost_usd=0.0) + + +@salesforce.action("get_event_summary") +class GetEventSummaryAction(ActionHandler): + async def execute(self, inputs: Dict[str, Any], context: ExecutionContext) -> ActionResult: + try: + token, instance_url = _get_token_and_instance(context) + event_id = inputs["event_id"] + fields = ( + "Id, Subject, StartDateTime, EndDateTime, Location, Description, " + "OwnerId, WhoId, WhatId, IsAllDayEvent, CreatedDate" + ) + soql = f"SELECT {fields} FROM Event WHERE Id = '{event_id}' LIMIT 1" # nosec B608 + response = await context.fetch( + f"{_base_url(instance_url)}/query", + method="GET", + headers=_headers(token), + params={"q": soql}, + ) + records = response.data.get("records", []) + if not records: + return ActionResult(data={"result": False, "error": "Event not found"}, cost_usd=0.0) + event = records[0] + return ActionResult( + data={ + "result": True, + "summary": _summarise_event(event), + "event": event, + }, + cost_usd=0.0, + ) + except Exception as e: + return ActionResult(data={"result": False, "error": str(e)}, cost_usd=0.0) diff --git a/salesforce/tests/__init__.py b/salesforce/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/salesforce/tests/conftest.py b/salesforce/tests/conftest.py new file mode 100644 index 00000000..e669d95e --- /dev/null +++ b/salesforce/tests/conftest.py @@ -0,0 +1,4 @@ +import os +import sys + +sys.path.insert(0, os.path.dirname(__file__)) diff --git a/salesforce/tests/context.py b/salesforce/tests/context.py new file mode 100644 index 00000000..6b2fcba9 --- /dev/null +++ b/salesforce/tests/context.py @@ -0,0 +1,8 @@ +import os +import sys + +parent_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +deps_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "../dependencies")) +sys.path.insert(0, parent_dir) +sys.path.insert(0, deps_dir) +from salesforce import salesforce # noqa diff --git a/salesforce/tests/test_salesforce.py b/salesforce/tests/test_salesforce.py new file mode 100644 index 00000000..3ac3fde4 --- /dev/null +++ b/salesforce/tests/test_salesforce.py @@ -0,0 +1,101 @@ +""" +Integration tests for Salesforce — require real API credentials. +Run with: pytest salesforce/tests/test_salesforce.py -m integration +""" + +import asyncio +import os +import sys + +import pytest +from autohive_integrations_sdk import ExecutionContext, IntegrationResult + +from context import salesforce # noqa + +pytestmark = pytest.mark.integration + +ACCESS_TOKEN = sys.argv[1] if len(sys.argv) > 1 else os.getenv("SALESFORCE_TOKEN", "") +INSTANCE_URL = os.getenv("SALESFORCE_INSTANCE_URL", "https://login.salesforce.com") +TEST_AUTH = {"credentials": {"access_token": ACCESS_TOKEN, "instance_url": INSTANCE_URL}} + +RECORD_ID = os.getenv("SALESFORCE_RECORD_ID", "") +TASK_ID = os.getenv("SALESFORCE_TASK_ID", "") +EVENT_ID = os.getenv("SALESFORCE_EVENT_ID", "") + + +async def test_search_records(): + inputs = {"soql": "SELECT Id, Name FROM Contact LIMIT 5"} + async with ExecutionContext(auth=TEST_AUTH) as context: + result = await salesforce.execute_action("search_records", inputs, context) + assert isinstance(result, IntegrationResult) + data = result.result.data + assert data.get("result") is True + print(f"[OK] search_records: {len(data.get('records', []))} record(s)") + + +async def test_list_tasks(): + inputs = {"limit": 5} + async with ExecutionContext(auth=TEST_AUTH) as context: + result = await salesforce.execute_action("list_tasks", inputs, context) + assert isinstance(result, IntegrationResult) + data = result.result.data + assert data.get("result") is True + print(f"[OK] list_tasks: {len(data.get('tasks', []))} task(s)") + + +async def test_list_events(): + inputs = {"limit": 5} + async with ExecutionContext(auth=TEST_AUTH) as context: + result = await salesforce.execute_action("list_events", inputs, context) + assert isinstance(result, IntegrationResult) + data = result.result.data + assert data.get("result") is True + print(f"[OK] list_events: {len(data.get('events', []))} event(s)") + + +async def test_get_record(): + if not RECORD_ID: + print("[SKIP] get_record: set SALESFORCE_RECORD_ID to test") + return + inputs = {"object_type": "Contact", "record_id": RECORD_ID} + async with ExecutionContext(auth=TEST_AUTH) as context: + result = await salesforce.execute_action("get_record", inputs, context) + assert isinstance(result, IntegrationResult) + data = result.result.data + assert data.get("result") is True + print(f"[OK] get_record: {data.get('record', {}).get('Id')}") + + +async def test_get_task_summary(): + if not TASK_ID: + print("[SKIP] get_task_summary: set SALESFORCE_TASK_ID to test") + return + inputs = {"task_id": TASK_ID} + async with ExecutionContext(auth=TEST_AUTH) as context: + result = await salesforce.execute_action("get_task_summary", inputs, context) + assert isinstance(result, IntegrationResult) + data = result.result.data + assert data.get("result") is True + print(f"[OK] get_task_summary:\n{data.get('summary')}") + + +async def test_get_event_summary(): + if not EVENT_ID: + print("[SKIP] get_event_summary: set SALESFORCE_EVENT_ID to test") + return + inputs = {"event_id": EVENT_ID} + async with ExecutionContext(auth=TEST_AUTH) as context: + result = await salesforce.execute_action("get_event_summary", inputs, context) + assert isinstance(result, IntegrationResult) + data = result.result.data + assert data.get("result") is True + print(f"[OK] get_event_summary:\n{data.get('summary')}") + + +if __name__ == "__main__": + asyncio.run(test_search_records()) + asyncio.run(test_list_tasks()) + asyncio.run(test_list_events()) + asyncio.run(test_get_record()) + asyncio.run(test_get_task_summary()) + asyncio.run(test_get_event_summary()) diff --git a/salesforce/tests/test_salesforce_unit.py b/salesforce/tests/test_salesforce_unit.py new file mode 100644 index 00000000..33501dbf --- /dev/null +++ b/salesforce/tests/test_salesforce_unit.py @@ -0,0 +1,549 @@ +""" +Unit tests for Salesforce integration. + +All tests are fully mocked — no real API credentials required. +Covers all 7 action handlers plus helper functions. +""" + +import json +import os +import sys + +import pytest +from unittest.mock import AsyncMock, MagicMock + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) +sys.path.insert(0, os.path.abspath(os.path.dirname(__file__))) + +from autohive_integrations_sdk import FetchResponse # noqa: E402 + +from salesforce.salesforce import ( # noqa: E402 + SearchRecordsAction, + GetRecordAction, + UpdateRecordAction, + ListTasksAction, + ListEventsAction, + GetTaskSummaryAction, + GetEventSummaryAction, + _build_task_query, + _build_event_query, + _summarise_task, + _summarise_event, + salesforce as salesforce_integration, +) + +pytestmark = pytest.mark.unit + +CONFIG_PATH = os.path.join(os.path.dirname(__file__), "..", "config.json") + +TEST_TOKEN = "test_access_token" # nosec B105 +TEST_INSTANCE = "https://test.salesforce.com" +TEST_AUTH = {"credentials": {"access_token": TEST_TOKEN, "instance_url": TEST_INSTANCE}} + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def make_fetch_response(data: dict) -> MagicMock: + resp = MagicMock(spec=FetchResponse) + resp.data = data + return resp + + +@pytest.fixture +def mock_context(): + ctx = MagicMock(name="ExecutionContext") + ctx.fetch = AsyncMock(name="fetch") + ctx.auth = TEST_AUTH + return ctx + + +# --------------------------------------------------------------------------- +# Config validation +# --------------------------------------------------------------------------- + + +class TestConfigValidation: + def test_actions_match_handlers(self): + with open(CONFIG_PATH) as f: + config = json.load(f) + + defined = set(config.get("actions", {}).keys()) + registered = set(salesforce_integration._action_handlers.keys()) + + missing = defined - registered + extra = registered - defined + + assert not missing, f"Missing handlers: {missing}" + assert not extra, f"Extra handlers without config: {extra}" + + def test_auth_type_is_platform(self): + with open(CONFIG_PATH) as f: + config = json.load(f) + assert config["auth"]["type"] == "platform" + assert config["auth"]["provider"] == "salesforce" + + def test_all_actions_have_output_schema(self): + with open(CONFIG_PATH) as f: + config = json.load(f) + for name, action in config["actions"].items(): + assert "output_schema" in action, f"Action '{name}' missing output_schema" + + def test_all_actions_have_result_in_output(self): + with open(CONFIG_PATH) as f: + config = json.load(f) + for name, action in config["actions"].items(): + props = action.get("output_schema", {}).get("properties", {}) + assert "result" in props, f"Action '{name}' output_schema missing 'result' field" + + +# --------------------------------------------------------------------------- +# Helper function tests +# --------------------------------------------------------------------------- + + +class TestBuildTaskQuery: + def test_no_filters(self): + q = _build_task_query() + assert "FROM Task" in q + assert "WHERE" not in q + assert "LIMIT 25" in q + + def test_status_filter(self): + q = _build_task_query(status="Completed") + assert "Status = 'Completed'" in q + assert "WHERE" in q + + def test_status_escapes_single_quote(self): + q = _build_task_query(status="Won't do") + assert "Won\\'t do" in q + + def test_assigned_to_filter(self): + q = _build_task_query(assigned_to_id="005XXXX") + assert "OwnerId = '005XXXX'" in q + + def test_due_date_range(self): + q = _build_task_query(due_date_from="2026-01-01", due_date_to="2026-12-31") + assert "ActivityDate >= 2026-01-01" in q + assert "ActivityDate <= 2026-12-31" in q + + def test_limit_capped_at_200(self): + q = _build_task_query(limit=999) + assert "LIMIT 200" in q + + def test_custom_limit(self): + q = _build_task_query(limit=10) + assert "LIMIT 10" in q + + def test_multiple_conditions_use_and(self): + q = _build_task_query(status="Open", assigned_to_id="005XXX") + assert " AND " in q + + def test_required_fields_in_select(self): + q = _build_task_query() + for field in ["Id", "Subject", "Status", "Priority", "ActivityDate", "Description"]: + assert field in q + + +class TestBuildEventQuery: + def test_no_filters(self): + q = _build_event_query() + assert "FROM Event" in q + assert "WHERE" not in q + assert "LIMIT 25" in q + + def test_start_date_range(self): + q = _build_event_query(start_date_from="2026-01-01", start_date_to="2026-01-31") + assert "StartDateTime >= 2026-01-01T00:00:00Z" in q + assert "StartDateTime <= 2026-01-31T23:59:59Z" in q + + def test_assigned_to_filter(self): + q = _build_event_query(assigned_to_id="005XXX") + assert "OwnerId = '005XXX'" in q + + def test_limit_capped_at_200(self): + q = _build_event_query(limit=500) + assert "LIMIT 200" in q + + def test_required_fields_in_select(self): + q = _build_event_query() + for field in ["Id", "Subject", "StartDateTime", "EndDateTime", "Location", "Description"]: + assert field in q + + +class TestSummariseTask: + def test_full_task(self): + task = { + "Subject": "Follow up call", + "Status": "Not Started", + "Priority": "High", + "ActivityDate": "2026-05-01", + "Description": "Call the client to follow up on the proposal.", + } + summary = _summarise_task(task) + assert "Follow up call" in summary + assert "Not Started" in summary + assert "High" in summary + assert "2026-05-01" in summary + assert "Call the client" in summary + + def test_missing_fields_use_defaults(self): + summary = _summarise_task({}) + assert "No subject" in summary + assert "Unknown" in summary + assert "No due date" in summary + assert "No description" in summary + + +class TestSummariseEvent: + def test_full_event(self): + event = { + "Subject": "Quarterly review", + "StartDateTime": "2026-05-01T09:00:00Z", + "EndDateTime": "2026-05-01T10:00:00Z", + "Location": "Board Room", + "Description": "Q1 results discussion.", + "IsAllDayEvent": False, + } + summary = _summarise_event(event) + assert "Quarterly review" in summary + assert "2026-05-01T09:00:00Z" in summary + assert "Board Room" in summary + assert "Q1 results" in summary + assert "(All day)" not in summary + + def test_all_day_event_label(self): + event = {"Subject": "Holiday", "IsAllDayEvent": True} + summary = _summarise_event(event) + assert "(All day)" in summary + + def test_missing_fields_use_defaults(self): + summary = _summarise_event({}) + assert "No subject" in summary + assert "No location" in summary + assert "No description" in summary + + +# --------------------------------------------------------------------------- +# Action handler tests +# --------------------------------------------------------------------------- + + +class TestSearchRecordsAction: + async def test_success(self, mock_context): + mock_context.fetch.return_value = make_fetch_response( + {"records": [{"Id": "003XX", "Name": "Jane Doe"}], "totalSize": 1, "done": True} + ) + handler = SearchRecordsAction() + result = await handler.execute({"soql": "SELECT Id, Name FROM Contact LIMIT 1"}, mock_context) + + assert result.data["result"] is True + assert len(result.data["records"]) == 1 + assert result.data["records"][0]["Name"] == "Jane Doe" + assert result.data["total_size"] == 1 + assert result.data["done"] is True + + async def test_passes_soql_as_query_param(self, mock_context): + mock_context.fetch.return_value = make_fetch_response({"records": [], "totalSize": 0, "done": True}) + handler = SearchRecordsAction() + soql = "SELECT Id FROM Lead LIMIT 5" + await handler.execute({"soql": soql}, mock_context) + + call_kwargs = mock_context.fetch.call_args + assert call_kwargs.kwargs["params"]["q"] == soql + + async def test_uses_bearer_auth_header(self, mock_context): + mock_context.fetch.return_value = make_fetch_response({"records": [], "totalSize": 0, "done": True}) + handler = SearchRecordsAction() + await handler.execute({"soql": "SELECT Id FROM Contact"}, mock_context) + + headers = mock_context.fetch.call_args.kwargs["headers"] + assert headers["Authorization"] == f"Bearer {TEST_TOKEN}" + + async def test_error_returns_false(self, mock_context): + mock_context.fetch.side_effect = Exception("API error") + handler = SearchRecordsAction() + result = await handler.execute({"soql": "SELECT Id FROM Contact"}, mock_context) + + assert result.data["result"] is False + assert "API error" in result.data["error"] + + async def test_empty_results(self, mock_context): + mock_context.fetch.return_value = make_fetch_response({"records": [], "totalSize": 0, "done": True}) + handler = SearchRecordsAction() + result = await handler.execute({"soql": "SELECT Id FROM Contact WHERE Name = 'Nobody'"}, mock_context) + + assert result.data["result"] is True + assert result.data["records"] == [] + assert result.data["total_size"] == 0 + + +class TestGetRecordAction: + async def test_success(self, mock_context): + record = {"Id": "003XX", "Name": "Jane Doe", "Email": "jane@example.com"} + mock_context.fetch.return_value = make_fetch_response(record) + handler = GetRecordAction() + result = await handler.execute({"object_type": "Contact", "record_id": "003XX"}, mock_context) + + assert result.data["result"] is True + assert result.data["record"]["Name"] == "Jane Doe" + + async def test_url_contains_object_type_and_id(self, mock_context): + mock_context.fetch.return_value = make_fetch_response({"Id": "003XX"}) + handler = GetRecordAction() + await handler.execute({"object_type": "Contact", "record_id": "003XX"}, mock_context) + + url = mock_context.fetch.call_args.args[0] + assert "/sobjects/Contact/003XX" in url + + async def test_fields_param_passed_when_provided(self, mock_context): + mock_context.fetch.return_value = make_fetch_response({"Id": "003XX", "Name": "Jane"}) + handler = GetRecordAction() + await handler.execute({"object_type": "Contact", "record_id": "003XX", "fields": "Id,Name"}, mock_context) + + params = mock_context.fetch.call_args.kwargs["params"] + assert params["fields"] == "Id,Name" + + async def test_no_fields_param_when_not_provided(self, mock_context): + mock_context.fetch.return_value = make_fetch_response({"Id": "003XX"}) + handler = GetRecordAction() + await handler.execute({"object_type": "Contact", "record_id": "003XX"}, mock_context) + + params = mock_context.fetch.call_args.kwargs.get("params", {}) + assert "fields" not in params + + async def test_error_returns_false(self, mock_context): + mock_context.fetch.side_effect = Exception("Not found") + handler = GetRecordAction() + result = await handler.execute({"object_type": "Contact", "record_id": "BAD"}, mock_context) + + assert result.data["result"] is False + assert "Not found" in result.data["error"] + + +class TestUpdateRecordAction: + async def test_success(self, mock_context): + mock_context.fetch.return_value = make_fetch_response({}) + handler = UpdateRecordAction() + result = await handler.execute( + {"object_type": "Contact", "record_id": "003XX", "fields": {"Phone": "0400000000"}}, + mock_context, + ) + + assert result.data["result"] is True + assert result.data["record_id"] == "003XX" + assert result.data["object_type"] == "Contact" + + async def test_uses_patch_method(self, mock_context): + mock_context.fetch.return_value = make_fetch_response({}) + handler = UpdateRecordAction() + await handler.execute( + {"object_type": "Lead", "record_id": "00QXX", "fields": {"Title": "Manager"}}, + mock_context, + ) + + assert mock_context.fetch.call_args.kwargs["method"] == "PATCH" + + async def test_fields_sent_as_json_body(self, mock_context): + mock_context.fetch.return_value = make_fetch_response({}) + handler = UpdateRecordAction() + fields = {"Phone": "0400000000", "Title": "Director"} + await handler.execute({"object_type": "Contact", "record_id": "003XX", "fields": fields}, mock_context) + + assert mock_context.fetch.call_args.kwargs["json"] == fields + + async def test_error_returns_false(self, mock_context): + mock_context.fetch.side_effect = Exception("Forbidden") + handler = UpdateRecordAction() + result = await handler.execute( + {"object_type": "Contact", "record_id": "003XX", "fields": {"Name": "X"}}, mock_context + ) + + assert result.data["result"] is False + + +class TestListTasksAction: + async def test_success_no_filters(self, mock_context): + tasks = [{"Id": "00TXX", "Subject": "Call client", "Status": "Not Started"}] + mock_context.fetch.return_value = make_fetch_response({"records": tasks, "totalSize": 1}) + handler = ListTasksAction() + result = await handler.execute({}, mock_context) + + assert result.data["result"] is True + assert len(result.data["tasks"]) == 1 + assert result.data["total_size"] == 1 + + async def test_status_filter_applied(self, mock_context): + mock_context.fetch.return_value = make_fetch_response({"records": [], "totalSize": 0}) + handler = ListTasksAction() + await handler.execute({"status": "Completed"}, mock_context) + + soql = mock_context.fetch.call_args.kwargs["params"]["q"] + assert "Status = 'Completed'" in soql + + async def test_date_filter_applied(self, mock_context): + mock_context.fetch.return_value = make_fetch_response({"records": [], "totalSize": 0}) + handler = ListTasksAction() + await handler.execute({"due_date_from": "2026-01-01", "due_date_to": "2026-06-30"}, mock_context) + + soql = mock_context.fetch.call_args.kwargs["params"]["q"] + assert "ActivityDate >= 2026-01-01" in soql + assert "ActivityDate <= 2026-06-30" in soql + + async def test_default_limit_is_25(self, mock_context): + mock_context.fetch.return_value = make_fetch_response({"records": [], "totalSize": 0}) + handler = ListTasksAction() + await handler.execute({}, mock_context) + + soql = mock_context.fetch.call_args.kwargs["params"]["q"] + assert "LIMIT 25" in soql + + async def test_custom_limit(self, mock_context): + mock_context.fetch.return_value = make_fetch_response({"records": [], "totalSize": 0}) + handler = ListTasksAction() + await handler.execute({"limit": 50}, mock_context) + + soql = mock_context.fetch.call_args.kwargs["params"]["q"] + assert "LIMIT 50" in soql + + async def test_error_returns_false(self, mock_context): + mock_context.fetch.side_effect = Exception("timeout") + handler = ListTasksAction() + result = await handler.execute({}, mock_context) + + assert result.data["result"] is False + + +class TestListEventsAction: + async def test_success_no_filters(self, mock_context): + events = [{"Id": "00UXX", "Subject": "Client meeting", "StartDateTime": "2026-05-01T09:00:00Z"}] + mock_context.fetch.return_value = make_fetch_response({"records": events, "totalSize": 1}) + handler = ListEventsAction() + result = await handler.execute({}, mock_context) + + assert result.data["result"] is True + assert len(result.data["events"]) == 1 + assert result.data["total_size"] == 1 + + async def test_date_filter_applied(self, mock_context): + mock_context.fetch.return_value = make_fetch_response({"records": [], "totalSize": 0}) + handler = ListEventsAction() + await handler.execute({"start_date_from": "2026-05-01", "start_date_to": "2026-05-31"}, mock_context) + + soql = mock_context.fetch.call_args.kwargs["params"]["q"] + assert "StartDateTime >= 2026-05-01T00:00:00Z" in soql + assert "StartDateTime <= 2026-05-31T23:59:59Z" in soql + + async def test_default_limit_is_25(self, mock_context): + mock_context.fetch.return_value = make_fetch_response({"records": [], "totalSize": 0}) + handler = ListEventsAction() + await handler.execute({}, mock_context) + + soql = mock_context.fetch.call_args.kwargs["params"]["q"] + assert "LIMIT 25" in soql + + async def test_error_returns_false(self, mock_context): + mock_context.fetch.side_effect = Exception("network error") + handler = ListEventsAction() + result = await handler.execute({}, mock_context) + + assert result.data["result"] is False + + +class TestGetTaskSummaryAction: + async def test_success(self, mock_context): + task = { + "Id": "00TXX", + "Subject": "Follow up", + "Status": "In Progress", + "Priority": "High", + "ActivityDate": "2026-05-10", + "Description": "Check on contract status.", + } + mock_context.fetch.return_value = make_fetch_response({"records": [task], "totalSize": 1}) + handler = GetTaskSummaryAction() + result = await handler.execute({"task_id": "00TXX"}, mock_context) + + assert result.data["result"] is True + assert "Follow up" in result.data["summary"] + assert "In Progress" in result.data["summary"] + assert result.data["task"]["Id"] == "00TXX" + + async def test_task_not_found(self, mock_context): + mock_context.fetch.return_value = make_fetch_response({"records": [], "totalSize": 0}) + handler = GetTaskSummaryAction() + result = await handler.execute({"task_id": "00TBAD"}, mock_context) + + assert result.data["result"] is False + assert "not found" in result.data["error"].lower() + + async def test_soql_filters_by_task_id(self, mock_context): + mock_context.fetch.return_value = make_fetch_response({"records": [], "totalSize": 0}) + handler = GetTaskSummaryAction() + await handler.execute({"task_id": "00TXX123"}, mock_context) + + soql = mock_context.fetch.call_args.kwargs["params"]["q"] + assert "00TXX123" in soql + assert "FROM Task" in soql + + async def test_error_returns_false(self, mock_context): + mock_context.fetch.side_effect = Exception("API error") + handler = GetTaskSummaryAction() + result = await handler.execute({"task_id": "00TXX"}, mock_context) + + assert result.data["result"] is False + + +class TestGetEventSummaryAction: + async def test_success(self, mock_context): + event = { + "Id": "00UXX", + "Subject": "Board meeting", + "StartDateTime": "2026-06-01T09:00:00Z", + "EndDateTime": "2026-06-01T11:00:00Z", + "Location": "HQ", + "Description": "Annual board review.", + "IsAllDayEvent": False, + } + mock_context.fetch.return_value = make_fetch_response({"records": [event], "totalSize": 1}) + handler = GetEventSummaryAction() + result = await handler.execute({"event_id": "00UXX"}, mock_context) + + assert result.data["result"] is True + assert "Board meeting" in result.data["summary"] + assert "HQ" in result.data["summary"] + assert result.data["event"]["Id"] == "00UXX" + + async def test_event_not_found(self, mock_context): + mock_context.fetch.return_value = make_fetch_response({"records": [], "totalSize": 0}) + handler = GetEventSummaryAction() + result = await handler.execute({"event_id": "00UBAD"}, mock_context) + + assert result.data["result"] is False + assert "not found" in result.data["error"].lower() + + async def test_all_day_event_in_summary(self, mock_context): + event = {"Id": "00UXX", "Subject": "Public Holiday", "IsAllDayEvent": True} + mock_context.fetch.return_value = make_fetch_response({"records": [event], "totalSize": 1}) + handler = GetEventSummaryAction() + result = await handler.execute({"event_id": "00UXX"}, mock_context) + + assert "(All day)" in result.data["summary"] + + async def test_soql_filters_by_event_id(self, mock_context): + mock_context.fetch.return_value = make_fetch_response({"records": [], "totalSize": 0}) + handler = GetEventSummaryAction() + await handler.execute({"event_id": "00UABC"}, mock_context) + + soql = mock_context.fetch.call_args.kwargs["params"]["q"] + assert "00UABC" in soql + assert "FROM Event" in soql + + async def test_error_returns_false(self, mock_context): + mock_context.fetch.side_effect = Exception("timeout") + handler = GetEventSummaryAction() + result = await handler.execute({"event_id": "00UXX"}, mock_context) + + assert result.data["result"] is False From 6e1018e87be8071c471ebf6b3e6e674b2713f842 Mon Sep 17 00:00:00 2001 From: Shubhank <72601061+Sagsgit@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:44:39 +1200 Subject: [PATCH 02/12] =?UTF-8?q?test(salesforce):=20apply=20SDK=20skill?= =?UTF-8?q?=20test=20patterns=20=E2=80=94=20importlib=20loader,=20FetchRes?= =?UTF-8?q?ponse,=20live=5Fcontext?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrote test_salesforce_unit.py using importlib.util.spec_from_file_location loader and FetchResponse(status, headers, data) mocks per writing-unit-tests skill - Added test_salesforce_integration.py with live_context Variant 3 (platform OAuth with real aiohttp), require_*_id() skip helpers, and @pytest.mark.destructive guard - Removed old test_salesforce.py (asyncio.run style, superseded) - All 56 unit tests pass; ruff + bandit clean --- salesforce/tests/test_salesforce.py | 101 ---- .../tests/test_salesforce_integration.py | 191 +++++++ salesforce/tests/test_salesforce_unit.py | 483 ++++++++---------- 3 files changed, 411 insertions(+), 364 deletions(-) delete mode 100644 salesforce/tests/test_salesforce.py create mode 100644 salesforce/tests/test_salesforce_integration.py diff --git a/salesforce/tests/test_salesforce.py b/salesforce/tests/test_salesforce.py deleted file mode 100644 index 3ac3fde4..00000000 --- a/salesforce/tests/test_salesforce.py +++ /dev/null @@ -1,101 +0,0 @@ -""" -Integration tests for Salesforce — require real API credentials. -Run with: pytest salesforce/tests/test_salesforce.py -m integration -""" - -import asyncio -import os -import sys - -import pytest -from autohive_integrations_sdk import ExecutionContext, IntegrationResult - -from context import salesforce # noqa - -pytestmark = pytest.mark.integration - -ACCESS_TOKEN = sys.argv[1] if len(sys.argv) > 1 else os.getenv("SALESFORCE_TOKEN", "") -INSTANCE_URL = os.getenv("SALESFORCE_INSTANCE_URL", "https://login.salesforce.com") -TEST_AUTH = {"credentials": {"access_token": ACCESS_TOKEN, "instance_url": INSTANCE_URL}} - -RECORD_ID = os.getenv("SALESFORCE_RECORD_ID", "") -TASK_ID = os.getenv("SALESFORCE_TASK_ID", "") -EVENT_ID = os.getenv("SALESFORCE_EVENT_ID", "") - - -async def test_search_records(): - inputs = {"soql": "SELECT Id, Name FROM Contact LIMIT 5"} - async with ExecutionContext(auth=TEST_AUTH) as context: - result = await salesforce.execute_action("search_records", inputs, context) - assert isinstance(result, IntegrationResult) - data = result.result.data - assert data.get("result") is True - print(f"[OK] search_records: {len(data.get('records', []))} record(s)") - - -async def test_list_tasks(): - inputs = {"limit": 5} - async with ExecutionContext(auth=TEST_AUTH) as context: - result = await salesforce.execute_action("list_tasks", inputs, context) - assert isinstance(result, IntegrationResult) - data = result.result.data - assert data.get("result") is True - print(f"[OK] list_tasks: {len(data.get('tasks', []))} task(s)") - - -async def test_list_events(): - inputs = {"limit": 5} - async with ExecutionContext(auth=TEST_AUTH) as context: - result = await salesforce.execute_action("list_events", inputs, context) - assert isinstance(result, IntegrationResult) - data = result.result.data - assert data.get("result") is True - print(f"[OK] list_events: {len(data.get('events', []))} event(s)") - - -async def test_get_record(): - if not RECORD_ID: - print("[SKIP] get_record: set SALESFORCE_RECORD_ID to test") - return - inputs = {"object_type": "Contact", "record_id": RECORD_ID} - async with ExecutionContext(auth=TEST_AUTH) as context: - result = await salesforce.execute_action("get_record", inputs, context) - assert isinstance(result, IntegrationResult) - data = result.result.data - assert data.get("result") is True - print(f"[OK] get_record: {data.get('record', {}).get('Id')}") - - -async def test_get_task_summary(): - if not TASK_ID: - print("[SKIP] get_task_summary: set SALESFORCE_TASK_ID to test") - return - inputs = {"task_id": TASK_ID} - async with ExecutionContext(auth=TEST_AUTH) as context: - result = await salesforce.execute_action("get_task_summary", inputs, context) - assert isinstance(result, IntegrationResult) - data = result.result.data - assert data.get("result") is True - print(f"[OK] get_task_summary:\n{data.get('summary')}") - - -async def test_get_event_summary(): - if not EVENT_ID: - print("[SKIP] get_event_summary: set SALESFORCE_EVENT_ID to test") - return - inputs = {"event_id": EVENT_ID} - async with ExecutionContext(auth=TEST_AUTH) as context: - result = await salesforce.execute_action("get_event_summary", inputs, context) - assert isinstance(result, IntegrationResult) - data = result.result.data - assert data.get("result") is True - print(f"[OK] get_event_summary:\n{data.get('summary')}") - - -if __name__ == "__main__": - asyncio.run(test_search_records()) - asyncio.run(test_list_tasks()) - asyncio.run(test_list_events()) - asyncio.run(test_get_record()) - asyncio.run(test_get_task_summary()) - asyncio.run(test_get_event_summary()) diff --git a/salesforce/tests/test_salesforce_integration.py b/salesforce/tests/test_salesforce_integration.py new file mode 100644 index 00000000..1f0bee18 --- /dev/null +++ b/salesforce/tests/test_salesforce_integration.py @@ -0,0 +1,191 @@ +""" +End-to-end integration tests for the Salesforce integration. + +These tests call the real Salesforce API and require a valid access token +and instance URL set via environment variables (via .env or export). + +Run with: + pytest salesforce/tests/test_salesforce_integration.py -m integration + +Never runs in CI — the default pytest marker filter (-m unit) excludes these, +and the file naming (test_*_integration.py) is not matched by python_files. +""" + +import os +import sys +import importlib + +_parent = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +_deps = os.path.abspath(os.path.join(os.path.dirname(__file__), "../dependencies")) +sys.path.insert(0, _parent) +sys.path.insert(0, _deps) + +import pytest # noqa: E402 +from unittest.mock import MagicMock, AsyncMock # noqa: E402 +from autohive_integrations_sdk import FetchResponse # noqa: E402 + +_spec = importlib.util.spec_from_file_location("salesforce_mod", os.path.join(_parent, "salesforce.py")) +_mod = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_mod) + +salesforce = _mod.salesforce + +pytestmark = pytest.mark.integration + +ACCESS_TOKEN = os.environ.get("SALESFORCE_TOKEN", "") +INSTANCE_URL = os.environ.get("SALESFORCE_INSTANCE_URL", "") +RECORD_ID = os.environ.get("SALESFORCE_RECORD_ID", "") +TASK_ID = os.environ.get("SALESFORCE_TASK_ID", "") +EVENT_ID = os.environ.get("SALESFORCE_EVENT_ID", "") + + +def require_record_id(): + if not RECORD_ID: + pytest.skip("SALESFORCE_RECORD_ID not set") + + +def require_task_id(): + if not TASK_ID: + pytest.skip("SALESFORCE_TASK_ID not set") + + +def require_event_id(): + if not EVENT_ID: + pytest.skip("SALESFORCE_EVENT_ID not set") + + +@pytest.fixture +def live_context(): + if not ACCESS_TOKEN: + pytest.skip("SALESFORCE_TOKEN not set — skipping integration tests") + if not INSTANCE_URL: + pytest.skip("SALESFORCE_INSTANCE_URL not set — skipping integration tests") + + import aiohttp + + async def real_fetch(url, *, method="GET", json=None, headers=None, params=None, **kwargs): + merged_headers = dict(headers or {}) + merged_headers["Authorization"] = f"Bearer {ACCESS_TOKEN}" + async with aiohttp.ClientSession() as session: + async with session.request(method, url, json=json, headers=merged_headers, params=params) as resp: + data = await resp.json(content_type=None) + return FetchResponse(status=resp.status, headers=dict(resp.headers), data=data) + + ctx = MagicMock(name="ExecutionContext") + ctx.fetch = AsyncMock(side_effect=real_fetch) + ctx.auth = { + "auth_type": "PlatformOauth2", + "credentials": {"access_token": ACCESS_TOKEN}, # nosec B105 + } + ctx.metadata = {"instance_url": INSTANCE_URL} + return ctx + + +# ---- Read-Only Tests ---- + + +class TestSearchRecords: + async def test_search_contacts(self, live_context): + result = await salesforce.execute_action( + "search_records", {"soql": "SELECT Id, Name FROM Contact LIMIT 5"}, live_context + ) + data = result.result.data + assert data["result"] is True + assert "records" in data + assert isinstance(data["records"], list) + + async def test_search_returns_total_size(self, live_context): + result = await salesforce.execute_action( + "search_records", {"soql": "SELECT Id FROM Contact LIMIT 1"}, live_context + ) + assert "total_size" in result.result.data + assert isinstance(result.result.data["total_size"], int) + + +class TestGetRecord: + async def test_get_contact_by_id(self, live_context): + require_record_id() + result = await salesforce.execute_action( + "get_record", {"object_type": "Contact", "record_id": RECORD_ID}, live_context + ) + data = result.result.data + assert data["result"] is True + assert "record" in data + assert data["record"]["Id"] == RECORD_ID + + async def test_get_record_with_fields(self, live_context): + require_record_id() + result = await salesforce.execute_action( + "get_record", + {"object_type": "Contact", "record_id": RECORD_ID, "fields": "Id,Name"}, + live_context, + ) + data = result.result.data + assert data["result"] is True + assert "Id" in data["record"] + + +class TestListTasks: + async def test_list_tasks_no_filters(self, live_context): + result = await salesforce.execute_action("list_tasks", {"limit": 5}, live_context) + data = result.result.data + assert data["result"] is True + assert "tasks" in data + assert len(data["tasks"]) <= 5 + + async def test_list_tasks_with_status_filter(self, live_context): + result = await salesforce.execute_action("list_tasks", {"status": "Not Started", "limit": 5}, live_context) + data = result.result.data + assert data["result"] is True + for task in data["tasks"]: + assert task["Status"] == "Not Started" + + +class TestListEvents: + async def test_list_events_no_filters(self, live_context): + result = await salesforce.execute_action("list_events", {"limit": 5}, live_context) + data = result.result.data + assert data["result"] is True + assert "events" in data + assert len(data["events"]) <= 5 + + +class TestGetTaskSummary: + async def test_get_task_summary(self, live_context): + require_task_id() + result = await salesforce.execute_action("get_task_summary", {"task_id": TASK_ID}, live_context) + data = result.result.data + assert data["result"] is True + assert "summary" in data + assert "task" in data + assert isinstance(data["summary"], str) + assert len(data["summary"]) > 0 + + +class TestGetEventSummary: + async def test_get_event_summary(self, live_context): + require_event_id() + result = await salesforce.execute_action("get_event_summary", {"event_id": EVENT_ID}, live_context) + data = result.result.data + assert data["result"] is True + assert "summary" in data + assert "event" in data + assert isinstance(data["summary"], str) + + +# ---- Destructive Tests (Write Operations) ---- +# These update real data. Only run with: pytest -m "integration and destructive" + + +@pytest.mark.destructive +class TestUpdateRecord: + async def test_update_contact_field(self, live_context): + require_record_id() + result = await salesforce.execute_action( + "update_record", + {"object_type": "Contact", "record_id": RECORD_ID, "fields": {"Description": "Updated by Autohive test"}}, + live_context, + ) + data = result.result.data + assert data["result"] is True + assert data["record_id"] == RECORD_ID diff --git a/salesforce/tests/test_salesforce_unit.py b/salesforce/tests/test_salesforce_unit.py index 33501dbf..7513cb86 100644 --- a/salesforce/tests/test_salesforce_unit.py +++ b/salesforce/tests/test_salesforce_unit.py @@ -1,83 +1,59 @@ -""" -Unit tests for Salesforce integration. - -All tests are fully mocked — no real API credentials required. -Covers all 7 action handlers plus helper functions. -""" - -import json import os import sys +import importlib -import pytest -from unittest.mock import AsyncMock, MagicMock - -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) -sys.path.insert(0, os.path.abspath(os.path.dirname(__file__))) +_parent = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +_deps = os.path.abspath(os.path.join(os.path.dirname(__file__), "../dependencies")) +sys.path.insert(0, _parent) +sys.path.insert(0, _deps) +import pytest # noqa: E402 +from unittest.mock import AsyncMock, MagicMock # noqa: E402 from autohive_integrations_sdk import FetchResponse # noqa: E402 -from salesforce.salesforce import ( # noqa: E402 - SearchRecordsAction, - GetRecordAction, - UpdateRecordAction, - ListTasksAction, - ListEventsAction, - GetTaskSummaryAction, - GetEventSummaryAction, - _build_task_query, - _build_event_query, - _summarise_task, - _summarise_event, - salesforce as salesforce_integration, -) +_spec = importlib.util.spec_from_file_location("salesforce_mod", os.path.join(_parent, "salesforce.py")) +_mod = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_mod) + +salesforce = _mod.salesforce +_build_task_query = _mod._build_task_query +_build_event_query = _mod._build_event_query +_summarise_task = _mod._summarise_task +_summarise_event = _mod._summarise_event pytestmark = pytest.mark.unit -CONFIG_PATH = os.path.join(os.path.dirname(__file__), "..", "config.json") +import json # noqa: E402 + +CONFIG_PATH = os.path.join(_parent, "config.json") TEST_TOKEN = "test_access_token" # nosec B105 TEST_INSTANCE = "https://test.salesforce.com" -TEST_AUTH = {"credentials": {"access_token": TEST_TOKEN, "instance_url": TEST_INSTANCE}} - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def make_fetch_response(data: dict) -> MagicMock: - resp = MagicMock(spec=FetchResponse) - resp.data = data - return resp @pytest.fixture def mock_context(): ctx = MagicMock(name="ExecutionContext") ctx.fetch = AsyncMock(name="fetch") - ctx.auth = TEST_AUTH + ctx.auth = { + "auth_type": "PlatformOauth2", + "credentials": {"access_token": TEST_TOKEN}, # nosec B105 + } + ctx.metadata = {"instance_url": TEST_INSTANCE} return ctx -# --------------------------------------------------------------------------- -# Config validation -# --------------------------------------------------------------------------- +# ---- Config Validation ---- class TestConfigValidation: def test_actions_match_handlers(self): with open(CONFIG_PATH) as f: config = json.load(f) - defined = set(config.get("actions", {}).keys()) - registered = set(salesforce_integration._action_handlers.keys()) - - missing = defined - registered - extra = registered - defined - - assert not missing, f"Missing handlers: {missing}" - assert not extra, f"Extra handlers without config: {extra}" + registered = set(salesforce._action_handlers.keys()) + assert not (defined - registered), f"Missing handlers: {defined - registered}" + assert not (registered - defined), f"Extra handlers: {registered - defined}" def test_auth_type_is_platform(self): with open(CONFIG_PATH) as f: @@ -96,12 +72,10 @@ def test_all_actions_have_result_in_output(self): config = json.load(f) for name, action in config["actions"].items(): props = action.get("output_schema", {}).get("properties", {}) - assert "result" in props, f"Action '{name}' output_schema missing 'result' field" + assert "result" in props, f"Action '{name}' output_schema missing 'result'" -# --------------------------------------------------------------------------- -# Helper function tests -# --------------------------------------------------------------------------- +# ---- Helper: _build_task_query ---- class TestBuildTaskQuery: @@ -147,6 +121,9 @@ def test_required_fields_in_select(self): assert field in q +# ---- Helper: _build_event_query ---- + + class TestBuildEventQuery: def test_no_filters(self): q = _build_event_query() @@ -173,6 +150,9 @@ def test_required_fields_in_select(self): assert field in q +# ---- Helper: _summarise_task ---- + + class TestSummariseTask: def test_full_task(self): task = { @@ -180,7 +160,7 @@ def test_full_task(self): "Status": "Not Started", "Priority": "High", "ActivityDate": "2026-05-01", - "Description": "Call the client to follow up on the proposal.", + "Description": "Call the client.", } summary = _summarise_task(task) assert "Follow up call" in summary @@ -197,6 +177,9 @@ def test_missing_fields_use_defaults(self): assert "No description" in summary +# ---- Helper: _summarise_event ---- + + class TestSummariseEvent: def test_full_event(self): event = { @@ -204,19 +187,16 @@ def test_full_event(self): "StartDateTime": "2026-05-01T09:00:00Z", "EndDateTime": "2026-05-01T10:00:00Z", "Location": "Board Room", - "Description": "Q1 results discussion.", + "Description": "Q1 results.", "IsAllDayEvent": False, } summary = _summarise_event(event) assert "Quarterly review" in summary - assert "2026-05-01T09:00:00Z" in summary assert "Board Room" in summary - assert "Q1 results" in summary assert "(All day)" not in summary def test_all_day_event_label(self): - event = {"Subject": "Holiday", "IsAllDayEvent": True} - summary = _summarise_event(event) + summary = _summarise_event({"Subject": "Holiday", "IsAllDayEvent": True}) assert "(All day)" in summary def test_missing_fields_use_defaults(self): @@ -226,324 +206,301 @@ def test_missing_fields_use_defaults(self): assert "No description" in summary -# --------------------------------------------------------------------------- -# Action handler tests -# --------------------------------------------------------------------------- +# ---- search_records ---- -class TestSearchRecordsAction: - async def test_success(self, mock_context): - mock_context.fetch.return_value = make_fetch_response( - {"records": [{"Id": "003XX", "Name": "Jane Doe"}], "totalSize": 1, "done": True} +class TestSearchRecords: + async def test_returns_records(self, mock_context): + mock_context.fetch.return_value = FetchResponse( + status=200, + headers={}, + data={"records": [{"Id": "003XX", "Name": "Jane Doe"}], "totalSize": 1, "done": True}, ) - handler = SearchRecordsAction() - result = await handler.execute({"soql": "SELECT Id, Name FROM Contact LIMIT 1"}, mock_context) - - assert result.data["result"] is True - assert len(result.data["records"]) == 1 - assert result.data["records"][0]["Name"] == "Jane Doe" - assert result.data["total_size"] == 1 - assert result.data["done"] is True + result = await salesforce.execute_action( + "search_records", {"soql": "SELECT Id, Name FROM Contact LIMIT 1"}, mock_context + ) + assert result.result.data["result"] is True + assert len(result.result.data["records"]) == 1 + assert result.result.data["total_size"] == 1 async def test_passes_soql_as_query_param(self, mock_context): - mock_context.fetch.return_value = make_fetch_response({"records": [], "totalSize": 0, "done": True}) - handler = SearchRecordsAction() + mock_context.fetch.return_value = FetchResponse( + status=200, headers={}, data={"records": [], "totalSize": 0, "done": True} + ) soql = "SELECT Id FROM Lead LIMIT 5" - await handler.execute({"soql": soql}, mock_context) - - call_kwargs = mock_context.fetch.call_args - assert call_kwargs.kwargs["params"]["q"] == soql + await salesforce.execute_action("search_records", {"soql": soql}, mock_context) + assert mock_context.fetch.call_args.kwargs["params"]["q"] == soql async def test_uses_bearer_auth_header(self, mock_context): - mock_context.fetch.return_value = make_fetch_response({"records": [], "totalSize": 0, "done": True}) - handler = SearchRecordsAction() - await handler.execute({"soql": "SELECT Id FROM Contact"}, mock_context) - + mock_context.fetch.return_value = FetchResponse( + status=200, headers={}, data={"records": [], "totalSize": 0, "done": True} + ) + await salesforce.execute_action("search_records", {"soql": "SELECT Id FROM Contact"}, mock_context) headers = mock_context.fetch.call_args.kwargs["headers"] assert headers["Authorization"] == f"Bearer {TEST_TOKEN}" async def test_error_returns_false(self, mock_context): mock_context.fetch.side_effect = Exception("API error") - handler = SearchRecordsAction() - result = await handler.execute({"soql": "SELECT Id FROM Contact"}, mock_context) - - assert result.data["result"] is False - assert "API error" in result.data["error"] + result = await salesforce.execute_action("search_records", {"soql": "SELECT Id FROM Contact"}, mock_context) + assert result.result.data["result"] is False + assert "API error" in result.result.data["error"] async def test_empty_results(self, mock_context): - mock_context.fetch.return_value = make_fetch_response({"records": [], "totalSize": 0, "done": True}) - handler = SearchRecordsAction() - result = await handler.execute({"soql": "SELECT Id FROM Contact WHERE Name = 'Nobody'"}, mock_context) + mock_context.fetch.return_value = FetchResponse( + status=200, headers={}, data={"records": [], "totalSize": 0, "done": True} + ) + result = await salesforce.execute_action( + "search_records", {"soql": "SELECT Id FROM Contact WHERE Name = 'Nobody'"}, mock_context + ) + assert result.result.data["result"] is True + assert result.result.data["records"] == [] - assert result.data["result"] is True - assert result.data["records"] == [] - assert result.data["total_size"] == 0 +# ---- get_record ---- -class TestGetRecordAction: - async def test_success(self, mock_context): - record = {"Id": "003XX", "Name": "Jane Doe", "Email": "jane@example.com"} - mock_context.fetch.return_value = make_fetch_response(record) - handler = GetRecordAction() - result = await handler.execute({"object_type": "Contact", "record_id": "003XX"}, mock_context) - assert result.data["result"] is True - assert result.data["record"]["Name"] == "Jane Doe" +class TestGetRecord: + async def test_returns_record(self, mock_context): + mock_context.fetch.return_value = FetchResponse( + status=200, headers={}, data={"Id": "003XX", "Name": "Jane Doe", "Email": "jane@example.com"} + ) + result = await salesforce.execute_action( + "get_record", {"object_type": "Contact", "record_id": "003XX"}, mock_context + ) + assert result.result.data["result"] is True + assert result.result.data["record"]["Name"] == "Jane Doe" async def test_url_contains_object_type_and_id(self, mock_context): - mock_context.fetch.return_value = make_fetch_response({"Id": "003XX"}) - handler = GetRecordAction() - await handler.execute({"object_type": "Contact", "record_id": "003XX"}, mock_context) - - url = mock_context.fetch.call_args.args[0] - assert "/sobjects/Contact/003XX" in url + mock_context.fetch.return_value = FetchResponse(status=200, headers={}, data={"Id": "003XX"}) + await salesforce.execute_action("get_record", {"object_type": "Contact", "record_id": "003XX"}, mock_context) + assert "/sobjects/Contact/003XX" in mock_context.fetch.call_args.args[0] async def test_fields_param_passed_when_provided(self, mock_context): - mock_context.fetch.return_value = make_fetch_response({"Id": "003XX", "Name": "Jane"}) - handler = GetRecordAction() - await handler.execute({"object_type": "Contact", "record_id": "003XX", "fields": "Id,Name"}, mock_context) - - params = mock_context.fetch.call_args.kwargs["params"] - assert params["fields"] == "Id,Name" + mock_context.fetch.return_value = FetchResponse(status=200, headers={}, data={"Id": "003XX", "Name": "Jane"}) + await salesforce.execute_action( + "get_record", + {"object_type": "Contact", "record_id": "003XX", "fields": "Id,Name"}, + mock_context, + ) + assert mock_context.fetch.call_args.kwargs["params"]["fields"] == "Id,Name" async def test_no_fields_param_when_not_provided(self, mock_context): - mock_context.fetch.return_value = make_fetch_response({"Id": "003XX"}) - handler = GetRecordAction() - await handler.execute({"object_type": "Contact", "record_id": "003XX"}, mock_context) - - params = mock_context.fetch.call_args.kwargs.get("params", {}) - assert "fields" not in params + mock_context.fetch.return_value = FetchResponse(status=200, headers={}, data={"Id": "003XX"}) + await salesforce.execute_action("get_record", {"object_type": "Contact", "record_id": "003XX"}, mock_context) + assert "fields" not in mock_context.fetch.call_args.kwargs.get("params", {}) async def test_error_returns_false(self, mock_context): mock_context.fetch.side_effect = Exception("Not found") - handler = GetRecordAction() - result = await handler.execute({"object_type": "Contact", "record_id": "BAD"}, mock_context) + result = await salesforce.execute_action( + "get_record", {"object_type": "Contact", "record_id": "BAD"}, mock_context + ) + assert result.result.data["result"] is False - assert result.data["result"] is False - assert "Not found" in result.data["error"] +# ---- update_record ---- -class TestUpdateRecordAction: + +class TestUpdateRecord: async def test_success(self, mock_context): - mock_context.fetch.return_value = make_fetch_response({}) - handler = UpdateRecordAction() - result = await handler.execute( + mock_context.fetch.return_value = FetchResponse(status=204, headers={}, data=None) + result = await salesforce.execute_action( + "update_record", {"object_type": "Contact", "record_id": "003XX", "fields": {"Phone": "0400000000"}}, mock_context, ) - - assert result.data["result"] is True - assert result.data["record_id"] == "003XX" - assert result.data["object_type"] == "Contact" + assert result.result.data["result"] is True + assert result.result.data["record_id"] == "003XX" + assert result.result.data["object_type"] == "Contact" async def test_uses_patch_method(self, mock_context): - mock_context.fetch.return_value = make_fetch_response({}) - handler = UpdateRecordAction() - await handler.execute( + mock_context.fetch.return_value = FetchResponse(status=204, headers={}, data=None) + await salesforce.execute_action( + "update_record", {"object_type": "Lead", "record_id": "00QXX", "fields": {"Title": "Manager"}}, mock_context, ) - assert mock_context.fetch.call_args.kwargs["method"] == "PATCH" async def test_fields_sent_as_json_body(self, mock_context): - mock_context.fetch.return_value = make_fetch_response({}) - handler = UpdateRecordAction() + mock_context.fetch.return_value = FetchResponse(status=204, headers={}, data=None) fields = {"Phone": "0400000000", "Title": "Director"} - await handler.execute({"object_type": "Contact", "record_id": "003XX", "fields": fields}, mock_context) - + await salesforce.execute_action( + "update_record", {"object_type": "Contact", "record_id": "003XX", "fields": fields}, mock_context + ) assert mock_context.fetch.call_args.kwargs["json"] == fields async def test_error_returns_false(self, mock_context): mock_context.fetch.side_effect = Exception("Forbidden") - handler = UpdateRecordAction() - result = await handler.execute( - {"object_type": "Contact", "record_id": "003XX", "fields": {"Name": "X"}}, mock_context + result = await salesforce.execute_action( + "update_record", + {"object_type": "Contact", "record_id": "003XX", "fields": {"Name": "X"}}, + mock_context, ) - - assert result.data["result"] is False + assert result.result.data["result"] is False -class TestListTasksAction: - async def test_success_no_filters(self, mock_context): - tasks = [{"Id": "00TXX", "Subject": "Call client", "Status": "Not Started"}] - mock_context.fetch.return_value = make_fetch_response({"records": tasks, "totalSize": 1}) - handler = ListTasksAction() - result = await handler.execute({}, mock_context) +# ---- list_tasks ---- - assert result.data["result"] is True - assert len(result.data["tasks"]) == 1 - assert result.data["total_size"] == 1 - - async def test_status_filter_applied(self, mock_context): - mock_context.fetch.return_value = make_fetch_response({"records": [], "totalSize": 0}) - handler = ListTasksAction() - await handler.execute({"status": "Completed"}, mock_context) +class TestListTasks: + async def test_returns_tasks(self, mock_context): + tasks = [{"Id": "00TXX", "Subject": "Call client", "Status": "Not Started"}] + mock_context.fetch.return_value = FetchResponse(status=200, headers={}, data={"records": tasks, "totalSize": 1}) + result = await salesforce.execute_action("list_tasks", {}, mock_context) + assert result.result.data["result"] is True + assert len(result.result.data["tasks"]) == 1 + assert result.result.data["total_size"] == 1 + + async def test_status_filter_in_soql(self, mock_context): + mock_context.fetch.return_value = FetchResponse(status=200, headers={}, data={"records": [], "totalSize": 0}) + await salesforce.execute_action("list_tasks", {"status": "Completed"}, mock_context) soql = mock_context.fetch.call_args.kwargs["params"]["q"] assert "Status = 'Completed'" in soql - async def test_date_filter_applied(self, mock_context): - mock_context.fetch.return_value = make_fetch_response({"records": [], "totalSize": 0}) - handler = ListTasksAction() - await handler.execute({"due_date_from": "2026-01-01", "due_date_to": "2026-06-30"}, mock_context) - + async def test_date_filter_in_soql(self, mock_context): + mock_context.fetch.return_value = FetchResponse(status=200, headers={}, data={"records": [], "totalSize": 0}) + await salesforce.execute_action( + "list_tasks", {"due_date_from": "2026-01-01", "due_date_to": "2026-06-30"}, mock_context + ) soql = mock_context.fetch.call_args.kwargs["params"]["q"] assert "ActivityDate >= 2026-01-01" in soql assert "ActivityDate <= 2026-06-30" in soql - async def test_default_limit_is_25(self, mock_context): - mock_context.fetch.return_value = make_fetch_response({"records": [], "totalSize": 0}) - handler = ListTasksAction() - await handler.execute({}, mock_context) - - soql = mock_context.fetch.call_args.kwargs["params"]["q"] - assert "LIMIT 25" in soql + async def test_default_limit_25(self, mock_context): + mock_context.fetch.return_value = FetchResponse(status=200, headers={}, data={"records": [], "totalSize": 0}) + await salesforce.execute_action("list_tasks", {}, mock_context) + assert "LIMIT 25" in mock_context.fetch.call_args.kwargs["params"]["q"] async def test_custom_limit(self, mock_context): - mock_context.fetch.return_value = make_fetch_response({"records": [], "totalSize": 0}) - handler = ListTasksAction() - await handler.execute({"limit": 50}, mock_context) - - soql = mock_context.fetch.call_args.kwargs["params"]["q"] - assert "LIMIT 50" in soql + mock_context.fetch.return_value = FetchResponse(status=200, headers={}, data={"records": [], "totalSize": 0}) + await salesforce.execute_action("list_tasks", {"limit": 50}, mock_context) + assert "LIMIT 50" in mock_context.fetch.call_args.kwargs["params"]["q"] async def test_error_returns_false(self, mock_context): mock_context.fetch.side_effect = Exception("timeout") - handler = ListTasksAction() - result = await handler.execute({}, mock_context) - - assert result.data["result"] is False + result = await salesforce.execute_action("list_tasks", {}, mock_context) + assert result.result.data["result"] is False -class TestListEventsAction: - async def test_success_no_filters(self, mock_context): - events = [{"Id": "00UXX", "Subject": "Client meeting", "StartDateTime": "2026-05-01T09:00:00Z"}] - mock_context.fetch.return_value = make_fetch_response({"records": events, "totalSize": 1}) - handler = ListEventsAction() - result = await handler.execute({}, mock_context) +# ---- list_events ---- - assert result.data["result"] is True - assert len(result.data["events"]) == 1 - assert result.data["total_size"] == 1 - - async def test_date_filter_applied(self, mock_context): - mock_context.fetch.return_value = make_fetch_response({"records": [], "totalSize": 0}) - handler = ListEventsAction() - await handler.execute({"start_date_from": "2026-05-01", "start_date_to": "2026-05-31"}, mock_context) +class TestListEvents: + async def test_returns_events(self, mock_context): + events = [{"Id": "00UXX", "Subject": "Client meeting"}] + mock_context.fetch.return_value = FetchResponse( + status=200, headers={}, data={"records": events, "totalSize": 1} + ) + result = await salesforce.execute_action("list_events", {}, mock_context) + assert result.result.data["result"] is True + assert len(result.result.data["events"]) == 1 + + async def test_date_filter_in_soql(self, mock_context): + mock_context.fetch.return_value = FetchResponse(status=200, headers={}, data={"records": [], "totalSize": 0}) + await salesforce.execute_action( + "list_events", {"start_date_from": "2026-05-01", "start_date_to": "2026-05-31"}, mock_context + ) soql = mock_context.fetch.call_args.kwargs["params"]["q"] assert "StartDateTime >= 2026-05-01T00:00:00Z" in soql assert "StartDateTime <= 2026-05-31T23:59:59Z" in soql - async def test_default_limit_is_25(self, mock_context): - mock_context.fetch.return_value = make_fetch_response({"records": [], "totalSize": 0}) - handler = ListEventsAction() - await handler.execute({}, mock_context) - - soql = mock_context.fetch.call_args.kwargs["params"]["q"] - assert "LIMIT 25" in soql + async def test_default_limit_25(self, mock_context): + mock_context.fetch.return_value = FetchResponse(status=200, headers={}, data={"records": [], "totalSize": 0}) + await salesforce.execute_action("list_events", {}, mock_context) + assert "LIMIT 25" in mock_context.fetch.call_args.kwargs["params"]["q"] async def test_error_returns_false(self, mock_context): mock_context.fetch.side_effect = Exception("network error") - handler = ListEventsAction() - result = await handler.execute({}, mock_context) + result = await salesforce.execute_action("list_events", {}, mock_context) + assert result.result.data["result"] is False - assert result.data["result"] is False +# ---- get_task_summary ---- -class TestGetTaskSummaryAction: - async def test_success(self, mock_context): + +class TestGetTaskSummary: + async def test_returns_summary(self, mock_context): task = { "Id": "00TXX", "Subject": "Follow up", "Status": "In Progress", "Priority": "High", "ActivityDate": "2026-05-10", - "Description": "Check on contract status.", + "Description": "Check contract.", } - mock_context.fetch.return_value = make_fetch_response({"records": [task], "totalSize": 1}) - handler = GetTaskSummaryAction() - result = await handler.execute({"task_id": "00TXX"}, mock_context) - - assert result.data["result"] is True - assert "Follow up" in result.data["summary"] - assert "In Progress" in result.data["summary"] - assert result.data["task"]["Id"] == "00TXX" + mock_context.fetch.return_value = FetchResponse( + status=200, headers={}, data={"records": [task], "totalSize": 1} + ) + result = await salesforce.execute_action("get_task_summary", {"task_id": "00TXX"}, mock_context) + assert result.result.data["result"] is True + assert "Follow up" in result.result.data["summary"] + assert "In Progress" in result.result.data["summary"] + assert result.result.data["task"]["Id"] == "00TXX" async def test_task_not_found(self, mock_context): - mock_context.fetch.return_value = make_fetch_response({"records": [], "totalSize": 0}) - handler = GetTaskSummaryAction() - result = await handler.execute({"task_id": "00TBAD"}, mock_context) - - assert result.data["result"] is False - assert "not found" in result.data["error"].lower() + mock_context.fetch.return_value = FetchResponse(status=200, headers={}, data={"records": [], "totalSize": 0}) + result = await salesforce.execute_action("get_task_summary", {"task_id": "BAD"}, mock_context) + assert result.result.data["result"] is False + assert "not found" in result.result.data["error"].lower() async def test_soql_filters_by_task_id(self, mock_context): - mock_context.fetch.return_value = make_fetch_response({"records": [], "totalSize": 0}) - handler = GetTaskSummaryAction() - await handler.execute({"task_id": "00TXX123"}, mock_context) - + mock_context.fetch.return_value = FetchResponse(status=200, headers={}, data={"records": [], "totalSize": 0}) + await salesforce.execute_action("get_task_summary", {"task_id": "00TXX123"}, mock_context) soql = mock_context.fetch.call_args.kwargs["params"]["q"] assert "00TXX123" in soql assert "FROM Task" in soql async def test_error_returns_false(self, mock_context): mock_context.fetch.side_effect = Exception("API error") - handler = GetTaskSummaryAction() - result = await handler.execute({"task_id": "00TXX"}, mock_context) + result = await salesforce.execute_action("get_task_summary", {"task_id": "00TXX"}, mock_context) + assert result.result.data["result"] is False - assert result.data["result"] is False +# ---- get_event_summary ---- -class TestGetEventSummaryAction: - async def test_success(self, mock_context): + +class TestGetEventSummary: + async def test_returns_summary(self, mock_context): event = { "Id": "00UXX", "Subject": "Board meeting", "StartDateTime": "2026-06-01T09:00:00Z", "EndDateTime": "2026-06-01T11:00:00Z", "Location": "HQ", - "Description": "Annual board review.", + "Description": "Annual review.", "IsAllDayEvent": False, } - mock_context.fetch.return_value = make_fetch_response({"records": [event], "totalSize": 1}) - handler = GetEventSummaryAction() - result = await handler.execute({"event_id": "00UXX"}, mock_context) - - assert result.data["result"] is True - assert "Board meeting" in result.data["summary"] - assert "HQ" in result.data["summary"] - assert result.data["event"]["Id"] == "00UXX" + mock_context.fetch.return_value = FetchResponse( + status=200, headers={}, data={"records": [event], "totalSize": 1} + ) + result = await salesforce.execute_action("get_event_summary", {"event_id": "00UXX"}, mock_context) + assert result.result.data["result"] is True + assert "Board meeting" in result.result.data["summary"] + assert "HQ" in result.result.data["summary"] + assert result.result.data["event"]["Id"] == "00UXX" async def test_event_not_found(self, mock_context): - mock_context.fetch.return_value = make_fetch_response({"records": [], "totalSize": 0}) - handler = GetEventSummaryAction() - result = await handler.execute({"event_id": "00UBAD"}, mock_context) - - assert result.data["result"] is False - assert "not found" in result.data["error"].lower() + mock_context.fetch.return_value = FetchResponse(status=200, headers={}, data={"records": [], "totalSize": 0}) + result = await salesforce.execute_action("get_event_summary", {"event_id": "BAD"}, mock_context) + assert result.result.data["result"] is False + assert "not found" in result.result.data["error"].lower() async def test_all_day_event_in_summary(self, mock_context): event = {"Id": "00UXX", "Subject": "Public Holiday", "IsAllDayEvent": True} - mock_context.fetch.return_value = make_fetch_response({"records": [event], "totalSize": 1}) - handler = GetEventSummaryAction() - result = await handler.execute({"event_id": "00UXX"}, mock_context) - - assert "(All day)" in result.data["summary"] + mock_context.fetch.return_value = FetchResponse( + status=200, headers={}, data={"records": [event], "totalSize": 1} + ) + result = await salesforce.execute_action("get_event_summary", {"event_id": "00UXX"}, mock_context) + assert "(All day)" in result.result.data["summary"] async def test_soql_filters_by_event_id(self, mock_context): - mock_context.fetch.return_value = make_fetch_response({"records": [], "totalSize": 0}) - handler = GetEventSummaryAction() - await handler.execute({"event_id": "00UABC"}, mock_context) - + mock_context.fetch.return_value = FetchResponse(status=200, headers={}, data={"records": [], "totalSize": 0}) + await salesforce.execute_action("get_event_summary", {"event_id": "00UABC"}, mock_context) soql = mock_context.fetch.call_args.kwargs["params"]["q"] assert "00UABC" in soql assert "FROM Event" in soql async def test_error_returns_false(self, mock_context): mock_context.fetch.side_effect = Exception("timeout") - handler = GetEventSummaryAction() - result = await handler.execute({"event_id": "00UXX"}, mock_context) - - assert result.data["result"] is False + result = await salesforce.execute_action("get_event_summary", {"event_id": "00UXX"}, mock_context) + assert result.result.data["result"] is False From cd259edd4cd5d4f05845887721ec6857661033f4 Mon Sep 17 00:00:00 2001 From: Shubhank <72601061+Sagsgit@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:46:25 +1200 Subject: [PATCH 03/12] test(salesforce): align integration test env vars with repo conventions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename SALESFORCE_TOKEN → SALESFORCE_ACCESS_TOKEN to match _ACCESS_TOKEN pattern - Rename SALESFORCE_RECORD/TASK/EVENT_ID → SALESFORCE_TEST_*_ID to match TEST_ prefix pattern - Expand docstring with full env var reference - Add Salesforce block to .env.example (SALESFORCE_ACCESS_TOKEN, SALESFORCE_INSTANCE_URL, TEST_RECORD/TASK/EVENT_ID) --- .env.example | 7 +++++ .../tests/test_salesforce_integration.py | 28 +++++++++++++------ 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/.env.example b/.env.example index 52b37f8d..6564b880 100644 --- a/.env.example +++ b/.env.example @@ -50,5 +50,12 @@ # HUBSPOT_TEST_NOTE_ID= # HUBSPOT_TEST_OWNER_ID= +# -- Salesforce -- +# SALESFORCE_ACCESS_TOKEN= +# SALESFORCE_INSTANCE_URL= +# SALESFORCE_TEST_RECORD_ID= +# SALESFORCE_TEST_TASK_ID= +# SALESFORCE_TEST_EVENT_ID= + # -- Xero -- # (uses platform OAuth — tokens are short-lived, typically not set here) diff --git a/salesforce/tests/test_salesforce_integration.py b/salesforce/tests/test_salesforce_integration.py index 1f0bee18..69cbab85 100644 --- a/salesforce/tests/test_salesforce_integration.py +++ b/salesforce/tests/test_salesforce_integration.py @@ -2,7 +2,17 @@ End-to-end integration tests for the Salesforce integration. These tests call the real Salesforce API and require a valid access token -and instance URL set via environment variables (via .env or export). +set in SALESFORCE_ACCESS_TOKEN and instance URL in SALESFORCE_INSTANCE_URL +(via .env or export). + +Required env vars: + SALESFORCE_ACCESS_TOKEN — OAuth access token from a connected Salesforce org + SALESFORCE_INSTANCE_URL — e.g. https://yourorg.my.salesforce.com + +Optional env vars (skip tests that need real object IDs when not set): + SALESFORCE_TEST_RECORD_ID — ID of an existing Contact record + SALESFORCE_TEST_TASK_ID — ID of an existing Task record + SALESFORCE_TEST_EVENT_ID — ID of an existing Event record Run with: pytest salesforce/tests/test_salesforce_integration.py -m integration @@ -32,32 +42,32 @@ pytestmark = pytest.mark.integration -ACCESS_TOKEN = os.environ.get("SALESFORCE_TOKEN", "") +ACCESS_TOKEN = os.environ.get("SALESFORCE_ACCESS_TOKEN", "") INSTANCE_URL = os.environ.get("SALESFORCE_INSTANCE_URL", "") -RECORD_ID = os.environ.get("SALESFORCE_RECORD_ID", "") -TASK_ID = os.environ.get("SALESFORCE_TASK_ID", "") -EVENT_ID = os.environ.get("SALESFORCE_EVENT_ID", "") +RECORD_ID = os.environ.get("SALESFORCE_TEST_RECORD_ID", "") +TASK_ID = os.environ.get("SALESFORCE_TEST_TASK_ID", "") +EVENT_ID = os.environ.get("SALESFORCE_TEST_EVENT_ID", "") def require_record_id(): if not RECORD_ID: - pytest.skip("SALESFORCE_RECORD_ID not set") + pytest.skip("SALESFORCE_TEST_RECORD_ID not set") def require_task_id(): if not TASK_ID: - pytest.skip("SALESFORCE_TASK_ID not set") + pytest.skip("SALESFORCE_TEST_TASK_ID not set") def require_event_id(): if not EVENT_ID: - pytest.skip("SALESFORCE_EVENT_ID not set") + pytest.skip("SALESFORCE_TEST_EVENT_ID not set") @pytest.fixture def live_context(): if not ACCESS_TOKEN: - pytest.skip("SALESFORCE_TOKEN not set — skipping integration tests") + pytest.skip("SALESFORCE_ACCESS_TOKEN not set — skipping integration tests") if not INSTANCE_URL: pytest.skip("SALESFORCE_INSTANCE_URL not set — skipping integration tests") From eecd33e88e24a846726e98e45bea430f616e1184 Mon Sep 17 00:00:00 2001 From: Shubhank <72601061+Sagsgit@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:53:18 +1200 Subject: [PATCH 04/12] fix(salesforce): validate Salesforce IDs before SOQL/URL interpolation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add _validate_sf_id() that enforces 15/18-char alphanumeric format on record_id, task_id, and event_id before they are interpolated into SOQL WHERE clauses or sobject URL paths — addresses SOQL injection risk flagged by code review on get_task_summary and get_event_summary. - Add TestValidateSfId unit tests (accepts 15/18-char, rejects short/special/empty) - Update existing test fixtures to use valid 15-char IDs (e.g. 003000000000001) - 64 unit tests passing --- salesforce/salesforce.py | 19 +++- salesforce/tests/test_salesforce_unit.py | 115 ++++++++++++++++------- 2 files changed, 96 insertions(+), 38 deletions(-) diff --git a/salesforce/salesforce.py b/salesforce/salesforce.py index 70b9a9c5..444526a4 100644 --- a/salesforce/salesforce.py +++ b/salesforce/salesforce.py @@ -1,3 +1,4 @@ +import re from autohive_integrations_sdk import ( Integration, ExecutionContext, @@ -7,6 +8,16 @@ from typing import Any, Dict import os +_SF_ID_RE = re.compile(r"^[a-zA-Z0-9]{15}([a-zA-Z0-9]{3})?$") + + +def _validate_sf_id(value: str, name: str) -> str: + """Raise ValueError if value is not a valid 15- or 18-character Salesforce ID.""" + if not _SF_ID_RE.match(value): + raise ValueError(f"Invalid Salesforce ID for {name!r}: must be 15 or 18 alphanumeric characters") + return value + + salesforce = Integration.load() API_VERSION = "v62.0" @@ -63,7 +74,7 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext) -> Ac try: token, instance_url = _get_token_and_instance(context) object_type = inputs["object_type"] - record_id = inputs["record_id"] + record_id = _validate_sf_id(inputs["record_id"], "record_id") url = f"{_base_url(instance_url)}/sobjects/{object_type}/{record_id}" params = {} @@ -82,7 +93,7 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext) -> Ac try: token, instance_url = _get_token_and_instance(context) object_type = inputs["object_type"] - record_id = inputs["record_id"] + record_id = _validate_sf_id(inputs["record_id"], "record_id") url = f"{_base_url(instance_url)}/sobjects/{object_type}/{record_id}" await context.fetch(url, method="PATCH", headers=_headers(token), json=inputs["fields"]) @@ -231,7 +242,7 @@ class GetTaskSummaryAction(ActionHandler): async def execute(self, inputs: Dict[str, Any], context: ExecutionContext) -> ActionResult: try: token, instance_url = _get_token_and_instance(context) - task_id = inputs["task_id"] + task_id = _validate_sf_id(inputs["task_id"], "task_id") fields = ( "Id, Subject, Status, Priority, ActivityDate, Description, " "OwnerId, WhoId, WhatId, CreatedDate, LastModifiedDate" @@ -260,7 +271,7 @@ class GetEventSummaryAction(ActionHandler): async def execute(self, inputs: Dict[str, Any], context: ExecutionContext) -> ActionResult: try: token, instance_url = _get_token_and_instance(context) - event_id = inputs["event_id"] + event_id = _validate_sf_id(inputs["event_id"], "event_id") fields = ( "Id, Subject, StartDateTime, EndDateTime, Location, Description, " "OwnerId, WhoId, WhatId, IsAllDayEvent, CreatedDate" diff --git a/salesforce/tests/test_salesforce_unit.py b/salesforce/tests/test_salesforce_unit.py index 7513cb86..69d10e63 100644 --- a/salesforce/tests/test_salesforce_unit.py +++ b/salesforce/tests/test_salesforce_unit.py @@ -20,6 +20,7 @@ _build_event_query = _mod._build_event_query _summarise_task = _mod._summarise_task _summarise_event = _mod._summarise_event +_validate_sf_id = _mod._validate_sf_id pytestmark = pytest.mark.unit @@ -43,6 +44,46 @@ def mock_context(): return ctx +# ---- ID Validation ---- + + +class TestValidateSfId: + def test_accepts_15_char_id(self): + assert _validate_sf_id("003000000000001", "x") == "003000000000001" + + def test_accepts_18_char_id(self): + assert _validate_sf_id("003000000000001AAA", "x") == "003000000000001AAA" + + def test_rejects_short_id(self): + with pytest.raises(ValueError, match="Invalid Salesforce ID"): + _validate_sf_id("short", "record_id") + + def test_rejects_id_with_special_chars(self): + with pytest.raises(ValueError, match="Invalid Salesforce ID"): + _validate_sf_id("003abc' OR '1'='1", "record_id") + + def test_rejects_empty_string(self): + with pytest.raises(ValueError, match="Invalid Salesforce ID"): + _validate_sf_id("", "task_id") + + async def test_get_record_rejects_bad_id(self, mock_context): + result = await salesforce.execute_action( + "get_record", {"object_type": "Contact", "record_id": "bad-id!"}, mock_context + ) + assert result.result.data["result"] is False + assert "Invalid Salesforce ID" in result.result.data["error"] + + async def test_get_task_summary_rejects_bad_id(self, mock_context): + result = await salesforce.execute_action("get_task_summary", {"task_id": "bad-id!"}, mock_context) + assert result.result.data["result"] is False + assert "Invalid Salesforce ID" in result.result.data["error"] + + async def test_get_event_summary_rejects_bad_id(self, mock_context): + result = await salesforce.execute_action("get_event_summary", {"event_id": "bad-id!"}, mock_context) + assert result.result.data["result"] is False + assert "Invalid Salesforce ID" in result.result.data["error"] + + # ---- Config Validation ---- @@ -214,7 +255,7 @@ async def test_returns_records(self, mock_context): mock_context.fetch.return_value = FetchResponse( status=200, headers={}, - data={"records": [{"Id": "003XX", "Name": "Jane Doe"}], "totalSize": 1, "done": True}, + data={"records": [{"Id": "003000000000001", "Name": "Jane Doe"}], "totalSize": 1, "done": True}, ) result = await salesforce.execute_action( "search_records", {"soql": "SELECT Id, Name FROM Contact LIMIT 1"}, mock_context @@ -262,37 +303,43 @@ async def test_empty_results(self, mock_context): class TestGetRecord: async def test_returns_record(self, mock_context): mock_context.fetch.return_value = FetchResponse( - status=200, headers={}, data={"Id": "003XX", "Name": "Jane Doe", "Email": "jane@example.com"} + status=200, headers={}, data={"Id": "003000000000001", "Name": "Jane Doe", "Email": "jane@example.com"} ) result = await salesforce.execute_action( - "get_record", {"object_type": "Contact", "record_id": "003XX"}, mock_context + "get_record", {"object_type": "Contact", "record_id": "003000000000001"}, mock_context ) assert result.result.data["result"] is True assert result.result.data["record"]["Name"] == "Jane Doe" async def test_url_contains_object_type_and_id(self, mock_context): - mock_context.fetch.return_value = FetchResponse(status=200, headers={}, data={"Id": "003XX"}) - await salesforce.execute_action("get_record", {"object_type": "Contact", "record_id": "003XX"}, mock_context) - assert "/sobjects/Contact/003XX" in mock_context.fetch.call_args.args[0] + mock_context.fetch.return_value = FetchResponse(status=200, headers={}, data={"Id": "003000000000001"}) + await salesforce.execute_action( + "get_record", {"object_type": "Contact", "record_id": "003000000000001"}, mock_context + ) + assert "/sobjects/Contact/003000000000001" in mock_context.fetch.call_args.args[0] async def test_fields_param_passed_when_provided(self, mock_context): - mock_context.fetch.return_value = FetchResponse(status=200, headers={}, data={"Id": "003XX", "Name": "Jane"}) + mock_context.fetch.return_value = FetchResponse( + status=200, headers={}, data={"Id": "003000000000001", "Name": "Jane"} + ) await salesforce.execute_action( "get_record", - {"object_type": "Contact", "record_id": "003XX", "fields": "Id,Name"}, + {"object_type": "Contact", "record_id": "003000000000001", "fields": "Id,Name"}, mock_context, ) assert mock_context.fetch.call_args.kwargs["params"]["fields"] == "Id,Name" async def test_no_fields_param_when_not_provided(self, mock_context): - mock_context.fetch.return_value = FetchResponse(status=200, headers={}, data={"Id": "003XX"}) - await salesforce.execute_action("get_record", {"object_type": "Contact", "record_id": "003XX"}, mock_context) + mock_context.fetch.return_value = FetchResponse(status=200, headers={}, data={"Id": "003000000000001"}) + await salesforce.execute_action( + "get_record", {"object_type": "Contact", "record_id": "003000000000001"}, mock_context + ) assert "fields" not in mock_context.fetch.call_args.kwargs.get("params", {}) async def test_error_returns_false(self, mock_context): mock_context.fetch.side_effect = Exception("Not found") result = await salesforce.execute_action( - "get_record", {"object_type": "Contact", "record_id": "BAD"}, mock_context + "get_record", {"object_type": "Contact", "record_id": "003000000000001"}, mock_context ) assert result.result.data["result"] is False @@ -305,18 +352,18 @@ async def test_success(self, mock_context): mock_context.fetch.return_value = FetchResponse(status=204, headers={}, data=None) result = await salesforce.execute_action( "update_record", - {"object_type": "Contact", "record_id": "003XX", "fields": {"Phone": "0400000000"}}, + {"object_type": "Contact", "record_id": "003000000000001", "fields": {"Phone": "0400000000"}}, mock_context, ) assert result.result.data["result"] is True - assert result.result.data["record_id"] == "003XX" + assert result.result.data["record_id"] == "003000000000001" assert result.result.data["object_type"] == "Contact" async def test_uses_patch_method(self, mock_context): mock_context.fetch.return_value = FetchResponse(status=204, headers={}, data=None) await salesforce.execute_action( "update_record", - {"object_type": "Lead", "record_id": "00QXX", "fields": {"Title": "Manager"}}, + {"object_type": "Lead", "record_id": "00Q000000000001", "fields": {"Title": "Manager"}}, mock_context, ) assert mock_context.fetch.call_args.kwargs["method"] == "PATCH" @@ -325,7 +372,7 @@ async def test_fields_sent_as_json_body(self, mock_context): mock_context.fetch.return_value = FetchResponse(status=204, headers={}, data=None) fields = {"Phone": "0400000000", "Title": "Director"} await salesforce.execute_action( - "update_record", {"object_type": "Contact", "record_id": "003XX", "fields": fields}, mock_context + "update_record", {"object_type": "Contact", "record_id": "003000000000001", "fields": fields}, mock_context ) assert mock_context.fetch.call_args.kwargs["json"] == fields @@ -333,7 +380,7 @@ async def test_error_returns_false(self, mock_context): mock_context.fetch.side_effect = Exception("Forbidden") result = await salesforce.execute_action( "update_record", - {"object_type": "Contact", "record_id": "003XX", "fields": {"Name": "X"}}, + {"object_type": "Contact", "record_id": "003000000000001", "fields": {"Name": "X"}}, mock_context, ) assert result.result.data["result"] is False @@ -344,7 +391,7 @@ async def test_error_returns_false(self, mock_context): class TestListTasks: async def test_returns_tasks(self, mock_context): - tasks = [{"Id": "00TXX", "Subject": "Call client", "Status": "Not Started"}] + tasks = [{"Id": "00T000000000001", "Subject": "Call client", "Status": "Not Started"}] mock_context.fetch.return_value = FetchResponse(status=200, headers={}, data={"records": tasks, "totalSize": 1}) result = await salesforce.execute_action("list_tasks", {}, mock_context) assert result.result.data["result"] is True @@ -387,7 +434,7 @@ async def test_error_returns_false(self, mock_context): class TestListEvents: async def test_returns_events(self, mock_context): - events = [{"Id": "00UXX", "Subject": "Client meeting"}] + events = [{"Id": "00U000000000001", "Subject": "Client meeting"}] mock_context.fetch.return_value = FetchResponse( status=200, headers={}, data={"records": events, "totalSize": 1} ) @@ -421,7 +468,7 @@ async def test_error_returns_false(self, mock_context): class TestGetTaskSummary: async def test_returns_summary(self, mock_context): task = { - "Id": "00TXX", + "Id": "00T000000000001", "Subject": "Follow up", "Status": "In Progress", "Priority": "High", @@ -431,28 +478,28 @@ async def test_returns_summary(self, mock_context): mock_context.fetch.return_value = FetchResponse( status=200, headers={}, data={"records": [task], "totalSize": 1} ) - result = await salesforce.execute_action("get_task_summary", {"task_id": "00TXX"}, mock_context) + result = await salesforce.execute_action("get_task_summary", {"task_id": "00T000000000001"}, mock_context) assert result.result.data["result"] is True assert "Follow up" in result.result.data["summary"] assert "In Progress" in result.result.data["summary"] - assert result.result.data["task"]["Id"] == "00TXX" + assert result.result.data["task"]["Id"] == "00T000000000001" async def test_task_not_found(self, mock_context): mock_context.fetch.return_value = FetchResponse(status=200, headers={}, data={"records": [], "totalSize": 0}) - result = await salesforce.execute_action("get_task_summary", {"task_id": "BAD"}, mock_context) + result = await salesforce.execute_action("get_task_summary", {"task_id": "00T000000000001"}, mock_context) assert result.result.data["result"] is False assert "not found" in result.result.data["error"].lower() async def test_soql_filters_by_task_id(self, mock_context): mock_context.fetch.return_value = FetchResponse(status=200, headers={}, data={"records": [], "totalSize": 0}) - await salesforce.execute_action("get_task_summary", {"task_id": "00TXX123"}, mock_context) + await salesforce.execute_action("get_task_summary", {"task_id": "00T000000000001"}, mock_context) soql = mock_context.fetch.call_args.kwargs["params"]["q"] - assert "00TXX123" in soql + assert "00T000000000001" in soql assert "FROM Task" in soql async def test_error_returns_false(self, mock_context): mock_context.fetch.side_effect = Exception("API error") - result = await salesforce.execute_action("get_task_summary", {"task_id": "00TXX"}, mock_context) + result = await salesforce.execute_action("get_task_summary", {"task_id": "00T000000000001"}, mock_context) assert result.result.data["result"] is False @@ -462,7 +509,7 @@ async def test_error_returns_false(self, mock_context): class TestGetEventSummary: async def test_returns_summary(self, mock_context): event = { - "Id": "00UXX", + "Id": "00U000000000001", "Subject": "Board meeting", "StartDateTime": "2026-06-01T09:00:00Z", "EndDateTime": "2026-06-01T11:00:00Z", @@ -473,34 +520,34 @@ async def test_returns_summary(self, mock_context): mock_context.fetch.return_value = FetchResponse( status=200, headers={}, data={"records": [event], "totalSize": 1} ) - result = await salesforce.execute_action("get_event_summary", {"event_id": "00UXX"}, mock_context) + result = await salesforce.execute_action("get_event_summary", {"event_id": "00U000000000001"}, mock_context) assert result.result.data["result"] is True assert "Board meeting" in result.result.data["summary"] assert "HQ" in result.result.data["summary"] - assert result.result.data["event"]["Id"] == "00UXX" + assert result.result.data["event"]["Id"] == "00U000000000001" async def test_event_not_found(self, mock_context): mock_context.fetch.return_value = FetchResponse(status=200, headers={}, data={"records": [], "totalSize": 0}) - result = await salesforce.execute_action("get_event_summary", {"event_id": "BAD"}, mock_context) + result = await salesforce.execute_action("get_event_summary", {"event_id": "00U000000000001"}, mock_context) assert result.result.data["result"] is False assert "not found" in result.result.data["error"].lower() async def test_all_day_event_in_summary(self, mock_context): - event = {"Id": "00UXX", "Subject": "Public Holiday", "IsAllDayEvent": True} + event = {"Id": "00U000000000001", "Subject": "Public Holiday", "IsAllDayEvent": True} mock_context.fetch.return_value = FetchResponse( status=200, headers={}, data={"records": [event], "totalSize": 1} ) - result = await salesforce.execute_action("get_event_summary", {"event_id": "00UXX"}, mock_context) + result = await salesforce.execute_action("get_event_summary", {"event_id": "00U000000000001"}, mock_context) assert "(All day)" in result.result.data["summary"] async def test_soql_filters_by_event_id(self, mock_context): mock_context.fetch.return_value = FetchResponse(status=200, headers={}, data={"records": [], "totalSize": 0}) - await salesforce.execute_action("get_event_summary", {"event_id": "00UABC"}, mock_context) + await salesforce.execute_action("get_event_summary", {"event_id": "00U000000000001"}, mock_context) soql = mock_context.fetch.call_args.kwargs["params"]["q"] - assert "00UABC" in soql + assert "00U000000000001" in soql assert "FROM Event" in soql async def test_error_returns_false(self, mock_context): mock_context.fetch.side_effect = Exception("timeout") - result = await salesforce.execute_action("get_event_summary", {"event_id": "00UXX"}, mock_context) + result = await salesforce.execute_action("get_event_summary", {"event_id": "00U000000000001"}, mock_context) assert result.result.data["result"] is False From 991915aa73966db18cc11e5bb94b7192f0773e95 Mon Sep 17 00:00:00 2001 From: Shubhank <72601061+Sagsgit@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:56:13 +1200 Subject: [PATCH 05/12] =?UTF-8?q?chore(salesforce):=20remove=20context.py?= =?UTF-8?q?=20=E2=80=94=20superseded=20by=20importlib=20loader=20in=20test?= =?UTF-8?q?=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- salesforce/tests/context.py | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 salesforce/tests/context.py diff --git a/salesforce/tests/context.py b/salesforce/tests/context.py deleted file mode 100644 index 6b2fcba9..00000000 --- a/salesforce/tests/context.py +++ /dev/null @@ -1,8 +0,0 @@ -import os -import sys - -parent_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) -deps_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "../dependencies")) -sys.path.insert(0, parent_dir) -sys.path.insert(0, deps_dir) -from salesforce import salesforce # noqa From f4d5b0d22736f0fc687fce651b82c4f4d8829575 Mon Sep 17 00:00:00 2001 From: Shubhank <72601061+Sagsgit@users.noreply.github.com> Date: Fri, 24 Apr 2026 16:12:51 +1200 Subject: [PATCH 06/12] fix(salesforce): replace ActionResult error pattern with ActionError (SDK 2.0.0) --- salesforce/salesforce.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/salesforce/salesforce.py b/salesforce/salesforce.py index 444526a4..335d34db 100644 --- a/salesforce/salesforce.py +++ b/salesforce/salesforce.py @@ -4,6 +4,7 @@ ExecutionContext, ActionHandler, ActionResult, + ActionError, ) from typing import Any, Dict import os @@ -65,7 +66,7 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext) -> Ac cost_usd=0.0, ) except Exception as e: - return ActionResult(data={"result": False, "error": str(e)}, cost_usd=0.0) + return ActionError(message=str(e)) @salesforce.action("get_record") @@ -84,7 +85,7 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext) -> Ac response = await context.fetch(url, method="GET", headers=_headers(token), params=params) return ActionResult(data={"result": True, "record": response.data}, cost_usd=0.0) except Exception as e: - return ActionResult(data={"result": False, "error": str(e)}, cost_usd=0.0) + return ActionError(message=str(e)) @salesforce.action("update_record") @@ -106,7 +107,7 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext) -> Ac cost_usd=0.0, ) except Exception as e: - return ActionResult(data={"result": False, "error": str(e)}, cost_usd=0.0) + return ActionError(message=str(e)) def _build_task_query( # nosec B608 @@ -186,7 +187,7 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext) -> Ac cost_usd=0.0, ) except Exception as e: - return ActionResult(data={"result": False, "error": str(e)}, cost_usd=0.0) + return ActionError(message=str(e)) @salesforce.action("list_events") @@ -215,7 +216,7 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext) -> Ac cost_usd=0.0, ) except Exception as e: - return ActionResult(data={"result": False, "error": str(e)}, cost_usd=0.0) + return ActionError(message=str(e)) def _summarise_task(task: Dict[str, Any]) -> str: @@ -256,14 +257,14 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext) -> Ac ) records = response.data.get("records", []) if not records: - return ActionResult(data={"result": False, "error": "Task not found"}, cost_usd=0.0) + return ActionError(message="Task not found") task = records[0] return ActionResult( data={"result": True, "summary": _summarise_task(task), "task": task}, cost_usd=0.0, ) except Exception as e: - return ActionResult(data={"result": False, "error": str(e)}, cost_usd=0.0) + return ActionError(message=str(e)) @salesforce.action("get_event_summary") @@ -285,7 +286,7 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext) -> Ac ) records = response.data.get("records", []) if not records: - return ActionResult(data={"result": False, "error": "Event not found"}, cost_usd=0.0) + return ActionError(message="Event not found") event = records[0] return ActionResult( data={ @@ -296,4 +297,4 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext) -> Ac cost_usd=0.0, ) except Exception as e: - return ActionResult(data={"result": False, "error": str(e)}, cost_usd=0.0) + return ActionError(message=str(e)) From 720658dd3bf5c2f5e7b7c1f16d1b38f16dc3a641 Mon Sep 17 00:00:00 2001 From: Shubhank <72601061+Sagsgit@users.noreply.github.com> Date: Fri, 24 Apr 2026 16:13:07 +1200 Subject: [PATCH 07/12] =?UTF-8?q?fix(salesforce):=20remove=20result=20bool?= =?UTF-8?q?ean=20from=20output=5Fschema=20=E2=80=94=20errors=20now=20use?= =?UTF-8?q?=20ActionError?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- salesforce/config.json | 62 ++++++++++++++++++++++++++++-------------- 1 file changed, 42 insertions(+), 20 deletions(-) diff --git a/salesforce/config.json b/salesforce/config.json index 465ab3a5..b7f3535a 100644 --- a/salesforce/config.json +++ b/salesforce/config.json @@ -7,7 +7,9 @@ "auth": { "type": "platform", "provider": "salesforce", - "scopes": ["api"] + "scopes": [ + "api" + ] }, "actions": { "search_records": { @@ -21,15 +23,18 @@ "description": "A valid SOQL query string, e.g. SELECT Id, Name, Email FROM Contact WHERE LastName = 'Smith' LIMIT 10" } }, - "required": ["soql"] + "required": [ + "soql" + ] }, "output_schema": { "type": "object", "properties": { - "result": { "type": "boolean" }, "records": { "type": "array", - "items": { "type": "object" }, + "items": { + "type": "object" + }, "description": "List of matching Salesforce records" }, "total_size": { @@ -62,12 +67,14 @@ "description": "Comma-separated list of fields to return. If omitted, all fields are returned." } }, - "required": ["object_type", "record_id"] + "required": [ + "object_type", + "record_id" + ] }, "output_schema": { "type": "object", "properties": { - "result": { "type": "boolean" }, "record": { "type": "object", "description": "The Salesforce record fields" @@ -94,14 +101,21 @@ "description": "Key-value pairs of fields to update, e.g. {\"Phone\": \"0400000000\", \"Title\": \"Manager\"}" } }, - "required": ["object_type", "record_id", "fields"] + "required": [ + "object_type", + "record_id", + "fields" + ] }, "output_schema": { "type": "object", "properties": { - "result": { "type": "boolean" }, - "record_id": { "type": "string" }, - "object_type": { "type": "string" } + "record_id": { + "type": "string" + }, + "object_type": { + "type": "string" + } } } }, @@ -138,13 +152,16 @@ "output_schema": { "type": "object", "properties": { - "result": { "type": "boolean" }, "tasks": { "type": "array", - "items": { "type": "object" }, + "items": { + "type": "object" + }, "description": "List of Task records" }, - "total_size": { "type": "integer" } + "total_size": { + "type": "integer" + } } } }, @@ -177,13 +194,16 @@ "output_schema": { "type": "object", "properties": { - "result": { "type": "boolean" }, "events": { "type": "array", - "items": { "type": "object" }, + "items": { + "type": "object" + }, "description": "List of Event records" }, - "total_size": { "type": "integer" } + "total_size": { + "type": "integer" + } } } }, @@ -198,12 +218,13 @@ "description": "The Salesforce Task record ID" } }, - "required": ["task_id"] + "required": [ + "task_id" + ] }, "output_schema": { "type": "object", "properties": { - "result": { "type": "boolean" }, "summary": { "type": "string", "description": "Human-readable summary of the task" @@ -226,12 +247,13 @@ "description": "The Salesforce Event record ID" } }, - "required": ["event_id"] + "required": [ + "event_id" + ] }, "output_schema": { "type": "object", "properties": { - "result": { "type": "boolean" }, "summary": { "type": "string", "description": "Human-readable summary of the event" From 44515ea5f6a45bc8eee922221650c6158e32f83a Mon Sep 17 00:00:00 2001 From: Shubhank <72601061+Sagsgit@users.noreply.github.com> Date: Fri, 24 Apr 2026 16:13:28 +1200 Subject: [PATCH 08/12] =?UTF-8?q?chore(salesforce):=20restore=20context.py?= =?UTF-8?q?=20=E2=80=94=20required=20by=20validate=5Fintegration.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- salesforce/tests/context.py | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 salesforce/tests/context.py diff --git a/salesforce/tests/context.py b/salesforce/tests/context.py new file mode 100644 index 00000000..6b2fcba9 --- /dev/null +++ b/salesforce/tests/context.py @@ -0,0 +1,8 @@ +import os +import sys + +parent_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +deps_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "../dependencies")) +sys.path.insert(0, parent_dir) +sys.path.insert(0, deps_dir) +from salesforce import salesforce # noqa From ec207efac0f409c3da975afd7f6922656c406ac9 Mon Sep 17 00:00:00 2001 From: Shubhank <72601061+Sagsgit@users.noreply.github.com> Date: Fri, 24 Apr 2026 16:14:58 +1200 Subject: [PATCH 09/12] test(salesforce): update error assertions to ResultType.ACTION_ERROR pattern --- salesforce/tests/test_salesforce_unit.py | 44 ++++++++++-------------- 1 file changed, 19 insertions(+), 25 deletions(-) diff --git a/salesforce/tests/test_salesforce_unit.py b/salesforce/tests/test_salesforce_unit.py index 69d10e63..0f9eca08 100644 --- a/salesforce/tests/test_salesforce_unit.py +++ b/salesforce/tests/test_salesforce_unit.py @@ -10,6 +10,7 @@ import pytest # noqa: E402 from unittest.mock import AsyncMock, MagicMock # noqa: E402 from autohive_integrations_sdk import FetchResponse # noqa: E402 +from autohive_integrations_sdk.integration import ResultType # noqa: E402 _spec = importlib.util.spec_from_file_location("salesforce_mod", os.path.join(_parent, "salesforce.py")) _mod = importlib.util.module_from_spec(_spec) @@ -70,18 +71,18 @@ async def test_get_record_rejects_bad_id(self, mock_context): result = await salesforce.execute_action( "get_record", {"object_type": "Contact", "record_id": "bad-id!"}, mock_context ) - assert result.result.data["result"] is False - assert "Invalid Salesforce ID" in result.result.data["error"] + assert result.type == ResultType.ACTION_ERROR + assert "Invalid Salesforce ID" in result.result.message async def test_get_task_summary_rejects_bad_id(self, mock_context): result = await salesforce.execute_action("get_task_summary", {"task_id": "bad-id!"}, mock_context) - assert result.result.data["result"] is False - assert "Invalid Salesforce ID" in result.result.data["error"] + assert result.type == ResultType.ACTION_ERROR + assert "Invalid Salesforce ID" in result.result.message async def test_get_event_summary_rejects_bad_id(self, mock_context): result = await salesforce.execute_action("get_event_summary", {"event_id": "bad-id!"}, mock_context) - assert result.result.data["result"] is False - assert "Invalid Salesforce ID" in result.result.data["error"] + assert result.type == ResultType.ACTION_ERROR + assert "Invalid Salesforce ID" in result.result.message # ---- Config Validation ---- @@ -108,13 +109,6 @@ def test_all_actions_have_output_schema(self): for name, action in config["actions"].items(): assert "output_schema" in action, f"Action '{name}' missing output_schema" - def test_all_actions_have_result_in_output(self): - with open(CONFIG_PATH) as f: - config = json.load(f) - for name, action in config["actions"].items(): - props = action.get("output_schema", {}).get("properties", {}) - assert "result" in props, f"Action '{name}' output_schema missing 'result'" - # ---- Helper: _build_task_query ---- @@ -283,8 +277,8 @@ async def test_uses_bearer_auth_header(self, mock_context): async def test_error_returns_false(self, mock_context): mock_context.fetch.side_effect = Exception("API error") result = await salesforce.execute_action("search_records", {"soql": "SELECT Id FROM Contact"}, mock_context) - assert result.result.data["result"] is False - assert "API error" in result.result.data["error"] + assert result.type == ResultType.ACTION_ERROR + assert "API error" in result.result.message async def test_empty_results(self, mock_context): mock_context.fetch.return_value = FetchResponse( @@ -341,7 +335,7 @@ async def test_error_returns_false(self, mock_context): result = await salesforce.execute_action( "get_record", {"object_type": "Contact", "record_id": "003000000000001"}, mock_context ) - assert result.result.data["result"] is False + assert result.type == ResultType.ACTION_ERROR # ---- update_record ---- @@ -383,7 +377,7 @@ async def test_error_returns_false(self, mock_context): {"object_type": "Contact", "record_id": "003000000000001", "fields": {"Name": "X"}}, mock_context, ) - assert result.result.data["result"] is False + assert result.type == ResultType.ACTION_ERROR # ---- list_tasks ---- @@ -426,7 +420,7 @@ async def test_custom_limit(self, mock_context): async def test_error_returns_false(self, mock_context): mock_context.fetch.side_effect = Exception("timeout") result = await salesforce.execute_action("list_tasks", {}, mock_context) - assert result.result.data["result"] is False + assert result.type == ResultType.ACTION_ERROR # ---- list_events ---- @@ -459,7 +453,7 @@ async def test_default_limit_25(self, mock_context): async def test_error_returns_false(self, mock_context): mock_context.fetch.side_effect = Exception("network error") result = await salesforce.execute_action("list_events", {}, mock_context) - assert result.result.data["result"] is False + assert result.type == ResultType.ACTION_ERROR # ---- get_task_summary ---- @@ -487,8 +481,8 @@ async def test_returns_summary(self, mock_context): async def test_task_not_found(self, mock_context): mock_context.fetch.return_value = FetchResponse(status=200, headers={}, data={"records": [], "totalSize": 0}) result = await salesforce.execute_action("get_task_summary", {"task_id": "00T000000000001"}, mock_context) - assert result.result.data["result"] is False - assert "not found" in result.result.data["error"].lower() + assert result.type == ResultType.ACTION_ERROR + assert "not found" in result.result.message.lower() async def test_soql_filters_by_task_id(self, mock_context): mock_context.fetch.return_value = FetchResponse(status=200, headers={}, data={"records": [], "totalSize": 0}) @@ -500,7 +494,7 @@ async def test_soql_filters_by_task_id(self, mock_context): async def test_error_returns_false(self, mock_context): mock_context.fetch.side_effect = Exception("API error") result = await salesforce.execute_action("get_task_summary", {"task_id": "00T000000000001"}, mock_context) - assert result.result.data["result"] is False + assert result.type == ResultType.ACTION_ERROR # ---- get_event_summary ---- @@ -529,8 +523,8 @@ async def test_returns_summary(self, mock_context): async def test_event_not_found(self, mock_context): mock_context.fetch.return_value = FetchResponse(status=200, headers={}, data={"records": [], "totalSize": 0}) result = await salesforce.execute_action("get_event_summary", {"event_id": "00U000000000001"}, mock_context) - assert result.result.data["result"] is False - assert "not found" in result.result.data["error"].lower() + assert result.type == ResultType.ACTION_ERROR + assert "not found" in result.result.message.lower() async def test_all_day_event_in_summary(self, mock_context): event = {"Id": "00U000000000001", "Subject": "Public Holiday", "IsAllDayEvent": True} @@ -550,4 +544,4 @@ async def test_soql_filters_by_event_id(self, mock_context): async def test_error_returns_false(self, mock_context): mock_context.fetch.side_effect = Exception("timeout") result = await salesforce.execute_action("get_event_summary", {"event_id": "00U000000000001"}, mock_context) - assert result.result.data["result"] is False + assert result.type == ResultType.ACTION_ERROR From a99f73dfcf075cd90f80cc20efb48b4b4ad82830 Mon Sep 17 00:00:00 2001 From: Shubhank <72601061+Sagsgit@users.noreply.github.com> Date: Fri, 24 Apr 2026 16:16:03 +1200 Subject: [PATCH 10/12] fix(salesforce): validate assigned_to_id with _validate_sf_id before SOQL interpolation --- salesforce/salesforce.py | 4 ++-- salesforce/tests/test_salesforce_unit.py | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/salesforce/salesforce.py b/salesforce/salesforce.py index 335d34db..02c23f4b 100644 --- a/salesforce/salesforce.py +++ b/salesforce/salesforce.py @@ -123,7 +123,7 @@ def _build_task_query( # nosec B608 safe_status = status.replace("'", "\\'") conditions.append(f"Status = '{safe_status}'") if assigned_to_id: - conditions.append(f"OwnerId = '{assigned_to_id}'") + conditions.append(f"OwnerId = '{_validate_sf_id(assigned_to_id, 'assigned_to_id')}'") if due_date_from: conditions.append(f"ActivityDate >= {due_date_from}") if due_date_to: @@ -150,7 +150,7 @@ def _build_event_query( # nosec B608 if start_date_to: conditions.append(f"StartDateTime <= {start_date_to}T23:59:59Z") if assigned_to_id: - conditions.append(f"OwnerId = '{assigned_to_id}'") + conditions.append(f"OwnerId = '{_validate_sf_id(assigned_to_id, 'assigned_to_id')}'") where = f" WHERE {' AND '.join(conditions)}" if conditions else "" fields = ( diff --git a/salesforce/tests/test_salesforce_unit.py b/salesforce/tests/test_salesforce_unit.py index 0f9eca08..9bf906cc 100644 --- a/salesforce/tests/test_salesforce_unit.py +++ b/salesforce/tests/test_salesforce_unit.py @@ -130,8 +130,8 @@ def test_status_escapes_single_quote(self): assert "Won\\'t do" in q def test_assigned_to_filter(self): - q = _build_task_query(assigned_to_id="005XXXX") - assert "OwnerId = '005XXXX'" in q + q = _build_task_query(assigned_to_id="005000000000001") + assert "OwnerId = '005000000000001'" in q def test_due_date_range(self): q = _build_task_query(due_date_from="2026-01-01", due_date_to="2026-12-31") @@ -147,7 +147,7 @@ def test_custom_limit(self): assert "LIMIT 10" in q def test_multiple_conditions_use_and(self): - q = _build_task_query(status="Open", assigned_to_id="005XXX") + q = _build_task_query(status="Open", assigned_to_id="005000000000001") assert " AND " in q def test_required_fields_in_select(self): @@ -172,8 +172,8 @@ def test_start_date_range(self): assert "StartDateTime <= 2026-01-31T23:59:59Z" in q def test_assigned_to_filter(self): - q = _build_event_query(assigned_to_id="005XXX") - assert "OwnerId = '005XXX'" in q + q = _build_event_query(assigned_to_id="005000000000001") + assert "OwnerId = '005000000000001'" in q def test_limit_capped_at_200(self): q = _build_event_query(limit=500) From 29c1c0cb2d80f6e2355653e83ed1f2b6cf9b9e3b Mon Sep 17 00:00:00 2001 From: Shubhank <72601061+Sagsgit@users.noreply.github.com> Date: Fri, 24 Apr 2026 16:16:22 +1200 Subject: [PATCH 11/12] =?UTF-8?q?docs(salesforce):=20fix=20running=20tests?= =?UTF-8?q?=20section=20=E2=80=94=20correct=20commands=20and=20env=20var?= =?UTF-8?q?=20names?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- salesforce/README.md | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/salesforce/README.md b/salesforce/README.md index f4a1b668..5cee5246 100644 --- a/salesforce/README.md +++ b/salesforce/README.md @@ -34,15 +34,26 @@ This integration uses **OAuth 2.0** via a Salesforce Connected App. ## Running Tests +Copy `.env.example` to `.env` in the repo root and fill in your credentials: + +```bash +SALESFORCE_ACCESS_TOKEN=your_access_token +SALESFORCE_INSTANCE_URL=https://yourorg.my.salesforce.com +# Optional — tests that need real object IDs will skip if unset +SALESFORCE_TEST_RECORD_ID=003XXXXXXXXXXXXXXX +SALESFORCE_TEST_TASK_ID=00TXXXXXXXXXXXXXXX +SALESFORCE_TEST_EVENT_ID=00UXXXXXXXXXXXXXXX +``` + ```bash -cd salesforce/tests -export SALESFORCE_TOKEN=your_access_token -export SALESFORCE_INSTANCE_URL=https://yourinstance.salesforce.com -# Optional: set record IDs to test get/update actions -export SALESFORCE_RECORD_ID=003XXXXXXXXXXXXXXX -export SALESFORCE_TASK_ID=00TXXXXXXXXXXXXXXX -export SALESFORCE_EVENT_ID=00UXXXXXXXXXXXXXXX -python test_salesforce.py +# Unit tests (no credentials needed) +pytest salesforce/ -v + +# Integration tests (read-only, requires .env) +pytest salesforce/tests/test_salesforce_integration.py -m "integration and not destructive" + +# Destructive integration tests (updates real data) +pytest salesforce/tests/test_salesforce_integration.py -m "integration and destructive" ``` ## Troubleshooting From 100126d862bea2d52e7bc10277099532bb2d2a54 Mon Sep 17 00:00:00 2001 From: Shubhank <72601061+Sagsgit@users.noreply.github.com> Date: Fri, 24 Apr 2026 16:27:44 +1200 Subject: [PATCH 12/12] =?UTF-8?q?chore(salesforce):=20remove=20context.py?= =?UTF-8?q?=20=E2=80=94=20no=20longer=20required=20by=20validator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- salesforce/tests/context.py | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 salesforce/tests/context.py diff --git a/salesforce/tests/context.py b/salesforce/tests/context.py deleted file mode 100644 index 6b2fcba9..00000000 --- a/salesforce/tests/context.py +++ /dev/null @@ -1,8 +0,0 @@ -import os -import sys - -parent_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) -deps_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "../dependencies")) -sys.path.insert(0, parent_dir) -sys.path.insert(0, deps_dir) -from salesforce import salesforce # noqa