Adobe Sign for Symfonyconceptlz/adobe-sign-symfony GitHub

Help

Testing your application

Your tests should never email real signers or depend on Adobe being up. This page shows how to replace the HTTP client the SDK uses in the test environment, script Adobe's responses, assert on what your code sent, and drive the webhook and OAuth routes from WebTestCase. The patterns are the ones the bundle's own test suite uses.

Where to fake: the PSR-18 client

You could mock AdobeSign or AdobeSignManager, but you would then be testing your mocks: the builders, the JSON they produce, the data-centre lookup and the error mapping would never run. Fake one level lower instead. Every request the SDK makes goes through a single PSR-18 client, and the bundle lets you choose that client with adobe_sign.http_client. Point it at a mock in the test environment and everything above it, including the bundle's controllers, runs for real.

Setting up the mock client

composer require --dev php-http/mock-client
config/packages/adobe_sign.yaml
when@test:
    adobe_sign:
        integration_key: 'test-key'
        http_client: test.adobe_sign_http
        cache_pool: test.adobe_sign_cache
        retries:
            max: 0                              # a queued 429/503 fails at once instead of sleeping
        oauth:
            client_id: 'test-client-id'
            client_secret: 'test-secret'
        webhooks:
            client_ids: ['test-client-id']

    services:
        test.adobe_sign_http:
            class: Http\Mock\Client
            public: true
        test.adobe_sign_cache:                  # fresh per kernel, survives between requests
            class: Symfony\Component\Cache\Adapter\ArrayAdapter
            public: true

    framework:
        messenger:
            transports:
                async: 'in-memory://'

Three details make this work:

  • The mock is public. A private service referenced only by the manager would be inlined into it, and you couldn't fetch it to queue responses. public: true keeps it addressable as static::getContainer()->get('test.adobe_sign_http').
  • The cache is a plain ArrayAdapter service. With the default cache.app (filesystem), the data-centre lookup from one test is still cached in the next, so the number of requests changes between runs. Array pools declared under framework.cache.pools are cleared after every request, which would break duplicate detection across two webhook requests in one test. A plain service is fresh per kernel and stable within it, which is exactly what tests need.
  • Retries are off. The SDK retries 429 and 503 with real waits. With max: 0 a queued error surfaces immediately as an exception.
tests/AdobeSignTestTrait.php: helpers used in the examples below
namespace App\Tests;

use Http\Mock\Client as MockClient;
use Nyholm\Psr7\Response;
use Psr\Http\Message\RequestInterface;

trait AdobeSignTestTrait
{
    protected static function adobe(): MockClient
    {
        return static::getContainer()->get('test.adobe_sign_http');
    }

    /** Queue a response: a fixture file from tests/Fixtures/adobe, or a literal JSON body. */
    protected static function queue(int $status, ?string $body = null, array $headers = []): void
    {
        $file = __DIR__."/Fixtures/adobe/$body.json";
        $contents = $body === null ? '' : (is_file($file) ? (string) file_get_contents($file) : $body);
        if ($body !== null) {
            $headers += ['Content-Type' => 'application/json'];
        }
        static::adobe()->addResponse(new Response($status, $headers, $contents));
    }

    protected static function sent(int $index): RequestInterface
    {
        $requests = static::adobe()->getRequests();
        self::assertArrayHasKey($index, $requests, "No request #$index was sent to Adobe.");

        return $requests[$index];
    }
}
tests/Fixtures/adobe/base_uris.json
{"apiAccessPoint": "https://api.na4.adobesign.com/", "webAccessPoint": "https://secure.na4.adobesign.com/"}

Know the request order

The mock answers requests in the order you queued them, so you need to know what the SDK sends. For an Integration Key client in a fresh kernel:

  1. GET https://api.adobesign.com/api/rest/v6/baseUris: the data-centre lookup, once per kernel (then cached in test.adobe_sign_cache).
  2. Your calls, in order, against https://api.na4.adobesign.com/api/rest/v6/… (the host from the fixture).

A forUser() client whose stored token has an api_access_point skips the lookup. A token that is about to expire triggers a refresh (POST /oauth/v2/token) before the call; store tokens that expire in the future in tests. signatureEvidence() and signatureShortfalls() send three calls: the agreement, its members, its events.

If you queue too few responses, Http\Mock\Client quietly answers the extra request with an empty 200, which then fails somewhere less obvious. Make unexpected calls fail loudly in setUp(): self::adobe()->setDefaultException(new \LogicException('Unexpected request to Acrobat Sign.')).

Testing a controller that sends an agreement

tests/Functional/ContractControllerTest.php
namespace App\Tests\Functional;

use App\Tests\AdobeSignTestTrait;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\File\UploadedFile;

final class ContractControllerTest extends WebTestCase
{
    use AdobeSignTestTrait;

    public function test_sending_a_contract_creates_the_agreement_in_order(): void
    {
        $client = static::createClient();
        $client->disableReboot();           // keep the same container (and mock) for the whole test
        $client->loginUser($this->staffUser());
        $client->setServerParameter('HTTPS', 'on');   // WebhookInfo only accepts https:// URLs

        self::queue(200, 'base_uris');
        self::queue(201, '{"transientDocumentId":"tdoc-1"}');
        self::queue(201, '{"id":"agr-1"}');
        self::queue(201, '{"id":"wh-1"}');

        $client->request('POST', '/contracts', [
            'title' => 'MSA',
            'client_email' => 'client@example.com',
            '_token' => $this->csrfToken($client, 'send-contract'),
        ], ['contract' => new UploadedFile(__DIR__.'/../Fixtures/contract.pdf', 'msa.pdf', 'application/pdf', test: true)]);

        self::assertResponseRedirects('/contracts/new');

        // What did we send?
        self::assertStringEndsWith('/transientDocuments', (string) self::sent(1)->getUri());
        $agreement = json_decode((string) self::sent(2)->getBody(), true);
        self::assertSame('MSA', $agreement['name']);
        self::assertSame('client@example.com', $agreement['participantSetsInfo'][0]['memberInfos'][0]['email']);
        self::assertSame(1, $agreement['participantSetsInfo'][0]['order']);
        self::assertSame(2, $agreement['participantSetsInfo'][1]['order']);

        $webhook = json_decode((string) self::sent(3)->getBody(), true);
        self::assertSame('agr-1', $webhook['resourceId']);
        self::assertStringEndsWith('/adobe-sign/webhook', $webhook['webhookUrlInfo']['url']);
    }
}

staffUser() and csrfToken() stand for your own helpers. Asserting on the JSON body is the most valuable part: it proves the builder produced what Adobe expects, not just that your code called a method. The HTTPS server parameter matters because the webhook URL is generated from the test request, and the core's WebhookInfo rejects http:// URLs before sending anything.

Simulating Adobe errors

use Conceptlz\AdobeSign\Exception\{AccountCapabilityException, AuthenticationException, RateLimitException};

self::queue(401, '{"code":"INVALID_ACCESS_TOKEN","message":"Access token provided is invalid or has expired."}');
// -> AuthenticationException

self::queue(400, '{"code":"HYBRID_ROUTING_NOT_ALLOWED","message":"Hybrid routing is not allowed."}');
// -> AccountCapabilityException

self::queue(429, '{"code":"THROTTLING_TOO_MANY_REQUESTS","message":"Slow down."}', ['Retry-After' => '120']);
// -> RateLimitException with $retryAfter === 120

self::adobe()->addException(new \Http\Client\Exception\NetworkException('Connection refused', new \Nyholm\Psr7\Request('GET', '/')));
// -> TransportException

Then assert on what your app does with them: the status code your exception listener chose, the flash message, the message re-dispatched with a DelayStamp. The core's errors page lists which codes map to which class.

Testing webhooks

The webhook route doesn't call Adobe at all, so these tests need no queued responses. POST a payload with the client-id header, exactly as the bundle's own WebhookTest does:

tests/Functional/WebhookTest.php
use Conceptlz\AdobeSign\Symfony\Event\WebhookReceivedEvent;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;

final class WebhookTest extends WebTestCase
{
    private static function post(KernelBrowser $client, string $body, string $clientId = 'test-client-id'): void
    {
        $client->request('POST', '/adobe-sign/webhook', server: [
            'HTTP_X_ADOBESIGN_CLIENTID' => $clientId,
            'CONTENT_TYPE' => 'application/json',
        ], content: $body);
    }

    public function test_verification_echoes_the_client_id(): void
    {
        $client = static::createClient();
        $client->request('GET', '/adobe-sign/webhook', server: ['HTTP_X_ADOBESIGN_CLIENTID' => 'test-client-id']);

        self::assertResponseIsSuccessful();
        self::assertResponseHeaderSame('X-AdobeSign-ClientId', 'test-client-id');
        self::assertSame(['xAdobeSignClientId' => 'test-client-id'], json_decode($client->getResponse()->getContent(), true));
    }

    public function test_a_retry_is_dispatched_only_once(): void
    {
        $client = static::createClient();
        $client->disableReboot();
        $received = [];
        static::getContainer()->get('event_dispatcher')
            ->addListener(WebhookReceivedEvent::class, function (WebhookReceivedEvent $e) use (&$received): void {
                $received[] = $e;
            });

        $payload = file_get_contents(__DIR__.'/../Fixtures/adobe/webhook_workflow_completed.json');
        self::post($client, $payload);
        self::post($client, $payload);                     // Adobe retries the same notification

        self::assertResponseIsSuccessful();
        self::assertCount(1, $received);
    }

    public function test_an_unknown_client_is_rejected(): void
    {
        $client = static::createClient();
        self::post($client, '{"event":"AGREEMENT_CREATED"}', 'someone-else');

        self::assertResponseStatusCodeSame(403);
        self::assertFalse($client->getResponse()->headers->has('X-AdobeSign-ClientId'));
    }
}

disableReboot() matters in the second test: by default the test client reboots the kernel between requests, which would give you a fresh cache and a fresh dispatcher, and the retry would look new.

Asserting on the Messenger transport

use Conceptlz\AdobeSign\Symfony\Message\AdobeSignWebhookMessage;
use Symfony\Component\Messenger\Transport\InMemory\InMemoryTransport;

self::post($client, $payload);

/** @var InMemoryTransport $transport */
$transport = static::getContainer()->get('messenger.transport.async');
self::assertCount(1, $transport->getSent());
$message = $transport->getSent()[0]->getMessage();
self::assertInstanceOf(AdobeSignWebhookMessage::class, $message);
self::assertSame('CBJCHBCAABAAnotif1', $message->notificationId());

Assert straight after the request: the in-memory transport is a resettable service and starts empty again after the next request. To check that a duplicate produced no second message, assert getSent() is empty after posting the same payload again.

Testing your own handlers

Handlers are plain services. Build the message yourself, queue Adobe's answers, call the handler, and assert on the result. A KernelTestCase gives you the real services with the mock client:

tests/Integration/StoreSignedAgreementTest.php
use App\MessageHandler\StoreSignedAgreement;
use App\Tests\AdobeSignTestTrait;
use Conceptlz\AdobeSign\Symfony\Message\AdobeSignWebhookMessage;
use Nyholm\Psr7\Response;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

final class StoreSignedAgreementTest extends KernelTestCase
{
    use AdobeSignTestTrait;

    public function test_a_signed_agreement_is_downloaded_and_stored(): void
    {
        self::bootKernel();
        $contract = $this->createSentContract(agreementId: 'agr-1');     // your fixture helper

        self::queue(200, 'base_uris');
        self::queue(200, '{"id":"agr-1","name":"MSA","status":"SIGNED","signatureType":"ESIGN","participantSetsInfo":[]}');
        self::adobe()->addResponse(new Response(200, ['Content-Type' => 'application/pdf'], '%PDF-1.7 signed'));

        $handler = static::getContainer()->get(StoreSignedAgreement::class);
        $handler(new AdobeSignWebhookMessage([
            'event' => 'AGREEMENT_WORKFLOW_COMPLETED',
            'webhookNotificationId' => 'n-1',
            'agreement' => ['id' => 'agr-1', 'status' => 'SIGNED'],
        ]));

        self::assertSame('signed', $contract->getStatus());
        self::assertStringContainsString('/combinedDocument', (string) self::sent(2)->getUri());
        self::assertStringContainsString('attachAuditReport=true', (string) self::sent(2)->getUri());
    }

    public function test_a_second_run_does_nothing(): void
    {
        self::bootKernel();
        $this->createSignedContract(agreementId: 'agr-1');

        $handler = static::getContainer()->get(StoreSignedAgreement::class);
        $handler(new AdobeSignWebhookMessage(['event' => 'AGREEMENT_WORKFLOW_COMPLETED', 'agreement' => ['id' => 'agr-1']]));

        self::assertSame([], self::adobe()->getRequests());   // idempotent: no API calls at all
    }
}

The second test is often the more valuable one: it pins down idempotency, which is what saves you when Messenger redelivers a message in production.

Testing OAuth code

The connect flow

Drive both routes with a logged-in user, read the state from the redirect, and queue Adobe's token response. This mirrors the bundle's OAuthTest:

tests/Functional/ConnectAdobeSignTest.php
use Conceptlz\AdobeSign\Symfony\AdobeSignManager;

public function test_connecting_stores_the_token(): void
{
    $client = static::createClient();
    $client->disableReboot();
    $client->loginUser($user = $this->userWithEmail('jane@example.com'));

    $client->request('GET', '/adobe-sign/oauth/redirect');
    self::assertResponseRedirects();
    parse_str((string) parse_url($client->getResponse()->headers->get('Location'), PHP_URL_QUERY), $query);
    self::assertSame('test-client-id', $query['client_id']);

    self::queue(200, '{"access_token":"at","refresh_token":"rt","expires_in":3600,"token_type":"Bearer","api_access_point":"https://api.eu1.adobesign.com/"}');
    $client->request('GET', '/adobe-sign/oauth/callback?'.http_build_query([
        'code' => 'auth-code',
        'state' => $query['state'],
        'api_access_point' => 'https://api.eu1.adobesign.com/',
    ]));

    self::assertResponseRedirects('/');            // oauth.redirect_after
    self::assertSame('https://api.eu1.adobesign.com/oauth/v2/token', (string) self::sent(0)->getUri());
    self::assertTrue(static::getContainer()->get(AdobeSignManager::class)->isConnected($user));
}

Worth adding: a forged state (state=attacker) answers 400 and sends nothing to Adobe; a callback without a logged-in user answers 401; error=ACCESS_DENIED answers 403.

Code that uses forUser()

Skip the flow and put a token in the store directly:

use Conceptlz\AdobeSign\Auth\AccessToken;

$manager = static::getContainer()->get(AdobeSignManager::class);
$manager->tokens()->put('jane@example.com', new AccessToken(
    'user-token', 'refresh', new \DateTimeImmutable('+1 hour'), 'https://api.na4.adobesign.com/',
));

self::queue(200, 'user_me');                       // no /baseUris: the token carries the access point
$manager->forUser('jane@example.com')->users()->me();

self::assertSame('Bearer user-token', self::sent(0)->getHeaderLine('Authorization'));

With the default token_store: cache and cache.app, the store in tests is whatever cache.app is in your test environment. To isolate tests, point oauth.token_cache_pool at test.adobe_sign_cache too, or use token_store: doctrine with your test database.

Changing configuration inside a test

Configuration is compiled, so a test can't change signature_level at runtime. Options: a separate environment (static::createClient(['environment' => 'test_qes']) with its own config files), or a custom test kernel that takes configuration as a constructor argument, which is what the bundle's own tests/App/TestKernel.php does. For one-off checks of the manager, constructing it directly isn't supported: its constructor is internal.

Common questions

Can I use Guzzle's MockHandler instead of php-http/mock-client?

Yes. Register a GuzzleHttp\Client built with a HandlerStack around a MockHandler as the http_client service, and add a Middleware::history() to inspect requests. php-http/mock-client is simpler because it records requests itself.

Can I use Symfony's MockHttpClient?

Yes, wrapped as PSR-18: new Psr18Client(new MockHttpClient([...])) registered as the http_client service. Its MockResponse objects work, but you inspect requests through the response callback rather than a request list.

Should I ever test against the real Acrobat Sign API?

Occasionally, in a separate opt-in suite with a developer account and addresses you control, because real calls send real emails. Cancel every agreement you create. Run it before releases, not on every push.

My queued responses disappear between two requests. Why?

The test client reboots the kernel between requests, creating a new container and a new mock. Call $client->disableReboot() right after createClient().

Creating a webhook in a test throws "only delivers webhooks to HTTPS URLs". Why?

The URL was generated from the test request, which is http://localhost. Set $client->setServerParameter('HTTPS', 'on'), or configure framework.router.default_uri: 'https://localhost' for the test environment.