Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ba6e2a32df |
@@ -486,19 +486,56 @@ def _validate_tool_credentials(tools_list: list[str]) -> dict | None:
|
||||
|
||||
store = _get_credential_store()
|
||||
|
||||
# Build tool -> credential mapping
|
||||
tool_to_cred: dict[str, str] = {}
|
||||
# Build tool -> credential mapping (1:many for multi-provider tools)
|
||||
tool_to_creds: dict[str, list[str]] = {}
|
||||
for cred_name, spec in CREDENTIAL_SPECS.items():
|
||||
for tool_name in spec.tools:
|
||||
tool_to_cred[tool_name] = cred_name
|
||||
tool_to_creds.setdefault(tool_name, []).append(cred_name)
|
||||
|
||||
# Find missing credentials
|
||||
cred_errors = []
|
||||
checked: set[str] = set()
|
||||
for tool_name in tools_list:
|
||||
cred_name = tool_to_cred.get(tool_name)
|
||||
if cred_name is None or cred_name in checked:
|
||||
cred_names = tool_to_creds.get(tool_name)
|
||||
if cred_names is None:
|
||||
continue
|
||||
|
||||
# Filter to credentials we haven't already checked
|
||||
unchecked = [cn for cn in cred_names if cn not in checked]
|
||||
if not unchecked:
|
||||
continue
|
||||
|
||||
# Multi-provider tool (e.g. send_email → resend OR google):
|
||||
# satisfied if ANY provider credential is available.
|
||||
if len(unchecked) > 1:
|
||||
any_available = False
|
||||
for cn in unchecked:
|
||||
checked.add(cn)
|
||||
spec = CREDENTIAL_SPECS[cn]
|
||||
cred_id = spec.credential_id or cn
|
||||
if store.is_available(cred_id):
|
||||
any_available = True
|
||||
break
|
||||
if any_available:
|
||||
continue
|
||||
# None available — report all alternatives
|
||||
for cn in unchecked:
|
||||
spec = CREDENTIAL_SPECS[cn]
|
||||
affected_tools = [t for t in tools_list if t in spec.tools]
|
||||
cred_errors.append(
|
||||
{
|
||||
"credential": cn,
|
||||
"env_var": spec.env_var,
|
||||
"tools_affected": affected_tools,
|
||||
"help_url": spec.help_url,
|
||||
"description": spec.description,
|
||||
"alternative_group": tool_name,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
# Single provider
|
||||
cred_name = unchecked[0]
|
||||
checked.add(cred_name)
|
||||
spec = CREDENTIAL_SPECS[cred_name]
|
||||
cred_id = spec.credential_id or cred_name
|
||||
@@ -3264,12 +3301,12 @@ def check_missing_credentials(
|
||||
info = runner.info()
|
||||
node_types = list({node.node_type for node in runner.graph.nodes})
|
||||
|
||||
# Build reverse mappings: tool/node_type -> credential name
|
||||
tool_to_cred: dict[str, str] = {}
|
||||
# Build reverse mappings (1:many for multi-provider tools like send_email)
|
||||
tool_to_creds: dict[str, list[str]] = {}
|
||||
node_type_to_cred: dict[str, str] = {}
|
||||
for cred_name, spec in CREDENTIAL_SPECS.items():
|
||||
for tool_name in spec.tools:
|
||||
tool_to_cred[tool_name] = cred_name
|
||||
tool_to_creds.setdefault(tool_name, []).append(cred_name)
|
||||
for nt in spec.node_types:
|
||||
node_type_to_cred[nt] = cred_name
|
||||
|
||||
@@ -3277,27 +3314,76 @@ def check_missing_credentials(
|
||||
seen: set[str] = set()
|
||||
all_missing = []
|
||||
|
||||
for name_list, mapping in [
|
||||
(info.required_tools, tool_to_cred),
|
||||
(node_types, node_type_to_cred),
|
||||
]:
|
||||
for item_name in name_list:
|
||||
cred_name = mapping.get(item_name)
|
||||
if cred_name is None or cred_name in seen:
|
||||
# Check tool credentials
|
||||
for tool_name in info.required_tools:
|
||||
cred_names = tool_to_creds.get(tool_name)
|
||||
if cred_names is None:
|
||||
continue
|
||||
unchecked = [cn for cn in cred_names if cn not in seen]
|
||||
if not unchecked:
|
||||
continue
|
||||
|
||||
# Multi-provider: satisfied if ANY provider credential is available
|
||||
if len(unchecked) > 1:
|
||||
any_available = False
|
||||
for cn in unchecked:
|
||||
seen.add(cn)
|
||||
spec = CREDENTIAL_SPECS[cn]
|
||||
cred_id = spec.credential_id or cn
|
||||
if store.is_available(cred_id):
|
||||
any_available = True
|
||||
break
|
||||
if any_available:
|
||||
continue
|
||||
seen.add(cred_name)
|
||||
spec = CREDENTIAL_SPECS[cred_name]
|
||||
cred_id = spec.credential_id or cred_name
|
||||
if spec.required and not store.is_available(cred_id):
|
||||
# None available — report all alternatives
|
||||
for cn in unchecked:
|
||||
spec = CREDENTIAL_SPECS[cn]
|
||||
all_missing.append(
|
||||
{
|
||||
"credential_name": cred_name,
|
||||
"credential_name": cn,
|
||||
"env_var": spec.env_var,
|
||||
"description": spec.description,
|
||||
"help_url": spec.help_url,
|
||||
"tools": spec.tools,
|
||||
"alternative_group": tool_name,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
# Single provider
|
||||
cred_name = unchecked[0]
|
||||
seen.add(cred_name)
|
||||
spec = CREDENTIAL_SPECS[cred_name]
|
||||
cred_id = spec.credential_id or cred_name
|
||||
if spec.required and not store.is_available(cred_id):
|
||||
all_missing.append(
|
||||
{
|
||||
"credential_name": cred_name,
|
||||
"env_var": spec.env_var,
|
||||
"description": spec.description,
|
||||
"help_url": spec.help_url,
|
||||
"tools": spec.tools,
|
||||
}
|
||||
)
|
||||
|
||||
# Check node type credentials
|
||||
for nt in node_types:
|
||||
cred_name = node_type_to_cred.get(nt)
|
||||
if cred_name is None or cred_name in seen:
|
||||
continue
|
||||
seen.add(cred_name)
|
||||
spec = CREDENTIAL_SPECS[cred_name]
|
||||
cred_id = spec.credential_id or cred_name
|
||||
if spec.required and not store.is_available(cred_id):
|
||||
all_missing.append(
|
||||
{
|
||||
"credential_name": cred_name,
|
||||
"env_var": spec.env_var,
|
||||
"description": spec.description,
|
||||
"help_url": spec.help_url,
|
||||
"tools": spec.tools,
|
||||
}
|
||||
)
|
||||
|
||||
# Also check what's already set
|
||||
available = []
|
||||
|
||||
@@ -122,11 +122,11 @@ class CredentialManager:
|
||||
self._specs = specs
|
||||
self._overrides = _overrides or {}
|
||||
self._dotenv_path = dotenv_path
|
||||
# Build reverse mapping: tool_name -> credential_name
|
||||
self._tool_to_cred: dict[str, str] = {}
|
||||
# Build reverse mapping: tool_name -> credential_names (1:many for multi-provider)
|
||||
self._tool_to_creds: dict[str, list[str]] = {}
|
||||
for cred_name, spec in self._specs.items():
|
||||
for tool_name in spec.tools:
|
||||
self._tool_to_cred[tool_name] = cred_name
|
||||
self._tool_to_creds.setdefault(tool_name, []).append(cred_name)
|
||||
# Build reverse mapping: node_type -> credential_name
|
||||
self._node_type_to_cred: dict[str, str] = {}
|
||||
for cred_name, spec in self._specs.items():
|
||||
@@ -234,18 +234,30 @@ class CredentialManager:
|
||||
"""
|
||||
Get the credential name required by a tool.
|
||||
|
||||
For multi-provider tools (e.g. send_email), returns the first
|
||||
available provider's credential name, or the first one if none available.
|
||||
|
||||
Args:
|
||||
tool_name: Name of the tool (e.g., "web_search")
|
||||
|
||||
Returns:
|
||||
Credential name if tool requires one, None otherwise
|
||||
"""
|
||||
return self._tool_to_cred.get(tool_name)
|
||||
cred_names = self._tool_to_creds.get(tool_name)
|
||||
if not cred_names:
|
||||
return None
|
||||
for cn in cred_names:
|
||||
if self.is_available(cn):
|
||||
return cn
|
||||
return cred_names[0]
|
||||
|
||||
def get_missing_for_tools(self, tool_names: list[str]) -> list[tuple[str, CredentialSpec]]:
|
||||
"""
|
||||
Get list of missing credentials for the given tools.
|
||||
|
||||
For multi-provider tools (e.g. send_email → resend OR google),
|
||||
satisfied if ANY provider credential is available.
|
||||
|
||||
Args:
|
||||
tool_names: List of tool names to check
|
||||
|
||||
@@ -256,15 +268,31 @@ class CredentialManager:
|
||||
checked: set[str] = set()
|
||||
|
||||
for tool_name in tool_names:
|
||||
cred_name = self._tool_to_cred.get(tool_name)
|
||||
if cred_name is None:
|
||||
# Tool doesn't require credentials
|
||||
cred_names = self._tool_to_creds.get(tool_name)
|
||||
if cred_names is None:
|
||||
continue
|
||||
if cred_name in checked:
|
||||
# Already checked this credential
|
||||
unchecked = [cn for cn in cred_names if cn not in checked]
|
||||
if not unchecked:
|
||||
continue
|
||||
checked.add(cred_name)
|
||||
|
||||
# Multi-provider: satisfied if ANY is available
|
||||
if len(unchecked) > 1:
|
||||
any_available = False
|
||||
for cn in unchecked:
|
||||
checked.add(cn)
|
||||
if self.is_available(cn):
|
||||
any_available = True
|
||||
break
|
||||
if not any_available:
|
||||
for cn in unchecked:
|
||||
spec = self._specs[cn]
|
||||
if spec.required:
|
||||
missing.append((cn, spec))
|
||||
continue
|
||||
|
||||
# Single provider
|
||||
cred_name = unchecked[0]
|
||||
checked.add(cred_name)
|
||||
spec = self._specs[cred_name]
|
||||
if spec.required and not self.is_available(cred_name):
|
||||
missing.append((cred_name, spec))
|
||||
|
||||
@@ -73,13 +73,13 @@ class CredentialStoreAdapter:
|
||||
self._store = store
|
||||
self._specs = specs
|
||||
|
||||
# Build reverse mappings for validation
|
||||
self._tool_to_cred: dict[str, str] = {}
|
||||
# Build reverse mappings for validation (1:many for multi-provider tools)
|
||||
self._tool_to_creds: dict[str, list[str]] = {}
|
||||
self._node_type_to_cred: dict[str, str] = {}
|
||||
|
||||
for cred_name, spec in self._specs.items():
|
||||
for tool_name in spec.tools:
|
||||
self._tool_to_cred[tool_name] = cred_name
|
||||
self._tool_to_creds.setdefault(tool_name, []).append(cred_name)
|
||||
for node_type in spec.node_types:
|
||||
self._node_type_to_cred[node_type] = cred_name
|
||||
|
||||
@@ -135,18 +135,31 @@ class CredentialStoreAdapter:
|
||||
"""
|
||||
Get the credential name required by a tool.
|
||||
|
||||
For multi-provider tools (e.g. send_email), returns the first
|
||||
available provider's credential name, or the first one if none available.
|
||||
|
||||
Args:
|
||||
tool_name: Name of the tool (e.g., "web_search")
|
||||
|
||||
Returns:
|
||||
Credential name if tool requires one, None otherwise
|
||||
"""
|
||||
return self._tool_to_cred.get(tool_name)
|
||||
cred_names = self._tool_to_creds.get(tool_name)
|
||||
if not cred_names:
|
||||
return None
|
||||
# Return first available, or first if none available
|
||||
for cn in cred_names:
|
||||
if self.is_available(cn):
|
||||
return cn
|
||||
return cred_names[0]
|
||||
|
||||
def get_missing_for_tools(self, tool_names: list[str]) -> list[tuple[str, CredentialSpec]]:
|
||||
"""
|
||||
Get list of missing credentials for the given tools.
|
||||
|
||||
For multi-provider tools (e.g. send_email → resend OR google),
|
||||
satisfied if ANY provider credential is available.
|
||||
|
||||
Args:
|
||||
tool_names: List of tool names to check
|
||||
|
||||
@@ -157,13 +170,31 @@ class CredentialStoreAdapter:
|
||||
checked: set[str] = set()
|
||||
|
||||
for tool_name in tool_names:
|
||||
cred_name = self._tool_to_cred.get(tool_name)
|
||||
if cred_name is None:
|
||||
cred_names = self._tool_to_creds.get(tool_name)
|
||||
if cred_names is None:
|
||||
continue
|
||||
if cred_name in checked:
|
||||
unchecked = [cn for cn in cred_names if cn not in checked]
|
||||
if not unchecked:
|
||||
continue
|
||||
checked.add(cred_name)
|
||||
|
||||
# Multi-provider: satisfied if ANY is available
|
||||
if len(unchecked) > 1:
|
||||
any_available = False
|
||||
for cn in unchecked:
|
||||
checked.add(cn)
|
||||
if self.is_available(cn):
|
||||
any_available = True
|
||||
break
|
||||
if not any_available:
|
||||
for cn in unchecked:
|
||||
spec = self._specs[cn]
|
||||
if spec.required:
|
||||
missing.append((cn, spec))
|
||||
continue
|
||||
|
||||
# Single provider
|
||||
cred_name = unchecked[0]
|
||||
checked.add(cred_name)
|
||||
spec = self._specs[cred_name]
|
||||
if spec.required and not self.is_available(cred_name):
|
||||
missing.append((cred_name, spec))
|
||||
@@ -324,11 +355,20 @@ class CredentialStoreAdapter:
|
||||
def get_tool_provider_map(self) -> dict[str, str]:
|
||||
"""Map tool names to provider names for account routing.
|
||||
|
||||
For multi-provider tools, maps to the available provider's aden_provider_name.
|
||||
|
||||
Returns:
|
||||
Dict mapping tool_name -> provider_name
|
||||
(e.g. {"gmail_list_messages": "google", "slack_send_message": "slack"})
|
||||
"""
|
||||
return dict(self._tool_to_cred)
|
||||
result: dict[str, str] = {}
|
||||
for tool_name, cred_names in self._tool_to_creds.items():
|
||||
for cn in cred_names:
|
||||
spec = self._specs[cn]
|
||||
if spec.aden_provider_name:
|
||||
result[tool_name] = spec.aden_provider_name
|
||||
break
|
||||
return result
|
||||
|
||||
def get_by_alias(self, provider_name: str, alias: str) -> str | None:
|
||||
"""Resolve a specific account's token by alias."""
|
||||
|
||||
@@ -130,6 +130,18 @@ def register_tools(
|
||||
return credentials.get("resend")
|
||||
return os.getenv("RESEND_API_KEY")
|
||||
|
||||
def _detect_provider(account: str = "") -> Literal["resend", "gmail"] | None:
|
||||
"""Auto-detect email provider from available credentials.
|
||||
|
||||
Checks gmail first (more common), then resend.
|
||||
Returns None if no provider credentials are found.
|
||||
"""
|
||||
if _get_credential("gmail", account):
|
||||
return "gmail"
|
||||
if _get_credential("resend"):
|
||||
return "resend"
|
||||
return None
|
||||
|
||||
def _resolve_from_email(from_email: str | None) -> str | None:
|
||||
"""Resolve sender address: explicit param > EMAIL_FROM env var."""
|
||||
if from_email:
|
||||
@@ -151,13 +163,22 @@ def register_tools(
|
||||
to: str | list[str],
|
||||
subject: str,
|
||||
html: str,
|
||||
provider: Literal["resend", "gmail"],
|
||||
provider: Literal["resend", "gmail"] | None = None,
|
||||
from_email: str | None = None,
|
||||
cc: str | list[str] | None = None,
|
||||
bcc: str | list[str] | None = None,
|
||||
account: str = "",
|
||||
) -> dict:
|
||||
"""Core email sending logic, callable by other tools."""
|
||||
# Auto-detect provider if not specified
|
||||
if provider is None:
|
||||
provider = _detect_provider(account)
|
||||
if provider is None:
|
||||
return {
|
||||
"error": "No email credentials configured",
|
||||
"help": "Connect Gmail via hive.adenhq.com or set RESEND_API_KEY",
|
||||
}
|
||||
|
||||
from_email = _resolve_from_email(from_email)
|
||||
|
||||
to_list = _normalize_recipients(to)
|
||||
@@ -217,7 +238,7 @@ def register_tools(
|
||||
to: str | list[str],
|
||||
subject: str,
|
||||
html: str,
|
||||
provider: Literal["resend", "gmail"],
|
||||
provider: Literal["resend", "gmail"] | None = None,
|
||||
from_email: str | None = None,
|
||||
cc: str | list[str] | None = None,
|
||||
bcc: str | list[str] | None = None,
|
||||
@@ -230,11 +251,15 @@ def register_tools(
|
||||
- "gmail": Use Gmail API (requires Gmail OAuth2 via Aden)
|
||||
- "resend": Use Resend API (requires RESEND_API_KEY)
|
||||
|
||||
If provider is not specified, auto-detects from available credentials
|
||||
(prefers Gmail over Resend).
|
||||
|
||||
Args:
|
||||
to: Recipient email address(es). Single string or list of strings.
|
||||
subject: Email subject line (1-998 chars per RFC 2822).
|
||||
html: Email body as HTML string.
|
||||
provider: Email provider to use ("gmail" or "resend"). Required.
|
||||
provider: Email provider to use ("gmail" or "resend"). Optional.
|
||||
If omitted, auto-detects from configured credentials.
|
||||
from_email: Sender email address. Falls back to EMAIL_FROM env var if not provided.
|
||||
Optional for Gmail (defaults to authenticated user's address).
|
||||
cc: CC recipient(s). Single string or list of strings. Optional.
|
||||
|
||||
@@ -149,6 +149,74 @@ class TestCredentialStoreAdapterToolMapping:
|
||||
# Should only appear once even though two tools need it
|
||||
assert len(missing) == 1
|
||||
|
||||
def test_multi_provider_satisfied_by_any(self, monkeypatch):
|
||||
"""For multi-provider tools, having ANY provider credential is sufficient."""
|
||||
custom_specs = {
|
||||
"provider_a": CredentialSpec(
|
||||
env_var="PROVIDER_A_KEY",
|
||||
tools=["multi_tool"],
|
||||
required=True,
|
||||
),
|
||||
"provider_b": CredentialSpec(
|
||||
env_var="PROVIDER_B_KEY",
|
||||
tools=["multi_tool"],
|
||||
required=True,
|
||||
),
|
||||
}
|
||||
monkeypatch.delenv("PROVIDER_A_KEY", raising=False)
|
||||
monkeypatch.setenv("PROVIDER_B_KEY", "some-key")
|
||||
|
||||
creds = CredentialStoreAdapter.with_env_storage(specs=custom_specs)
|
||||
missing = creds.get_missing_for_tools(["multi_tool"])
|
||||
|
||||
assert missing == []
|
||||
|
||||
def test_multi_provider_reports_all_when_none_available(self, monkeypatch):
|
||||
"""For multi-provider tools, reports ALL alternatives when none available."""
|
||||
custom_specs = {
|
||||
"provider_a": CredentialSpec(
|
||||
env_var="PROVIDER_A_KEY",
|
||||
tools=["multi_tool"],
|
||||
required=True,
|
||||
),
|
||||
"provider_b": CredentialSpec(
|
||||
env_var="PROVIDER_B_KEY",
|
||||
tools=["multi_tool"],
|
||||
required=True,
|
||||
),
|
||||
}
|
||||
monkeypatch.delenv("PROVIDER_A_KEY", raising=False)
|
||||
monkeypatch.delenv("PROVIDER_B_KEY", raising=False)
|
||||
|
||||
creds = CredentialStoreAdapter.with_env_storage(specs=custom_specs)
|
||||
missing = creds.get_missing_for_tools(["multi_tool"])
|
||||
|
||||
assert len(missing) == 2
|
||||
cred_names = {name for name, _ in missing}
|
||||
assert cred_names == {"provider_a", "provider_b"}
|
||||
|
||||
def test_get_credential_for_tool_prefers_available(self, monkeypatch):
|
||||
"""get_credential_for_tool returns available provider for multi-provider tools."""
|
||||
custom_specs = {
|
||||
"provider_a": CredentialSpec(
|
||||
env_var="PROVIDER_A_KEY",
|
||||
tools=["multi_tool"],
|
||||
required=True,
|
||||
),
|
||||
"provider_b": CredentialSpec(
|
||||
env_var="PROVIDER_B_KEY",
|
||||
tools=["multi_tool"],
|
||||
required=True,
|
||||
),
|
||||
}
|
||||
monkeypatch.delenv("PROVIDER_A_KEY", raising=False)
|
||||
monkeypatch.setenv("PROVIDER_B_KEY", "some-key")
|
||||
|
||||
creds = CredentialStoreAdapter.with_env_storage(specs=custom_specs)
|
||||
result = creds.get_credential_for_tool("multi_tool")
|
||||
|
||||
assert result == "provider_b"
|
||||
|
||||
|
||||
class TestCredentialStoreAdapterValidation:
|
||||
"""Tests for validate_for_tools() behavior."""
|
||||
|
||||
@@ -488,13 +488,85 @@ class TestGmailProvider:
|
||||
assert result["provider"] == "gmail"
|
||||
|
||||
|
||||
class TestProviderRequired:
|
||||
"""Tests that provider is a required parameter."""
|
||||
class TestProviderAutoDetect:
|
||||
"""Tests that provider auto-detects from available credentials."""
|
||||
|
||||
def test_missing_provider_raises_type_error(self, send_email_fn):
|
||||
"""Calling send_email without provider raises TypeError."""
|
||||
with pytest.raises(TypeError):
|
||||
send_email_fn(to="test@example.com", subject="Test", html="<p>Hi</p>")
|
||||
def test_no_credentials_returns_error(self, send_email_fn, monkeypatch):
|
||||
"""Omitting provider with no credentials returns helpful error."""
|
||||
monkeypatch.delenv("RESEND_API_KEY", raising=False)
|
||||
monkeypatch.delenv("GOOGLE_ACCESS_TOKEN", raising=False)
|
||||
|
||||
result = send_email_fn(to="test@example.com", subject="Test", html="<p>Hi</p>")
|
||||
|
||||
assert "error" in result
|
||||
assert "No email credentials configured" in result["error"]
|
||||
|
||||
def test_auto_detects_gmail(self, send_email_fn, monkeypatch):
|
||||
"""Omitting provider with Gmail credentials uses Gmail."""
|
||||
monkeypatch.setenv("GOOGLE_ACCESS_TOKEN", "test_gmail_token")
|
||||
monkeypatch.delenv("RESEND_API_KEY", raising=False)
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"id": "gmail_auto"}
|
||||
|
||||
with patch(_HTTPX_POST, return_value=mock_response):
|
||||
result = send_email_fn(
|
||||
to="test@example.com", subject="Test", html="<p>Hi</p>"
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["provider"] == "gmail"
|
||||
|
||||
def test_auto_detects_resend(self, send_email_fn, monkeypatch):
|
||||
"""Omitting provider with only Resend credentials uses Resend."""
|
||||
monkeypatch.setenv("RESEND_API_KEY", "re_test_key")
|
||||
monkeypatch.setenv("EMAIL_FROM", "test@example.com")
|
||||
monkeypatch.delenv("GOOGLE_ACCESS_TOKEN", raising=False)
|
||||
|
||||
with patch("resend.Emails.send") as mock_send:
|
||||
mock_send.return_value = {"id": "resend_auto"}
|
||||
result = send_email_fn(
|
||||
to="test@example.com", subject="Test", html="<p>Hi</p>"
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["provider"] == "resend"
|
||||
|
||||
def test_prefers_gmail_over_resend(self, send_email_fn, monkeypatch):
|
||||
"""When both providers are available, prefers Gmail."""
|
||||
monkeypatch.setenv("GOOGLE_ACCESS_TOKEN", "test_gmail_token")
|
||||
monkeypatch.setenv("RESEND_API_KEY", "re_test_key")
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"id": "gmail_preferred"}
|
||||
|
||||
with patch(_HTTPX_POST, return_value=mock_response):
|
||||
result = send_email_fn(
|
||||
to="test@example.com", subject="Test", html="<p>Hi</p>"
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["provider"] == "gmail"
|
||||
|
||||
def test_explicit_provider_still_works(self, send_email_fn, monkeypatch):
|
||||
"""Explicit provider overrides auto-detection."""
|
||||
monkeypatch.setenv("GOOGLE_ACCESS_TOKEN", "test_gmail_token")
|
||||
monkeypatch.setenv("RESEND_API_KEY", "re_test_key")
|
||||
monkeypatch.setenv("EMAIL_FROM", "test@example.com")
|
||||
|
||||
with patch("resend.Emails.send") as mock_send:
|
||||
mock_send.return_value = {"id": "resend_explicit"}
|
||||
result = send_email_fn(
|
||||
to="test@example.com",
|
||||
subject="Test",
|
||||
html="<p>Hi</p>",
|
||||
provider="resend",
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["provider"] == "resend"
|
||||
|
||||
|
||||
_HTTPX_GET = "aden_tools.tools.email_tool.email_tool.httpx.get"
|
||||
|
||||
Reference in New Issue
Block a user