diff --git a/README.md b/README.md
index 42ff1e1..11cf0f0 100644
--- a/README.md
+++ b/README.md
@@ -8,8 +8,9 @@ This is a work-in-progress project done for fun! Don't expect it to be productio
The following XRPC endpoints are implemented:
-- `com.atproto.server.describeServer`
- `com.atproto.identity.resolveHandle`
+- `com.atproto.server.describeServer`
+- `com.atproto.sync.listRepos`
## Installation
diff --git a/app/routes.php b/app/routes.php
index bf202ba..38e2bb0 100644
--- a/app/routes.php
+++ b/app/routes.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
use App\Application\Actions\Pds\Atproto\Identity\ResolveHandleAction;
use App\Application\Actions\Pds\Atproto\Server\DescribeServerAction;
+use App\Application\Actions\Pds\Atproto\Sync\ListReposAction;
use Composer\InstalledVersions;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
@@ -32,8 +33,16 @@ ASCII;
$response->getBody()->write("
this is phpds, an atproto personal data server implemented in PHP!
");
$response->getBody()->write("useful routes are under /xrpc/
");
$response->getBody()->write("please don't use this in prod!
");
- $response->getBody()->write("with love, @andresitorresm.com
");
- $response->getBody()->write("source code: GitHub and tangled.sh
");
+ $response
+ ->getBody()
+ ->write("with love, @andresitorresm.com
");
+ $response
+ ->getBody()
+ ->write(
+ "source code: "
+ . "GitHub and "
+ . "tangled.sh
"
+ );
return $response;
});
@@ -54,10 +63,13 @@ ASCII;
// atproto identity
$group->get('/com.atproto.identity.resolveHandle', ResolveHandleAction::class);
+ // atproto sync
+ $group->get('/com.atproto.sync.listRepos', ListReposAction::class);
+
// misc
$group->get('/_health', function (Request $request, Response $response) {
- $version = InstalledVersions::getRootPackage()['pretty_version'] ?? 'unknown';
- $response->getBody()->write(json_encode(['version' => $version]));
+ $version = InstalledVersions::getRootPackage()['pretty_version'];
+ $response->getBody()->write((string) json_encode(['version' => $version]));
return $response->withHeader('Content-Type', 'application/json');
});
diff --git a/src/Application/Actions/Pds/Atproto/Sync/ListReposAction.php b/src/Application/Actions/Pds/Atproto/Sync/ListReposAction.php
new file mode 100644
index 0000000..47782d7
--- /dev/null
+++ b/src/Application/Actions/Pds/Atproto/Sync/ListReposAction.php
@@ -0,0 +1,147 @@
+actorRepository = $actorRepository;
+ $this->actorStoreFactory = $actorStoreFactory;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function action(): Response
+ {
+ $params = $this->request->getQueryParams();
+
+ $limit = $this->parseLimit($params['limit'] ?? null);
+ $cursor = $this->parseCursor($params['cursor'] ?? null);
+
+ $actors = $this->actorRepository->findPage($cursor, $limit);
+
+ $repos = [];
+ foreach ($actors as $actor) {
+ try {
+ $root = $this->actorStoreFactory->get($actor->getDid())
+ ->getRepoRoot()
+ ->findByDid($actor->getDid());
+ } catch (RepoRootNotFoundException $e) {
+ // actor exists but its repo hasn't been initialised; skip it
+ continue;
+ }
+
+ $status = $this->deriveStatus($actor);
+ $repos[] = new RepoView(
+ did: $actor->getDid(),
+ head: $root->getCid(),
+ rev: $root->getRev(),
+ active: $status === null,
+ status: $status,
+ );
+ }
+
+ // a full page means there may be more results; the cursor is the
+ // last actor DID returned (whether or not it produced a RepoView)
+ $nextCursor = null;
+ if (count($actors) === $limit) {
+ $last = end($actors);
+ assert($last !== false);
+ $nextCursor = $last->getDid();
+ }
+
+ return $this->respondWithData(new ListReposResponse($repos, $nextCursor));
+ }
+
+ private function parseLimit(mixed $raw): int
+ {
+ if ($raw === null || $raw === '') {
+ return self::DEFAULT_LIMIT;
+ }
+
+ if (!is_string($raw) && !is_int($raw)) {
+ throw XrpcException::invalidParam(
+ $this->actionName,
+ 'limit must be an integer',
+ (string) (is_scalar($raw) ? $raw : '')
+ );
+ }
+
+ if (is_string($raw) && !preg_match('/^-?\d+$/', $raw)) {
+ throw XrpcException::invalidParam($this->actionName, 'limit must be an integer', $raw);
+ }
+
+ $limit = (int) $raw;
+ if ($limit < 1 || $limit > self::MAX_LIMIT) {
+ throw XrpcException::invalidParam(
+ $this->actionName,
+ sprintf('limit must be between 1 and %d', self::MAX_LIMIT),
+ (string) $raw
+ );
+ }
+
+ return $limit;
+ }
+
+ private function parseCursor(mixed $raw): ?string
+ {
+ if ($raw === null) {
+ return null;
+ }
+
+ if (!is_string($raw)) {
+ throw XrpcException::invalidParam($this->actionName, 'cursor must be a string', '');
+ }
+
+ $cursor = trim($raw);
+ return $cursor === '' ? null : $cursor;
+ }
+
+ /**
+ * Derive the lex `status` for an actor's repo view.
+ *
+ * Returns null when the repo is active, and otherwise
+ * returns a string indicating a non-active repo status
+ * (e.g. "takendown" or "deactivated").
+ */
+ private function deriveStatus(\App\Domain\Actor\Actor $actor): ?string
+ {
+ if ($actor->getTakedownRef() !== null) {
+ return RepoView::STATUS_TAKENDOWN;
+ }
+
+ if ($actor->getDeactivatedAt() !== null) {
+ return RepoView::STATUS_DEACTIVATED;
+ }
+
+ return null;
+ }
+}
diff --git a/src/Domain/Actor/ActorRepository.php b/src/Domain/Actor/ActorRepository.php
index f018cf4..6335a3d 100644
--- a/src/Domain/Actor/ActorRepository.php
+++ b/src/Domain/Actor/ActorRepository.php
@@ -11,6 +11,15 @@ interface ActorRepository
*/
public function findAll(): array;
+ /**
+ * Return a page of actors ordered by DID ascending, starting strictly
+ * after $cursor (exclusive). When $cursor is null, starts from the
+ * beginning.
+ *
+ * @return Actor[]
+ */
+ public function findPage(?string $cursor, int $limit): array;
+
/**
* @throws ActorNotFoundException
*/
diff --git a/src/Domain/Pds/Atproto/Sync/ListReposResponse.php b/src/Domain/Pds/Atproto/Sync/ListReposResponse.php
new file mode 100644
index 0000000..9f1c36f
--- /dev/null
+++ b/src/Domain/Pds/Atproto/Sync/ListReposResponse.php
@@ -0,0 +1,50 @@
+ */
+ private array $repos;
+
+ private ?string $cursor;
+
+ /**
+ * @param list $repos
+ */
+ public function __construct(array $repos, ?string $cursor = null)
+ {
+ $this->repos = $repos;
+ $this->cursor = $cursor;
+ }
+
+ /**
+ * @return list
+ */
+ public function getRepos(): array
+ {
+ return $this->repos;
+ }
+
+ public function getCursor(): ?string
+ {
+ return $this->cursor;
+ }
+
+ /**
+ * @return array
+ */
+ #[\ReturnTypeWillChange]
+ public function jsonSerialize(): array
+ {
+ $out = ['repos' => $this->repos];
+ if ($this->cursor !== null) {
+ $out['cursor'] = $this->cursor;
+ }
+ return $out;
+ }
+}
diff --git a/src/Domain/Pds/Atproto/Sync/RepoView.php b/src/Domain/Pds/Atproto/Sync/RepoView.php
new file mode 100644
index 0000000..4a34cde
--- /dev/null
+++ b/src/Domain/Pds/Atproto/Sync/RepoView.php
@@ -0,0 +1,72 @@
+did;
+ }
+
+ public function getHead(): string
+ {
+ return $this->head;
+ }
+
+ public function getRev(): string
+ {
+ return $this->rev;
+ }
+
+ public function isActive(): bool
+ {
+ return $this->active;
+ }
+
+ public function getStatus(): ?string
+ {
+ return $this->status;
+ }
+
+ /**
+ * @return array
+ */
+ #[\ReturnTypeWillChange]
+ public function jsonSerialize(): array
+ {
+ $out = [
+ 'did' => $this->did,
+ 'head' => $this->head,
+ 'rev' => $this->rev,
+ 'active' => $this->active,
+ ];
+ if ($this->status !== null) {
+ $out['status'] = $this->status;
+ }
+ return $out;
+ }
+}
diff --git a/src/Infrastructure/Persistence/Actor/SqliteActorRepository.php b/src/Infrastructure/Persistence/Actor/SqliteActorRepository.php
index fe563d3..f805940 100644
--- a/src/Infrastructure/Persistence/Actor/SqliteActorRepository.php
+++ b/src/Infrastructure/Persistence/Actor/SqliteActorRepository.php
@@ -42,6 +42,34 @@ class SqliteActorRepository implements ActorRepository
return $this->hydrate($row);
}
+ /**
+ * @return Actor[]
+ */
+ public function findPage(?string $cursor, int $limit): array
+ {
+ if ($limit <= 0) {
+ return [];
+ }
+
+ if ($cursor === null || $cursor === '') {
+ $rows = $this->db->fetchAll(
+ 'SELECT * FROM actor ORDER BY did ASC LIMIT ?',
+ [$limit]
+ );
+ } else {
+ $rows = $this->db->fetchAll(
+ 'SELECT * FROM actor WHERE did > ? ORDER BY did ASC LIMIT ?',
+ [$cursor, $limit]
+ );
+ }
+
+ $result = [];
+ foreach ($rows as $row) {
+ $result[] = $this->hydrate($row);
+ }
+ return $result;
+ }
+
public function findActorByHandle(string $handle): Actor
{
$handle = strtolower(trim($handle));
diff --git a/tests/Application/Actions/Pds/Atproto/Sync/ListReposActionTest.php b/tests/Application/Actions/Pds/Atproto/Sync/ListReposActionTest.php
new file mode 100644
index 0000000..63843f4
--- /dev/null
+++ b/tests/Application/Actions/Pds/Atproto/Sync/ListReposActionTest.php
@@ -0,0 +1,382 @@
+ $root (or throws when $root is null).
+ *
+ * @param array $rootsByDid
+ */
+ private function makeFactory(array $rootsByDid): ActorStoreFactory
+ {
+ $factoryProphecy = $this->prophesize(ActorStoreFactory::class);
+
+ foreach ($rootsByDid as $did => $root) {
+ $repoRootProphecy = $this->prophesize(RepoRootRepository::class);
+ if ($root === null) {
+ $repoRootProphecy->findByDid($did)->willThrow(new RepoRootNotFoundException());
+ } else {
+ $repoRootProphecy->findByDid($did)->willReturn($root);
+ }
+
+ $storeProphecy = $this->prophesize(ActorStore::class);
+ $storeProphecy->getRepoRoot()->willReturn($repoRootProphecy->reveal());
+
+ $factoryProphecy->get($did)->willReturn($storeProphecy->reveal());
+ }
+
+ return $factoryProphecy->reveal();
+ }
+
+ public function testActionReturnsAllReposWhenUnderLimit(): void
+ {
+ $logger = $this->prophesize(LoggerInterface::class)->reveal();
+ $settings = new Settings([]);
+
+ $alice = $this->makeActor('did:web:alice.pds.test');
+ $bob = $this->makeActor('did:web:bob.pds.test', new DateTimeImmutable('2026-02-01T00:00:00Z'));
+
+ $repoProphecy = $this->prophesize(ActorRepository::class);
+ $repoProphecy->findPage(null, ListReposAction::DEFAULT_LIMIT)
+ ->willReturn([$alice, $bob])
+ ->shouldBeCalledOnce();
+
+ $factory = $this->makeFactory([
+ 'did:web:alice.pds.test' => new RepoRoot(
+ 'did:web:alice.pds.test',
+ 'bafyAliceHead',
+ '3kabc',
+ new DateTimeImmutable('2026-01-02T00:00:00Z')
+ ),
+ 'did:web:bob.pds.test' => new RepoRoot(
+ 'did:web:bob.pds.test',
+ 'bafyBobHead',
+ '3kxyz',
+ new DateTimeImmutable('2026-01-03T00:00:00Z')
+ ),
+ ]);
+
+ $action = new ListReposAction($logger, $settings, $repoProphecy->reveal(), $factory);
+
+ $request = $this->createRequest('GET', '/xrpc/com.atproto.sync.listRepos');
+ $response = (new ResponseFactory())->createResponse();
+
+ $actualResponse = $action($request, $response, []);
+
+ $expectedPayload = json_encode([
+ 'repos' => [
+ [
+ 'did' => 'did:web:alice.pds.test',
+ 'head' => 'bafyAliceHead',
+ 'rev' => '3kabc',
+ 'active' => true,
+ ],
+ [
+ 'did' => 'did:web:bob.pds.test',
+ 'head' => 'bafyBobHead',
+ 'rev' => '3kxyz',
+ 'active' => false,
+ 'status' => 'deactivated',
+ ],
+ ],
+ ], JSON_PRETTY_PRINT);
+
+ $this->assertSame(200, $actualResponse->getStatusCode());
+ $this->assertSame('application/json', $actualResponse->getHeaderLine('Content-Type'));
+ $this->assertSame($expectedPayload, (string) $actualResponse->getBody());
+ }
+
+ public function testActionEmitsCursorWhenPageIsFull(): void
+ {
+ $logger = $this->prophesize(LoggerInterface::class)->reveal();
+ $settings = new Settings([]);
+
+ $alice = $this->makeActor('did:web:alice.pds.test');
+ $bob = $this->makeActor('did:web:bob.pds.test');
+
+ $repoProphecy = $this->prophesize(ActorRepository::class);
+ $repoProphecy->findPage(null, 2)
+ ->willReturn([$alice, $bob])
+ ->shouldBeCalledOnce();
+
+ $factory = $this->makeFactory([
+ 'did:web:alice.pds.test' => new RepoRoot(
+ 'did:web:alice.pds.test',
+ 'bafyAliceHead',
+ '3kabc',
+ new DateTimeImmutable('2026-01-02T00:00:00Z')
+ ),
+ 'did:web:bob.pds.test' => new RepoRoot(
+ 'did:web:bob.pds.test',
+ 'bafyBobHead',
+ '3kxyz',
+ new DateTimeImmutable('2026-01-03T00:00:00Z')
+ ),
+ ]);
+
+ $action = new ListReposAction($logger, $settings, $repoProphecy->reveal(), $factory);
+
+ $request = $this->createRequest('GET', '/xrpc/com.atproto.sync.listRepos')
+ ->withQueryParams(['limit' => '2']);
+ $response = (new ResponseFactory())->createResponse();
+
+ $actualResponse = $action($request, $response, []);
+
+ $body = json_decode((string) $actualResponse->getBody(), true);
+ $this->assertIsArray($body);
+ $repos = $body['repos'] ?? null;
+ $this->assertIsArray($repos);
+ $this->assertSame('did:web:bob.pds.test', $body['cursor']);
+ $this->assertCount(2, $repos);
+ }
+
+ public function testActionAdvancesUsingProvidedCursor(): void
+ {
+ $logger = $this->prophesize(LoggerInterface::class)->reveal();
+ $settings = new Settings([]);
+
+ $bob = $this->makeActor('did:web:bob.pds.test');
+
+ $repoProphecy = $this->prophesize(ActorRepository::class);
+ $repoProphecy->findPage('did:web:alice.pds.test', ListReposAction::DEFAULT_LIMIT)
+ ->willReturn([$bob])
+ ->shouldBeCalledOnce();
+
+ $factory = $this->makeFactory([
+ 'did:web:bob.pds.test' => new RepoRoot(
+ 'did:web:bob.pds.test',
+ 'bafyBobHead',
+ '3kxyz',
+ new DateTimeImmutable('2026-01-03T00:00:00Z')
+ ),
+ ]);
+
+ $action = new ListReposAction($logger, $settings, $repoProphecy->reveal(), $factory);
+
+ $request = $this->createRequest('GET', '/xrpc/com.atproto.sync.listRepos')
+ ->withQueryParams(['cursor' => 'did:web:alice.pds.test']);
+ $response = (new ResponseFactory())->createResponse();
+
+ $actualResponse = $action($request, $response, []);
+
+ $body = json_decode((string) $actualResponse->getBody(), true);
+ $this->assertIsArray($body);
+ $repos = $body['repos'] ?? null;
+ $this->assertIsArray($repos);
+ $this->assertArrayNotHasKey('cursor', $body);
+ $this->assertCount(1, $repos);
+ $first = $repos[0] ?? null;
+ $this->assertIsArray($first);
+ $this->assertSame('did:web:bob.pds.test', $first['did']);
+ }
+
+ public function testActionSkipsActorsWithoutRepoRoot(): void
+ {
+ $logger = $this->prophesize(LoggerInterface::class)->reveal();
+ $settings = new Settings([]);
+
+ $alice = $this->makeActor('did:web:alice.pds.test');
+ $bob = $this->makeActor('did:web:bob.pds.test');
+
+ $repoProphecy = $this->prophesize(ActorRepository::class);
+ $repoProphecy->findPage(null, ListReposAction::DEFAULT_LIMIT)
+ ->willReturn([$alice, $bob]);
+
+ $factory = $this->makeFactory([
+ 'did:web:alice.pds.test' => null,
+ 'did:web:bob.pds.test' => new RepoRoot(
+ 'did:web:bob.pds.test',
+ 'bafyBobHead',
+ '3kxyz',
+ new DateTimeImmutable('2026-01-03T00:00:00Z')
+ ),
+ ]);
+
+ $action = new ListReposAction($logger, $settings, $repoProphecy->reveal(), $factory);
+
+ $request = $this->createRequest('GET', '/xrpc/com.atproto.sync.listRepos');
+ $response = (new ResponseFactory())->createResponse();
+
+ $actualResponse = $action($request, $response, []);
+
+ $body = json_decode((string) $actualResponse->getBody(), true);
+ $this->assertIsArray($body);
+ $repos = $body['repos'] ?? null;
+ $this->assertIsArray($repos);
+ $this->assertCount(1, $repos);
+ $first = $repos[0] ?? null;
+ $this->assertIsArray($first);
+ $this->assertSame('did:web:bob.pds.test', $first['did']);
+ }
+
+ public function testActionRejectsLimitOutOfRange(): void
+ {
+ $logger = $this->prophesize(LoggerInterface::class)->reveal();
+ $settings = new Settings([]);
+ $repo = $this->prophesize(ActorRepository::class);
+ $repo->findPage(Argument::any(), Argument::any())->shouldNotBeCalled();
+ $factory = $this->prophesize(ActorStoreFactory::class)->reveal();
+
+ $action = new ListReposAction($logger, $settings, $repo->reveal(), $factory);
+
+ $request = $this->createRequest('GET', '/xrpc/com.atproto.sync.listRepos')
+ ->withQueryParams(['limit' => '0']);
+ $response = (new ResponseFactory())->createResponse();
+
+ $this->expectException(XrpcException::class);
+ $action($request, $response, []);
+ }
+
+ public function testActionRejectsNonIntegerLimit(): void
+ {
+ $logger = $this->prophesize(LoggerInterface::class)->reveal();
+ $settings = new Settings([]);
+ $repo = $this->prophesize(ActorRepository::class);
+ $repo->findPage(Argument::any(), Argument::any())->shouldNotBeCalled();
+ $factory = $this->prophesize(ActorStoreFactory::class)->reveal();
+
+ $action = new ListReposAction($logger, $settings, $repo->reveal(), $factory);
+
+ $request = $this->createRequest('GET', '/xrpc/com.atproto.sync.listRepos')
+ ->withQueryParams(['limit' => 'banana']);
+ $response = (new ResponseFactory())->createResponse();
+
+ $this->expectException(XrpcException::class);
+ $action($request, $response, []);
+ }
+
+ public function testActionMarksTakendownActorsAsInactiveWithTakedownStatus(): void
+ {
+ $logger = $this->prophesize(LoggerInterface::class)->reveal();
+ $settings = new Settings([]);
+
+ $banned = $this->makeActor('did:web:banned.pds.test', null, 'mod-action-123');
+
+ $repoProphecy = $this->prophesize(ActorRepository::class);
+ $repoProphecy->findPage(null, ListReposAction::DEFAULT_LIMIT)
+ ->willReturn([$banned]);
+
+ $factory = $this->makeFactory([
+ 'did:web:banned.pds.test' => new RepoRoot(
+ 'did:web:banned.pds.test',
+ 'bafyBannedHead',
+ '3kbanned',
+ new DateTimeImmutable('2026-01-04T00:00:00Z')
+ ),
+ ]);
+
+ $action = new ListReposAction($logger, $settings, $repoProphecy->reveal(), $factory);
+
+ $request = $this->createRequest('GET', '/xrpc/com.atproto.sync.listRepos');
+ $response = (new ResponseFactory())->createResponse();
+
+ $actualResponse = $action($request, $response, []);
+
+ $body = json_decode((string) $actualResponse->getBody(), true);
+ $this->assertIsArray($body);
+ $repos = $body['repos'] ?? null;
+ $this->assertIsArray($repos);
+ $first = $repos[0] ?? null;
+ $this->assertIsArray($first);
+ $this->assertSame('did:web:banned.pds.test', $first['did']);
+ $this->assertFalse($first['active']);
+ $this->assertSame('takendown', $first['status']);
+ }
+
+ public function testActionPrefersTakedownStatusOverDeactivation(): void
+ {
+ $logger = $this->prophesize(LoggerInterface::class)->reveal();
+ $settings = new Settings([]);
+
+ $actor = $this->makeActor(
+ 'did:web:both.pds.test',
+ new DateTimeImmutable('2026-02-01T00:00:00Z'),
+ 'mod-action-123'
+ );
+
+ $repoProphecy = $this->prophesize(ActorRepository::class);
+ $repoProphecy->findPage(null, ListReposAction::DEFAULT_LIMIT)
+ ->willReturn([$actor]);
+
+ $factory = $this->makeFactory([
+ 'did:web:both.pds.test' => new RepoRoot(
+ 'did:web:both.pds.test',
+ 'bafyHead',
+ '3kboth',
+ new DateTimeImmutable('2026-01-05T00:00:00Z')
+ ),
+ ]);
+
+ $action = new ListReposAction($logger, $settings, $repoProphecy->reveal(), $factory);
+
+ $request = $this->createRequest('GET', '/xrpc/com.atproto.sync.listRepos');
+ $response = (new ResponseFactory())->createResponse();
+
+ $actualResponse = $action($request, $response, []);
+
+ $body = json_decode((string) $actualResponse->getBody(), true);
+ $this->assertIsArray($body);
+ $repos = $body['repos'] ?? null;
+ $this->assertIsArray($repos);
+ $first = $repos[0] ?? null;
+ $this->assertIsArray($first);
+ $this->assertSame('takendown', $first['status']);
+ }
+
+ public function testActionReturnsEmptyListWhenNoActors(): void
+ {
+ $logger = $this->prophesize(LoggerInterface::class)->reveal();
+ $settings = new Settings([]);
+
+ $repoProphecy = $this->prophesize(ActorRepository::class);
+ $repoProphecy->findPage(null, ListReposAction::DEFAULT_LIMIT)->willReturn([]);
+
+ $factory = $this->prophesize(ActorStoreFactory::class);
+ $factory->get(Argument::any())->shouldNotBeCalled();
+
+ $action = new ListReposAction($logger, $settings, $repoProphecy->reveal(), $factory->reveal());
+
+ $request = $this->createRequest('GET', '/xrpc/com.atproto.sync.listRepos');
+ $response = (new ResponseFactory())->createResponse();
+
+ $actualResponse = $action($request, $response, []);
+
+ $body = json_decode((string) $actualResponse->getBody(), true);
+ $this->assertSame(['repos' => []], $body);
+ }
+}
diff --git a/tests/Infrastructure/Persistence/Actor/SqliteActorRepositoryTest.php b/tests/Infrastructure/Persistence/Actor/SqliteActorRepositoryTest.php
index 3bca401..2e69402 100644
--- a/tests/Infrastructure/Persistence/Actor/SqliteActorRepositoryTest.php
+++ b/tests/Infrastructure/Persistence/Actor/SqliteActorRepositoryTest.php
@@ -83,4 +83,59 @@ class SqliteActorRepositoryTest extends TestCase
$this->expectException(ActorNotFoundException::class);
$repo->findActorByHandle('headless.pds.test');
}
+
+ public function testFindPageReturnsEmptyArrayWhenNoActors(): void
+ {
+ $repo = $this->newRepo();
+ $this->assertSame([], $repo->findPage(null, 10));
+ }
+
+ public function testFindPageReturnsActorsOrderedByDid(): void
+ {
+ $repo = $this->newRepo();
+ $repo->save($this->makeActor('did:web:bob.pds.test', 'bob.pds.test'));
+ $repo->save($this->makeActor('did:web:alice.pds.test', 'alice.pds.test'));
+ $repo->save($this->makeActor('did:web:carol.pds.test', 'carol.pds.test'));
+
+ $page = $repo->findPage(null, 10);
+ $this->assertCount(3, $page);
+ $this->assertSame('did:web:alice.pds.test', $page[0]->getDid());
+ $this->assertSame('did:web:bob.pds.test', $page[1]->getDid());
+ $this->assertSame('did:web:carol.pds.test', $page[2]->getDid());
+ }
+
+ public function testFindPageRespectsLimit(): void
+ {
+ $repo = $this->newRepo();
+ $repo->save($this->makeActor('did:web:alice.pds.test', 'alice.pds.test'));
+ $repo->save($this->makeActor('did:web:bob.pds.test', 'bob.pds.test'));
+ $repo->save($this->makeActor('did:web:carol.pds.test', 'carol.pds.test'));
+
+ $page = $repo->findPage(null, 2);
+ $this->assertCount(2, $page);
+ $this->assertSame('did:web:alice.pds.test', $page[0]->getDid());
+ $this->assertSame('did:web:bob.pds.test', $page[1]->getDid());
+ }
+
+ public function testFindPageStartsStrictlyAfterCursor(): void
+ {
+ $repo = $this->newRepo();
+ $repo->save($this->makeActor('did:web:alice.pds.test', 'alice.pds.test'));
+ $repo->save($this->makeActor('did:web:bob.pds.test', 'bob.pds.test'));
+ $repo->save($this->makeActor('did:web:carol.pds.test', 'carol.pds.test'));
+
+ $page = $repo->findPage('did:web:alice.pds.test', 10);
+ $this->assertCount(2, $page);
+ $this->assertSame('did:web:bob.pds.test', $page[0]->getDid());
+ $this->assertSame('did:web:carol.pds.test', $page[1]->getDid());
+ }
+
+ public function testFindPageReturnsEmptyForNonPositiveLimit(): void
+ {
+ $repo = $this->newRepo();
+ $repo->save($this->makeActor('did:web:alice.pds.test', 'alice.pds.test'));
+
+ $this->assertSame([], $repo->findPage(null, 0));
+ $this->assertSame([], $repo->findPage(null, -5));
+ }
}