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/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..5cee5246 --- /dev/null +++ b/salesforce/README.md @@ -0,0 +1,66 @@ +# 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 + +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 +# 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 + +| 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..b7f3535a --- /dev/null +++ b/salesforce/config.json @@ -0,0 +1,269 @@ +{ + "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": { + "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": { + "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": { + "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": { + "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": { + "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": { + "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": { + "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 00000000..9a6cb7c4 Binary files /dev/null and b/salesforce/icon.png differ diff --git a/salesforce/requirements.txt b/salesforce/requirements.txt new file mode 100644 index 00000000..1af9591f --- /dev/null +++ b/salesforce/requirements.txt @@ -0,0 +1 @@ +autohive-integrations-sdk~=2.0.0 diff --git a/salesforce/salesforce.py b/salesforce/salesforce.py new file mode 100644 index 00000000..02c23f4b --- /dev/null +++ b/salesforce/salesforce.py @@ -0,0 +1,300 @@ +import re +from autohive_integrations_sdk import ( + Integration, + ExecutionContext, + ActionHandler, + ActionResult, + ActionError, +) +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" + + +def _base_url(instance_url: str) -> 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 ActionError(message=str(e)) + + +@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 = _validate_sf_id(inputs["record_id"], "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 ActionError(message=str(e)) + + +@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 = _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"]) + return ActionResult( + data={ + "result": True, + "record_id": record_id, + "object_type": object_type, + }, + cost_usd=0.0, + ) + except Exception as e: + return ActionError(message=str(e)) + + +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 = '{_validate_sf_id(assigned_to_id, '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 = '{_validate_sf_id(assigned_to_id, '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 ActionError(message=str(e)) + + +@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 ActionError(message=str(e)) + + +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 = _validate_sf_id(inputs["task_id"], "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 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 ActionError(message=str(e)) + + +@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 = _validate_sf_id(inputs["event_id"], "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 ActionError(message="Event not found") + event = records[0] + return ActionResult( + data={ + "result": True, + "summary": _summarise_event(event), + "event": event, + }, + cost_usd=0.0, + ) + except Exception as e: + return ActionError(message=str(e)) 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/test_salesforce_integration.py b/salesforce/tests/test_salesforce_integration.py new file mode 100644 index 00000000..69cbab85 --- /dev/null +++ b/salesforce/tests/test_salesforce_integration.py @@ -0,0 +1,201 @@ +""" +End-to-end integration tests for the Salesforce integration. + +These tests call the real Salesforce API and require a valid access token +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 + +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_ACCESS_TOKEN", "") +INSTANCE_URL = os.environ.get("SALESFORCE_INSTANCE_URL", "") +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_TEST_RECORD_ID not set") + + +def require_task_id(): + if not TASK_ID: + pytest.skip("SALESFORCE_TEST_TASK_ID not set") + + +def require_event_id(): + if not EVENT_ID: + pytest.skip("SALESFORCE_TEST_EVENT_ID not set") + + +@pytest.fixture +def live_context(): + if not ACCESS_TOKEN: + pytest.skip("SALESFORCE_ACCESS_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 new file mode 100644 index 00000000..9bf906cc --- /dev/null +++ b/salesforce/tests/test_salesforce_unit.py @@ -0,0 +1,547 @@ +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 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) +_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 +_validate_sf_id = _mod._validate_sf_id + +pytestmark = pytest.mark.unit + +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" + + +@pytest.fixture +def mock_context(): + ctx = MagicMock(name="ExecutionContext") + ctx.fetch = AsyncMock(name="fetch") + ctx.auth = { + "auth_type": "PlatformOauth2", + "credentials": {"access_token": TEST_TOKEN}, # nosec B105 + } + ctx.metadata = {"instance_url": TEST_INSTANCE} + 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.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.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.type == ResultType.ACTION_ERROR + assert "Invalid Salesforce ID" in result.result.message + + +# ---- 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._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: + 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" + + +# ---- Helper: _build_task_query ---- + + +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="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") + 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="005000000000001") + 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 + + +# ---- Helper: _build_event_query ---- + + +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="005000000000001") + assert "OwnerId = '005000000000001'" 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 + + +# ---- Helper: _summarise_task ---- + + +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.", + } + 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 + + +# ---- Helper: _summarise_event ---- + + +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.", + "IsAllDayEvent": False, + } + summary = _summarise_event(event) + assert "Quarterly review" in summary + assert "Board Room" in summary + assert "(All day)" not in summary + + def test_all_day_event_label(self): + summary = _summarise_event({"Subject": "Holiday", "IsAllDayEvent": True}) + 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 + + +# ---- search_records ---- + + +class TestSearchRecords: + async def test_returns_records(self, mock_context): + mock_context.fetch.return_value = FetchResponse( + status=200, + headers={}, + 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 + ) + 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 = FetchResponse( + status=200, headers={}, data={"records": [], "totalSize": 0, "done": True} + ) + soql = "SELECT Id FROM Lead LIMIT 5" + 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 = 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") + result = await salesforce.execute_action("search_records", {"soql": "SELECT Id FROM Contact"}, mock_context) + 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( + 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"] == [] + + +# ---- get_record ---- + + +class TestGetRecord: + async def test_returns_record(self, mock_context): + mock_context.fetch.return_value = FetchResponse( + 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": "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": "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": "003000000000001", "Name": "Jane"} + ) + await salesforce.execute_action( + "get_record", + {"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": "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": "003000000000001"}, mock_context + ) + assert result.type == ResultType.ACTION_ERROR + + +# ---- update_record ---- + + +class TestUpdateRecord: + 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": "003000000000001", "fields": {"Phone": "0400000000"}}, + mock_context, + ) + assert result.result.data["result"] is True + 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": "00Q000000000001", "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 = FetchResponse(status=204, headers={}, data=None) + fields = {"Phone": "0400000000", "Title": "Director"} + await salesforce.execute_action( + "update_record", {"object_type": "Contact", "record_id": "003000000000001", "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") + result = await salesforce.execute_action( + "update_record", + {"object_type": "Contact", "record_id": "003000000000001", "fields": {"Name": "X"}}, + mock_context, + ) + assert result.type == ResultType.ACTION_ERROR + + +# ---- list_tasks ---- + + +class TestListTasks: + async def test_returns_tasks(self, mock_context): + 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 + 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_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_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 = 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") + result = await salesforce.execute_action("list_tasks", {}, mock_context) + assert result.type == ResultType.ACTION_ERROR + + +# ---- list_events ---- + + +class TestListEvents: + async def test_returns_events(self, mock_context): + events = [{"Id": "00U000000000001", "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_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") + result = await salesforce.execute_action("list_events", {}, mock_context) + assert result.type == ResultType.ACTION_ERROR + + +# ---- get_task_summary ---- + + +class TestGetTaskSummary: + async def test_returns_summary(self, mock_context): + task = { + "Id": "00T000000000001", + "Subject": "Follow up", + "Status": "In Progress", + "Priority": "High", + "ActivityDate": "2026-05-10", + "Description": "Check contract.", + } + mock_context.fetch.return_value = FetchResponse( + status=200, headers={}, data={"records": [task], "totalSize": 1} + ) + 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"] == "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": "00T000000000001"}, mock_context) + 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}) + await salesforce.execute_action("get_task_summary", {"task_id": "00T000000000001"}, mock_context) + soql = mock_context.fetch.call_args.kwargs["params"]["q"] + 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": "00T000000000001"}, mock_context) + assert result.type == ResultType.ACTION_ERROR + + +# ---- get_event_summary ---- + + +class TestGetEventSummary: + async def test_returns_summary(self, mock_context): + event = { + "Id": "00U000000000001", + "Subject": "Board meeting", + "StartDateTime": "2026-06-01T09:00:00Z", + "EndDateTime": "2026-06-01T11:00:00Z", + "Location": "HQ", + "Description": "Annual review.", + "IsAllDayEvent": False, + } + mock_context.fetch.return_value = FetchResponse( + status=200, headers={}, data={"records": [event], "totalSize": 1} + ) + 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"] == "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": "00U000000000001"}, mock_context) + 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} + mock_context.fetch.return_value = FetchResponse( + status=200, headers={}, data={"records": [event], "totalSize": 1} + ) + 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": "00U000000000001"}, mock_context) + soql = mock_context.fetch.call_args.kwargs["params"]["q"] + 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": "00U000000000001"}, mock_context) + assert result.type == ResultType.ACTION_ERROR