Testing a Connector

The fakes the SDK ships, the assertions they offer, what a connector's own test suite should cover port by port, how the kit runs against a package, and what only a real platform can prove.

A connector has two things to test: that its ports do the right thing against the platform, and that the core does the right thing with the connector. The SDK ships fakes for the second, the kit for the shape of the first, and the platform's own client fake is yours to write for the rest.

The fake connector

Subscriby\Connector\Testing\FakeConnector is the reference implementation and the double every core path is tested against. Its manifest is the connector.json beside it, read through the same loader as every package, so the loader runs in every test and the file doubles as a worked example. It deliberately violates every assumption a single-platform core would make: 280-character messages with two buttons a row and no files, membership grants instead of invite links plus a creator_task kind, no early admission, three admin commands, a one-message-a-second pace, identities that look nothing like a chat id.

Subscriby's application registers it in its registry whenever tests run (and on any environment with connectors.fake switched on, which is how a staging site gets a demo project with no real platform). A core path that still assumes one platform fails against it; that is what keeps the core honest for the platform your connector brings.

The port fakes

Each fake implements one port, keeps what it was asked in memory and answers something plausible. Each also has a trigger: a value in the input that makes it fail the way a platform would, so both halves of a core path can be driven without a platform. Register them beside a real port of yours to test one port in isolation, or all of them to test a flow.

FakeBehaviourTriggers and assertions
FakeInstallationLifecycleConnects in one step by default and remembers what it was asked; start links are https://fake.test/<id>?start=<payload>; answers a platform installation.A token of revoked verifies as Revoked with reason token_revoked, creator-actionable. A token starting steps- asks a region field before completing; one starting oauth- returns a continueUrl on the fake host and completes from returned['code'], minting oauth-<code> into meta; returned['error'] is a platform refusal.
FakeIdentityResolverReads the actor from the envelope payload's from; external ids are phone-number shaped (+15550001) so a core path that treats them as numeric chat ids fails.Adopting keeps no row: the storage ref is derived from the id, so a core path that needs the connector's row to exist fails too.
FakeInboundGatewayA request is authentic when the X-Fake-Token header is fake; the JSON body's id is the idempotency key and its kind the event kind.Post the same id twice to test the replay path.
FakeFailureClassifierAn exception is Transient; an array carrying error maps a few named codes to kinds.error codes hand back any kind; a RateLimited one carries a five-second retry-after.
FakeTextRendererRenders the canonical HTML to plain text, as a platform with no formatting would.
FakeSettingsSchemaOne secret to install with, one text setting to edit afterwards.
FakeUiSlotsNo contributions.
FakeMessengerKeeps every send; delivers with ids fake-message-N.An identity whose id starts with blocked- is Unreachable; files fail as Configuration. assertSentTo($externalId, ?$containing), assertNotSentTo($externalId, ?$containing), assertNothingSent().
FakeAccessControllerKeeps memberships in memory; grants are memberships (membership:<space>:<identity>), never links; granting twice is one membership, revoking a stranger is revoked.blocked- identities fail Unreachable. assertGranted($space, $identity), assertNotGranted(…), assertAnnounced($identity, ?$windowId), assertNotAnnounced($identity).
FakeSpaceCatalogParks one link request per creator and purpose; describes a place as Room <id>; records every diagnosis it was asked.A space whose external id starts with lost- diagnoses as not a member, creator-actionable.
FakeManagementSurfaceRenders the manifest's three commands and records every envelope it handled.
FakePortalLoginMethod"Continue with Fake" with a sparkles icon; a start link on the fake host carrying the handshake token.
FakeRecoverySupportProbes and nothing else, as a connector whose platform cannot ban a bot: healthy probes, a vocabulary, readiness items; every other facet throws UnsupportedByConnector.An identity marked deleted probes TargetMissing with code account_deleted; a blocked- one probes Unreachable.
$messenger = new FakeMessenger;
$registrar->port(Messenger::class, $messenger);

// … drive the core path that should tell the member …

$messenger->assertSentTo('member-42', containing: 'Payment received');

What your own suite covers

Test each port against a fake of the platform's client (an HTTP fake for a REST platform, a recorded socket for a gateway), never against the live platform, and pin the wire format the way Subscriby pins the Telegram connector's: characterisation tests that record what the connector sends for each core message and each admin flow, and fail on any change, so a behaviour change is a deliberate commit with a justification rather than a surprise.

PortCover at least
InstallationLifecycleConnect with a good and a bad credential, reconnect naming the existing installation, verify healthy and revoked, disconnect after the platform already revoked.
IdentityResolverAn event with an actor, one without, adoption twice for the same account returning the same row.
InboundGatewayA signed and an unsigned request, a body with two events and their idempotency keys, an empty body.
FailureClassifierOne row per platform error you map, plus a network exception and an unknown string.
TextRendererEach of the eight tags, a nested pair, entities, plain text unchanged.
MessengerA send that the platform refuses inside a 200, a rate limit with its retry-after, a file when the manifest allows one.
AccessControllerGrant twice yielding one grant, revoke of a missing grant reporting revoked, a held grant admitted, reconcile letting a banned holder back in.
SupportRelayA reply with and without a quote, an attachment the platform serves and one it cannot.
RecoverySupportEach probe's three answers, a standby registered and removed.

For the core's side, register your connector beside the fake in a Subscriby test run and assert the same scenario on both: a purchase grants, a cancellation revokes, a member with no identity waits as pending_identity and materialises after a handshake. If the core behaves differently for yours, either your manifest says something the fake's does not, or the core has a platform assumption left, and either is worth a report.

Running the kit

The kit needs a ConnectorRegistry, and the SDK ships one for exactly this: Subscriby\Connector\Testing\TestRegistry. Register the manifest and the connector into it, hand it to the suite and assert the report, all inside your package's own test suite with no Subscriby checkout:

use Subscriby\Connector\Manifest\ManifestFile;
use Subscriby\Connector\Testing\Conformance\ConformanceSuite;
use Subscriby\Connector\Testing\TestRegistry;

it('passes the conformance kit', function (): void {
    $registry = new TestRegistry;
    $registry->register(ManifestFile::load(dirname(__DIR__).'/connector.json'), new ExampleConnector);

    $report = (new ConformanceSuite($registry))->run('example', packagePath: dirname(__DIR__));

    expect($report->passed())->toBeTrue($report->summary());
});

TestRegistry::register() runs the same manifest-to-port checks the application's registry runs at boot, through the same class (Subscriby\Connector\Registry\PortAgreement): a capability without its port, a port without its capability, a missing required port, or install fields both declared in the file and bound as a SettingsSchema throw InvalidManifest with the words production would use, before a single rule runs. What the application reads from configuration is a constructor argument here: official (a connector declaring native_payments passes official: ['example'] or is refused), available (every registered key by default) and disabled. A form declared in the file comes back through a passthrough translator, so labels read as you wrote them. The rules that need the application's own behaviour (the per-installation capability switches, the inbound gate) are not in the kit, so this run is complete for what the kit checks.

The other way is inside Subscriby's suite: put your package under packages/subscriby-connector-<key>/ in a checkout of Subscriby; its Pest configuration includes every packages/subscriby-connector-*/tests directory, your service provider boots with the application, and a test of yours runs the suite against app(ConnectorRegistry::class) exactly as the first-party connectors do. That checkout is also where the core's side is tested against your connector: a purchase grants, a cancellation revokes, a member with no identity waits.

Whichever way you run it, three checks need no registry at all and belong in every package's suite from the first commit: ManifestFile::load(__DIR__.'/../connector.json') for the manifest, MigrationRules::violations('<key>', __DIR__.'/../database/migrations') for the data rules, and the Field and MessageAction constructors for your forms and buttons.

What only the platform proves

The kit and the fakes cannot tell you that a real bot can add a real member to a real room, that the platform's rate limit is where its documentation says, or that a revoked token answers the way you classified it. Keep one sandbox installation on the platform, run the connector against it by hand before each release, and record what you see into the characterisation snapshots.

Never a catch-all fake

A fake of the platform's client that answers everything with success hides every refusal the classifier exists to read. Fake each call you make, one answer at a time, and let an unexpected call fail the test.

How is this guide?

On this page

Subscriby is a product designed by you — for you.

No boardroom full of executives deciding what we ships next. Our roadmap always shaped by you with your feedback.

Share feedback or a request