From 46a85b981e62b6a3f5d4f33866923695734cf4d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Contreras=20Guill=C3=A9n?= Date: Sun, 7 Jun 2026 21:25:16 +0200 Subject: [PATCH] test(adapters): 42 mocked-I/O behavior tests for external adapters + bump v26.06.71 Closes the audit gap (wiring/isinstance-only coverage). Added behavior tests (fake httpx / boto3 clients, no network/Docker) asserting request shape + response parsing for: notifications resend/twilio/firebase, ecm docusign/adobe_sign/logalty + aws_s3 (injected boto3 client), idp keycloak. Each covers happy path + error/non-2xx mapping + edge cases (sender precedence, attachment encoding, multi-token partial success, status mapping). Adapters unchanged. Written + self-verified by a parallel workflow (one agent per adapter). Gates: mypy --strict (637), ruff + format, full suite green. --- CHANGELOG.md | 16 + README.md | 2 +- pyproject.toml | 2 +- src/pyfly/__init__.py | 2 +- tests/ecm/test_adobe_sign_behavior.py | 250 +++++++++++++++ tests/ecm/test_aws_s3_behavior.py | 255 ++++++++++++++++ tests/ecm/test_docusign_behavior.py | 242 +++++++++++++++ tests/ecm/test_logalty_behavior.py | 213 +++++++++++++ tests/idp/test_keycloak_behavior.py | 287 ++++++++++++++++++ tests/notifications/test_firebase_behavior.py | 132 ++++++++ tests/notifications/test_resend_behavior.py | 139 +++++++++ tests/notifications/test_twilio_behavior.py | 150 +++++++++ uv.lock | 2 +- 13 files changed, 1688 insertions(+), 4 deletions(-) create mode 100644 tests/ecm/test_adobe_sign_behavior.py create mode 100644 tests/ecm/test_aws_s3_behavior.py create mode 100644 tests/ecm/test_docusign_behavior.py create mode 100644 tests/ecm/test_logalty_behavior.py create mode 100644 tests/idp/test_keycloak_behavior.py create mode 100644 tests/notifications/test_firebase_behavior.py create mode 100644 tests/notifications/test_resend_behavior.py create mode 100644 tests/notifications/test_twilio_behavior.py 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 @@ Firefly Framework Python 3.12+ License: Apache 2.0 - Version: 26.06.70 + Version: 26.06.71 Type Checked: mypy strict Code Style: Ruff Async First 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" },