diff --git a/CHANGELOG.md b/CHANGELOG.md
index 499ae7f8..6e83c2aa 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
---
+## v26.06.71 (2026-06-07)
+
+### Added (tests — behavior coverage for external adapters)
+
+Closes the audit gap where several external adapters were only wiring/`isinstance`-tested. Added
+**42 mocked-I/O behavior tests** (no network/Docker) asserting both the outbound request shape
+(URL, verb, payload, auth headers) and the response→domain parsing, for:
+
+- **Notifications**: `ResendEmailProvider`, `TwilioSmsProvider`, `FirebasePushProvider`.
+- **ECM e-signature**: `DocuSignESignatureAdapter`, `AdobeSignESignatureAdapter`, `LogaltyESignatureAdapter`.
+- **ECM storage**: `AwsS3StorageAdapter` (fake injected boto3 client).
+- **IDP**: `KeycloakIdpAdapter`.
+
+Each covers happy-path, error/non-2xx mapping, and key edge cases (sender precedence, attachment
+encoding, multi-token partial success, status mapping, etc.).
+
## v26.06.70 (2026-06-07)
### Performance (notifications/ECM — pooled outbound HTTP clients)
diff --git a/README.md b/README.md
index c8129544..24876604 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@
-
+
diff --git a/pyproject.toml b/pyproject.toml
index b18a7ed8..5d26b13d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -7,7 +7,7 @@ name = "pyfly"
# CalVer YY.MM.PATCH — package metadata uses PEP 440 normalized form (26.5.4);
# git tag, GitHub release and human-readable display use leading-zero form
# (v26.05.04) to match the Java/.NET/Go siblings.
-version = "26.6.70"
+version = "26.6.71"
description = "The official Python implementation of the Firefly Framework — DI, CQRS, EDA, hexagonal architecture, and more."
readme = "README.md"
license = "Apache-2.0"
diff --git a/src/pyfly/__init__.py b/src/pyfly/__init__.py
index 1cb50280..24fe5d08 100644
--- a/src/pyfly/__init__.py
+++ b/src/pyfly/__init__.py
@@ -13,4 +13,4 @@
# limitations under the License.
"""PyFly — Enterprise Python Framework."""
-__version__ = "26.06.70"
+__version__ = "26.06.71"
diff --git a/tests/ecm/test_adobe_sign_behavior.py b/tests/ecm/test_adobe_sign_behavior.py
new file mode 100644
index 00000000..b49159c6
--- /dev/null
+++ b/tests/ecm/test_adobe_sign_behavior.py
@@ -0,0 +1,250 @@
+# Copyright 2026 Firefly Software Foundation.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Behavior tests for :class:`AdobeSignESignatureAdapter`.
+
+These exercise the adapter against a fake httpx client injected at ``adapter._http``
+so we verify BOTH the outbound request the adapter builds (URL, verb, payload, auth
+headers) AND how it parses each response into its domain types — with no network,
+Docker, or real httpx connections involved.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+
+from pyfly.ecm.adapters.adobe_sign import AdobeSignESignatureAdapter
+from pyfly.ecm.models import (
+ ESignatureEnvelope,
+ ESignatureStatus,
+ Recipient,
+ SignatureRequest,
+)
+
+API_BASE = "https://api.eu1.adobesign.com/api/rest/v6"
+TOKEN = "secret-integration-key" # noqa: S105 - test fixture, not a real credential
+
+
+class _HttpStatusError(Exception):
+ """Mirrors ``httpx.HTTPStatusError`` for the error-path assertion."""
+
+
+class FakeResponse:
+ """A minimal stand-in for ``httpx.Response``."""
+
+ def __init__(
+ self,
+ *,
+ status_code: int,
+ json_body: Any = None,
+ text: str = "",
+ headers: dict[str, str] | None = None,
+ ) -> None:
+ self.status_code = status_code
+ self._json = json_body
+ self.text = text
+ self.headers = headers or {}
+
+ def json(self) -> Any:
+ return self._json
+
+ def raise_for_status(self) -> None:
+ if self.status_code >= 400:
+ msg = f"HTTP {self.status_code}"
+ raise _HttpStatusError(msg)
+
+
+class FakeHttpClient:
+ """Records each outbound call and replays canned responses.
+
+ ``responses`` maps the lower-case HTTP verb to the :class:`FakeResponse`
+ that verb should return. Every invocation appends ``(url, kwargs)`` to
+ ``self.calls`` so tests can assert exactly what the adapter built.
+ """
+
+ def __init__(self, responses: dict[str, FakeResponse]) -> None:
+ self._responses = responses
+ self.calls: list[tuple[str, str, dict[str, Any]]] = []
+
+ async def post(self, url: str, **kwargs: Any) -> FakeResponse:
+ return self._record("post", url, kwargs)
+
+ async def get(self, url: str, **kwargs: Any) -> FakeResponse:
+ return self._record("get", url, kwargs)
+
+ async def put(self, url: str, **kwargs: Any) -> FakeResponse:
+ return self._record("put", url, kwargs)
+
+ async def delete(self, url: str, **kwargs: Any) -> FakeResponse:
+ return self._record("delete", url, kwargs)
+
+ async def patch(self, url: str, **kwargs: Any) -> FakeResponse:
+ return self._record("patch", url, kwargs)
+
+ def _record(self, verb: str, url: str, kwargs: dict[str, Any]) -> FakeResponse:
+ self.calls.append((verb, url, kwargs))
+ return self._responses[verb]
+
+
+def _adapter(fake: FakeHttpClient) -> AdobeSignESignatureAdapter:
+ adapter = AdobeSignESignatureAdapter(api_base=API_BASE, access_token=TOKEN)
+ adapter._http = fake # inject before any method call
+ return adapter
+
+
+def _signature_request() -> SignatureRequest:
+ return SignatureRequest(
+ document_id="transient-doc-123",
+ recipients=[
+ Recipient(name="Alice", email="alice@example.com"),
+ Recipient(name="Bob", email="bob@example.com"),
+ ],
+ subject="Loan agreement",
+ message="Please review and sign.",
+ )
+
+
+# ---------------------------------------------------------------------------
+# send()
+# ---------------------------------------------------------------------------
+
+
+class TestSend:
+ @pytest.mark.asyncio
+ async def test_builds_request_and_parses_envelope(self) -> None:
+ fake = FakeHttpClient({"post": FakeResponse(status_code=201, json_body={"id": "CBJCHBCAABAA-agreement-id"})})
+ adapter = _adapter(fake)
+
+ envelope = await adapter.send(_signature_request())
+
+ # (a) outbound request the adapter built
+ assert len(fake.calls) == 1
+ verb, url, kwargs = fake.calls[0]
+ assert verb == "post"
+ assert url == f"{API_BASE}/agreements"
+
+ payload = kwargs["json"]
+ assert payload["fileInfos"] == [{"transientDocumentId": "transient-doc-123"}]
+ assert payload["name"] == "Loan agreement"
+ assert payload["message"] == "Please review and sign."
+ assert payload["signatureType"] == "ESIGN"
+ assert payload["state"] == "IN_PROCESS"
+ # recipients become ordered SIGNER participant sets
+ assert payload["participantSetsInfo"] == [
+ {"memberInfos": [{"email": "alice@example.com"}], "order": 1, "role": "SIGNER"},
+ {"memberInfos": [{"email": "bob@example.com"}], "order": 2, "role": "SIGNER"},
+ ]
+
+ # auth headers
+ headers = kwargs["headers"]
+ assert headers["Authorization"] == f"Bearer {TOKEN}"
+ assert headers["Content-Type"] == "application/json"
+ assert headers["Accept"] == "application/json"
+
+ # (b) parsed domain return type
+ assert isinstance(envelope, ESignatureEnvelope)
+ assert envelope.provider == "adobe-sign"
+ assert envelope.document_id == "transient-doc-123"
+ assert envelope.status == ESignatureStatus.SENT
+ assert envelope.provider_envelope_id == "CBJCHBCAABAA-agreement-id"
+ assert envelope.sent_at is not None
+
+ @pytest.mark.asyncio
+ async def test_non_2xx_raises_via_raise_for_status(self) -> None:
+ fake = FakeHttpClient({"post": FakeResponse(status_code=400, text="INVALID_FILE_INFO")})
+ adapter = _adapter(fake)
+
+ with pytest.raises(_HttpStatusError):
+ await adapter.send(_signature_request())
+
+ # the adapter still issued exactly one request before failing
+ assert len(fake.calls) == 1
+ assert fake.calls[0][0] == "post"
+
+
+# ---------------------------------------------------------------------------
+# get()
+# ---------------------------------------------------------------------------
+
+
+class TestGet:
+ @pytest.mark.asyncio
+ async def test_maps_signed_status(self) -> None:
+ fake = FakeHttpClient({"get": FakeResponse(status_code=200, json_body={"status": "SIGNED"})})
+ adapter = _adapter(fake)
+
+ envelope = await adapter.get("agreement-42")
+
+ verb, url, kwargs = fake.calls[0]
+ assert verb == "get"
+ assert url == f"{API_BASE}/agreements/agreement-42"
+ assert kwargs["headers"]["Authorization"] == f"Bearer {TOKEN}"
+
+ assert envelope is not None
+ assert envelope.provider == "adobe-sign"
+ assert envelope.provider_envelope_id == "agreement-42"
+ assert envelope.status == ESignatureStatus.SIGNED
+
+ @pytest.mark.asyncio
+ async def test_maps_out_for_signature_to_sent(self) -> None:
+ fake = FakeHttpClient({"get": FakeResponse(status_code=200, json_body={"status": "OUT_FOR_SIGNATURE"})})
+ adapter = _adapter(fake)
+
+ envelope = await adapter.get("agreement-99")
+
+ assert envelope is not None
+ assert envelope.status == ESignatureStatus.SENT
+
+ @pytest.mark.asyncio
+ async def test_404_returns_none(self) -> None:
+ fake = FakeHttpClient({"get": FakeResponse(status_code=404, text="not found")})
+ adapter = _adapter(fake)
+
+ result = await adapter.get("missing-agreement")
+
+ assert result is None
+ # a 404 short-circuits BEFORE raise_for_status, so the request was still made
+ assert fake.calls[0][1] == f"{API_BASE}/agreements/missing-agreement"
+
+
+# ---------------------------------------------------------------------------
+# cancel()
+# ---------------------------------------------------------------------------
+
+
+class TestCancel:
+ @pytest.mark.asyncio
+ async def test_success_returns_true_and_sends_cancel_state(self) -> None:
+ fake = FakeHttpClient({"put": FakeResponse(status_code=200, json_body={})})
+ adapter = _adapter(fake)
+
+ ok = await adapter.cancel("agreement-7")
+
+ assert ok is True
+ verb, url, kwargs = fake.calls[0]
+ assert verb == "put"
+ assert url == f"{API_BASE}/agreements/agreement-7/state"
+ assert kwargs["json"]["state"] == "CANCELLED"
+ assert kwargs["json"]["agreementCancellationInfo"]["comment"] == "cancelled by app"
+ assert kwargs["headers"]["Authorization"] == f"Bearer {TOKEN}"
+
+ @pytest.mark.asyncio
+ async def test_failure_status_returns_false(self) -> None:
+ fake = FakeHttpClient({"put": FakeResponse(status_code=403, text="forbidden")})
+ adapter = _adapter(fake)
+
+ ok = await adapter.cancel("agreement-8")
+
+ assert ok is False
diff --git a/tests/ecm/test_aws_s3_behavior.py b/tests/ecm/test_aws_s3_behavior.py
new file mode 100644
index 00000000..9e8be983
--- /dev/null
+++ b/tests/ecm/test_aws_s3_behavior.py
@@ -0,0 +1,255 @@
+# Copyright 2026 Firefly Software Foundation.
+# Licensed under the Apache License, Version 2.0.
+"""Behavior tests for :class:`AwsS3StorageAdapter`.
+
+These exercise the adapter end-to-end against a *fake* boto3-style ``s3``
+client injected via the ``client=`` constructor parameter — no network, no
+Docker, no real boto3. Each test asserts both halves of the contract:
+
+* the outbound boto3 call the adapter built (method, ``Bucket``/``Key``,
+ ``Body``/``ContentType`` payload fields), and
+* how the adapter parsed the canned response into its domain return types
+ (``DocumentVersion``, raw ``bytes``, ``bool``).
+"""
+
+from __future__ import annotations
+
+import hashlib
+from typing import Any
+
+import pytest
+
+from pyfly.ecm.adapters.aws_s3 import AwsS3StorageAdapter
+from pyfly.ecm.models import Document, DocumentVersion
+
+
+class _StreamingBody:
+ """Mimics botocore's StreamingBody — a one-shot ``.read()`` over bytes."""
+
+ def __init__(self, payload: bytes) -> None:
+ self._payload = payload
+
+ def read(self) -> bytes:
+ return self._payload
+
+
+class _FakeS3Client:
+ """A minimal boto3 ``s3`` client double.
+
+ Records every call into ``self.calls`` as ``(method_name, kwargs)`` tuples
+ and returns canned responses. ``delete_object`` can be told to raise to
+ drive the adapter's error path.
+ """
+
+ def __init__(
+ self,
+ *,
+ get_payload: bytes = b"",
+ delete_raises: Exception | None = None,
+ ) -> None:
+ self.calls: list[tuple[str, dict[str, Any]]] = []
+ self._get_payload = get_payload
+ self._delete_raises = delete_raises
+
+ def put_object(self, **kwargs: Any) -> dict[str, Any]:
+ self.calls.append(("put_object", kwargs))
+ # A real S3 PutObject returns an ETag + version metadata.
+ return {"ETag": '"d41d8cd98f00b204e9800998ecf8427e"', "VersionId": "null"}
+
+ def get_object(self, **kwargs: Any) -> dict[str, Any]:
+ self.calls.append(("get_object", kwargs))
+ return {
+ "Body": _StreamingBody(self._get_payload),
+ "ContentLength": len(self._get_payload),
+ "ContentType": "application/octet-stream",
+ }
+
+ def delete_object(self, **kwargs: Any) -> dict[str, Any]:
+ self.calls.append(("delete_object", kwargs))
+ if self._delete_raises is not None:
+ raise self._delete_raises
+ return {"ResponseMetadata": {"HTTPStatusCode": 204}}
+
+
+def _doc(**overrides: Any) -> Document:
+ base: dict[str, Any] = {
+ "id": "doc-abc",
+ "name": "report.pdf",
+ "content_type": "application/pdf",
+ }
+ base.update(overrides)
+ return Document(**base)
+
+
+# ---------------------------------------------------------------------------
+# upload — request construction + DocumentVersion parsing
+# ---------------------------------------------------------------------------
+
+
+class TestUpload:
+ @pytest.mark.asyncio
+ async def test_first_upload_builds_v1_put_and_returns_version(self) -> None:
+ client = _FakeS3Client()
+ adapter = AwsS3StorageAdapter("my-bucket", region="eu-west-1", client=client)
+ document = _doc()
+ content = b"hello world"
+
+ result = await adapter.upload(document, content)
+
+ # (a) outbound request: exactly one put_object with the right payload.
+ assert len(client.calls) == 1
+ method, kwargs = client.calls[0]
+ assert method == "put_object"
+ assert kwargs["Bucket"] == "my-bucket"
+ assert kwargs["Key"] == "doc-abc/v1"
+ assert kwargs["Body"] == content
+ assert kwargs["ContentType"] == "application/pdf"
+
+ # (b) parsed domain return type.
+ assert isinstance(result, DocumentVersion)
+ assert result.version == 1
+ assert result.size_bytes == len(content)
+ assert result.content_hash == hashlib.sha256(content).hexdigest()
+ assert result.storage_uri == "s3://my-bucket/doc-abc/v1"
+
+ @pytest.mark.asyncio
+ async def test_upload_honors_key_prefix_and_increments_version(self) -> None:
+ client = _FakeS3Client()
+ adapter = AwsS3StorageAdapter("bkt", key_prefix="tenants/acme/", client=client)
+ # Existing version 1 means the next upload must become version 2.
+ document = _doc(
+ versions=[
+ DocumentVersion(
+ version=1,
+ content_hash="x",
+ size_bytes=3,
+ storage_uri="s3://bkt/tenants/acme/doc-abc/v1",
+ )
+ ]
+ )
+
+ result = await adapter.upload(document, b"second")
+
+ _method, kwargs = client.calls[0]
+ # Prefix is normalized to a single trailing slash and prepended to the key.
+ assert kwargs["Key"] == "tenants/acme/doc-abc/v2"
+ assert result.version == 2
+ assert result.storage_uri == "s3://bkt/tenants/acme/doc-abc/v2"
+
+
+# ---------------------------------------------------------------------------
+# download — request construction + bytes parsing
+# ---------------------------------------------------------------------------
+
+
+class TestDownload:
+ @pytest.mark.asyncio
+ async def test_download_latest_reads_streaming_body(self) -> None:
+ payload = b"PDF-BYTES"
+ client = _FakeS3Client(get_payload=payload)
+ adapter = AwsS3StorageAdapter("docs", client=client)
+ document = _doc(
+ versions=[
+ DocumentVersion(version=1, content_hash="a", size_bytes=1, storage_uri="s3://docs/doc-abc/v1"),
+ DocumentVersion(version=2, content_hash="b", size_bytes=2, storage_uri="s3://docs/doc-abc/v2"),
+ ]
+ )
+
+ body = await adapter.download(document)
+
+ # (a) outbound request targets the *latest* version key.
+ method, kwargs = client.calls[0]
+ assert method == "get_object"
+ assert kwargs == {"Bucket": "docs", "Key": "doc-abc/v2"}
+
+ # (b) StreamingBody was consumed into raw bytes.
+ assert body == payload
+ assert isinstance(body, bytes)
+
+ @pytest.mark.asyncio
+ async def test_download_explicit_version_targets_that_key(self) -> None:
+ client = _FakeS3Client(get_payload=b"v1-bytes")
+ adapter = AwsS3StorageAdapter("docs", client=client)
+ document = _doc(
+ versions=[
+ DocumentVersion(version=1, content_hash="a", size_bytes=1, storage_uri="s3://docs/doc-abc/v1"),
+ DocumentVersion(version=2, content_hash="b", size_bytes=2, storage_uri="s3://docs/doc-abc/v2"),
+ ]
+ )
+
+ body = await adapter.download(document, version=1)
+
+ _method, kwargs = client.calls[0]
+ assert kwargs["Key"] == "doc-abc/v1"
+ assert body == b"v1-bytes"
+
+ @pytest.mark.asyncio
+ async def test_download_without_versions_raises_and_makes_no_call(self) -> None:
+ client = _FakeS3Client()
+ adapter = AwsS3StorageAdapter("docs", client=client)
+
+ with pytest.raises(FileNotFoundError):
+ await adapter.download(_doc(versions=[]))
+
+ # The adapter short-circuits before ever touching S3.
+ assert client.calls == []
+
+
+# ---------------------------------------------------------------------------
+# delete — request construction, bool result, and error-path mapping
+# ---------------------------------------------------------------------------
+
+
+class TestDelete:
+ @pytest.mark.asyncio
+ async def test_delete_all_versions_issues_one_call_each(self) -> None:
+ client = _FakeS3Client()
+ adapter = AwsS3StorageAdapter("docs", client=client)
+ document = _doc(
+ versions=[
+ DocumentVersion(version=1, content_hash="a", size_bytes=1, storage_uri="s3://docs/doc-abc/v1"),
+ DocumentVersion(version=2, content_hash="b", size_bytes=2, storage_uri="s3://docs/doc-abc/v2"),
+ ]
+ )
+
+ ok = await adapter.delete(document)
+
+ assert ok is True
+ keys = [kwargs["Key"] for method, kwargs in client.calls if method == "delete_object"]
+ assert keys == ["doc-abc/v1", "doc-abc/v2"]
+
+ @pytest.mark.asyncio
+ async def test_delete_single_version_targets_that_key(self) -> None:
+ client = _FakeS3Client()
+ adapter = AwsS3StorageAdapter("docs", client=client)
+
+ ok = await adapter.delete(_doc(versions=[]), version=3)
+
+ assert ok is True
+ assert len(client.calls) == 1
+ method, kwargs = client.calls[0]
+ assert method == "delete_object"
+ assert kwargs == {"Bucket": "docs", "Key": "doc-abc/v3"}
+
+ @pytest.mark.asyncio
+ async def test_delete_no_versions_returns_false_without_calling(self) -> None:
+ client = _FakeS3Client()
+ adapter = AwsS3StorageAdapter("docs", client=client)
+
+ ok = await adapter.delete(_doc(versions=[]))
+
+ assert ok is False
+ assert client.calls == []
+
+ @pytest.mark.asyncio
+ async def test_delete_maps_client_error_to_false(self) -> None:
+ # Error path: a failing S3 delete_object is mapped to a False result,
+ # not re-raised, when a specific version is requested.
+ client = _FakeS3Client(delete_raises=RuntimeError("AccessDenied"))
+ adapter = AwsS3StorageAdapter("docs", client=client)
+
+ ok = await adapter.delete(_doc(versions=[]), version=1)
+
+ assert ok is False
+ # The call was still attempted before the failure was swallowed.
+ assert client.calls[0][0] == "delete_object"
diff --git a/tests/ecm/test_docusign_behavior.py b/tests/ecm/test_docusign_behavior.py
new file mode 100644
index 00000000..135c303d
--- /dev/null
+++ b/tests/ecm/test_docusign_behavior.py
@@ -0,0 +1,242 @@
+# Copyright 2026 Firefly Software Foundation.
+# Licensed under the Apache License, Version 2.0.
+"""Behavior tests for :class:`DocuSignESignatureAdapter`.
+
+These exercise the adapter end-to-end against a fake pooled HTTP client (no
+network, no Docker). They assert BOTH the outbound request the adapter builds
+(URL/verb/payload/auth headers) AND that the canned response is parsed into the
+correct ``ESignatureEnvelope`` domain object.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+
+from pyfly.ecm.adapters.docusign import DocuSignESignatureAdapter
+from pyfly.ecm.models import (
+ ESignatureStatus,
+ Recipient,
+ SignatureRequest,
+)
+
+
+class _FakeResponse:
+ """Stand-in for ``httpx.Response`` covering only what the adapter touches."""
+
+ def __init__(
+ self,
+ *,
+ status_code: int = 200,
+ json_body: Any = None,
+ text: str = "",
+ headers: dict[str, str] | None = None,
+ ) -> None:
+ self.status_code = status_code
+ self._json = json_body if json_body is not None else {}
+ self.text = text
+ self.headers = headers or {}
+
+ def json(self) -> Any:
+ return self._json
+
+ def raise_for_status(self) -> None:
+ if self.status_code >= 400:
+ msg = f"HTTP {self.status_code}"
+ raise RuntimeError(msg)
+
+
+class _FakeHttpClient:
+ """Records every request and replays a pre-seeded response per verb.
+
+ Set on ``adapter._http`` BEFORE calling a method; the adapter's ``_client()``
+ then wraps it in a ``PooledHttpClient`` which yields this instance unchanged.
+ """
+
+ def __init__(self, responses: dict[str, _FakeResponse]) -> None:
+ self._responses = responses
+ self.calls: list[tuple[str, str, dict[str, Any]]] = []
+
+ async def _record(self, verb: str, url: str, **kwargs: Any) -> _FakeResponse:
+ self.calls.append((verb, url, kwargs))
+ return self._responses[verb]
+
+ async def post(self, url: str, **kwargs: Any) -> _FakeResponse:
+ return await self._record("post", url, **kwargs)
+
+ async def get(self, url: str, **kwargs: Any) -> _FakeResponse:
+ return await self._record("get", url, **kwargs)
+
+ async def put(self, url: str, **kwargs: Any) -> _FakeResponse:
+ return await self._record("put", url, **kwargs)
+
+
+_BASE_URL = "https://demo.docusign.net/restapi"
+_ACCOUNT_ID = "acct-123"
+_ACCESS_TOKEN = "tok-abc"
+
+
+def _adapter(fake: _FakeHttpClient) -> DocuSignESignatureAdapter:
+ adapter = DocuSignESignatureAdapter(
+ base_url=_BASE_URL + "/", # trailing slash must be stripped
+ account_id=_ACCOUNT_ID,
+ access_token=_ACCESS_TOKEN,
+ )
+ adapter._http = fake # inject the fake before any I/O
+ return adapter
+
+
+# ---------------------------------------------------------------------------
+# send()
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_send_builds_request_and_parses_envelope() -> None:
+ fake = _FakeHttpClient({"post": _FakeResponse(status_code=201, json_body={"envelopeId": "env-789"})})
+ adapter = _adapter(fake)
+
+ request = SignatureRequest(
+ document_id="doc-1",
+ recipients=[
+ Recipient(name="Alice", email="alice@example.com"),
+ Recipient(name="Bob", email="bob@example.com"),
+ ],
+ subject="Sign please",
+ message="Kindly review and sign.",
+ )
+
+ envelope = await adapter.send(request)
+
+ # (a) outbound request the adapter built
+ assert len(fake.calls) == 1
+ verb, url, kwargs = fake.calls[0]
+ assert verb == "post"
+ assert url == f"{_BASE_URL}/v2.1/accounts/{_ACCOUNT_ID}/envelopes"
+
+ headers = kwargs["headers"]
+ assert headers["Authorization"] == f"Bearer {_ACCESS_TOKEN}"
+ assert headers["Content-Type"] == "application/json"
+ assert headers["Accept"] == "application/json"
+
+ payload = kwargs["json"]
+ assert payload["emailSubject"] == "Sign please"
+ assert payload["emailBlurb"] == "Kindly review and sign."
+ assert payload["status"] == "sent"
+ assert payload["documents"][0]["documentId"] == "doc-1"
+ assert payload["documents"][0]["fileExtension"] == "pdf"
+ signers = payload["recipients"]["signers"]
+ assert [s["email"] for s in signers] == ["alice@example.com", "bob@example.com"]
+ assert [s["name"] for s in signers] == ["Alice", "Bob"]
+ # recipientId / routingOrder are 1-based strings
+ assert [s["recipientId"] for s in signers] == ["1", "2"]
+ assert [s["routingOrder"] for s in signers] == ["1", "2"]
+
+ # (b) parsed domain return type
+ assert envelope.provider == "docusign"
+ assert envelope.document_id == "doc-1"
+ assert envelope.status is ESignatureStatus.SENT
+ assert envelope.provider_envelope_id == "env-789"
+ assert envelope.sent_at is not None
+
+
+@pytest.mark.asyncio
+async def test_send_raises_on_error_status() -> None:
+ fake = _FakeHttpClient({"post": _FakeResponse(status_code=401, json_body={"errorCode": "AUTH"})})
+ adapter = _adapter(fake)
+ request = SignatureRequest(
+ document_id="doc-err",
+ recipients=[Recipient(name="Carol", email="carol@example.com")],
+ )
+
+ with pytest.raises(RuntimeError):
+ await adapter.send(request)
+
+ # the adapter still issued exactly one POST before raising
+ assert len(fake.calls) == 1
+ assert fake.calls[0][0] == "post"
+
+
+# ---------------------------------------------------------------------------
+# get()
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_get_parses_completed_envelope() -> None:
+ fake = _FakeHttpClient(
+ {
+ "get": _FakeResponse(
+ status_code=200,
+ json_body={
+ "status": "completed",
+ "sentDateTime": "2026-06-01T10:00:00Z",
+ "completedDateTime": "2026-06-02T12:30:00Z",
+ },
+ )
+ }
+ )
+ adapter = _adapter(fake)
+
+ envelope = await adapter.get("env-555")
+
+ # (a) outbound request
+ verb, url, kwargs = fake.calls[0]
+ assert verb == "get"
+ assert url == f"{_BASE_URL}/v2.1/accounts/{_ACCOUNT_ID}/envelopes/env-555"
+ assert kwargs["headers"]["Authorization"] == f"Bearer {_ACCESS_TOKEN}"
+
+ # (b) "completed" maps to SIGNED, both timestamps parsed
+ assert envelope is not None
+ assert envelope.status is ESignatureStatus.SIGNED
+ assert envelope.provider_envelope_id == "env-555"
+ assert envelope.sent_at is not None
+ assert envelope.sent_at.year == 2026
+ assert envelope.signed_at is not None
+ assert envelope.signed_at.day == 2
+
+
+@pytest.mark.asyncio
+async def test_get_returns_none_on_404() -> None:
+ fake = _FakeHttpClient({"get": _FakeResponse(status_code=404, json_body={})})
+ adapter = _adapter(fake)
+
+ result = await adapter.get("missing")
+
+ assert result is None
+ assert fake.calls[0][0] == "get"
+
+
+# ---------------------------------------------------------------------------
+# cancel()
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_cancel_voids_envelope_and_returns_true() -> None:
+ fake = _FakeHttpClient({"put": _FakeResponse(status_code=200, json_body={})})
+ adapter = _adapter(fake)
+
+ ok = await adapter.cancel("env-321")
+
+ verb, url, kwargs = fake.calls[0]
+ assert verb == "put"
+ assert url == f"{_BASE_URL}/v2.1/accounts/{_ACCOUNT_ID}/envelopes/env-321"
+ assert kwargs["json"] == {
+ "status": "voided",
+ "voidedReason": "cancelled by application",
+ }
+ assert kwargs["headers"]["Authorization"] == f"Bearer {_ACCESS_TOKEN}"
+ assert ok is True
+
+
+@pytest.mark.asyncio
+async def test_cancel_returns_false_on_non_200() -> None:
+ fake = _FakeHttpClient({"put": _FakeResponse(status_code=409, json_body={})})
+ adapter = _adapter(fake)
+
+ ok = await adapter.cancel("env-409")
+
+ assert ok is False
+ assert fake.calls[0][0] == "put"
diff --git a/tests/ecm/test_logalty_behavior.py b/tests/ecm/test_logalty_behavior.py
new file mode 100644
index 00000000..b405648d
--- /dev/null
+++ b/tests/ecm/test_logalty_behavior.py
@@ -0,0 +1,213 @@
+# Copyright 2026 Firefly Software Foundation.
+# Licensed under the Apache License, Version 2.0.
+"""Behavior tests for ``LogaltyESignatureAdapter`` (mocked HTTP, no network).
+
+A fake pooled HTTP client is injected at ``adapter._http`` so that
+``async with await adapter._client() as client:`` yields the fake. Each test
+asserts both the outbound request the adapter builds (URL, verb, payload, auth
+headers) and how the adapter parses the canned response into its domain types.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+import httpx
+import pytest
+
+from pyfly.ecm.adapters.logalty import LogaltyESignatureAdapter
+from pyfly.ecm.models import (
+ ESignatureEnvelope,
+ ESignatureStatus,
+ Recipient,
+ SignatureRequest,
+)
+
+API_BASE = "https://tenant.logalty.example/api/v1"
+API_KEY = "secret-key-123"
+
+
+class FakeResponse:
+ """Minimal stand-in for an ``httpx.Response``."""
+
+ def __init__(
+ self,
+ *,
+ status_code: int,
+ json_body: Any = None,
+ text: str = "",
+ ) -> None:
+ self.status_code = status_code
+ self._json_body = json_body
+ self.text = text
+ self.headers: dict[str, str] = {"content-type": "application/json"}
+
+ def json(self) -> Any:
+ return self._json_body
+
+ def raise_for_status(self) -> None:
+ if self.status_code >= 400:
+ request = httpx.Request("POST", "https://example.invalid")
+ response = httpx.Response(self.status_code, request=request)
+ raise httpx.HTTPStatusError(
+ f"HTTP {self.status_code}",
+ request=request,
+ response=response,
+ )
+
+
+class FakeHttpClient:
+ """Records outbound calls and replays a queue of canned responses.
+
+ Implements the verbs the adapter actually invokes (``post``/``get``/``delete``)
+ as async methods that capture ``(url, kwargs)`` and pop the next response.
+ """
+
+ def __init__(self, *responses: FakeResponse) -> None:
+ self._responses = list(responses)
+ self.calls: list[dict[str, Any]] = []
+
+ def _next(self) -> FakeResponse:
+ if not self._responses:
+ raise AssertionError("FakeHttpClient: no more canned responses queued")
+ return self._responses.pop(0)
+
+ async def post(self, url: str, **kwargs: Any) -> FakeResponse:
+ self.calls.append({"verb": "POST", "url": url, "kwargs": kwargs})
+ return self._next()
+
+ async def get(self, url: str, **kwargs: Any) -> FakeResponse:
+ self.calls.append({"verb": "GET", "url": url, "kwargs": kwargs})
+ return self._next()
+
+ async def delete(self, url: str, **kwargs: Any) -> FakeResponse:
+ self.calls.append({"verb": "DELETE", "url": url, "kwargs": kwargs})
+ return self._next()
+
+
+def _adapter(*responses: FakeResponse) -> tuple[LogaltyESignatureAdapter, FakeHttpClient]:
+ adapter = LogaltyESignatureAdapter(api_base=API_BASE, api_key=API_KEY)
+ fake = FakeHttpClient(*responses)
+ adapter._http = fake # inject before any method call
+ return adapter, fake
+
+
+# ---------------------------------------------------------------------------
+# send()
+# ---------------------------------------------------------------------------
+
+
+class TestSend:
+ @pytest.mark.asyncio
+ async def test_builds_request_and_parses_envelope(self) -> None:
+ adapter, fake = _adapter(FakeResponse(status_code=201, json_body={"envelopeId": "env-789"}))
+ request = SignatureRequest(
+ document_id="doc-42",
+ recipients=[
+ Recipient(name="Alice", email="alice@example.com"),
+ Recipient(name="Bob", email="bob@example.com", role="approver"),
+ ],
+ subject="Sign this",
+ message="Please review and sign.",
+ )
+
+ envelope = await adapter.send(request)
+
+ # (a) outbound request the adapter built
+ assert len(fake.calls) == 1
+ call = fake.calls[0]
+ assert call["verb"] == "POST"
+ assert call["url"] == f"{API_BASE}/envelopes"
+ assert call["kwargs"]["headers"]["X-Api-Key"] == API_KEY
+ assert call["kwargs"]["headers"]["Content-Type"] == "application/json"
+ payload = call["kwargs"]["json"]
+ assert payload["documentId"] == "doc-42"
+ assert payload["subject"] == "Sign this"
+ assert payload["message"] == "Please review and sign."
+ assert payload["signers"] == [
+ {"name": "Alice", "email": "alice@example.com", "role": "signer"},
+ {"name": "Bob", "email": "bob@example.com", "role": "approver"},
+ ]
+
+ # (b) parsed domain return type
+ assert isinstance(envelope, ESignatureEnvelope)
+ assert envelope.provider == "logalty"
+ assert envelope.document_id == "doc-42"
+ assert envelope.status is ESignatureStatus.SENT
+ assert envelope.provider_envelope_id == "env-789"
+ assert envelope.sent_at is not None
+
+ @pytest.mark.asyncio
+ async def test_error_status_raises(self) -> None:
+ adapter, fake = _adapter(FakeResponse(status_code=422, text="bad request"))
+ request = SignatureRequest(
+ document_id="doc-1",
+ recipients=[Recipient(name="Alice", email="alice@example.com")],
+ )
+
+ with pytest.raises(httpx.HTTPStatusError):
+ await adapter.send(request)
+
+ assert fake.calls[0]["verb"] == "POST"
+ assert fake.calls[0]["url"] == f"{API_BASE}/envelopes"
+
+
+# ---------------------------------------------------------------------------
+# get()
+# ---------------------------------------------------------------------------
+
+
+class TestGet:
+ @pytest.mark.asyncio
+ async def test_maps_provider_status_to_domain_enum(self) -> None:
+ adapter, fake = _adapter(FakeResponse(status_code=200, json_body={"status": "COMPLETED"}))
+
+ envelope = await adapter.get("env-789")
+
+ call = fake.calls[0]
+ assert call["verb"] == "GET"
+ assert call["url"] == f"{API_BASE}/envelopes/env-789"
+ assert call["kwargs"]["headers"]["X-Api-Key"] == API_KEY
+
+ assert isinstance(envelope, ESignatureEnvelope)
+ # "COMPLETED" maps to SIGNED per the adapter's status table.
+ assert envelope.status is ESignatureStatus.SIGNED
+ assert envelope.provider_envelope_id == "env-789"
+ assert envelope.provider == "logalty"
+
+ @pytest.mark.asyncio
+ async def test_not_found_returns_none(self) -> None:
+ adapter, fake = _adapter(FakeResponse(status_code=404))
+
+ result = await adapter.get("missing")
+
+ assert result is None
+ assert fake.calls[0]["url"] == f"{API_BASE}/envelopes/missing"
+
+
+# ---------------------------------------------------------------------------
+# cancel()
+# ---------------------------------------------------------------------------
+
+
+class TestCancel:
+ @pytest.mark.asyncio
+ async def test_returns_true_on_204(self) -> None:
+ adapter, fake = _adapter(FakeResponse(status_code=204))
+
+ ok = await adapter.cancel("env-1")
+
+ assert ok is True
+ call = fake.calls[0]
+ assert call["verb"] == "DELETE"
+ assert call["url"] == f"{API_BASE}/envelopes/env-1"
+ assert call["kwargs"]["headers"]["X-Api-Key"] == API_KEY
+
+ @pytest.mark.asyncio
+ async def test_returns_false_on_error_status(self) -> None:
+ adapter, fake = _adapter(FakeResponse(status_code=409))
+
+ ok = await adapter.cancel("env-2")
+
+ assert ok is False
+ assert fake.calls[0]["url"] == f"{API_BASE}/envelopes/env-2"
diff --git a/tests/idp/test_keycloak_behavior.py b/tests/idp/test_keycloak_behavior.py
new file mode 100644
index 00000000..f72743c4
--- /dev/null
+++ b/tests/idp/test_keycloak_behavior.py
@@ -0,0 +1,287 @@
+# Copyright 2026 Firefly Software Foundation.
+# Licensed under the Apache License, Version 2.0.
+"""Behavior tests for :class:`KeycloakIdpAdapter`.
+
+These exercise the adapter against a *fake* httpx client (no network, no
+Docker). The adapter obtains its client via the async ``_client()`` helper and
+uses it inside ``async with await self._client() as client:`` blocks, so we
+inject by monkeypatching ``_client`` on the adapter instance to hand back a
+single recording fake. Each test asserts BOTH the outbound request the adapter
+built (URL, verb, payload, auth headers) AND that the adapter parsed the canned
+response into the right domain object.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+
+from pyfly.idp.adapters.keycloak import KeycloakIdpAdapter
+from pyfly.idp.models import IdpUser, LoginRequest, SessionIntrospection
+
+BASE_URL = "https://keycloak.example.com"
+REALM = "demo"
+TOKEN_URL = f"{BASE_URL}/realms/{REALM}/protocol/openid-connect/token"
+ADMIN_USERS = f"{BASE_URL}/admin/realms/{REALM}/users"
+INTROSPECT_URL = f"{BASE_URL}/realms/{REALM}/protocol/openid-connect/token/introspect"
+
+
+class FakeResponse:
+ """Minimal stand-in for an ``httpx.Response``."""
+
+ def __init__(
+ self,
+ status_code: int = 200,
+ *,
+ json_body: Any = None,
+ headers: dict[str, str] | None = None,
+ ) -> None:
+ self.status_code = status_code
+ self._json = json_body
+ self.headers = headers or {}
+
+ def json(self) -> Any:
+ return self._json
+
+ def raise_for_status(self) -> None:
+ if self.status_code >= 400:
+ # The real httpx raises httpx.HTTPStatusError; for behavior tests a
+ # generic exception is enough to prove the adapter does not swallow
+ # server errors on the happy-path methods.
+ msg = f"HTTP {self.status_code}"
+ raise RuntimeError(msg)
+
+
+class FakeClient:
+ """Records every outbound request and returns canned responses.
+
+ A single instance is returned for *every* ``await self._client()`` call so
+ the token fetch in ``_admin_auth_header`` and the subsequent admin call are
+ captured on the same recorder.
+ """
+
+ def __init__(self, routes: list[tuple[str, FakeResponse]]) -> None:
+ # routes: ordered list of (url-substring, response); first match wins.
+ self._routes = routes
+ self.requests: list[dict[str, Any]] = []
+
+ # async context-manager protocol (`async with await self._client() as c`)
+ async def __aenter__(self) -> FakeClient:
+ return self
+
+ async def __aexit__(self, *exc: object) -> bool:
+ return False
+
+ def _record(self, method: str, url: str, **kwargs: Any) -> FakeResponse:
+ self.requests.append({"method": method, "url": url, **kwargs})
+ for needle, resp in self._routes:
+ if needle in url:
+ return resp
+ msg = f"no canned route for {method} {url}"
+ raise AssertionError(msg)
+
+ async def post(self, url: str, **kwargs: Any) -> FakeResponse:
+ return self._record("POST", url, **kwargs)
+
+ async def get(self, url: str, **kwargs: Any) -> FakeResponse:
+ return self._record("GET", url, **kwargs)
+
+ async def put(self, url: str, **kwargs: Any) -> FakeResponse:
+ return self._record("PUT", url, **kwargs)
+
+ async def delete(self, url: str, **kwargs: Any) -> FakeResponse:
+ return self._record("DELETE", url, **kwargs)
+
+ async def request(self, method: str, url: str, **kwargs: Any) -> FakeResponse:
+ return self._record(method, url, **kwargs)
+
+
+def _adapter() -> KeycloakIdpAdapter:
+ return KeycloakIdpAdapter(
+ base_url=BASE_URL,
+ realm=REALM,
+ client_id="admin-cli",
+ client_secret="s3cr3t",
+ )
+
+
+def _inject(adapter: KeycloakIdpAdapter, fake: FakeClient) -> None:
+ """Make every ``await self._client()`` return the same recording fake."""
+
+ async def _fake_client() -> FakeClient:
+ return fake
+
+ adapter._client = _fake_client # type: ignore[method-assign] # noqa: SLF001
+
+
+def _find(requests: list[dict[str, Any]], *, method: str, needle: str) -> dict[str, Any]:
+ for req in requests:
+ if req["method"] == method and needle in req["url"]:
+ return req
+ msg = f"no {method} request to …{needle} was made (got {requests})"
+ raise AssertionError(msg)
+
+
+# --------------------------------------------------------------------------- #
+# create_user — admin token grant + user POST + Location id parsing
+# --------------------------------------------------------------------------- #
+@pytest.mark.asyncio
+async def test_create_user_builds_admin_request_and_parses_location_id() -> None:
+ fake = FakeClient(
+ [
+ (
+ "openid-connect/token",
+ FakeResponse(200, json_body={"access_token": "ADMIN-TOK", "expires_in": 300}),
+ ),
+ (
+ "/users",
+ FakeResponse(
+ 201,
+ headers={"Location": f"{ADMIN_USERS}/abc-123-uuid"},
+ ),
+ ),
+ ]
+ )
+ adapter = _adapter()
+ _inject(adapter, fake)
+
+ result = await adapter.create_user(
+ IdpUser(username="alice", email="alice@example.com", first_name="Al", last_name="Ice"),
+ password="p@ss-w0rd",
+ )
+
+ # (a) outbound: the client_credentials admin token grant came first.
+ token_req = _find(fake.requests, method="POST", needle="openid-connect/token")
+ assert token_req["url"] == TOKEN_URL
+ assert token_req["data"]["grant_type"] == "client_credentials"
+ assert token_req["data"]["client_id"] == "admin-cli"
+ assert token_req["data"]["client_secret"] == "s3cr3t"
+
+ # (a) outbound: the user-creation POST carries the bearer header + payload.
+ create_req = _find(fake.requests, method="POST", needle="/admin/realms/demo/users")
+ assert create_req["url"] == ADMIN_USERS
+ assert create_req["headers"] == {"Authorization": "Bearer ADMIN-TOK"}
+ body = create_req["json"]
+ assert body["username"] == "alice"
+ assert body["email"] == "alice@example.com"
+ assert body["enabled"] is True
+ assert body["credentials"] == [{"type": "password", "value": "p@ss-w0rd", "temporary": False}]
+
+ # (b) parsed: id extracted from the Location header tail.
+ assert result.id == "abc-123-uuid"
+ assert result.username == "alice"
+
+
+# --------------------------------------------------------------------------- #
+# login — password grant, token parsing, find_by_username follow-up
+# --------------------------------------------------------------------------- #
+@pytest.mark.asyncio
+async def test_login_password_grant_returns_authresult() -> None:
+ fake = FakeClient(
+ [
+ (
+ "openid-connect/token",
+ FakeResponse(
+ 200,
+ json_body={
+ "access_token": "ACCESS-XYZ",
+ "refresh_token": "REFRESH-ABC",
+ "expires_in": 1800,
+ },
+ ),
+ ),
+ # find_by_username GET on the admin users endpoint
+ (
+ "/users",
+ FakeResponse(200, json_body=[{"id": "user-9", "username": "bob", "email": "bob@x.io"}]),
+ ),
+ ]
+ )
+ adapter = _adapter()
+ _inject(adapter, fake)
+
+ result = await adapter.login(LoginRequest(username="bob", password="hunter2"))
+
+ # (a) outbound: ROPC password grant with credentials in the form body.
+ token_req = _find(fake.requests, method="POST", needle="openid-connect/token")
+ assert token_req["url"] == TOKEN_URL
+ assert token_req["data"]["grant_type"] == "password"
+ assert token_req["data"]["username"] == "bob"
+ assert token_req["data"]["password"] == "hunter2"
+ assert token_req["data"]["client_id"] == "admin-cli"
+
+ # (a) outbound: the username lookup uses exact match query params.
+ lookup_req = _find(fake.requests, method="GET", needle="/admin/realms/demo/users")
+ assert lookup_req["params"] == {"username": "bob", "exact": "true"}
+
+ # (b) parsed: tokens + resolved user mapped into AuthResult.
+ assert result.access_token == "ACCESS-XYZ"
+ assert result.refresh_token == "REFRESH-ABC"
+ assert result.expires_in == 1800
+ assert result.user.id == "user-9"
+ assert result.user.username == "bob"
+
+
+@pytest.mark.asyncio
+async def test_login_invalid_credentials_raises_permission_error() -> None:
+ fake = FakeClient(
+ [
+ (
+ "openid-connect/token",
+ FakeResponse(401, json_body={"error": "invalid_grant"}),
+ ),
+ ]
+ )
+ adapter = _adapter()
+ _inject(adapter, fake)
+
+ # error path: a non-200 token response maps to PermissionError, and the
+ # adapter must NOT attempt the find_by_username follow-up.
+ with pytest.raises(PermissionError):
+ await adapter.login(LoginRequest(username="bob", password="wrong"))
+
+ assert all("/admin/realms" not in req["url"] for req in fake.requests)
+
+
+# --------------------------------------------------------------------------- #
+# introspect — token introspection mapped into SessionIntrospection
+# --------------------------------------------------------------------------- #
+@pytest.mark.asyncio
+async def test_introspect_maps_active_session() -> None:
+ fake = FakeClient(
+ [
+ (
+ "token/introspect",
+ FakeResponse(
+ 200,
+ json_body={
+ "active": True,
+ "sub": "user-9",
+ "preferred_username": "bob",
+ "scope": "openid profile email",
+ },
+ ),
+ ),
+ ]
+ )
+ adapter = _adapter()
+ _inject(adapter, fake)
+
+ result = await adapter.introspect("ACCESS-XYZ")
+
+ # (a) outbound: introspect endpoint with client creds + token in form body.
+ req = _find(fake.requests, method="POST", needle="token/introspect")
+ assert req["url"] == INTROSPECT_URL
+ assert req["data"] == {
+ "client_id": "admin-cli",
+ "client_secret": "s3cr3t",
+ "token": "ACCESS-XYZ",
+ }
+
+ # (b) parsed: domain SessionIntrospection with split scopes.
+ assert isinstance(result, SessionIntrospection)
+ assert result.active is True
+ assert result.user_id == "user-9"
+ assert result.username == "bob"
+ assert result.scopes == ["openid", "profile", "email"]
diff --git a/tests/notifications/test_firebase_behavior.py b/tests/notifications/test_firebase_behavior.py
new file mode 100644
index 00000000..65e5effa
--- /dev/null
+++ b/tests/notifications/test_firebase_behavior.py
@@ -0,0 +1,132 @@
+# Copyright 2026 Firefly Software Foundation.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Behavior tests for :class:`FirebasePushProvider` (FCM HTTP v1).
+
+These exercise the real ``send`` code path with a fake httpx client injected at
+``provider._http`` — no network, no Docker. We assert BOTH the outbound request
+the adapter builds (URL, verb, payload, auth header) AND that the canned FCM
+response is parsed into the correct :class:`NotificationResult`.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+
+from pyfly.notifications.models import EmailStatus, PushMessage
+from pyfly.notifications.providers.firebase import FirebasePushProvider
+
+
+class FakeResponse:
+ """Minimal stand-in for ``httpx.Response`` covering what the adapter touches."""
+
+ def __init__(self, *, status_code: int, json_body: dict[str, Any] | None = None, text: str = "") -> None:
+ self.status_code = status_code
+ self._json = json_body or {}
+ self.text = text
+ self.headers: dict[str, str] = {"content-type": "application/json"}
+
+ def json(self) -> dict[str, Any]:
+ return self._json
+
+ def raise_for_status(self) -> None:
+ if self.status_code >= 400:
+ msg = f"HTTP {self.status_code}"
+ raise RuntimeError(msg)
+
+
+class FakeHttpClient:
+ """Records each outbound call and replays a queue of canned responses."""
+
+ def __init__(self, responses: list[FakeResponse]) -> None:
+ self._responses = list(responses)
+ self.calls: list[dict[str, Any]] = []
+
+ async def post(self, url: str, **kwargs: Any) -> FakeResponse:
+ self.calls.append({"verb": "POST", "url": url, **kwargs})
+ return self._responses.pop(0)
+
+
+def _provider(responses: list[FakeResponse]) -> FirebasePushProvider:
+ provider = FirebasePushProvider(project_id="my-proj", access_token="ya29.token")
+ provider._http = FakeHttpClient(responses) # type: ignore[assignment] # noqa: SLF001
+ return provider
+
+
+@pytest.mark.asyncio
+async def test_send_success_builds_request_and_parses_message_name() -> None:
+ fake = FakeHttpClient([FakeResponse(status_code=200, json_body={"name": "projects/my-proj/messages/0:abc"})])
+ provider = FirebasePushProvider(project_id="my-proj", access_token="ya29.token")
+ provider._http = fake # type: ignore[assignment] # noqa: SLF001
+
+ msg = PushMessage(
+ device_tokens=["device-token-1"],
+ title="Hello",
+ body="World",
+ data={"badge": 3, "deep_link": "app://home"},
+ )
+ result = await provider.send(msg)
+
+ # (a) outbound request the adapter built
+ assert len(fake.calls) == 1
+ call = fake.calls[0]
+ assert call["verb"] == "POST"
+ assert call["url"] == "https://fcm.googleapis.com/v1/projects/my-proj/messages:send"
+ assert call["headers"] == {"Authorization": "Bearer ya29.token"}
+ payload = call["json"]["message"]
+ assert payload["token"] == "device-token-1"
+ assert payload["notification"] == {"title": "Hello", "body": "World"}
+ # data values are coerced to strings by the adapter
+ assert payload["data"] == {"badge": "3", "deep_link": "app://home"}
+
+ # (b) response parsed into the domain result
+ assert result.id == msg.id
+ assert result.provider == "firebase"
+ assert result.status == EmailStatus.SENT
+ assert result.provider_id == "projects/my-proj/messages/0:abc"
+ assert result.error is None
+
+
+@pytest.mark.asyncio
+async def test_send_error_response_maps_to_failed_result() -> None:
+ provider = _provider([FakeResponse(status_code=404, text="registration token not found")])
+
+ result = await provider.send(PushMessage(device_tokens=["stale-token"], title="t", body="b"))
+
+ assert result.status == EmailStatus.FAILED
+ assert result.provider_id is None
+ assert result.error == "stale-token: http 404"
+
+
+@pytest.mark.asyncio
+async def test_send_multi_token_partial_success_is_sent_with_error() -> None:
+ fake = FakeHttpClient(
+ [
+ FakeResponse(status_code=200, json_body={"name": "projects/my-proj/messages/ok-1"}),
+ FakeResponse(status_code=503, text="unavailable"),
+ ]
+ )
+ provider = FirebasePushProvider(project_id="my-proj", access_token="ya29.token")
+ provider._http = fake # type: ignore[assignment] # noqa: SLF001
+
+ msg = PushMessage(device_tokens=["good", "bad"], title="t", body="b")
+ result = await provider.send(msg)
+
+ # one request per device token, in order
+ assert [c["json"]["message"]["token"] for c in fake.calls] == ["good", "bad"]
+ # partial success: at least one delivered => SENT, but failures recorded
+ assert result.status == EmailStatus.SENT
+ assert result.provider_id == "projects/my-proj/messages/ok-1"
+ assert result.error == "bad: http 503"
diff --git a/tests/notifications/test_resend_behavior.py b/tests/notifications/test_resend_behavior.py
new file mode 100644
index 00000000..dbd93768
--- /dev/null
+++ b/tests/notifications/test_resend_behavior.py
@@ -0,0 +1,139 @@
+# Copyright 2026 Firefly Software Foundation.
+# Licensed under the Apache License, Version 2.0.
+"""Behavior tests for ResendEmailProvider with a mocked pooled HTTP client.
+
+No network or Docker: a fake httpx client is injected at ``adapter._http`` so
+``async with await adapter._client() as client:`` yields the fake, letting us
+assert the outbound request the adapter builds and how it parses the response.
+"""
+
+from __future__ import annotations
+
+import base64
+from typing import Any
+
+import pytest
+
+from pyfly.notifications.models import Attachment, EmailMessage, EmailStatus
+from pyfly.notifications.providers.resend import ResendEmailProvider
+
+
+class FakeResponse:
+ """Minimal stand-in for ``httpx.Response``."""
+
+ def __init__(self, status_code: int, json_body: dict[str, Any] | None = None, text: str = "") -> None:
+ self.status_code = status_code
+ self._json = json_body or {}
+ self.text = text
+ self.headers: dict[str, str] = {"content-type": "application/json"}
+
+ def json(self) -> dict[str, Any]:
+ return self._json
+
+ def raise_for_status(self) -> None:
+ if self.status_code >= 400:
+ msg = f"http {self.status_code}"
+ raise RuntimeError(msg)
+
+
+class FakeHttpClient:
+ """Records outbound calls and returns a canned response."""
+
+ def __init__(self, response: FakeResponse) -> None:
+ self._response = response
+ self.calls: list[tuple[str, str, dict[str, Any]]] = []
+
+ async def post(self, url: str, **kwargs: Any) -> FakeResponse:
+ self.calls.append(("post", url, kwargs))
+ return self._response
+
+ async def get(self, url: str, **kwargs: Any) -> FakeResponse:
+ self.calls.append(("get", url, kwargs))
+ return self._response
+
+
+@pytest.mark.asyncio
+async def test_send_builds_request_and_parses_sent_result() -> None:
+ fake = FakeHttpClient(FakeResponse(200, json_body={"id": "re_abc123"}))
+ adapter = ResendEmailProvider(api_key="re_test_key")
+ adapter._http = fake # inject before calling — PooledHttpClient yields it
+
+ msg = EmailMessage(
+ to=["dest@example.com"],
+ sender="from@example.com",
+ subject="Hello",
+ body_text="plain body",
+ )
+ result = await adapter.send(msg)
+
+ # (a) outbound request the adapter built
+ assert len(fake.calls) == 1
+ verb, url, kwargs = fake.calls[0]
+ assert verb == "post"
+ assert url == "https://api.resend.com/emails"
+ payload = kwargs["json"]
+ assert payload["from"] == "from@example.com"
+ assert payload["to"] == ["dest@example.com"]
+ assert payload["subject"] == "Hello"
+ assert payload["text"] == "plain body"
+ assert "html" not in payload
+ assert "cc" not in payload
+ headers = kwargs["headers"]
+ assert headers["Authorization"] == "Bearer re_test_key"
+ assert headers["Content-Type"] == "application/json"
+
+ # (b) response correctly parsed into the domain result
+ assert result.status == EmailStatus.SENT
+ assert result.provider == "resend"
+ assert result.id == msg.id
+ assert result.provider_id == "re_abc123"
+ assert result.error is None
+
+
+@pytest.mark.asyncio
+async def test_send_includes_cc_bcc_html_and_base64_attachments() -> None:
+ fake = FakeHttpClient(FakeResponse(202, json_body={"id": "re_xyz"}))
+ adapter = ResendEmailProvider(api_key="re_key", default_from="default@x.io")
+ adapter._http = fake
+
+ raw = b"hello-bytes"
+ msg = EmailMessage(
+ to=["a@x.io"],
+ cc=["c@x.io"],
+ bcc=["b@x.io"],
+ subject="rich",
+ body_html="
hi
", + attachments=[Attachment(filename="f.txt", content_type="text/plain", data=raw)], + ) + result = await adapter.send(msg) + + payload = fake.calls[0][2]["json"] + # default_from is used when message.sender is empty/falsy + assert payload["from"] == "default@x.io" + assert payload["cc"] == ["c@x.io"] + assert payload["bcc"] == ["b@x.io"] + assert payload["html"] == "hi
" + assert "text" not in payload + assert payload["attachments"] == [{"filename": "f.txt", "content": base64.b64encode(raw).decode("ascii")}] + # 202 is still 2xx -> SENT + assert result.status == EmailStatus.SENT + assert result.provider_id == "re_xyz" + + +@pytest.mark.asyncio +async def test_send_maps_non_2xx_to_failed_result() -> None: + fake = FakeHttpClient(FakeResponse(422, text="invalid recipient")) + adapter = ResendEmailProvider(api_key="re_key") + adapter._http = fake + + msg = EmailMessage(to=["bad@x.io"], sender="from@x.io", subject="oops") + result = await adapter.send(msg) + + # request was still attempted + assert fake.calls[0][1] == "https://api.resend.com/emails" + # error path: FAILED, no provider_id, error carries status + body + assert result.status == EmailStatus.FAILED + assert result.provider == "resend" + assert result.id == msg.id + assert result.provider_id is None + assert result.error == "http 422: invalid recipient" diff --git a/tests/notifications/test_twilio_behavior.py b/tests/notifications/test_twilio_behavior.py new file mode 100644 index 00000000..0386a236 --- /dev/null +++ b/tests/notifications/test_twilio_behavior.py @@ -0,0 +1,150 @@ +# Copyright 2026 Firefly Software Foundation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Behavior tests for :class:`TwilioSmsProvider` with mocked HTTP I/O (no network).""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from pyfly.notifications.models import EmailStatus, NotificationResult, SmsMessage +from pyfly.notifications.providers.twilio import TwilioSmsProvider + + +class FakeResponse: + """Minimal stand-in for ``httpx.Response`` covering what the adapter touches.""" + + def __init__( + self, + status_code: int, + *, + json_body: dict[str, Any] | None = None, + text: str = "", + ) -> None: + self.status_code = status_code + self._json = json_body or {} + self.text = text + self.headers: dict[str, str] = {} + + def json(self) -> dict[str, Any]: + return self._json + + def raise_for_status(self) -> None: + if self.status_code >= 400: + msg = f"http {self.status_code}" + raise AssertionError(msg) # adapter never calls this on success; guard anyway + + +class FakeHttpClient: + """Records outbound requests and returns a canned response per verb.""" + + def __init__(self, response: FakeResponse) -> None: + self._response = response + self.calls: list[tuple[str, str, dict[str, Any]]] = [] + + async def post(self, url: str, **kwargs: Any) -> FakeResponse: + self.calls.append(("post", url, kwargs)) + return self._response + + async def get(self, url: str, **kwargs: Any) -> FakeResponse: + self.calls.append(("get", url, kwargs)) + return self._response + + async def put(self, url: str, **kwargs: Any) -> FakeResponse: + self.calls.append(("put", url, kwargs)) + return self._response + + async def delete(self, url: str, **kwargs: Any) -> FakeResponse: + self.calls.append(("delete", url, kwargs)) + return self._response + + async def patch(self, url: str, **kwargs: Any) -> FakeResponse: + self.calls.append(("patch", url, kwargs)) + return self._response + + +@pytest.mark.asyncio +async def test_send_builds_request_and_parses_sent_result() -> None: + provider = TwilioSmsProvider("AC_sid_123", "tok_secret", from_number="+15550001111") + fake = FakeHttpClient(FakeResponse(201, json_body={"sid": "SM_provider_abc"})) + provider._http = fake # inject before calling — _client() wraps it in PooledHttpClient + + message = SmsMessage(to="+15559876543", body="hello world") + result = await provider.send(message) + + # (a) the outbound request the adapter built. + assert len(fake.calls) == 1 + verb, url, kwargs = fake.calls[0] + assert verb == "post" + assert url == "https://api.twilio.com/2010-04-01/Accounts/AC_sid_123/Messages.json" + assert kwargs["data"] == { + "From": "+15550001111", + "To": "+15559876543", + "Body": "hello world", + } + assert kwargs["auth"] == ("AC_sid_123", "tok_secret") + + # (b) the adapter parsed the response into its domain return type. + assert isinstance(result, NotificationResult) + assert result.status == EmailStatus.SENT + assert result.provider == "twilio" + assert result.provider_id == "SM_provider_abc" + assert result.id == message.id + assert result.error is None + + +@pytest.mark.asyncio +async def test_send_prefers_message_sender_over_provider_from() -> None: + provider = TwilioSmsProvider("AC_sid_123", "tok_secret", from_number="+15550001111") + fake = FakeHttpClient(FakeResponse(201, json_body={"sid": "SM_xyz"})) + provider._http = fake + + message = SmsMessage(to="+15559876543", body="hi", sender="+15552223333") + await provider.send(message) + + _verb, _url, kwargs = fake.calls[0] + # message.sender wins over the provider's configured from_number. + assert kwargs["data"]["From"] == "+15552223333" + + +@pytest.mark.asyncio +async def test_send_maps_non_2xx_to_failed_result() -> None: + provider = TwilioSmsProvider("AC_sid_123", "tok_secret", from_number="+15550001111") + fake = FakeHttpClient(FakeResponse(401, text='{"code": 20003, "message": "Authenticate"}')) + provider._http = fake + + message = SmsMessage(to="+15559876543", body="nope") + result = await provider.send(message) + + assert result.status == EmailStatus.FAILED + assert result.provider == "twilio" + assert result.provider_id is None + assert result.error is not None + assert "http 401" in result.error + assert "Authenticate" in result.error + + +@pytest.mark.asyncio +async def test_send_without_any_sender_raises() -> None: + provider = TwilioSmsProvider("AC_sid_123", "tok_secret") # no from_number + fake = FakeHttpClient(FakeResponse(201, json_body={"sid": "SM_unused"})) + provider._http = fake + + message = SmsMessage(to="+15559876543", body="orphan") # no sender + with pytest.raises(ValueError, match="needs a sender"): + await provider.send(message) + + # nothing should have been sent over the wire. + assert fake.calls == [] diff --git a/uv.lock b/uv.lock index d4d33554..993476c5 100644 --- a/uv.lock +++ b/uv.lock @@ -1981,7 +1981,7 @@ wheels = [ [[package]] name = "pyfly" -version = "26.6.70" +version = "26.6.71" source = { editable = "." } dependencies = [ { name = "pydantic" },