socialdept/atp-testnet

Stand up a local AT Protocol testnet for integration testing in PHP

Maintainers

Package info

github.com/socialdept/atp-testnet

pkg:composer/socialdept/atp-testnet

Statistics

Installs: 4

Dependents: 0

Suggesters: 0

Stars: 4

Open Issues: 0

v0.2.0 2026-04-16 08:49 UTC

This package is auto-updated.

Last update: 2026-04-16 08:50:23 UTC


README

Testnet Header

Stand up a local AT Protocol testnet for integration testing in PHP.


What is ATP Testnet?

ATP Testnet spins up a complete local AT Protocol network using Docker Compose for integration testing. No mocks, no fakes — real PLC directory, real PDS, real relay with firehose.

Quick Example

use SocialDept\AtpTestnet\Testnet;

// Boot the network
$testnet = Testnet::start();

// Create an account
$alice = $testnet->createAccount('alice');
echo $alice->did;    // did:plc:abc123...
echo $alice->handle; // alice.test

// Create a record
$testnet->pds()->createRecord(
    'app.bsky.feed.post',
    ['$type' => 'app.bsky.feed.post', 'text' => 'Hello from testnet!', 'createdAt' => gmdate('c')],
    $alice->accessJwt,
);

// Query the PLC directory
$doc = $testnet->plc()->getDocument($alice->did);

// Subscribe to the firehose
$testnet->requestRelayCrawl();
$frames = $testnet->relay()->consumeFirehose(timeoutMs: 3000);

// Tear down
$testnet->stop();

Installation

composer require socialdept/atp-testnet --dev

Requirements

  • PHP 8.3+
  • Docker & Docker Compose
  • ~2 GB disk for Docker images on first build

Building Docker Images

The PLC directory and relay are built from source since they don't publish official Docker images. Images are built automatically on the first Testnet::start() call, or you can pre-build them:

# Build missing images (skips if already built)
composer testnet:build

# Force rebuild all images
composer testnet:rebuild

# Or use the binary directly
vendor/bin/testnet-build
vendor/bin/testnet-build --rebuild

The first build clones the repos and compiles Go and Node — this takes a few minutes. After that, starts are instant.

The relay image is patched during build to work in Docker's internal network (SSRF bypass, hostname validation, domain ban checks, and account host matching are all disabled via the RELAY_DISABLE_SSRF environment variable).

The PLC image is patched to support disabling rate limits via the PLC_DISABLE_RATE_LIMIT environment variable (enabled by default in the testnet compose file). The production PLC enforces 10 ops/hour per DID — this patch bypasses that for local testing.

If you update the package and the build-time patches change, force a rebuild with composer testnet:rebuild or vendor/bin/testnet-build --rebuild.

Services

Service Image Default Port Purpose
PLC Directory Built from did-method-plc 7100 DID registration and PLC operations
PDS ghcr.io/bluesky-social/pds:0.4 7102 Account creation, repos, XRPC
Relay Built from indigo 7101 Firehose relay, crawls PDS

Each service also runs its own Postgres instance internally.

Usage with PHPUnit / Pest

use SocialDept\AtpTestnet\Concerns\UsesTestnet;

// PHPUnit
class MyTest extends TestCase
{
    use UsesTestnet;

    public function test_account_creation(): void
    {
        $account = $this->testnet->createAccount('bob');
        $this->assertStringStartsWith('did:plc:', $account->did);
    }
}

// Pest
uses(UsesTestnet::class);

test('can create account', function () {
    $account = $this->testnet->createAccount('bob');
    expect($account->did)->toStartWith('did:plc:');
});

The UsesTestnet trait boots the testnet once per test class and tears it down after all tests complete.

Test Isolation

The testnet accumulates state across tests (accounts, DIDs, relay subscriptions). Use the reset methods to start each test with a clean slate:

// Reset everything before each test
beforeEach(function () {
    $this->testnet->resetAll();
});

// Or reset individual services
beforeEach(function () {
    $this->testnet->resetPds();   // Clear accounts and repos only
});
Method What it clears
resetPds() All accounts, repos, sessions, invite codes (SQLite truncate)
resetPlc() All DIDs and operations (Postgres truncate)
resetRelay() All host subscriptions, account tracking, persisted data
resetAll() All of the above

Services stay running and healthy after reset — no container restarts needed.

Configuration

use SocialDept\AtpTestnet\TestnetConfig;
use SocialDept\AtpTestnet\Testnet;

$testnet = Testnet::start(new TestnetConfig(
    plcPort: 8100,
    pdsPort: 8102,
    relayPort: 8101,
    adminPassword: 'custom-admin',
));

Docker Compose Overrides

You can customize the testnet's Docker Compose configuration by publishing an override file to your project root:

cp vendor/socialdept/atp-testnet/stubs/docker-compose.testnet.yml docker-compose.testnet.yml

Edit the file to override any service configuration:

# docker-compose.testnet.yml
services:
  plc:
    environment:
      PLC_DISABLE_RATE_LIMIT: "0"  # Re-enable rate limits
  pds:
    environment:
      PDS_DEV_MODE: "0"
      PDS_HOSTNAME: custom.test

The testnet automatically detects docker-compose.testnet.yml in your working directory and merges it on top of the base configuration. Only specify values you want to change — everything else keeps its default.

No rebuild needed. Override files are applied at runtime via Docker Compose's -f merge. Changes take effect on the next Testnet::start() — no image rebuild required. Image rebuilds are only needed when the package itself updates its build-time patches (PLC rate limit bypass, relay SSRF bypass).

You can also pass an explicit path via config:

$testnet = Testnet::start(new TestnetConfig(
    composeOverride: base_path('my-custom-overrides.yml'),
));

Environment Variables

Variable Service Default Description
PLC_DISABLE_RATE_LIMIT PLC 1 Disable the 10 ops/hour rate limit per DID
RELAY_DISABLE_SSRF Relay 1 Disable SSRF protection for Docker networking
RELAY_ALLOW_INSECURE_HOSTS Relay 1 Allow HTTP (non-TLS) host connections
PDS_DEV_MODE PDS 1 Allow HTTP hostnames, disable SSRF protection
PDS_INVITE_REQUIRED PDS false Require invite codes for account creation
PDS_HOSTNAME PDS pds.test PDS hostname for handle domains

API Reference

Testnet

// Lifecycle
Testnet::start(?TestnetConfig $config = null): Testnet
$testnet->stop(): void
$testnet->isRunning(): bool

// Accounts
$testnet->createAccount(string $handle, ?string $email = null): TestAccount
$testnet->createAccountWithSession(string $handle, ?string $email = null): TestAccount
$testnet->authenticatedClient(TestAccount $account): \GuzzleHttp\Client

// Services
$testnet->plc(): PlcService
$testnet->pds(): PdsService
$testnet->relay(): RelayService

// Relay
$testnet->requestRelayCrawl(): void

// Reset (for test isolation)
$testnet->resetPds(): void       // Truncate all PDS accounts and repos
$testnet->resetPlc(): void       // Truncate all DIDs and operations
$testnet->resetRelay(): void     // Truncate relay subscriptions and data
$testnet->resetAll(): void       // Reset PDS + PLC + Relay

// Config
$testnet->config(): TestnetConfig
$testnet->rotationKeypair(): Secp256k1Keypair

PLC Service

// DID documents
$testnet->plc()->getDocument(string $did): array
$testnet->plc()->getDocumentData(string $did): array
$testnet->plc()->getLastOperation(string $did): array
$testnet->plc()->getOperationLog(string $did): array
$testnet->plc()->getAuditLog(string $did): array
$testnet->plc()->submitOperation(string $did, array $operation): void

// PLC operations (requires atp-cbor for signing)
$testnet->plc()->updateServiceEndpoint(string $did, string $newEndpoint, Secp256k1Keypair $signer): void
$testnet->plc()->updateHandle(string $did, string $newHandle, Secp256k1Keypair $signer): void
$testnet->plc()->updateRotationKeys(string $did, array $newRotationKeys, Secp256k1Keypair $signer): void

$testnet->plc()->isHealthy(): bool

PDS Service

// Accounts
$testnet->pds()->createAccount(string $handle, ?string $email = null, ?string $password = null): TestAccount
$testnet->pds()->deleteAccount(string $did, string $password, string $token): array
$testnet->pds()->deactivateAccount(string $accessJwt): array
$testnet->pds()->activateAccount(string $accessJwt): array

// Sessions
$testnet->pds()->createSession(string $identifier, string $password): array
$testnet->pds()->getSession(string $accessJwt): array
$testnet->pds()->refreshSession(string $refreshJwt): array
$testnet->pds()->deleteSession(string $refreshJwt): void

// Records
$testnet->pds()->createRecord(string $collection, array $record, string $accessJwt, ?string $repo = null): array
$testnet->pds()->getRecord(string $repo, string $collection, string $rkey): array
$testnet->pds()->deleteRecord(string $collection, string $rkey, string $accessJwt, ?string $repo = null): array
$testnet->pds()->listRecords(string $repo, string $collection, int $limit = 50): array

// Repos and blobs
$testnet->pds()->describeRepo(string $repo): array
$testnet->pds()->uploadBlob(string $data, string $mimeType, string $accessJwt): array
$testnet->pds()->getRepo(string $did): string

// Identity
$testnet->pds()->resolveHandle(string $handle): array
$testnet->pds()->updateHandle(string $handle, string $accessJwt): array

// Admin
$testnet->pds()->createInviteCode(int $useCount = 1, ?string $forAccount = null): array
$testnet->pds()->getAccountInfo(string $did): array
$testnet->pds()->adminUpdateHandle(string $did, string $handle): array
$testnet->pds()->adminUpdateEmail(string $did, string $email): array
$testnet->pds()->updateSubjectStatus(string $did, string $deactivated = 'false'): array

$testnet->pds()->describeServer(): array
$testnet->pds()->isHealthy(): bool

Relay Service

$testnet->relay()->requestCrawl(string $pdsHostname): void
$testnet->relay()->listRepos(?int $limit = null, ?string $cursor = null): array
$testnet->relay()->listHosts(?int $limit = null): array
$testnet->relay()->getRepoStatus(string $did): array
$testnet->relay()->getLatestCommit(string $did): array
$testnet->relay()->getBlob(string $did, string $cid): string
$testnet->relay()->consumeFirehose(int $timeoutMs = 3000, ?int $cursor = null): array

$testnet->relay()->isHealthy(): bool

TestAccount

$account->did;        // did:plc:abc123...
$account->handle;     // alice.test
$account->email;      // alice@test.invalid
$account->password;   // auto-generated
$account->accessJwt;  // session token
$account->refreshJwt; // refresh token

Contributing

Please see CONTRIBUTING.md for details.

License

The MIT License (MIT). Please see License File for more information.