AnthropicClaudeApiModule

Provides common Anthropic Claude Compliance API code shared by the AnthropicClaude and AnthropicClaudeStandardConnector integrations.

python · ApiModules

Details

IDAnthropicClaudeApiModule
Languagepython
From Version6.10.0
Docker Imagedemisto/parse-emails:0.1.48.10569905
Tagsinfra server

README

AnthropicClaudeApiModule

Common Anthropic Claude Compliance API code shared by:

  • The full AnthropicClaude integration (parent pack, all commands + event collector).
  • The narrower AnthropicClaudeStandardConnector integration (ConnectUs satellite pack, delete commands only).

The module carries only what the two irreversible compliance delete commands
(claude-chat-file-delete and claude-project-document-delete) need. The LLM
client, the compliance list/get commands, and the event collector stay inline
in the parent pack because they are not shared.

What lives here

  • Config — retry/back-off constants and the compliance-key docs URL.
  • ApiPaths — the two flat delete paths (CHAT_FILES, PROJECT_DOCUMENTS).
  • ComplianceClient — subclass of BaseClient that:
    • Pre-populates the x-api-key header on the legacy Cortex XSOAR path.
    • Overrides _apply_ucp_api_key to write x-api-key (not Authorization: Bearer …) on the ConnectUs / UCP path.
  • ensure_compliance_key, _delete_command, chat_file_delete_command, project_document_delete_command.

UCP auth override

The ConnectUs profile for this connector uses type: api_key with
metadata.auth.parameter: api_key. The Content Serialization Protocol aliases
that to the envelope key key. Because the Anthropic Compliance API rejects
Bearer auth and requires x-api-key, the client overrides the default
BaseClient._apply_ucp_api_key and writes the header directly.

"""Unit tests for the shared AnthropicClaudeApiModule.

The shared module owns every integration concern: the ``AnthropicClient`` (LLM Messages
API), the ``ComplianceClient`` (with the ``_apply_ucp_api_key`` override), the event
collector, all fourteen read-only and delete compliance commands, all four LLM commands,
and the ``run_anthropic_claude_integration()`` dispatcher. Both consuming integrations
(``AnthropicClaude`` parent + ``AnthropicClaudeStandardConnector`` satellite) are
byte-identical shims that just call the dispatcher — the YAML is the enforcement boundary.
"""

import json
import os
from types import SimpleNamespace

import pytest
import requests
from CommonServerPython import CommandResults, DemistoException, UcpException

from AnthropicClaudeApiModule import (
    AnthropicClient,
    ApiPaths,
    ComplianceClient,
    Config,
    _delete_command,
    add_time_to_events,
    chat_file_delete_command,
    conversation_to_chat_context,
    deduplicate_events,
    ensure_api_key,
    ensure_compliance_key,
    extract_assistant_message,
    fetch_events_command,
    fetch_events_with_pagination,
    get_events_command,
    get_project_document_command,
    list_chat_messages_command,
    list_chats_command,
    list_group_members_command,
    list_groups_command,
    list_organization_users_command,
    list_organizations_command,
    list_project_attachments_command,
    list_projects_command,
    list_role_permissions_command,
    list_roles_command,
    module_test_compliance,
    project_document_delete_command,
    resolve_org_uuid,
    module_test_llm,
    send_message_command,
)


BASE_URL = "https://api.anthropic.com/"


def build_compliance_client() -> ComplianceClient:
    return ComplianceClient(url=BASE_URL, api_key="sk-ant-api01-test", proxy=False, verify=False)


def build_llm_client() -> AnthropicClient:
    return AnthropicClient(url=BASE_URL, api_key="llm-key", model="claude-3-haiku-20240307", proxy=False, verify=False)


def load_test_data(filename: str) -> dict:
    """Loads a JSON fixture from the test_data directory colocated with this test file."""
    path = os.path.join(os.path.dirname(__file__), "test_data", filename)
    with open(path) as fh:
        return json.load(fh)


def make_response(status_code: int, body: dict | None = None) -> requests.Response:
    """Build a real requests.Response so ``.status_code`` and ``.json()`` behave normally."""
    response = requests.Response()
    response.status_code = status_code
    if body is not None:
        response._content = json.dumps(body).encode("utf-8")
        response.headers["Content-Type"] = "application/json"
    return response


def make_activities(start: int, count: int, base_minute: int = 0) -> list[dict]:
    """Builds a list of activity events with increasing ids/timestamps."""
    return [
        {
            "id": f"activity_{i:04d}",
            "activity_type": "chat.created",
            "created_at": f"2026-06-11T07:{base_minute:02d}:{i % 60:02d}Z",
        }
        for i in range(start, start + count)
    ]


# ── Config / ApiPaths ─────────────────────────────────────────────────────────


def test_config_carries_full_surface():
    """Sanity: the shared Config carries every constant the collector + all commands need."""
    assert Config.VENDOR == "anthropic"
    assert Config.PRODUCT == "claude"
    assert Config.ACTIVITIES_PAGE_SIZE == 5000
    assert Config.MAX_FETCH_CALLS == 10
    assert Config.DEFAULT_MAX_EVENTS_PER_FETCH == 50000
    assert Config.DEFAULT_FETCH_LOOKBACK == "1 minute"
    assert Config.MAX_RETRIES == 3
    assert Config.BACKOFF_FACTOR == 2
    assert Config.RETRY_STATUS_CODES == (429, 500, 502, 503, 504)
    assert Config.DEFAULT_LIST_LIMIT == 50
    assert Config.COMPLIANCE_KEY_DOCS.startswith("https://")
    assert Config.API_KEY_DOCS.startswith("https://")


def test_apipaths_carries_full_surface():
    assert ApiPaths.ACTIVITIES == "v1/compliance/activities"
    assert ApiPaths.ORGANIZATIONS == "v1/compliance/organizations"
    assert ApiPaths.GROUPS == "v1/compliance/groups"
    assert ApiPaths.CHATS == "v1/compliance/apps/chats"
    assert ApiPaths.PROJECTS == "v1/compliance/apps/projects"
    assert ApiPaths.CHAT_FILES == "v1/compliance/apps/chats/files"
    assert ApiPaths.PROJECT_DOCUMENTS == "v1/compliance/apps/projects/documents"
    # Classmethod interpolation.
    assert ApiPaths.organization_users("org-1") == "v1/compliance/organizations/org-1/users"
    assert ApiPaths.role_permissions("org-1", "role-1") == "v1/compliance/organizations/org-1/roles/role-1/permissions"
    assert ApiPaths.group_members("grp-1") == "v1/compliance/groups/grp-1/members"
    assert ApiPaths.chat_messages("chat-1") == "v1/compliance/apps/chats/chat-1/messages"
    assert ApiPaths.project_attachments("proj-1") == "v1/compliance/apps/projects/proj-1/attachments"
    assert ApiPaths.project_document("proj-1", "doc-1") == "v1/compliance/apps/projects/proj-1/documents/doc-1"


# ── ensure_api_key / ensure_compliance_key / resolve_org_uuid ─────────────────


def test_ensure_api_key_raises_when_missing():
    with pytest.raises(DemistoException, match="API Key"):
        ensure_api_key(None)
    with pytest.raises(DemistoException, match="API Key"):
        ensure_api_key("")


def test_ensure_api_key_passes_when_present():
    ensure_api_key("some-key")


def test_ensure_compliance_key_raises_when_missing():
    with pytest.raises(DemistoException, match="Compliance Access Key"):
        ensure_compliance_key(None)
    with pytest.raises(DemistoException, match="Compliance Access Key"):
        ensure_compliance_key("")


def test_ensure_compliance_key_passes_when_present():
    ensure_compliance_key("sk-ant-api01-test")


def test_resolve_org_uuid_falls_back_to_param():
    assert resolve_org_uuid({"org_uuid": "arg-org"}, {"organization_uuid": "param-org"}) == "arg-org"
    assert resolve_org_uuid({}, {"organization_uuid": "param-org"}) == "param-org"


def test_resolve_org_uuid_missing_raises():
    with pytest.raises(DemistoException, match="Organization UUID is required"):
        resolve_org_uuid({}, {})


# ── ComplianceClient constructor (legacy XSOAR path) ──────────────────────────


def test_client_constructor_sets_x_api_key_header_for_legacy_path():
    """On the legacy XSOAR path the header is pre-populated in the constructor."""
    client = build_compliance_client()
    assert client.headers["x-api-key"] == "sk-ant-api01-test"
    assert client.headers["accept"] == "application/json"


# ── ComplianceClient._apply_ucp_api_key (UCP path) ────────────────────────────


def _make_ctx() -> SimpleNamespace:
    """Minimal UcpRequestContext-compatible stub — the override only touches ``headers``."""
    return SimpleNamespace(headers={}, params={}, auth=None, data=None, json_data=None)


def test_apply_ucp_api_key_writes_x_api_key_from_flat_form():
    """Flat form: {'type': 'api_key', 'key': '...'} — the CSP alias for manifest auth.parameter='api_key'."""
    client = build_compliance_client()
    ctx = _make_ctx()
    creds = {"type": "api_key", "key": "sk-ant-flat-key"}

    client._apply_ucp_api_key(creds, ctx)

    assert ctx.headers["x-api-key"] == "sk-ant-flat-key"
    assert ctx.headers["accept"] == "application/json"
    # Guard-rail: MUST NOT set the default Authorization header the base implementation writes.
    assert "Authorization" not in ctx.headers


def test_apply_ucp_api_key_writes_x_api_key_from_nested_form():
    """Nested form: {'type': 'api_key', 'api_key': {'key': '...'}} — also valid per BaseClient contract."""
    client = build_compliance_client()
    ctx = _make_ctx()
    creds = {"type": "api_key", "api_key": {"key": "sk-ant-nested-key"}}

    client._apply_ucp_api_key(creds, ctx)

    assert ctx.headers["x-api-key"] == "sk-ant-nested-key"
    assert "Authorization" not in ctx.headers


def test_apply_ucp_api_key_raises_ucp_exception_on_empty_key(mocker):
    """Empty key must raise UcpException so the dispatcher surfaces the generic UCP error."""
    mocker.patch("AnthropicClaudeApiModule.demisto.error")
    client = build_compliance_client()
    ctx = _make_ctx()
    creds = {"type": "api_key", "api_key": {"key": ""}}

    with pytest.raises(UcpException):
        client._apply_ucp_api_key(creds, ctx)


def test_apply_ucp_api_key_raises_when_no_key_provided(mocker):
    mocker.patch("AnthropicClaudeApiModule.demisto.error")
    client = build_compliance_client()
    ctx = _make_ctx()

    with pytest.raises(UcpException):
        client._apply_ucp_api_key({"type": "api_key"}, ctx)


def test_apply_ucp_api_key_preserves_existing_accept_header():
    """setdefault must not overwrite an accept header the caller already set."""
    client = build_compliance_client()
    ctx = _make_ctx()
    ctx.headers["accept"] = "application/vnd.api+json"

    client._apply_ucp_api_key({"type": "api_key", "key": "k"}, ctx)

    assert ctx.headers["accept"] == "application/vnd.api+json"


# ── http_get / http_delete ────────────────────────────────────────────────────


def test_http_get_retries_on_rate_limit(mocker):
    """ComplianceClient.http_get enables back-off retries on 429 and transient 5xx codes."""
    client = build_compliance_client()
    request_mock = mocker.patch.object(client, "_http_request", return_value={"data": []})

    client.http_get("v1/compliance/activities", params={"limit": 1})

    kwargs = request_mock.call_args.kwargs
    assert kwargs["retries"] == Config.MAX_RETRIES
    assert kwargs["backoff_factor"] == Config.BACKOFF_FACTOR
    assert 429 in kwargs["status_list_to_retry"]


def test_http_delete_treats_404_as_ok(mocker):
    """404 is included in ok_codes so 'already deleted' does not raise."""
    client = build_compliance_client()
    request_mock = mocker.patch.object(client, "_http_request", return_value=make_response(404))

    response = client.http_delete("v1/compliance/apps/chats/files/gone")

    assert response.status_code == 404
    call_kwargs = request_mock.call_args.kwargs
    assert call_kwargs["method"] == "DELETE"
    assert 404 in call_kwargs["ok_codes"]
    assert call_kwargs["status_list_to_retry"] == list(Config.RETRY_STATUS_CODES)
    assert call_kwargs["retries"] == Config.MAX_RETRIES
    assert call_kwargs["backoff_factor"] == Config.BACKOFF_FACTOR
    assert call_kwargs["resp_type"] == "response"


# ── Event collector: helpers ──────────────────────────────────────────────────


def test_add_time_to_events():
    events = [{"created_at": "2026-06-11T07:08:59Z"}, {"id": "no_time"}]
    add_time_to_events(events)
    assert events[0]["_time"] == "2026-06-11T07:08:59Z"
    assert "_time" not in events[1]


def test_deduplicate_events():
    events = [{"id": "a"}, {"id": "b"}, {"id": "c"}]
    assert deduplicate_events(events, ["b"]) == [{"id": "a"}, {"id": "c"}]
    assert deduplicate_events(events, []) == events
    assert deduplicate_events([], ["b"]) == []


# ── Event collector: fetch_events_with_pagination ─────────────────────────────


def test_fetch_events_first_run(mocker):
    """First run: uses the one-minute lookback lower bound, single page, no has_more."""
    client = build_compliance_client()
    response = {"data": make_activities(0, 3), "has_more": False, "last_id": "activity_0002"}
    get_mock = mocker.patch.object(client, "get_activities", return_value=response)

    events, next_run = fetch_events_with_pagination(client, last_run={}, max_events=50000, activity_types=None)

    assert len(events) == 3
    # First call should use created_at.gte (first-fetch lower bound), not after_id.
    _, kwargs = get_mock.call_args
    assert kwargs["created_at_gte"] is not None
    assert kwargs["after_id"] is None
    assert next_run["newest_created_at"] == "2026-06-11T07:00:02Z"


def test_fetch_events_subsequent_run(mocker):
    """Subsequent run: uses created_at.gt against the previously stored newest timestamp."""
    client = build_compliance_client()
    response = {"data": make_activities(5, 2), "has_more": False, "last_id": "activity_0006"}
    get_mock = mocker.patch.object(client, "get_activities", return_value=response)

    last_run = {"newest_created_at": "2026-06-11T07:00:04Z", "last_fetched_ids": ["activity_0004"]}
    events, next_run = fetch_events_with_pagination(client, last_run, max_events=50000, activity_types=None)

    assert len(events) == 2
    _, kwargs = get_mock.call_args
    assert kwargs["created_at_gt"] == "2026-06-11T07:00:04Z"
    assert kwargs["created_at_gte"] is None


def test_fetch_events_pagination(mocker):
    """Cursor pagination: walks multiple pages until has_more is False."""
    client = build_compliance_client()
    page1 = {"data": make_activities(0, 2), "has_more": True, "last_id": "activity_0001"}
    page2 = {"data": make_activities(2, 2), "has_more": False, "last_id": "activity_0003"}
    get_mock = mocker.patch.object(client, "get_activities", side_effect=[page1, page2])

    events, _ = fetch_events_with_pagination(client, last_run={}, max_events=50000, activity_types=None)

    assert len(events) == 4
    assert get_mock.call_count == 2
    # The second call must carry the cursor from page1's last_id.
    second_kwargs = get_mock.call_args_list[1].kwargs
    assert second_kwargs["after_id"] == "activity_0001"


def test_fetch_events_dedup(mocker):
    """Boundary events already seen in the previous run are not returned again."""
    client = build_compliance_client()
    response = {
        "data": [
            {"id": "activity_dup", "created_at": "2026-06-11T07:00:04Z", "activity_type": "x"},
            {"id": "activity_new", "created_at": "2026-06-11T07:00:05Z", "activity_type": "y"},
        ],
        "has_more": False,
        "last_id": "activity_new",
    }
    mocker.patch.object(client, "get_activities", return_value=response)

    last_run = {"newest_created_at": "2026-06-11T07:00:04Z", "last_fetched_ids": ["activity_dup"]}
    events, _ = fetch_events_with_pagination(client, last_run, max_events=50000, activity_types=None)

    ids = [e["id"] for e in events]
    assert "activity_dup" not in ids
    assert "activity_new" in ids


def test_fetch_events_respects_max_events(mocker):
    """The collector stops once max_events is reached even if more pages exist."""
    client = build_compliance_client()
    page = {"data": make_activities(0, 3), "has_more": True, "last_id": "activity_0002"}
    mocker.patch.object(client, "get_activities", return_value=page)

    events, _ = fetch_events_with_pagination(client, last_run={}, max_events=3, activity_types=None)

    assert len(events) == 3


def test_fetch_events_no_drop_across_cap_boundary_two_runs(mocker):
    """When total events exceed max_events_per_fetch, the cap must not drop events across runs.

    Run 1 collects exactly `max_events`; the persisted cursor must reflect only the delivered
    events so run 2 resumes from the correct boundary and the remaining events are returned with
    no gaps and no overlap.
    """
    client = build_compliance_client()
    # Six unique events across two ascending pages; cap each run at 3.
    all_events = make_activities(0, 6)
    page_first_half = {"data": all_events[:3], "has_more": True, "last_id": "activity_0002"}
    mocker.patch.object(client, "get_activities", return_value=page_first_half)

    run1_events, run1_next = fetch_events_with_pagination(client, last_run={}, max_events=3, activity_types=None)
    run1_ids = [e["id"] for e in run1_events]

    assert run1_ids == ["activity_0000", "activity_0001", "activity_0002"]
    # Cursor reflects the newest DELIVERED event only.
    assert run1_next["newest_created_at"] == all_events[2]["created_at"]

    # Run 2 resumes after the boundary; the API returns the remaining events.
    page_second_half = {"data": all_events[3:], "has_more": False, "last_id": "activity_0005"}
    mocker.patch.object(client, "get_activities", return_value=page_second_half)

    run2_events, _ = fetch_events_with_pagination(client, last_run=run1_next, max_events=3, activity_types=None)
    run2_ids = [e["id"] for e in run2_events]

    # No event is dropped and none is duplicated across the cap boundary.
    assert run2_ids == ["activity_0003", "activity_0004", "activity_0005"]
    assert set(run1_ids).isdisjoint(run2_ids)
    assert sorted(run1_ids + run2_ids) == [e["id"] for e in all_events]


def test_fetch_events_descending_feed_shape(mocker):
    """The real Activity Feed returns events newest-first; the cursor must capture the newest one."""
    client = build_compliance_client()
    page = load_test_data("activities_page1.json")
    # Close out pagination so the single fixture page is the whole cycle.
    page = {**page, "has_more": False}
    mocker.patch.object(client, "get_activities", return_value=page)

    events, next_run = fetch_events_with_pagination(client, last_run={}, max_events=50000, activity_types=None)

    assert len(events) == 2
    # activity_002 (07:08:59) is newer than activity_001 (07:08:58) despite appearing first.
    assert next_run["newest_created_at"] == "2026-06-11T07:08:59Z"
    assert next_run["last_fetched_ids"] == ["activity_002"]


# ── Event collector: fetch_events_command / get_events_command ────────────────


def test_fetch_events_pushes_to_xsiam(mocker):
    """fetch_events sets _time, pushes events with the correct vendor/product, and persists last_run."""
    client = build_compliance_client()
    response = {"data": make_activities(0, 2), "has_more": False, "last_id": "activity_0001"}
    mocker.patch.object(client, "get_activities", return_value=response)
    mocker.patch("AnthropicClaudeApiModule.demisto.getLastRun", return_value={})
    set_last_run = mocker.patch("AnthropicClaudeApiModule.demisto.setLastRun")
    send_mock = mocker.patch("AnthropicClaudeApiModule.send_events_to_xsiam")

    fetch_events_command(client, params={"max_events_per_fetch": "1000"})

    send_mock.assert_called_once()
    sent_events = send_mock.call_args.args[0]
    assert send_mock.call_args.kwargs["vendor"] == Config.VENDOR
    assert send_mock.call_args.kwargs["product"] == Config.PRODUCT
    assert all("_time" in e for e in sent_events)
    set_last_run.assert_called_once()


def test_get_events_command_no_push(mocker):
    client = build_compliance_client()
    response = {"data": make_activities(0, 2), "has_more": False, "last_id": "activity_0001"}
    mocker.patch.object(client, "get_activities", return_value=response)

    events, results = get_events_command(client, args={"limit": "50"})

    assert len(events) == 2
    assert isinstance(results, CommandResults)
    assert all("_time" in e for e in events)


def test_get_events_command_with_time_range(mocker):
    """start_time/end_time map to created_at.gte / created_at.lt bounds on the Activity Feed query."""
    client = build_compliance_client()
    response = {"data": make_activities(0, 1), "has_more": False, "last_id": "activity_0000"}
    get_mock = mocker.patch.object(client, "get_activities", return_value=response)

    get_events_command(
        client,
        args={"limit": "10", "start_time": "2025-06-07T08:09:10Z", "end_time": "2025-06-07T09:09:10Z"},
    )

    kwargs = get_mock.call_args.kwargs
    assert kwargs["created_at_gte"] == "2025-06-07T08:09:10Z"
    assert kwargs["created_at_lt"] == "2025-06-07T09:09:10Z"


# ── Read-only compliance commands ─────────────────────────────────────────────


def test_list_organizations_command(mocker):
    client = build_compliance_client()
    response = {"data": [{"uuid": "org-1", "name": "Acme", "created_at": "2026-01-01T00:00:00Z"}]}
    mocker.patch.object(client, "http_get", return_value=response)

    results = list_organizations_command(client, args={"limit": "50"})

    assert results.outputs_prefix == "AnthropicClaude.Organization"
    assert results.outputs[0]["uuid"] == "org-1"


def test_list_organization_users_command(mocker):
    client = build_compliance_client()
    response = {"data": [{"id": "u1", "email": "user@example.com", "organization_role": "admin"}]}
    get_mock = mocker.patch.object(client, "http_get", return_value=response)

    results = list_organization_users_command(client, args={"org_uuid": "org-1", "limit": "10"}, params={})

    assert results.outputs_prefix == "AnthropicClaude.Organization.User"
    get_mock.assert_called_once()
    assert "organizations/org-1/users" in get_mock.call_args.args[0]


def test_list_roles_single_role(mocker):
    """When role_id is provided, the single-role endpoint is used (no data[] wrapper)."""
    client = build_compliance_client()
    response = {"id": "role-1", "name": "Owner", "description": "desc"}
    get_mock = mocker.patch.object(client, "http_get", return_value=response)

    results = list_roles_command(client, args={"org_uuid": "org-1", "role_id": "role-1"}, params={})

    assert results.outputs["id"] == "role-1"
    assert "roles/role-1" in get_mock.call_args.args[0]


def test_list_roles_list_mode(mocker):
    client = build_compliance_client()
    response = {"data": [{"id": "role-1", "name": "Owner"}], "next_page": "tok123"}
    mocker.patch.object(client, "http_get", return_value=response)

    results = list_roles_command(client, args={"org_uuid": "org-1"}, params={})

    assert results.outputs[0]["id"] == "role-1"
    assert "tok123" in results.readable_output


def test_list_role_permissions_command(mocker):
    client = build_compliance_client()
    response = {"data": [{"resource_type": "chats", "action": "read"}], "next_page": "tok-perm"}
    get_mock = mocker.patch.object(client, "http_get", return_value=response)

    results = list_role_permissions_command(client, args={"org_uuid": "org-1", "role_id": "role-1"}, params={})

    assert results.outputs_prefix == "AnthropicClaude.Organization.Role.Permission"
    assert results.outputs[0]["resource_type"] == "chats"
    assert "roles/role-1/permissions" in get_mock.call_args.args[0]


def test_list_role_permissions_missing_role_id_raises(mocker):
    """role_id is required for the permissions endpoint; omitting it must raise."""
    client = build_compliance_client()
    mocker.patch.object(client, "http_get")
    with pytest.raises(KeyError):
        list_role_permissions_command(client, args={"org_uuid": "org-1"}, params={})


def test_list_groups_single_group(mocker):
    client = build_compliance_client()
    response = {"id": "grp-1", "name": "Engineers", "source_type": "scim"}
    get_mock = mocker.patch.object(client, "http_get", return_value=response)

    results = list_groups_command(client, args={"group_id": "grp-1"})

    assert results.outputs["id"] == "grp-1"
    assert "groups/grp-1" in get_mock.call_args.args[0]


def test_list_group_members_dt_prefix(mocker):
    """Group members merge into the parent Group entry via DT."""
    client = build_compliance_client()
    response = {"data": [{"user_id": "u1", "email": "user@example.com"}]}
    mocker.patch.object(client, "http_get", return_value=response)

    results = list_group_members_command(client, args={"group_id": "grp-1"})

    assert results.outputs_prefix == "AnthropicClaude.Group(val.id == 'grp-1').Member"
    assert results.outputs[0]["user_id"] == "u1"


def test_list_chats_command(mocker):
    client = build_compliance_client()
    response = {"data": [{"id": "chat-1", "name": "Chat", "model": "claude-3"}]}
    get_mock = mocker.patch.object(client, "http_get", return_value=response)

    results = list_chats_command(client, args={"user_ids": "u1,u2", "limit": "100"})

    assert results.outputs_prefix == "AnthropicClaude.Chat"
    params = get_mock.call_args.kwargs["params"]
    assert params["user_ids[]"] == ["u1", "u2"]


def test_list_chats_date_range_param_mapping(mocker):
    """created_at_gte argument maps to the created_at.gte query parameter."""
    client = build_compliance_client()
    response = {"data": [{"id": "chat-1", "name": "Chat"}]}
    get_mock = mocker.patch.object(client, "http_get", return_value=response)

    list_chats_command(client, args={"user_ids": "u1", "created_at_gte": "2025-06-07T08:09:10Z"})

    params = get_mock.call_args.kwargs["params"]
    assert params["created_at.gte"] == "2025-06-07T08:09:10Z"


def test_list_chat_messages_command(mocker):
    client = build_compliance_client()
    response = {"chat_messages": [{"id": "m1", "role": "user", "created_at": "2026-01-01T00:00:00Z"}]}
    mocker.patch.object(client, "http_get", return_value=response)

    results = list_chat_messages_command(client, args={"chat_id": "chat-1"})

    # Messages merge into the parent Chat entry via DT.
    assert results.outputs_prefix == "AnthropicClaude.Chat(val.id == 'chat-1').Message"
    assert results.outputs[0]["id"] == "m1"


def test_list_projects_single_project(mocker):
    client = build_compliance_client()
    response = {"id": "proj-1", "name": "Project", "is_private": True}
    get_mock = mocker.patch.object(client, "http_get", return_value=response)

    results = list_projects_command(client, args={"project_id": "proj-1"})

    assert results.outputs["id"] == "proj-1"
    assert "projects/proj-1" in get_mock.call_args.args[0]


def test_list_project_attachments_command(mocker):
    client = build_compliance_client()
    response = {
        "data": [{"id": "att-1", "filename": "diagram.png", "mime_type": "image/png"}],
        "next_page": "tok-att",
    }
    get_mock = mocker.patch.object(client, "http_get", return_value=response)

    results = list_project_attachments_command(client, args={"project_id": "proj-1"})

    # Attachments merge into the parent Project entry via DT.
    assert results.outputs_prefix == "AnthropicClaude.Project(val.id == 'proj-1').Attachment"
    assert results.outputs[0]["id"] == "att-1"
    assert "projects/proj-1/attachments" in get_mock.call_args.args[0]
    assert "tok-att" in results.readable_output


def test_get_project_document_command(mocker):
    client = build_compliance_client()
    response = {"id": "claude_proj_doc_1", "filename": "spec.md", "content": "hello"}
    get_mock = mocker.patch.object(client, "http_get", return_value=response)

    results = get_project_document_command(client, args={"project_id": "proj-1", "document_id": "claude_proj_doc_1"})

    assert results.outputs_prefix == "AnthropicClaude.ProjectDocument"
    assert results.outputs["content"] == "hello"
    assert "projects/proj-1/documents/claude_proj_doc_1" in get_mock.call_args.args[0]


# ── Delete commands ──────────────────────────────────────────────────────────


def test_chat_file_delete_command_happy_path(mocker):
    """Happy path: hits the flat chat-files delete path and reports the deleted id."""
    client = build_compliance_client()
    delete_mock = mocker.patch.object(
        client, "http_delete", return_value=make_response(200, {"id": "claude_file_1", "type": "claude_file_deleted"})
    )

    results = chat_file_delete_command(client, args={"file_id": "claude_file_1"})

    delete_mock.assert_called_once_with(f"{ApiPaths.CHAT_FILES}/claude_file_1")
    assert isinstance(results, CommandResults)
    assert results.outputs["id"] == "claude_file_1"
    assert results.outputs["type"] == "claude_file_deleted"
    assert results.outputs["Deleted"] is True
    assert results.outputs_prefix == "AnthropicClaude.DeletedFile"


def test_chat_file_delete_command_idempotent_on_404(mocker):
    """A 404 (already deleted / unknown id) is treated as an idempotent success and annotated."""
    client = build_compliance_client()
    mocker.patch.object(client, "http_delete", return_value=make_response(404))

    results = chat_file_delete_command(client, args={"file_id": "claude_file_gone"})

    assert results.outputs["Deleted"] is True
    assert "was already deleted" in results.readable_output


def test_chat_file_delete_command_missing_arg_raises():
    client = build_compliance_client()
    with pytest.raises(KeyError):
        chat_file_delete_command(client, args={})


def test_project_document_delete_command_happy_path(mocker):
    """Happy path: hits the flat project-documents delete path and reports the deleted id."""
    client = build_compliance_client()
    delete_mock = mocker.patch.object(
        client,
        "http_delete",
        return_value=make_response(200, {"id": "claude_proj_doc_1", "type": "claude_project_document_deleted"}),
    )

    results = project_document_delete_command(client, args={"document_id": "claude_proj_doc_1"})

    delete_mock.assert_called_once_with(f"{ApiPaths.PROJECT_DOCUMENTS}/claude_proj_doc_1")
    assert results.outputs["id"] == "claude_proj_doc_1"
    assert results.outputs["type"] == "claude_project_document_deleted"
    assert results.outputs["Deleted"] is True
    assert results.outputs_prefix == "AnthropicClaude.DeletedProjectDocument"


def test_project_document_delete_command_idempotent_on_404(mocker):
    client = build_compliance_client()
    mocker.patch.object(client, "http_delete", return_value=make_response(404))

    results = project_document_delete_command(client, args={"document_id": "claude_proj_doc_gone"})

    assert results.outputs["Deleted"] is True
    assert "was already deleted" in results.readable_output


def test_project_document_delete_command_missing_arg_raises():
    client = build_compliance_client()
    with pytest.raises(KeyError):
        project_document_delete_command(client, args={})


def test_delete_command_falls_back_when_response_body_not_json(mocker):
    """When the DELETE response body cannot be parsed as JSON the helper still returns a valid result."""
    client = build_compliance_client()
    response = requests.Response()
    response.status_code = 200
    response._content = b"not-json"
    mocker.patch.object(client, "http_delete", return_value=response)

    results = _delete_command(
        client,
        resource_id="rid",
        url_suffix="v1/some/path/rid",
        deleted_type="some_type",
        outputs_prefix="Any.Prefix",
        resource_label="Thing",
    )

    assert results.outputs["id"] == "rid"
    assert results.outputs["type"] == "some_type"
    assert results.raw_response == {"id": "rid", "type": "some_type"}


# ── module_test_compliance ────────────────────────────────────────────────────


def test_module_test_compliance_success(mocker):
    client = build_compliance_client()
    mocker.patch.object(client, "get_activities", return_value={"data": []})
    assert module_test_compliance(client) == "ok"


def test_module_test_compliance_auth_failure(mocker):
    client = build_compliance_client()
    mocker.patch.object(client, "get_activities", side_effect=DemistoException("Error 401 Unauthorized"))
    result = module_test_compliance(client)
    assert "Authorization Error" in result


def test_module_test_compliance_other_error_raises(mocker):
    client = build_compliance_client()
    mocker.patch.object(client, "get_activities", side_effect=DemistoException("500 Server Error"))
    with pytest.raises(DemistoException):
        module_test_compliance(client)


# ── LLM: AnthropicClient / helpers / commands ────────────────────────────────


def test_anthropic_client_constructor_sets_llm_headers():
    client = build_llm_client()
    assert client.api_key == "llm-key"
    assert client.model == "claude-3-haiku-20240307"
    assert client.headers["x-api-key"] == "llm-key"
    assert client.headers["Content-Type"] == "application/json"
    assert client.headers["anthropic-version"]  # populated to the module constant


def test_conversation_to_chat_context_alternates_user_and_assistant():
    conversation = [
        {"user": "hi", "assistant": "hello"},
        {"user": "bye", "assistant": "goodbye"},
    ]
    context = conversation_to_chat_context(conversation)
    assert context == [
        {"role": "user", "content": "hi"},
        {"role": "assistant", "content": "hello"},
        {"role": "user", "content": "bye"},
        {"role": "assistant", "content": "goodbye"},
    ]


def test_extract_assistant_message_concatenates_text_parts():
    response = {"content": [{"type": "text", "text": "Hello, "}, {"type": "text", "text": "world!"}]}
    assert extract_assistant_message(response) == "Hello, world!"


def test_extract_assistant_message_skips_non_text_parts():
    response = {
        "content": [
            {"type": "tool_use", "name": "some_tool"},
            {"type": "text", "text": "only this"},
        ]
    }
    assert extract_assistant_message(response) == "only this"


def test_send_message_command_happy_path(mocker):
    client = build_llm_client()
    mocker.patch("AnthropicClaudeApiModule.demisto.context", return_value={})
    mocker.patch.object(
        client,
        "get_messages",
        return_value={
            "model": "claude-3-haiku-20240307",
            "content": [{"type": "text", "text": "hi there"}],
            "usage": {"input_tokens": 5, "output_tokens": 2},
        },
    )

    results, response = send_message_command(client, args={"message": "hello", "reset_conversation_history": "yes"})

    assert isinstance(results, CommandResults)
    assert response["content"][0]["text"] == "hi there"
    # The conversation step is written back to the AnthropicClaude.Conversation context path.
    assert results.outputs_prefix == "AnthropicClaude.Conversation"
    assert results.outputs == [{"user": "hello", "assistant": "hi there"}]
    assert results.replace_existing is True


def test_send_message_command_requires_message():
    client = build_llm_client()
    with pytest.raises(ValueError, match="Message not provided"):
        send_message_command(client, args={})


def test_module_test_llm_success(mocker):
    client = build_llm_client()
    mocker.patch.object(client, "get_messages", return_value={"content": [{"type": "text", "text": "pong"}]})
    assert module_test_llm(client, params={"max_tokens": "1024"}) == "ok"


def test_module_test_llm_auth_error_returns_labeled_message(mocker):
    client = build_llm_client()
    mocker.patch.object(client, "get_messages", side_effect=DemistoException("403 Forbidden"))
    assert "Authorization Error" in module_test_llm(client, params={"max_tokens": "1024"})


def test_module_test_llm_other_error_raises(mocker):
    client = build_llm_client()
    mocker.patch.object(client, "get_messages", side_effect=DemistoException("500 Server Error"))
    with pytest.raises(DemistoException):
        module_test_llm(client, params={"max_tokens": "1024"})