Adobe Sign for Laravelconceptlz/adobe-sign-laravel 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, script Adobe's responses, assert on what your code sent, and drive the webhook and OAuth routes from feature tests.

Where to fake: the PSR-18 client

The SDK sends every request through a PSR-18 Psr\Http\Client\ClientInterface. The bridge uses whatever is bound to that interface in the container, and only falls back to discovering Guzzle when nothing is bound. So the seam for tests is one line:

$this->app->instance(\Psr\Http\Client\ClientInterface::class, $fakeClient);

Everything above that line runs for real: the builders validate, the SDK picks the data centre, adds headers, encodes JSON and multipart bodies, maps error responses to exceptions. Your test sees exactly the requests that would have gone to Adobe.

Http::fake() doesn't reach the SDK

Laravel's Http facade fakes its own client only. The SDK talks PSR-18 directly, so Http::fake() and Http::preventStrayRequests() have no effect on it. Bind a fake ClientInterface instead.

Choose a fake client

Option 1: php-http/mock-client

This is what the package's own test suite uses. It records every request and returns queued responses in order.

composer require --dev php-http/mock-client nyholm/psr7
tests/Concerns/FakesAdobeSign.php
<?php

namespace Tests\Concerns;

use Conceptlz\AdobeSign\Laravel\Facades\AdobeSign;
use Http\Mock\Client as MockClient;
use Nyholm\Psr7\Response;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestInterface;

trait FakesAdobeSign
{
    protected MockClient $adobe;

    protected function fakeAdobeSign(): void
    {
        $this->adobe = new MockClient();
        // Fail loudly when code makes a request the test didn't script.
        $this->adobe->setDefaultException(new \LogicException('Unexpected Acrobat Sign request.'));
        $this->app->instance(ClientInterface::class, $this->adobe);

        config([
            'adobe-sign.integration_key' => 'test-integration-key',
            'adobe-sign.log_channel' => false,
            'adobe-sign.retries.max' => 0,      // don't sleep through retries in tests
            'adobe-sign.webhooks.client_ids' => ['test-client-id'],
        ]);
        $this->app->forgetScopedInstances();   // rebuild the manager with this config
        AdobeSign::clearResolvedInstance();
    }

    protected function adobeResponds(int $status, array|string|null $body = null, array $headers = []): void
    {
        if (is_array($body)) {
            $body = json_encode($body);
            $headers += ['Content-Type' => 'application/json'];
        }

        $this->adobe->addResponse(new Response($status, $headers, $body ?? ''));
    }

    protected function adobeKnowsTheDataCentre(): void
    {
        $this->adobeResponds(200, [
            'apiAccessPoint' => 'https://api.na1.adobesign.com/',
            'webAccessPoint' => 'https://secure.na1.adobesign.com/',
        ]);
    }

    protected function adobeRequest(int $index): RequestInterface
    {
        return $this->adobe->getRequests()[$index] ?? $this->fail("No Acrobat Sign request #$index was sent.");
    }
}

Option 2: Guzzle's MockHandler

No extra packages: Guzzle is already installed and its client implements PSR-18.

use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use GuzzleHttp\Psr7\Response;
use Psr\Http\Client\ClientInterface;

$mock = new MockHandler([
    new Response(200, ['Content-Type' => 'application/json'], '{"apiAccessPoint":"https://api.na1.adobesign.com/","webAccessPoint":"https://secure.na1.adobesign.com/"}'),
    new Response(200, ['Content-Type' => 'application/json'], '{"id":"CBJCHBCAABAAagreement1"}'),
]);
$history = [];
$stack = HandlerStack::create($mock);
$stack->push(Middleware::history($history));

$this->app->instance(ClientInterface::class, new Client(['handler' => $stack]));

// ... run your code; $history[1]['request'] is the second request sent

An empty MockHandler queue throws, so unexpected requests fail the test here too.

Know the request order

Queue responses in the order your code will make requests:

  • A fresh Integration Key client starts with GET /baseUris to find the data centre. Use the array cache store in tests (Laravel's default under PHPUnit), so every test starts fresh and the order is predictable. Later requests in the same test reuse the cached answer.
  • forUser() clients skip it: the stored token carries the account's api_access_point. An expired token adds a POST /oauth/v2/refresh first.
  • Each resource call is one request, except helpers that combine several, such as signatureEvidence() (agreement, members, events) and sendReminder() without participant ids (members, then the reminder).

Testing a controller that sends an agreement

tests/Feature/SendAgreementTest.php
<?php

namespace Tests\Feature;

use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\URL;
use Tests\Concerns\FakesAdobeSign;
use Tests\TestCase;

final class SendAgreementTest extends TestCase
{
    use FakesAdobeSign;

    protected function setUp(): void
    {
        parent::setUp();
        $this->fakeAdobeSign();

        // WebhookInfo only accepts https URLs; route() needs an https root in tests.
        URL::forceRootUrl('https://app.example.com');
        URL::forceScheme('https');
    }

    public function test_it_uploads_and_sends_in_order(): void
    {
        $this->adobeKnowsTheDataCentre();
        $this->adobeResponds(200, ['transientDocumentId' => 'tdoc-1']);
        $this->adobeResponds(200, ['id' => 'agr-1']);
        $this->adobeResponds(200, ['id' => 'webhook-1']);

        $this->actingAs(User::factory()->create())
            ->post('/agreements', [
                'contract' => UploadedFile::fake()->createWithContent('msa.pdf', '%PDF-1.4 test'),
                'name' => 'Master Services Agreement',
                'signers' => ['jane@client.example.com', 'ceo@example.com'],
            ], ['Accept' => 'application/json'])
            ->assertCreated()
            ->assertJson(['agreement_id' => 'agr-1']);

        $this->assertStringEndsWith('/transientDocuments', $this->adobeRequest(1)->getUri()->getPath());

        $created = json_decode((string) $this->adobeRequest(2)->getBody(), true);
        $this->assertSame('IN_PROCESS', $created['state']);
        $this->assertSame([1, 2], array_column($created['participantSetsInfo'], 'order'));

        $webhook = json_decode((string) $this->adobeRequest(3)->getBody(), true);
        $this->assertSame('https://app.example.com/adobe-sign/webhook', $webhook['webhookUrlInfo']['url']);
    }

    public function test_nothing_is_sent_for_a_non_pdf(): void
    {
        $this->actingAs(User::factory()->create())
            ->post('/agreements', [
                'contract' => UploadedFile::fake()->create('notes.txt', 1, 'text/plain'),
                'name' => 'x',
                'signers' => ['a@example.com'],
            ], ['Accept' => 'application/json'])
            ->assertUnprocessable();

        $this->assertSame([], $this->adobe->getRequests());
    }
}

Assert on request bodies rather than on the order of internal calls: the payload is the contract with Adobe, and it is what changes behaviour.

Simulating Adobe errors

Queue an error body and the SDK throws the same typed exception it would in production:

use Conceptlz\AdobeSign\Exception\AccountCapabilityException;

$this->adobeKnowsTheDataCentre();
$this->adobeResponds(200, ['transientDocumentId' => 'tdoc-1']);
$this->adobeResponds(403, ['code' => 'HYBRID_ROUTING_NOT_ALLOWED', 'message' => 'Hybrid routing is not enabled.']);

$this->expectException(AccountCapabilityException::class);
// ... call the code that creates a mixed-order agreement

Common shapes: 401 with INVALID_ACCESS_TOKEN (AuthenticationException), 404 with AGREEMENT_NOT_SIGNABLE (NotFoundException), 429 with a Retry-After header (RateLimitException, since retries are off).

Testing webhooks

The webhook route needs no fake HTTP client: it only checks the header, parses, de-duplicates and dispatches. Post a payload with an allowed client id and assert on the event. These tests assume fakeAdobeSign() ran in setUp(), which allow-lists test-client-id:

tests/Feature/AdobeSignWebhookTest.php
use Conceptlz\AdobeSign\Laravel\Events\WebhookReceived;
use Conceptlz\AdobeSign\Webhook\AgreementEvent;
use Illuminate\Support\Facades\Event;

public function test_completed_agreement_fires_the_event_once(): void
{
    Event::fake([WebhookReceived::class]);

    $payload = [
        'event' => 'AGREEMENT_WORKFLOW_COMPLETED',
        'webhookNotificationId' => 'notification-1',
        'agreement' => ['id' => 'agr-1', 'status' => 'SIGNED'],
    ];

    foreach ([1, 2] as $attempt) {   // Adobe retrying the same notification
        $this->postJson('/adobe-sign/webhook', $payload, ['X-AdobeSign-ClientId' => 'test-client-id'])
            ->assertOk()
            ->assertHeader('X-AdobeSign-ClientId', 'test-client-id');
    }

    Event::assertDispatchedTimes(WebhookReceived::class, 1);
    Event::assertDispatched(WebhookReceived::class, fn (WebhookReceived $e) =>
        $e->event instanceof AgreementEvent
        && $e->event->isWorkflowCompleted()
        && $e->event->agreementId() === 'agr-1');
}

public function test_unknown_senders_are_rejected(): void
{
    Event::fake([WebhookReceived::class]);

    $this->postJson('/adobe-sign/webhook', ['event' => 'AGREEMENT_CREATED'], ['X-AdobeSign-ClientId' => 'someone-else'])
        ->assertForbidden();

    Event::assertNotDispatched(WebhookReceived::class);
}

Use a different webhookNotificationId in each test, or the array cache's memory within one test will treat a second post as a duplicate. The verification handshake is a GET with the same header, answered with {"xAdobeSignClientId": "…"}.

Testing a listener on its own

Build the event from a payload with the core's WebhookEvent::fromArray() and call the listener directly; fake the bus to see what it dispatched:

use App\Jobs\DownloadSignedAgreement;
use App\Listeners\ProcessAdobeSignWebhook;
use Conceptlz\AdobeSign\Laravel\Events\WebhookReceived;
use Conceptlz\AdobeSign\Webhook\WebhookEvent;
use Illuminate\Support\Facades\Bus;

Bus::fake();

(new ProcessAdobeSignWebhook())->handle(new WebhookReceived(WebhookEvent::fromArray([
    'event' => 'AGREEMENT_WORKFLOW_COMPLETED',
    'agreement' => ['id' => 'agr-1'],
])));

Bus::assertDispatched(DownloadSignedAgreement::class, fn ($job) => $job->agreementId === 'agr-1');

Testing the download job

use App\Jobs\DownloadSignedAgreement;
use Illuminate\Support\Facades\Storage;

Storage::fake(config('filesystems.default'));
$this->adobeKnowsTheDataCentre();
$this->adobeResponds(200, ['id' => 'agr-1', 'name' => 'MSA', 'status' => 'SIGNED']);
$this->adobeResponds(200, '%PDF-1.4 signed', ['Content-Type' => 'application/pdf']);

$this->app->call([new DownloadSignedAgreement('agr-1'), 'handle']);

Storage::assertExists('agreements/agr-1.pdf');
$this->assertSame('attachAuditReport=true', $this->adobeRequest(2)->getUri()->getQuery());

Add a case where the status is OUT_FOR_SIGNATURE and assert nothing was stored: that is the guard against forged or premature webhooks.

Testing the full loop

The package's SigningLifecycleTest runs upload, send, webhook and storage in one test. The ingredients, for your own version:

  1. Run queued work inline

    Set queue.default to sync for the test, so the listener and the job run during the webhook post.

  2. Fake the disk

    Storage::fake() on the disk the job writes to.

  3. Make route() produce https

    URL::forceRootUrl('https://app.example.com') and URL::forceScheme('https'), because WebhookInfo rejects http://.

  4. Script both halves

    Queue the sending responses, post the form, then queue the agreement (SIGNED) and PDF responses and post the completion webhook.

  5. Assert on the outcome

    The file exists on the fake disk with the PDF bytes you queued, and the requests had the expected paths and bodies.

Testing OAuth code

The callback

use Conceptlz\AdobeSign\Laravel\Events\OAuthConnected;
use Illuminate\Support\Facades\Event;

Event::fake([OAuthConnected::class]);
config(['adobe-sign.oauth.client_id' => 'test-client-id', 'adobe-sign.oauth.client_secret' => 'test-secret']);
$this->adobeResponds(200, [
    'access_token' => 'access-1',
    'refresh_token' => 'refresh-1',
    'expires_in' => 3600,
    'token_type' => 'Bearer',
    'api_access_point' => 'https://api.eu1.adobesign.com/',
]);

$this->actingAs($user)
    ->withSession(['adobe_sign_oauth_state' => 'state-1'])
    ->get('/adobe-sign/oauth/callback?code=auth-code&state=state-1&api_access_point=https://api.eu1.adobesign.com/')
    ->assertRedirect('/');

Event::assertDispatched(OAuthConnected::class, fn ($e) => $e->key === (string) $user->id);

The session key adobe_sign_oauth_state is where the redirect route stores the state; seeding it is how you skip the trip to Adobe. A wrong state should give 400 and send no request at all.

Code that uses forUser()

Seed a token instead of running the flow. With RefreshDatabase and the published migration, the default EloquentTokenStore works as in production:

use Conceptlz\AdobeSign\Auth\{AccessToken, TokenStore};

$this->app->make(TokenStore::class)->put((string) $user->id, new AccessToken(
    'user-access-token',
    'user-refresh-token',
    new \DateTimeImmutable('+1 hour'),          // not expired: no refresh request
    'https://api.eu1.adobesign.com/',
));

$this->adobeResponds(200, ['id' => 'agr-1', 'name' => 'NDA', 'status' => 'OUT_FOR_SIGNATURE']);
// ... code calling AdobeSign::forUser($user)->agreements()->get('agr-1')

$this->assertSame('Bearer user-access-token', $this->adobeRequest(0)->getHeaderLine('Authorization'));

An expiry in the past makes the SDK refresh first, so queue a refresh response (access_token, expires_in) before the API response to test that path.

Mocking the manager's own methods

For code that only branches on the manager's answers, the facade's Mockery support is enough:

AdobeSign::shouldReceive('isConnected')->once()->andReturn(false);

$this->actingAs($user)->get('/contracts/new')->assertSee('Connect Acrobat Sign');

Don't mock the resources (agreements() and friends): they are final classes, and a fake HTTP client tests more of the real behaviour anyway.

Testing a package with Orchestra Testbench

If you build a package on top of the bridge, Orchestra Testbench boots a minimal Laravel app for its tests. Register the bridge's provider next to yours, alias the facade if you use it, and load the token migration from vendor when you need OAuth:

tests/TestCase.php
<?php

namespace Acme\Contracts\Tests;

use Acme\Contracts\ContractsServiceProvider;
use Conceptlz\AdobeSign\Laravel\AdobeSignServiceProvider;
use Conceptlz\AdobeSign\Laravel\Facades\AdobeSign;
use Http\Mock\Client as MockClient;
use Orchestra\Testbench\TestCase as BaseTestCase;
use Psr\Http\Client\ClientInterface;

abstract class TestCase extends BaseTestCase
{
    protected MockClient $adobe;

    protected function setUp(): void
    {
        parent::setUp();

        $this->adobe = new MockClient();
        $this->adobe->setDefaultException(new \LogicException('Unexpected request: no queued response left.'));
        $this->app->instance(ClientInterface::class, $this->adobe);
    }

    protected function getPackageProviders($app): array
    {
        return [AdobeSignServiceProvider::class, ContractsServiceProvider::class];
    }

    protected function getPackageAliases($app): array
    {
        return ['AdobeSign' => AdobeSign::class];
    }

    protected function defineEnvironment($app): void
    {
        $app['config']->set('adobe-sign.integration_key', 'test-integration-key');
        $app['config']->set('adobe-sign.oauth.client_id', 'test-client-id');
        $app['config']->set('adobe-sign.oauth.client_secret', 'test-secret');
        $app['config']->set('adobe-sign.log_channel', false);
        $app['config']->set('cache.default', 'array');
        $app['config']->set('database.default', 'testing');
    }

    protected function defineDatabaseMigrations(): void
    {
        $this->loadMigrationsFrom(__DIR__.'/../vendor/conceptlz/adobe-sign-laravel/database/migrations');
    }
}

This mirrors the bridge's own tests/TestCase.php. Setting config in defineEnvironment() means it is in place before anything resolves the scoped manager. The bridge's CI runs Laravel 12 with Testbench 10 and Laravel 13 with Testbench 11.

Changing config inside a test

The manager copies the adobe-sign config when it is first resolved in a scope, and the facade caches the instance it got. To change config after that point:

config(['adobe-sign.auth' => 'oauth']);
$this->app->forgetScopedInstances();
AdobeSign::clearResolvedInstance();

Route config (oauth.route_prefix, webhooks.path, the enabled flags) is read when routes load at boot, so set it in defineEnvironment() or your phpunit.xml environment instead.

Common questions

Should my CI ever call the real Acrobat Sign API?

Not in the normal suite: real calls send real emails and depend on Adobe's availability. If you want a smoke test, keep it in a separate, opt-in suite with a developer account and addresses you control, and cancel every agreement it creates. php artisan adobe-sign:verify is a safe deploy-time check.

My test fails with "Unexpected Acrobat Sign request". What happened?

The code made one more request than you queued responses for. The most common reason is the initial GET /baseUris of a fresh Integration Key client; queue the data-centre response first. Dump $this->adobe->getRequests() to see what was sent.

Why does a test with a 429 response take seconds?

The SDK retries throttled responses and sleeps between attempts. Set adobe-sign.retries.max to 0 in tests so a 429 surfaces immediately as RateLimitException.

Does this work with Pest?

Yes. Pest runs on PHPUnit and Laravel's testing helpers; use the trait in your Pest.php (uses(FakesAdobeSign::class)) and call $this->fakeAdobeSign() in beforeEach.

How do I know my faked responses look like Adobe's?

Copy real shapes: the bridge's tests/Fixtures and the core's fixtures hold responses checked against Adobe's OpenAPI spec, and some captured from the live API. The core's testing page explains its spec-validated approach.