Testing your integration
How to verify a custom Provider subclass works end-to-end before handing it to QA.
The plan
- Unit-test the provider in isolation. Pure PHP. No Craft bootstrap.
- Smoke-test the plugin’s integration with it. Stand up a local Craft install, wire in your site module, hit the CP and integration gateway manually.
- Integration-test the gateway path. Post signed payloads, confirm the shipment transitions.
- Integration-test the push path. Queue a push, run the queue, confirm the remote received the expected payload.
- Fuzz the mapping layer. Send codes that aren’t mapped, confirm the attention-needed page fills.
1. Unit tests
Your sendShipment(Shipment, Order): void, cancelShipment(Shipment, Order): void, and handleGatewayRequest(Request): Response are pure-ish given mocked dependencies. Don’t bootstrap Craft, mock what you need.
Test cases:
- Signature verification rejects tampered body. Use
WebhookSigning::verifyHmacSignature/verifyHmacSignatureBase64directly. See the existing test attests/unit/base/WebhookSigningTest.php. - Send serializes the expected payload shape. Use Guzzle’s
MockHandlerto capture the outbound request; assert on the body. - Send throws
PermanentIntegrationExceptionon misconfiguration (missing credentials, missing endpoint URL). Don’t let a bad config cause retries. - Send throws
IntegrationExceptionon a 5xx or network error (retryable). - Cancel hits the expected endpoint and surfaces remote 4xx as
PermanentIntegrationException. - Webhook rejects missing signature header.
- Webhook rejects malformed JSON body.
Example:
use fostercommerce\shipments\errors\PermanentIntegrationException;
use PHPUnit\Framework\TestCase;
final class ExampleErpProviderTest extends TestCase
{
public function testSendThrowsPermanentWhenMisconfigured(): void
{
$provider = new ExampleErpProvider();
$provider->endpointUrl = null;
$this->expectException(PermanentIntegrationException::class);
$provider->sendShipment($this->stubShipment(), $this->stubOrder());
}
}
2. Smoke test, local Craft
ddev start # or your local Craft setup
./craft plugin/install shipments
With your site module registered in config/app.php, hit Shipments -> Settings -> Integrations -> New, pick your provider from the dropdown. If it’s missing, your EVENT_REGISTER_INTEGRATIONS listener isn’t firing.
Save the integration. Open the status-mapping editor. Add a handful of inbound mappings for your vendor’s common codes. Save.
Stage a shipment on a test order, confirm the shipment card shows up.
3. Gateway path
Your integration endpoint is at https://your-site.test/actions/shipments/gateway/handle?integration={handle}. It’s public, unauthenticated at the Craft layer, your provider is responsible for signature verification.
Generate a signed test request:
BODY='{"event":"shipped","shipmentId":"EXT-123","status":"SHIPPED_TO_CARRIER"}'
SECRET='your-webhook-secret'
SIG=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
curl -X POST 'https://your-site.test/actions/shipments/gateway/handle?integration=example-erp' \
-H "Content-Type: application/json" \
-H "X-Example-Signature: $SIG" \
-d "$BODY"
Expect:
- HTTP 200 with
{success: true, shipmentId: <id>}if the webhook resolved to a known shipment and applied an update. - HTTP 400 if signature verification fails. Check the log for
shipmentscategory entries.
Check the shipment’s Status history tab: there should be a row with sourceIntegration = your integration and sourceExternalCode = SHIPPED_TO_CARRIER.
If the transition didn’t happen but the webhook returned 200: the external code wasn’t mapped. Unmapped codes are skipped silently. Add a mapping for the code in the integration’s status-mapping editor and resend.
4. Push path
From the shipments element index, select your test shipment -> Actions -> Push to {your integration name}. A PushShipmentJob queues.
./craft queue/run
Check the log for shipments category entries. Check the target system received the payload.
Programmatic push for CI:
Craft::$app->getQueue()->push(new PushShipmentJob([
'shipmentId' => $shipment->id,
'integrationId' => $integration->id,
]));
Craft::$app->getQueue()->run(); // process synchronously
5. Mapping layer
Send a handful of deliberately-unmapped codes:
for code in BLAZE STORM OPERATION_SUNSET; do
# construct signed body with status=$code, POST to webhook
done
Each webhook returns 200, but the shipment’s status does not change: an unmapped code resolves to null and is skipped. Add a mapping for one of the codes, save, resend that webhook, and confirm the shipment now transitions.
Common bugs surfaced by testing
- Signature verification passes locally but fails in staging. Usually a trailing newline or BOM in the env var.
App::parseEnvdoesn’t trim; the vendor’s secret usually has no surrounding whitespace. - Shipment resolves but transition silently doesn’t happen. The external code isn’t mapped, so it resolves to null and is skipped. Add a mapping for it.
- Push fails once then retries forever. The provider is throwing
IntegrationExceptionwhen it should throwPermanentIntegrationException. Review the error taxonomy in custom-providers.md. - Queue job dies with a lock error. Two pushes on the same shipment are queuing at the same time. The per-shipment mutex on
applyTransitionserializes transitions, not pushes, the push itself doesn’t need a lock, but your provider shouldn’t assume the shipment state stays frozen during the request.
Staging
Before production:
- Point the integration at the vendor’s staging environment, not production. Vendors usually expose separate endpoint URLs + separate credentials.
- Use a sandboxed webhook delivery tool (ngrok, webhook.site, Hookdeck) so you can inspect what the vendor sent and replay it.
- Run through every status transition the vendor can send. Any code you haven’t mapped is skipped silently, so map them all before production.
- Push a test shipment end-to-end, confirm the vendor sees it in their UI, confirm the callback webhook transitions it back.
What to automate in CI
Minimum viable CI for an integration:
composer phpstancomposer ecs:checkcomposer test(unit tests)./vendor/bin/phpunit --testsuite=integration(once the full Craft test harness is wired; seetests/integration/README.md)
Before each release of the integration plugin, manually run the smoke + webhook + push paths against the vendor’s sandbox.