diff --git a/app/constants.php b/app/constants.php new file mode 100644 index 0000000..70457e2 --- /dev/null +++ b/app/constants.php @@ -0,0 +1,8 @@ +get(SettingsInterface::class); + assert($settingsService instanceof SettingsInterface); + /** @var array{accountDb:string,sequencerDb:string,didCacheDb:string,actorStoreDir:string,blobstoreDir:string}|null $settings */ + $settings = $settingsService->get('database'); + if (!is_array($settings)) { + throw new \RuntimeException('Database settings are missing.'); + } + return $settings; +}; + +/** + * @param ContainerInterface $c + * @param string $key + * @return Database + */ +$getDb = static function (ContainerInterface $c, string $key): Database { + $db = $c->get($key); + assert($db instanceof Database); + return $db; +}; + +return function (ContainerBuilder $containerBuilder) use ($dbSettings, $getDb) { $containerBuilder->addDefinitions([ - AccountDeviceRepository::class => autowire(InMemoryAccountDeviceRepository::class), - AccountRepository::class => autowire(InMemoryAccountRepository::class), - ActorRepository::class => autowire(InMemoryActorRepository::class), - ActorStoreFactory::class => autowire(InMemoryActorStoreFactory::class), - AppPasswordRepository::class => autowire(InMemoryAppPasswordRepository::class), + // Database singletons + DB_ACCOUNT => function (ContainerInterface $c) use ($dbSettings): Database { + $db = new Database($dbSettings($c)['accountDb']); + AccountSchema::apply($db); + return $db; + }, + DB_SEQUENCER => function (ContainerInterface $c) use ($dbSettings): Database { + $db = new Database($dbSettings($c)['sequencerDb']); + SequencerSchema::apply($db); + return $db; + }, + DB_DID_CACHE => function (ContainerInterface $c) use ($dbSettings): Database { + $db = new Database($dbSettings($c)['didCacheDb']); + DidCacheSchema::apply($db); + return $db; + }, + + // Clients and services AppViewClient::class => function (ContainerInterface $container): AppViewClient { $settings = $container->get(SettingsInterface::class); - $baseUrl = $settings->get('pds')['bskyAppViewUrl'] + assert($settings instanceof SettingsInterface); + /** @var array{bskyAppViewUrl?: string} $pdsSettings */ + $pdsSettings = $settings->get('pds'); + $baseUrl = $pdsSettings['bskyAppViewUrl'] ?? throw new \RuntimeException('PDS_BSKY_APP_VIEW_URL is required to use the AppView client.'); $httpClient = new GuzzleClient([ @@ -73,21 +121,48 @@ return function (ContainerBuilder $containerBuilder) { return new GuzzleAppViewClient($httpClient); }, - AuthorizationRequestRepository::class => autowire(InMemoryAuthorizationRequestRepository::class), - AuthorizedClientRepository::class => autowire(InMemoryAuthorizedClientRepository::class), - CarReader::class => autowire(NativeCarReader::class), - CarWriter::class => autowire(NativeCarWriter::class), - DagCborDecoder::class => autowire(NativeDagCborDecoder::class), - DagCborEncoder::class => autowire(NativeDagCborEncoder::class), - DeviceRepository::class => autowire(InMemoryDeviceRepository::class), - DidCacheRepository::class => autowire(InMemoryDidCacheRepository::class), - EmailTokenRepository::class => autowire(InMemoryEmailTokenRepository::class), - InviteCodeRepository::class => autowire(InMemoryInviteCodeRepository::class), - LexiconRepository::class => autowire(InMemoryLexiconRepository::class), - OAuthTokenRepository::class => autowire(InMemoryOAuthTokenRepository::class), - RefreshTokenRepository::class => autowire(InMemoryRefreshTokenRepository::class), - RepoRootRepository::class => autowire(InMemoryRepoRootRepository::class), - SequencerRepository::class => autowire(InMemorySequencerRepository::class), - UsedRefreshTokenRepository::class => autowire(InMemoryUsedRefreshTokenRepository::class), + AccountRepository::class => fn (ContainerInterface $c): SqliteAccountRepository => + new SqliteAccountRepository($getDb($c, DB_ACCOUNT)), + AccountDeviceRepository::class => fn (ContainerInterface $c): SqliteAccountDeviceRepository => + new SqliteAccountDeviceRepository($getDb($c, DB_ACCOUNT)), + ActorRepository::class => fn (ContainerInterface $c): SqliteActorRepository => + new SqliteActorRepository($getDb($c, DB_ACCOUNT)), + ActorStoreFactory::class => function (ContainerInterface $c) use ($dbSettings): SqliteActorStoreFactory { + $settings = $dbSettings($c); + return new SqliteActorStoreFactory( + actorsDirectory: $settings['actorStoreDir'], + blobsDirectory: $settings['blobstoreDir'], + ); + }, + AuthorizationRequestRepository::class => fn (ContainerInterface $c): SqliteAuthorizationRequestRepository => + new SqliteAuthorizationRequestRepository($getDb($c, DB_ACCOUNT)), + AuthorizedClientRepository::class => fn (ContainerInterface $c): SqliteAuthorizedClientRepository => + new SqliteAuthorizedClientRepository($getDb($c, DB_ACCOUNT)), + CarReader::class => autowire(NativeCarReader::class), + CarWriter::class => autowire(NativeCarWriter::class), + DagCborDecoder::class => autowire(NativeDagCborDecoder::class), + DagCborEncoder::class => autowire(NativeDagCborEncoder::class), + DeviceRepository::class => fn (ContainerInterface $c): SqliteDeviceRepository => + new SqliteDeviceRepository($getDb($c, DB_ACCOUNT)), + DidCacheRepository::class => fn (ContainerInterface $c): SqliteDidCacheRepository => + new SqliteDidCacheRepository($getDb($c, DB_DID_CACHE)), + AppPasswordRepository::class => fn (ContainerInterface $c): SqliteAppPasswordRepository => + new SqliteAppPasswordRepository($getDb($c, DB_ACCOUNT)), + EmailTokenRepository::class => fn (ContainerInterface $c): SqliteEmailTokenRepository => + new SqliteEmailTokenRepository($getDb($c, DB_ACCOUNT)), + InviteCodeRepository::class => fn (ContainerInterface $c): SqliteInviteCodeRepository => + new SqliteInviteCodeRepository($getDb($c, DB_ACCOUNT)), + LexiconRepository::class => fn (ContainerInterface $c): SqliteLexiconRepository => + new SqliteLexiconRepository($getDb($c, DB_ACCOUNT)), + OAuthTokenRepository::class => fn (ContainerInterface $c): SqliteOAuthTokenRepository => + new SqliteOAuthTokenRepository($getDb($c, DB_ACCOUNT)), + RefreshTokenRepository::class => fn (ContainerInterface $c): SqliteRefreshTokenRepository => + new SqliteRefreshTokenRepository($getDb($c, DB_ACCOUNT)), + RepoRootRepository::class => fn (ContainerInterface $c): SqliteRepoRootRepository => + new SqliteRepoRootRepository($getDb($c, DB_ACCOUNT)), + SequencerRepository::class => fn (ContainerInterface $c): SqliteSequencerRepository => + new SqliteSequencerRepository($getDb($c, DB_SEQUENCER)), + UsedRefreshTokenRepository::class => fn (ContainerInterface $c): SqliteUsedRefreshTokenRepository => + new SqliteUsedRefreshTokenRepository($getDb($c, DB_ACCOUNT)), ]); }; diff --git a/public/index.php b/public/index.php index c539ceb..f238463 100644 --- a/public/index.php +++ b/public/index.php @@ -13,30 +13,35 @@ use Slim\Factory\AppFactory; use Slim\Factory\ServerRequestCreatorFactory; require __DIR__ . '/../vendor/autoload.php'; +require_once __DIR__ . '/../app/constants.php'; // Loading environment variables from .env file, if it exists $envPath = __DIR__ . '/../.env'; if (is_file($envPath)) { - $dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . '/../'); - $dotenv->safeLoad(); + $dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . '/../'); + $dotenv->safeLoad(); } // Instantiate PHP-DI ContainerBuilder $containerBuilder = new ContainerBuilder(); +// @phpstan-ignore if.alwaysFalse (toggle for production) if (false) { // Should be set to true in production - $containerBuilder->enableCompilation(__DIR__ . '/../var/cache'); + $containerBuilder->enableCompilation(__DIR__ . '/../var/cache'); } // Set up settings +/** @var callable(\DI\ContainerBuilder<\DI\Container>): void $settings */ $settings = require __DIR__ . '/../app/settings.php'; $settings($containerBuilder); // Set up dependencies +/** @var callable(\DI\ContainerBuilder<\DI\Container>): void $dependencies */ $dependencies = require __DIR__ . '/../app/dependencies.php'; $dependencies($containerBuilder); // Set up repositories +/** @var callable(\DI\ContainerBuilder<\DI\Container>): void $repositories */ $repositories = require __DIR__ . '/../app/repositories.php'; $repositories($containerBuilder); @@ -49,18 +54,23 @@ $app = AppFactory::create(); $callableResolver = $app->getCallableResolver(); // Register middleware +/** @var callable(\Slim\App<\Psr\Container\ContainerInterface|null>): void $middleware */ $middleware = require __DIR__ . '/../app/middleware.php'; $middleware($app); // Register routes +/** @var callable(\Slim\App<\Psr\Container\ContainerInterface|null>): void $routes */ $routes = require __DIR__ . '/../app/routes.php'; $routes($app); /** @var SettingsInterface $settings */ $settings = $container->get(SettingsInterface::class); +/** @var bool $displayErrorDetails */ $displayErrorDetails = $settings->get('displayErrorDetails'); +/** @var bool $logError */ $logError = $settings->get('logError'); +/** @var bool $logErrorDetails */ $logErrorDetails = $settings->get('logErrorDetails'); // Create Request object from globals diff --git a/src/Infrastructure/Persistence/Account/AppPassword/InMemoryAppPasswordRepository.php b/src/Infrastructure/Persistence/Account/AppPassword/InMemoryAppPasswordRepository.php deleted file mode 100644 index 1576fd4..0000000 --- a/src/Infrastructure/Persistence/Account/AppPassword/InMemoryAppPasswordRepository.php +++ /dev/null @@ -1,56 +0,0 @@ - name => AppPassword. - * - * @var array> - */ - private array $passwords = []; - - /** - * @param AppPassword[] $seeds - */ - public function __construct(array $seeds = []) - { - foreach ($seeds as $p) { - $this->passwords[$p->getDid()][$p->getName()] = $p; - } - } - - /** - * @return AppPassword[] - */ - public function findAllForDid(string $did): array - { - return array_values($this->passwords[$did] ?? []); - } - - public function findByDidAndName(string $did, string $name): AppPassword - { - if (!isset($this->passwords[$did][$name])) { - throw new AppPasswordNotFoundException(); - } - - return $this->passwords[$did][$name]; - } - - public function save(AppPassword $appPassword): void - { - $this->passwords[$appPassword->getDid()][$appPassword->getName()] = $appPassword; - } - - public function deleteByDidAndName(string $did, string $name): void - { - unset($this->passwords[$did][$name]); - } -} diff --git a/src/Infrastructure/Persistence/Account/AppPassword/SqliteAppPasswordRepository.php b/src/Infrastructure/Persistence/Account/AppPassword/SqliteAppPasswordRepository.php new file mode 100644 index 0000000..466e076 --- /dev/null +++ b/src/Infrastructure/Persistence/Account/AppPassword/SqliteAppPasswordRepository.php @@ -0,0 +1,91 @@ +db->fetchAll( + 'SELECT * FROM app_password WHERE did = ? ORDER BY name', + [$did] + ); + + $result = []; + foreach ($rows as $row) { + $result[] = $this->hydrate($row); + } + return $result; + } + + public function findByDidAndName(string $did, string $name): AppPassword + { + $row = $this->db->fetchOne( + 'SELECT * FROM app_password WHERE did = ? AND name = ?', + [$did, $name] + ); + + if ($row === null) { + throw new AppPasswordNotFoundException(); + } + + return $this->hydrate($row); + } + + public function save(AppPassword $appPassword): void + { + $this->db->execute( + 'INSERT INTO app_password (did, name, password_scrypt, created_at, privileged) + VALUES (:did, :name, :password_scrypt, :created_at, :privileged) + ON CONFLICT(did, name) DO UPDATE SET + password_scrypt = excluded.password_scrypt, + created_at = excluded.created_at, + privileged = excluded.privileged', + [ + 'did' => $appPassword->getDid(), + 'name' => $appPassword->getName(), + 'password_scrypt' => $appPassword->getPasswordScrypt(), + 'created_at' => $appPassword->getCreatedAt()->format(DATE_ATOM), + 'privileged' => $appPassword->isPrivileged() ? 1 : 0, + ] + ); + } + + public function deleteByDidAndName(string $did, string $name): void + { + $this->db->execute( + 'DELETE FROM app_password WHERE did = ? AND name = ?', + [$did, $name] + ); + } + + /** + * @param array $row + */ + private function hydrate(array $row): AppPassword + { + return new AppPassword( + did: Row::str($row, 'did'), + name: Row::str($row, 'name'), + passwordScrypt: Row::str($row, 'password_scrypt'), + createdAt: new DateTimeImmutable(Row::str($row, 'created_at')), + privileged: Row::bool($row, 'privileged'), + ); + } +} diff --git a/src/Infrastructure/Persistence/Account/EmailToken/InMemoryEmailTokenRepository.php b/src/Infrastructure/Persistence/Account/EmailToken/InMemoryEmailTokenRepository.php deleted file mode 100644 index 383d9f7..0000000 --- a/src/Infrastructure/Persistence/Account/EmailToken/InMemoryEmailTokenRepository.php +++ /dev/null @@ -1,60 +0,0 @@ - did => EmailToken. - * (Only one active token per purpose+did at a time, matching reference behavior.) - * - * @var array> - */ - private array $tokens = []; - - /** - * @param EmailToken[] $seeds - */ - public function __construct(array $seeds = []) - { - foreach ($seeds as $token) { - $this->tokens[$token->getPurpose()][$token->getDid()] = $token; - } - } - - public function findByPurposeAndDid(string $purpose, string $did): EmailToken - { - if (!isset($this->tokens[$purpose][$did])) { - throw new EmailTokenNotFoundException(); - } - - return $this->tokens[$purpose][$did]; - } - - public function findByPurposeAndToken(string $purpose, string $token): EmailToken - { - foreach ($this->tokens[$purpose] ?? [] as $entry) { - if ($entry->getToken() === $token) { - return $entry; - } - } - - throw new EmailTokenNotFoundException(); - } - - public function save(EmailToken $token): void - { - $this->tokens[$token->getPurpose()][$token->getDid()] = $token; - } - - public function deleteByPurposeAndDid(string $purpose, string $did): void - { - unset($this->tokens[$purpose][$did]); - } -} diff --git a/src/Infrastructure/Persistence/Account/EmailToken/SqliteEmailTokenRepository.php b/src/Infrastructure/Persistence/Account/EmailToken/SqliteEmailTokenRepository.php new file mode 100644 index 0000000..8b93857 --- /dev/null +++ b/src/Infrastructure/Persistence/Account/EmailToken/SqliteEmailTokenRepository.php @@ -0,0 +1,85 @@ +db->fetchOne( + 'SELECT * FROM email_token WHERE purpose = ? AND did = ?', + [$purpose, $did] + ); + + if ($row === null) { + throw new EmailTokenNotFoundException(); + } + + return $this->hydrate($row); + } + + public function findByPurposeAndToken(string $purpose, string $token): EmailToken + { + $row = $this->db->fetchOne( + 'SELECT * FROM email_token WHERE purpose = ? AND token = ?', + [$purpose, $token] + ); + + if ($row === null) { + throw new EmailTokenNotFoundException(); + } + + return $this->hydrate($row); + } + + public function save(EmailToken $token): void + { + $this->db->execute( + 'INSERT INTO email_token (purpose, did, token, requested_at) + VALUES (:purpose, :did, :token, :requested_at) + ON CONFLICT(purpose, did) DO UPDATE SET + token = excluded.token, + requested_at = excluded.requested_at', + [ + 'purpose' => $token->getPurpose(), + 'did' => $token->getDid(), + 'token' => $token->getToken(), + 'requested_at' => $token->getRequestedAt()->format(DATE_ATOM), + ] + ); + } + + public function deleteByPurposeAndDid(string $purpose, string $did): void + { + $this->db->execute( + 'DELETE FROM email_token WHERE purpose = ? AND did = ?', + [$purpose, $did] + ); + } + + /** + * @param array $row + */ + private function hydrate(array $row): EmailToken + { + return new EmailToken( + purpose: Row::str($row, 'purpose'), + did: Row::str($row, 'did'), + token: Row::str($row, 'token'), + requestedAt: new DateTimeImmutable(Row::str($row, 'requested_at')), + ); + } +} diff --git a/src/Infrastructure/Persistence/Account/InMemoryAccountRepository.php b/src/Infrastructure/Persistence/Account/InMemoryAccountRepository.php deleted file mode 100644 index 69e6ba0..0000000 --- a/src/Infrastructure/Persistence/Account/InMemoryAccountRepository.php +++ /dev/null @@ -1,109 +0,0 @@ - - */ - private array $accountsByDid; - - /** - * @param Account[]|null $accounts - */ - public function __construct(?SettingsInterface $settings = null, ?array $accounts = null) - { - if ($accounts === null) { - /** @var array{hostname: string}|null $pdsSettings */ - $pdsSettings = $settings?->get('pds'); - $hostname = $pdsSettings['hostname'] ?? 'localhost'; - $accounts = [ - new Account( - did: "did:web:alice.{$hostname}", - email: "alice@{$hostname}", - passwordScrypt: 'placeholder-scrypt-hash', - emailConfirmedAt: new \DateTimeImmutable('2026-01-01T00:00:00Z'), - invitesDisabled: false, - ), - new Account( - did: "did:web:bob.{$hostname}", - email: "bob@{$hostname}", - passwordScrypt: 'placeholder-scrypt-hash', - emailConfirmedAt: null, - invitesDisabled: false, - ), - new Account( - did: 'did:plc:carol000000000000000000000', - email: "carol@{$hostname}", - passwordScrypt: 'placeholder-scrypt-hash', - emailConfirmedAt: new \DateTimeImmutable('2026-01-03T00:00:00Z'), - invitesDisabled: true, - ), - ]; - } - - $this->accountsByDid = []; - foreach ($accounts as $account) { - $this->accountsByDid[$account->getDid()] = $account; - } - } - - /** - * {@inheritdoc} - */ - public function findAll(): array - { - return array_values($this->accountsByDid); - } - - /** - * {@inheritDoc} - */ - public function findAccountByHandle(string $handle): Account - { - // Not implemented yet - throw new AccountNotFoundException(); - } - - /** - * {@inheritdoc} - */ - public function findAccountByDid(string $did): Account - { - $key = trim($did); - - if (!isset($this->accountsByDid[$key])) { - throw new AccountNotFoundException(); - } - - return $this->accountsByDid[$key]; - } - - /** - * {@inheritdoc} - */ - public function findAccountByEmail(string $email): Account - { - $needle = strtolower(trim($email)); - - if ($needle === '') { - throw new AccountNotFoundException(); - } - - foreach ($this->accountsByDid as $account) { - if ($account->getEmail() === $needle) { - return $account; - } - } - - throw new AccountNotFoundException(); - } -} diff --git a/src/Infrastructure/Persistence/Account/InviteCode/InMemoryInviteCodeRepository.php b/src/Infrastructure/Persistence/Account/InviteCode/InMemoryInviteCodeRepository.php deleted file mode 100644 index 8a65f04..0000000 --- a/src/Infrastructure/Persistence/Account/InviteCode/InMemoryInviteCodeRepository.php +++ /dev/null @@ -1,98 +0,0 @@ - keyed by code */ - private array $codes = []; - - /** @var array keyed by code */ - private array $uses = []; - - /** - * @param InviteCode[] $seeds - * @param InviteCodeUse[] $useSeeds - */ - public function __construct(array $seeds = [], array $useSeeds = []) - { - foreach ($seeds as $code) { - $this->codes[$code->getCode()] = $code; - } - foreach ($useSeeds as $use) { - $this->uses[$use->getCode()][] = $use; - } - } - - /** - * @return InviteCode[] - */ - public function findAll(): array - { - return array_values($this->codes); - } - - public function findByCode(string $code): InviteCode - { - if (!isset($this->codes[$code])) { - throw new InviteCodeNotFoundException(); - } - - return $this->codes[$code]; - } - - /** - * @return InviteCode[] - */ - public function findAllForAccount(string $did): array - { - return array_values( - array_filter( - $this->codes, - fn(InviteCode $c) => $c->getForAccount() === $did, - ) - ); - } - - public function save(InviteCode $inviteCode): void - { - $this->codes[$inviteCode->getCode()] = $inviteCode; - } - - public function disable(string $code): void - { - if (!isset($this->codes[$code])) { - throw new InviteCodeNotFoundException(); - } - - $old = $this->codes[$code]; - $this->codes[$code] = new InviteCode( - code: $old->getCode(), - availableUses: $old->getAvailableUses(), - disabled: true, - forAccount: $old->getForAccount(), - createdBy: $old->getCreatedBy(), - createdAt: $old->getCreatedAt(), - ); - } - - /** - * @return InviteCodeUse[] - */ - public function findUsesForCode(string $code): array - { - return $this->uses[$code] ?? []; - } - - public function recordUse(InviteCodeUse $use): void - { - $this->uses[$use->getCode()][] = $use; - } -} diff --git a/src/Infrastructure/Persistence/Account/InviteCode/SqliteInviteCodeRepository.php b/src/Infrastructure/Persistence/Account/InviteCode/SqliteInviteCodeRepository.php new file mode 100644 index 0000000..5334307 --- /dev/null +++ b/src/Infrastructure/Persistence/Account/InviteCode/SqliteInviteCodeRepository.php @@ -0,0 +1,156 @@ +db->fetchAll('SELECT * FROM invite_code ORDER BY code'); + + $result = []; + foreach ($rows as $row) { + $result[] = $this->hydrateCode($row); + } + return $result; + } + + public function findByCode(string $code): InviteCode + { + $row = $this->db->fetchOne('SELECT * FROM invite_code WHERE code = ?', [$code]); + + if ($row === null) { + throw new InviteCodeNotFoundException(); + } + + return $this->hydrateCode($row); + } + + /** + * @return InviteCode[] + */ + public function findAllForAccount(string $did): array + { + $rows = $this->db->fetchAll( + 'SELECT * FROM invite_code WHERE for_account = ? ORDER BY code', + [$did] + ); + + $result = []; + foreach ($rows as $row) { + $result[] = $this->hydrateCode($row); + } + return $result; + } + + public function save(InviteCode $inviteCode): void + { + $this->db->execute( + 'INSERT INTO invite_code + (code, available_uses, disabled, for_account, created_by, created_at) + VALUES + (:code, :available_uses, :disabled, :for_account, :created_by, :created_at) + ON CONFLICT(code) DO UPDATE SET + available_uses = excluded.available_uses, + disabled = excluded.disabled, + for_account = excluded.for_account, + created_by = excluded.created_by, + created_at = excluded.created_at', + [ + 'code' => $inviteCode->getCode(), + 'available_uses' => $inviteCode->getAvailableUses(), + 'disabled' => $inviteCode->isDisabled() ? 1 : 0, + 'for_account' => $inviteCode->getForAccount(), + 'created_by' => $inviteCode->getCreatedBy(), + 'created_at' => $inviteCode->getCreatedAt()->format(DATE_ATOM), + ] + ); + } + + public function disable(string $code): void + { + $exists = $this->db->fetchOne('SELECT 1 FROM invite_code WHERE code = ?', [$code]); + if ($exists === null) { + throw new InviteCodeNotFoundException(); + } + + $this->db->execute( + 'UPDATE invite_code SET disabled = 1 WHERE code = ?', + [$code] + ); + } + + /** + * @return InviteCodeUse[] + */ + public function findUsesForCode(string $code): array + { + $rows = $this->db->fetchAll( + 'SELECT * FROM invite_code_use WHERE code = ? ORDER BY used_at', + [$code] + ); + + $result = []; + foreach ($rows as $row) { + $result[] = $this->hydrateUse($row); + } + return $result; + } + + public function recordUse(InviteCodeUse $use): void + { + $this->db->execute( + 'INSERT OR IGNORE INTO invite_code_use (code, used_by, used_at) + VALUES (?, ?, ?)', + [ + $use->getCode(), + $use->getUsedBy(), + $use->getUsedAt()->format(DATE_ATOM), + ] + ); + } + + /** + * @param array $row + */ + private function hydrateCode(array $row): InviteCode + { + return new InviteCode( + code: Row::str($row, 'code'), + availableUses: Row::int($row, 'available_uses'), + disabled: Row::bool($row, 'disabled'), + forAccount: Row::str($row, 'for_account'), + createdBy: Row::str($row, 'created_by'), + createdAt: new DateTimeImmutable(Row::str($row, 'created_at')), + ); + } + + /** + * @param array $row + */ + private function hydrateUse(array $row): InviteCodeUse + { + return new InviteCodeUse( + code: Row::str($row, 'code'), + usedBy: Row::str($row, 'used_by'), + usedAt: new DateTimeImmutable(Row::str($row, 'used_at')), + ); + } +} diff --git a/src/Infrastructure/Persistence/Account/RefreshToken/InMemoryRefreshTokenRepository.php b/src/Infrastructure/Persistence/Account/RefreshToken/InMemoryRefreshTokenRepository.php deleted file mode 100644 index 8cd7d76..0000000 --- a/src/Infrastructure/Persistence/Account/RefreshToken/InMemoryRefreshTokenRepository.php +++ /dev/null @@ -1,65 +0,0 @@ - keyed by token id */ - private array $tokens = []; - - /** - * @param RefreshToken[] $seeds - */ - public function __construct(array $seeds = []) - { - foreach ($seeds as $token) { - $this->tokens[$token->getId()] = $token; - } - } - - public function findById(string $id): RefreshToken - { - if (!isset($this->tokens[$id])) { - throw new RefreshTokenNotFoundException(); - } - - return $this->tokens[$id]; - } - - /** - * @return RefreshToken[] - */ - public function findAllForDid(string $did): array - { - return array_values( - array_filter( - $this->tokens, - fn(RefreshToken $t) => $t->getDid() === $did, - ) - ); - } - - public function save(RefreshToken $token): void - { - $this->tokens[$token->getId()] = $token; - } - - public function deleteById(string $id): void - { - unset($this->tokens[$id]); - } - - public function deleteAllForDid(string $did): void - { - $this->tokens = array_filter( - $this->tokens, - fn(RefreshToken $t) => $t->getDid() !== $did, - ); - } -} diff --git a/src/Infrastructure/Persistence/Account/RefreshToken/SqliteRefreshTokenRepository.php b/src/Infrastructure/Persistence/Account/RefreshToken/SqliteRefreshTokenRepository.php new file mode 100644 index 0000000..cd0af3a --- /dev/null +++ b/src/Infrastructure/Persistence/Account/RefreshToken/SqliteRefreshTokenRepository.php @@ -0,0 +1,90 @@ +db->fetchOne('SELECT * FROM refresh_token WHERE id = ?', [$id]); + + if ($row === null) { + throw new RefreshTokenNotFoundException(); + } + + return $this->hydrate($row); + } + + /** + * @return RefreshToken[] + */ + public function findAllForDid(string $did): array + { + $rows = $this->db->fetchAll( + 'SELECT * FROM refresh_token WHERE did = ? ORDER BY id', + [$did] + ); + + $result = []; + foreach ($rows as $row) { + $result[] = $this->hydrate($row); + } + return $result; + } + + public function save(RefreshToken $token): void + { + $this->db->execute( + 'INSERT INTO refresh_token (id, did, expires_at, app_password_name, next_id) + VALUES (:id, :did, :expires_at, :app_password_name, :next_id) + ON CONFLICT(id) DO UPDATE SET + did = excluded.did, + expires_at = excluded.expires_at, + app_password_name = excluded.app_password_name, + next_id = excluded.next_id', + [ + 'id' => $token->getId(), + 'did' => $token->getDid(), + 'expires_at' => $token->getExpiresAt(), + 'app_password_name' => $token->getAppPasswordName(), + 'next_id' => $token->getNextId(), + ] + ); + } + + public function deleteById(string $id): void + { + $this->db->execute('DELETE FROM refresh_token WHERE id = ?', [$id]); + } + + public function deleteAllForDid(string $did): void + { + $this->db->execute('DELETE FROM refresh_token WHERE did = ?', [$did]); + } + + /** + * @param array $row + */ + private function hydrate(array $row): RefreshToken + { + return new RefreshToken( + id: Row::str($row, 'id'), + did: Row::str($row, 'did'), + expiresAt: Row::str($row, 'expires_at'), + appPasswordName: Row::nstr($row, 'app_password_name'), + nextId: Row::nstr($row, 'next_id'), + ); + } +} diff --git a/src/Infrastructure/Persistence/Account/SqliteAccountRepository.php b/src/Infrastructure/Persistence/Account/SqliteAccountRepository.php new file mode 100644 index 0000000..d543970 --- /dev/null +++ b/src/Infrastructure/Persistence/Account/SqliteAccountRepository.php @@ -0,0 +1,117 @@ +db->fetchAll('SELECT * FROM account ORDER BY did'); + + $result = []; + foreach ($rows as $row) { + $result[] = $this->hydrate($row); + } + return $result; + } + + public function findAccountByHandle(string $handle): Account + { + $handle = strtolower(trim($handle)); + if ($handle === '') { + throw new AccountNotFoundException(); + } + + $row = $this->db->fetchOne( + 'SELECT a.* FROM account a JOIN actor ac ON ac.did = a.did WHERE ac.handle = ?', + [$handle] + ); + + if ($row === null) { + throw new AccountNotFoundException(); + } + + return $this->hydrate($row); + } + + public function findAccountByDid(string $did): Account + { + $row = $this->db->fetchOne('SELECT * FROM account WHERE did = ?', [trim($did)]); + + if ($row === null) { + throw new AccountNotFoundException(); + } + + return $this->hydrate($row); + } + + public function findAccountByEmail(string $email): Account + { + $email = strtolower(trim($email)); + if ($email === '') { + throw new AccountNotFoundException(); + } + + $row = $this->db->fetchOne('SELECT * FROM account WHERE email = ?', [$email]); + + if ($row === null) { + throw new AccountNotFoundException(); + } + + return $this->hydrate($row); + } + + public function save(Account $account): void + { + $this->db->execute( + 'INSERT INTO account (did, email, password_scrypt, email_confirmed_at, invites_disabled) + VALUES (:did, :email, :password_scrypt, :email_confirmed_at, :invites_disabled) + ON CONFLICT(did) DO UPDATE SET + email = excluded.email, + password_scrypt = excluded.password_scrypt, + email_confirmed_at = excluded.email_confirmed_at, + invites_disabled = excluded.invites_disabled', + [ + 'did' => $account->getDid(), + 'email' => $account->getEmail(), + 'password_scrypt' => $account->getPasswordScrypt(), + 'email_confirmed_at' => $account->getEmailConfirmedAt()?->format(DATE_ATOM), + 'invites_disabled' => $account->isInvitesDisabled() ? 1 : 0, + ] + ); + } + + /** + * @param array $row + */ + private function hydrate(array $row): Account + { + $emailConfirmedAt = Row::nstr($row, 'email_confirmed_at'); + + return new Account( + did: Row::str($row, 'did'), + email: Row::str($row, 'email'), + passwordScrypt: Row::str($row, 'password_scrypt'), + emailConfirmedAt: $emailConfirmedAt === null + ? null + : new DateTimeImmutable($emailConfirmedAt), + invitesDisabled: Row::bool($row, 'invites_disabled'), + ); + } +} diff --git a/src/Infrastructure/Persistence/Actor/InMemoryActorRepository.php b/src/Infrastructure/Persistence/Actor/InMemoryActorRepository.php deleted file mode 100644 index cc0e08c..0000000 --- a/src/Infrastructure/Persistence/Actor/InMemoryActorRepository.php +++ /dev/null @@ -1,94 +0,0 @@ - - */ - private array $actorsByDid; - - /** - * @param Actor[]|null $actors - */ - public function __construct(?SettingsInterface $settings = null, ?array $actors = null) - { - if ($actors === null) { - /** @var array{hostname: string}|null $pdsSettings */ - $pdsSettings = $settings?->get('pds'); - $hostname = $pdsSettings['hostname'] ?? 'localhost'; - $actors = [ - new Actor( - "did:web:alice.{$hostname}", - "alice.{$hostname}", - new \DateTimeImmutable('2026-01-01T00:00:00Z') - ), - new Actor( - "did:web:bob.{$hostname}", - "bob.{$hostname}", - new \DateTimeImmutable('2026-01-02T00:00:00Z') - ), - new Actor( - 'did:plc:carol000000000000000000000', - "carol.{$hostname}", - new \DateTimeImmutable('2026-01-03T00:00:00Z') - ), - ]; - } - - $this->actorsByDid = []; - foreach ($actors as $actor) { - $this->actorsByDid[$actor->getDid()] = $actor; - } - } - - /** - * {@inheritdoc} - */ - public function findAll(): array - { - return array_values($this->actorsByDid); - } - - /** - * {@inheritdoc} - */ - public function findActorByDid(string $did): Actor - { - $key = trim($did); - - if (!isset($this->actorsByDid[$key])) { - throw new ActorNotFoundException(); - } - - return $this->actorsByDid[$key]; - } - - /** - * {@inheritdoc} - */ - public function findActorByHandle(string $handle): Actor - { - $needle = strtolower(trim($handle)); - - if ($needle === '') { - throw new ActorNotFoundException(); - } - - foreach ($this->actorsByDid as $actor) { - if ($actor->getHandle() === $needle) { - return $actor; - } - } - - throw new ActorNotFoundException(); - } -} diff --git a/src/Infrastructure/Persistence/Actor/SqliteActorRepository.php b/src/Infrastructure/Persistence/Actor/SqliteActorRepository.php new file mode 100644 index 0000000..fe563d3 --- /dev/null +++ b/src/Infrastructure/Persistence/Actor/SqliteActorRepository.php @@ -0,0 +1,100 @@ +db->fetchAll('SELECT * FROM actor ORDER BY did'); + + $result = []; + foreach ($rows as $row) { + $result[] = $this->hydrate($row); + } + return $result; + } + + public function findActorByDid(string $did): Actor + { + $row = $this->db->fetchOne('SELECT * FROM actor WHERE did = ?', [trim($did)]); + + if ($row === null) { + throw new ActorNotFoundException(); + } + + return $this->hydrate($row); + } + + public function findActorByHandle(string $handle): Actor + { + $handle = strtolower(trim($handle)); + if ($handle === '') { + throw new ActorNotFoundException(); + } + + $row = $this->db->fetchOne('SELECT * FROM actor WHERE handle = ?', [$handle]); + + if ($row === null) { + throw new ActorNotFoundException(); + } + + return $this->hydrate($row); + } + + public function save(Actor $actor): void + { + $this->db->execute( + 'INSERT INTO actor (did, handle, created_at, takedown_ref, deactivated_at, delete_after) + VALUES (:did, :handle, :created_at, :takedown_ref, :deactivated_at, :delete_after) + ON CONFLICT(did) DO UPDATE SET + handle = excluded.handle, + created_at = excluded.created_at, + takedown_ref = excluded.takedown_ref, + deactivated_at = excluded.deactivated_at, + delete_after = excluded.delete_after', + [ + 'did' => $actor->getDid(), + 'handle' => $actor->getHandle(), + 'created_at' => $actor->getCreatedAt()->format(DATE_ATOM), + 'takedown_ref' => $actor->getTakedownRef(), + 'deactivated_at' => $actor->getDeactivatedAt()?->format(DATE_ATOM), + 'delete_after' => $actor->getDeleteAfter()?->format(DATE_ATOM), + ] + ); + } + + /** + * @param array $row + */ + private function hydrate(array $row): Actor + { + $deactivatedAt = Row::nstr($row, 'deactivated_at'); + $deleteAfter = Row::nstr($row, 'delete_after'); + + return new Actor( + did: Row::str($row, 'did'), + handle: Row::nstr($row, 'handle'), + createdAt: new DateTimeImmutable(Row::str($row, 'created_at')), + takedownRef: Row::nstr($row, 'takedown_ref'), + deactivatedAt: $deactivatedAt === null ? null : new DateTimeImmutable($deactivatedAt), + deleteAfter: $deleteAfter === null ? null : new DateTimeImmutable($deleteAfter), + ); + } +} diff --git a/src/Infrastructure/Persistence/ActorStore/InMemoryActorStoreFactory.php b/src/Infrastructure/Persistence/ActorStore/InMemoryActorStoreFactory.php deleted file mode 100644 index 88ba5f0..0000000 --- a/src/Infrastructure/Persistence/ActorStore/InMemoryActorStoreFactory.php +++ /dev/null @@ -1,52 +0,0 @@ - keyed by DID */ - private array $stores = []; - - public function get(string $did): ActorStore - { - if (!isset($this->stores[$did])) { - $this->stores[$did] = new ActorStore( - did: $did, - records: new InMemoryRecordRepository(), - recordBlobs: new InMemoryRecordBlobRepository(), - backlinks: new InMemoryBacklinkRepository(), - blobs: new InMemoryBlobRepository(), - blobStore: new InMemoryBlobStore(), - repoBlocks: new InMemoryRepoBlockRepository(), - prefs: new InMemoryAccountPrefRepository(), - repoRoot: new InMemoryRepoRootRepository(), - ); - } - - return $this->stores[$did]; - } - - public function destroy(string $did): void - { - unset($this->stores[$did]); - } -} diff --git a/src/Infrastructure/Persistence/ActorStore/SqliteActorStoreFactory.php b/src/Infrastructure/Persistence/ActorStore/SqliteActorStoreFactory.php new file mode 100644 index 0000000..37758d4 --- /dev/null +++ b/src/Infrastructure/Persistence/ActorStore/SqliteActorStoreFactory.php @@ -0,0 +1,113 @@ + keyed by DID */ + private array $cache = []; + + public function __construct( + private readonly string $actorsDirectory, + private readonly string $blobsDirectory, + ) { + } + + public function get(string $did): ActorStore + { + if (isset($this->cache[$did])) { + return $this->cache[$did]; + } + + $db = new Database($this->dbLocation($did)); + ActorStoreSchema::apply($db); + + $blobStore = new DiskBlobStore($this->blobDir($did)); + + return $this->cache[$did] = new ActorStore( + did: $did, + records: new SqliteRecordRepository($db), + recordBlobs: new SqliteRecordBlobRepository($db), + backlinks: new SqliteBacklinkRepository($db), + blobs: new SqliteBlobRepository($db), + blobStore: $blobStore, + repoBlocks: new SqliteRepoBlockRepository($db), + prefs: new SqliteAccountPrefRepository($db), + repoRoot: new SqliteRepoRootRepository($db), + ); + } + + public function destroy(string $did): void + { + unset($this->cache[$did]); + + $loc = $this->dbLocation($did); + if ($loc !== ':memory:' && is_file($loc)) { + @unlink($loc); + @unlink($loc . '-wal'); + @unlink($loc . '-shm'); + } + + $blobDir = $this->blobDir($did); + if (is_dir($blobDir)) { + self::removeDirectory($blobDir); + } + } + + private function dbLocation(string $did): string + { + if ($this->actorsDirectory === ':memory:') { + return ':memory:'; + } + + return rtrim($this->actorsDirectory, '/') . '/' . $this->safeId($did) . '.sqlite'; + } + + private function blobDir(string $did): string + { + return rtrim($this->blobsDirectory, '/') . '/' . $this->safeId($did); + } + + private function safeId(string $did): string + { + return hash('sha256', $did); + } + + private static function removeDirectory(string $dir): void + { + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ); + foreach ($iterator as $file) { + assert($file instanceof \SplFileInfo); + if ($file->isDir()) { + @rmdir($file->getPathname()); + } else { + @unlink($file->getPathname()); + } + } + @rmdir($dir); + } +} diff --git a/src/Infrastructure/Persistence/Blob/InMemoryBlobRepository.php b/src/Infrastructure/Persistence/Blob/InMemoryBlobRepository.php deleted file mode 100644 index 11a05f3..0000000 --- a/src/Infrastructure/Persistence/Blob/InMemoryBlobRepository.php +++ /dev/null @@ -1,70 +0,0 @@ - keyed by cid */ - private array $blobs = []; - - /** - * @param Blob[] $seeds - */ - public function __construct(array $seeds = []) - { - foreach ($seeds as $blob) { - $this->blobs[$blob->getCid()] = $blob; - } - } - - public function findByCid(string $cid): Blob - { - if (!isset($this->blobs[$cid])) { - throw new BlobNotFoundException(); - } - - return $this->blobs[$cid]; - } - - /** - * @return Blob[] - */ - public function findAll(): array - { - return array_values($this->blobs); - } - - /** - * @return Blob[] - */ - public function findTemporary(): array - { - return array_values( - array_filter( - $this->blobs, - fn(Blob $b) => $b->getTempKey() !== null, - ) - ); - } - - public function save(Blob $blob): void - { - $this->blobs[$blob->getCid()] = $blob; - } - - public function deleteByCid(string $cid): void - { - unset($this->blobs[$cid]); - } - - public function deleteAll(): void - { - $this->blobs = []; - } -} diff --git a/src/Infrastructure/Persistence/Blob/InMemoryBlobStore.php b/src/Infrastructure/Persistence/Blob/InMemoryBlobStore.php deleted file mode 100644 index 818241b..0000000 --- a/src/Infrastructure/Persistence/Blob/InMemoryBlobStore.php +++ /dev/null @@ -1,130 +0,0 @@ - tempKey => raw bytes */ - private array $temp = []; - - /** @var array cid => raw bytes */ - private array $perm = []; - - /** @var array set of quarantined CIDs */ - private array $quarantined = []; - - public function putTemp(string $bytes): string - { - $key = bin2hex(random_bytes(16)); - $this->temp[$key] = $bytes; - - return $key; - } - - public function makePermanent(string $tempKey, string $cid): void - { - if (!isset($this->temp[$tempKey])) { - throw new BlobNotFoundException(); - } - - $this->perm[$cid] = $this->temp[$tempKey]; - unset($this->temp[$tempKey]); - } - - public function putPermanent(string $cid, string $bytes): void - { - $this->perm[$cid] = $bytes; - } - - public function hasTemp(string $tempKey): bool - { - return isset($this->temp[$tempKey]); - } - - public function hasStored(string $cid): bool - { - return isset($this->perm[$cid]); - } - - public function getBytes(string $cid): string - { - if (!isset($this->perm[$cid])) { - throw new BlobNotFoundException(); - } - - return $this->perm[$cid]; - } - - public function getStream(string $cid): StreamInterface - { - $bytes = $this->getBytes($cid); - $resource = fopen('php://temp', 'r+'); - - if ($resource === false) { - throw new \RuntimeException('Could not open php://temp stream.'); - } - - fwrite($resource, $bytes); - rewind($resource); - - return new Stream($resource); - } - - public function delete(string $cid): void - { - if (!isset($this->perm[$cid])) { - throw new BlobNotFoundException(); - } - - unset($this->perm[$cid], $this->quarantined[$cid]); - } - - /** - * @param string[] $cids - */ - public function deleteMany(array $cids): void - { - foreach ($cids as $cid) { - unset($this->perm[$cid], $this->quarantined[$cid]); - } - } - - public function deleteTemp(string $tempKey): void - { - if (!isset($this->temp[$tempKey])) { - throw new BlobNotFoundException(); - } - - unset($this->temp[$tempKey]); - } - - public function quarantine(string $cid): void - { - if (!isset($this->perm[$cid])) { - throw new BlobNotFoundException(); - } - - $this->quarantined[$cid] = true; - } - - public function unquarantine(string $cid): void - { - if (!isset($this->perm[$cid])) { - throw new BlobNotFoundException(); - } - - unset($this->quarantined[$cid]); - } - - public function isQuarantined(string $cid): bool - { - return isset($this->quarantined[$cid]); - } -} diff --git a/src/Infrastructure/Persistence/Blob/SqliteBlobRepository.php b/src/Infrastructure/Persistence/Blob/SqliteBlobRepository.php new file mode 100644 index 0000000..e75278b --- /dev/null +++ b/src/Infrastructure/Persistence/Blob/SqliteBlobRepository.php @@ -0,0 +1,107 @@ +db->fetchOne('SELECT * FROM blob WHERE cid = ?', [$cid]); + + if ($row === null) { + throw new BlobNotFoundException(); + } + + return $this->hydrate($row); + } + + /** + * @return Blob[] + */ + public function findAll(): array + { + $rows = $this->db->fetchAll('SELECT * FROM blob ORDER BY cid'); + + $result = []; + foreach ($rows as $row) { + $result[] = $this->hydrate($row); + } + return $result; + } + + /** + * @return Blob[] + */ + public function findTemporary(): array + { + $rows = $this->db->fetchAll( + 'SELECT * FROM blob WHERE temp_key IS NOT NULL ORDER BY cid' + ); + + $result = []; + foreach ($rows as $row) { + $result[] = $this->hydrate($row); + } + return $result; + } + + public function save(Blob $blob): void + { + $this->db->execute( + 'INSERT INTO blob (cid, mime_type, size, temp_key, created_at, takedown_ref) + VALUES (:cid, :mime_type, :size, :temp_key, :created_at, :takedown_ref) + ON CONFLICT(cid) DO UPDATE SET + mime_type = excluded.mime_type, + size = excluded.size, + temp_key = excluded.temp_key, + created_at = excluded.created_at, + takedown_ref = excluded.takedown_ref', + [ + 'cid' => $blob->getCid(), + 'mime_type' => $blob->getMimeType(), + 'size' => $blob->getSize(), + 'temp_key' => $blob->getTempKey(), + 'created_at' => $blob->getCreatedAt()->format(DATE_ATOM), + 'takedown_ref' => $blob->getTakedownRef(), + ] + ); + } + + public function deleteByCid(string $cid): void + { + $this->db->execute('DELETE FROM blob WHERE cid = ?', [$cid]); + } + + public function deleteAll(): void + { + $this->db->pdo()->exec('DELETE FROM blob'); + } + + /** + * @param array $row + */ + private function hydrate(array $row): Blob + { + return new Blob( + cid: Row::str($row, 'cid'), + mimeType: Row::str($row, 'mime_type'), + size: Row::int($row, 'size'), + tempKey: Row::nstr($row, 'temp_key'), + createdAt: new DateTimeImmutable(Row::str($row, 'created_at')), + takedownRef: Row::nstr($row, 'takedown_ref'), + ); + } +} diff --git a/src/Infrastructure/Persistence/Did/InMemoryDidCacheRepository.php b/src/Infrastructure/Persistence/Did/InMemoryDidCacheRepository.php deleted file mode 100644 index 594a7e8..0000000 --- a/src/Infrastructure/Persistence/Did/InMemoryDidCacheRepository.php +++ /dev/null @@ -1,62 +0,0 @@ - keyed by did */ - private array $cache = []; - - /** - * @param DidDocEntry[] $seeds - */ - public function __construct(array $seeds = []) - { - foreach ($seeds as $entry) { - $this->cache[$entry->getDid()] = $entry; - } - } - - public function get(string $did): DidDocEntry - { - if (!isset($this->cache[$did])) { - throw new DidDocEntryNotFoundException(); - } - - return $this->cache[$did]; - } - - /** - * @param array $doc - */ - public function set(string $did, array $doc): void - { - $this->cache[$did] = new DidDocEntry( - did: $did, - doc: $doc, - updatedAt: new DateTimeImmutable(), - ); - } - - public function has(string $did): bool - { - return isset($this->cache[$did]); - } - - public function clear(string $did): void - { - unset($this->cache[$did]); - } - - public function clearAll(): void - { - $this->cache = []; - } -} diff --git a/src/Infrastructure/Persistence/Did/SqliteDidCacheRepository.php b/src/Infrastructure/Persistence/Did/SqliteDidCacheRepository.php new file mode 100644 index 0000000..8782e30 --- /dev/null +++ b/src/Infrastructure/Persistence/Did/SqliteDidCacheRepository.php @@ -0,0 +1,72 @@ +db->fetchOne('SELECT * FROM did_doc WHERE did = ?', [$did]); + + if ($row === null) { + throw new DidDocEntryNotFoundException(); + } + + $doc = json_decode(Row::str($row, 'doc_json'), true, 512, JSON_THROW_ON_ERROR); + assert(is_array($doc)); + /** @var array $doc */ + + return new DidDocEntry( + did: Row::str($row, 'did'), + doc: $doc, + updatedAt: new DateTimeImmutable(Row::str($row, 'updated_at')), + ); + } + + /** + * @param array $doc + */ + public function set(string $did, array $doc): void + { + $this->db->execute( + 'INSERT INTO did_doc (did, doc_json, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(did) DO UPDATE SET + doc_json = excluded.doc_json, + updated_at = excluded.updated_at', + [ + $did, + json_encode($doc, JSON_THROW_ON_ERROR), + (new DateTimeImmutable())->format(DATE_ATOM), + ] + ); + } + + public function has(string $did): bool + { + return $this->db->fetchOne('SELECT 1 FROM did_doc WHERE did = ?', [$did]) !== null; + } + + public function clear(string $did): void + { + $this->db->execute('DELETE FROM did_doc WHERE did = ?', [$did]); + } + + public function clearAll(): void + { + $this->db->pdo()->exec('DELETE FROM did_doc'); + } +} diff --git a/src/Infrastructure/Persistence/Lexicon/InMemoryLexiconRepository.php b/src/Infrastructure/Persistence/Lexicon/InMemoryLexiconRepository.php deleted file mode 100644 index 4bcfd9a..0000000 --- a/src/Infrastructure/Persistence/Lexicon/InMemoryLexiconRepository.php +++ /dev/null @@ -1,52 +0,0 @@ - keyed by nsid */ - private array $entries = []; - - /** - * @param LexiconEntry[] $seeds - */ - public function __construct(array $seeds = []) - { - foreach ($seeds as $entry) { - $this->entries[$entry->getNsid()] = $entry; - } - } - - public function findByNsid(string $nsid): LexiconEntry - { - if (!isset($this->entries[$nsid])) { - throw new LexiconEntryNotFoundException(); - } - - return $this->entries[$nsid]; - } - - /** - * @return LexiconEntry[] - */ - public function findAll(): array - { - return array_values($this->entries); - } - - public function save(LexiconEntry $entry): void - { - $this->entries[$entry->getNsid()] = $entry; - } - - public function deleteByNsid(string $nsid): void - { - unset($this->entries[$nsid]); - } -} diff --git a/src/Infrastructure/Persistence/Lexicon/SqliteLexiconRepository.php b/src/Infrastructure/Persistence/Lexicon/SqliteLexiconRepository.php new file mode 100644 index 0000000..93562f9 --- /dev/null +++ b/src/Infrastructure/Persistence/Lexicon/SqliteLexiconRepository.php @@ -0,0 +1,100 @@ +db->fetchOne('SELECT * FROM lexicon WHERE nsid = ?', [$nsid]); + + if ($row === null) { + throw new LexiconEntryNotFoundException(); + } + + return $this->hydrate($row); + } + + /** + * @return LexiconEntry[] + */ + public function findAll(): array + { + $rows = $this->db->fetchAll('SELECT * FROM lexicon ORDER BY nsid'); + + $result = []; + foreach ($rows as $row) { + $result[] = $this->hydrate($row); + } + return $result; + } + + public function save(LexiconEntry $entry): void + { + $lexicon = $entry->getLexicon(); + $lexiconJson = $lexicon === null + ? null + : json_encode($lexicon, JSON_THROW_ON_ERROR); + + $this->db->execute( + 'INSERT INTO lexicon (nsid, created_at, updated_at, last_succeeded_at, uri, lexicon_json) + VALUES (:nsid, :created_at, :updated_at, :last_succeeded_at, :uri, :lexicon_json) + ON CONFLICT(nsid) DO UPDATE SET + created_at = excluded.created_at, + updated_at = excluded.updated_at, + last_succeeded_at = excluded.last_succeeded_at, + uri = excluded.uri, + lexicon_json = excluded.lexicon_json', + [ + 'nsid' => $entry->getNsid(), + 'created_at' => $entry->getCreatedAt()->format(DATE_ATOM), + 'updated_at' => $entry->getUpdatedAt()->format(DATE_ATOM), + 'last_succeeded_at' => $entry->getLastSucceededAt()?->format(DATE_ATOM), + 'uri' => $entry->getUri(), + 'lexicon_json' => $lexiconJson, + ] + ); + } + + public function deleteByNsid(string $nsid): void + { + $this->db->execute('DELETE FROM lexicon WHERE nsid = ?', [$nsid]); + } + + /** + * @param array $row + */ + private function hydrate(array $row): LexiconEntry + { + $lexiconJson = Row::nstr($row, 'lexicon_json'); + $lexicon = $lexiconJson === null + ? null + : json_decode($lexiconJson, true, 512, JSON_THROW_ON_ERROR); + assert($lexicon === null || is_array($lexicon)); + /** @var array|null $lexicon */ + + $lastSucceededAt = Row::nstr($row, 'last_succeeded_at'); + + return new LexiconEntry( + nsid: Row::str($row, 'nsid'), + createdAt: new DateTimeImmutable(Row::str($row, 'created_at')), + updatedAt: new DateTimeImmutable(Row::str($row, 'updated_at')), + lastSucceededAt: $lastSucceededAt === null ? null : new DateTimeImmutable($lastSucceededAt), + uri: Row::nstr($row, 'uri'), + lexicon: $lexicon, + ); + } +} diff --git a/src/Infrastructure/Persistence/OAuth/InMemoryAccountDeviceRepository.php b/src/Infrastructure/Persistence/OAuth/InMemoryAccountDeviceRepository.php deleted file mode 100644 index 0d74a07..0000000 --- a/src/Infrastructure/Persistence/OAuth/InMemoryAccountDeviceRepository.php +++ /dev/null @@ -1,75 +0,0 @@ -entries = $seeds; - } - - public function findByDidAndDeviceId(string $did, string $deviceId): AccountDevice - { - foreach ($this->entries as $entry) { - if ($entry->getDid() === $did && $entry->getDeviceId() === $deviceId) { - return $entry; - } - } - - throw new AccountDeviceNotFoundException(); - } - - /** - * @return AccountDevice[] - */ - public function findAllForDid(string $did): array - { - return array_values( - array_filter( - $this->entries, - fn(AccountDevice $e) => $e->getDid() === $did, - ) - ); - } - - public function save(AccountDevice $accountDevice): void - { - foreach ($this->entries as $i => $existing) { - if ( - $existing->getDid() === $accountDevice->getDid() - && $existing->getDeviceId() === $accountDevice->getDeviceId() - ) { - $this->entries[$i] = $accountDevice; - return; - } - } - $this->entries[] = $accountDevice; - } - - public function deleteByDidAndDeviceId(string $did, string $deviceId): void - { - $this->entries = array_values( - array_filter( - $this->entries, - fn(AccountDevice $e) => !($e->getDid() === $did && $e->getDeviceId() === $deviceId), - ) - ); - } -} diff --git a/src/Infrastructure/Persistence/OAuth/InMemoryAuthorizationRequestRepository.php b/src/Infrastructure/Persistence/OAuth/InMemoryAuthorizationRequestRepository.php deleted file mode 100644 index c1eac51..0000000 --- a/src/Infrastructure/Persistence/OAuth/InMemoryAuthorizationRequestRepository.php +++ /dev/null @@ -1,69 +0,0 @@ - keyed by request id */ - private array $requests = []; - - /** - * @param AuthorizationRequest[] $seeds - */ - public function __construct(array $seeds = []) - { - foreach ($seeds as $req) { - $this->requests[$req->getId()] = $req; - } - } - - public function findById(string $id): AuthorizationRequest - { - if (!isset($this->requests[$id])) { - throw new AuthorizationRequestNotFoundException(); - } - - return $this->requests[$id]; - } - - public function findByCode(string $code): AuthorizationRequest - { - foreach ($this->requests as $req) { - if ($req->getCode() === $code) { - return $req; - } - } - - throw new AuthorizationRequestNotFoundException(); - } - - public function save(AuthorizationRequest $request): void - { - $this->requests[$request->getId()] = $request; - } - - public function deleteById(string $id): void - { - unset($this->requests[$id]); - } - - public function deleteExpired(): int - { - $now = new \DateTimeImmutable(); - $count = 0; - foreach ($this->requests as $id => $req) { - if ($req->getExpiresAt() < $now) { - unset($this->requests[$id]); - $count++; - } - } - - return $count; - } -} diff --git a/src/Infrastructure/Persistence/OAuth/InMemoryAuthorizedClientRepository.php b/src/Infrastructure/Persistence/OAuth/InMemoryAuthorizedClientRepository.php deleted file mode 100644 index a148b01..0000000 --- a/src/Infrastructure/Persistence/OAuth/InMemoryAuthorizedClientRepository.php +++ /dev/null @@ -1,85 +0,0 @@ -entries = $seeds; - } - - public function findByDidAndClientId(string $did, string $clientId): AuthorizedClient - { - foreach ($this->entries as $entry) { - if ($entry->getDid() === $did && $entry->getClientId() === $clientId) { - return $entry; - } - } - - throw new AuthorizedClientNotFoundException(); - } - - /** - * @return AuthorizedClient[] - */ - public function findAllForDid(string $did): array - { - return array_values( - array_filter( - $this->entries, - fn(AuthorizedClient $e) => $e->getDid() === $did, - ) - ); - } - - public function save(AuthorizedClient $authorizedClient): void - { - foreach ($this->entries as $i => $existing) { - if ( - $existing->getDid() === $authorizedClient->getDid() - && $existing->getClientId() === $authorizedClient->getClientId() - ) { - $this->entries[$i] = $authorizedClient; - return; - } - } - $this->entries[] = $authorizedClient; - } - - public function deleteByDidAndClientId(string $did, string $clientId): void - { - $this->entries = array_values( - array_filter( - $this->entries, - fn(AuthorizedClient $e) => !($e->getDid() === $did && $e->getClientId() === $clientId), - ) - ); - } - - public function deleteAllForDid(string $did): void - { - $this->entries = array_values( - array_filter( - $this->entries, - fn(AuthorizedClient $e) => $e->getDid() !== $did, - ) - ); - } -} diff --git a/src/Infrastructure/Persistence/OAuth/InMemoryDeviceRepository.php b/src/Infrastructure/Persistence/OAuth/InMemoryDeviceRepository.php deleted file mode 100644 index 03f6af0..0000000 --- a/src/Infrastructure/Persistence/OAuth/InMemoryDeviceRepository.php +++ /dev/null @@ -1,44 +0,0 @@ - keyed by device id */ - private array $devices = []; - - /** - * @param Device[] $seeds - */ - public function __construct(array $seeds = []) - { - foreach ($seeds as $device) { - $this->devices[$device->getId()] = $device; - } - } - - public function findById(string $id): Device - { - if (!isset($this->devices[$id])) { - throw new DeviceNotFoundException(); - } - - return $this->devices[$id]; - } - - public function save(Device $device): void - { - $this->devices[$device->getId()] = $device; - } - - public function deleteById(string $id): void - { - unset($this->devices[$id]); - } -} diff --git a/src/Infrastructure/Persistence/OAuth/InMemoryOAuthTokenRepository.php b/src/Infrastructure/Persistence/OAuth/InMemoryOAuthTokenRepository.php deleted file mode 100644 index b635b4c..0000000 --- a/src/Infrastructure/Persistence/OAuth/InMemoryOAuthTokenRepository.php +++ /dev/null @@ -1,87 +0,0 @@ - keyed by tokenId */ - private array $tokens = []; - - /** - * @param OAuthToken[] $seeds - */ - public function __construct(array $seeds = []) - { - foreach ($seeds as $token) { - $this->tokens[$token->getTokenId()] = $token; - } - } - - public function findByTokenId(string $tokenId): OAuthToken - { - if (!isset($this->tokens[$tokenId])) { - throw new OAuthTokenNotFoundException(); - } - - return $this->tokens[$tokenId]; - } - - public function findByCode(string $code): OAuthToken - { - foreach ($this->tokens as $token) { - if ($token->getCode() === $code) { - return $token; - } - } - - throw new OAuthTokenNotFoundException(); - } - - public function findByRefreshToken(string $refreshToken): OAuthToken - { - foreach ($this->tokens as $token) { - if ($token->getCurrentRefreshToken() === $refreshToken) { - return $token; - } - } - - throw new OAuthTokenNotFoundException(); - } - - /** - * @return OAuthToken[] - */ - public function findAllForDid(string $did): array - { - return array_values( - array_filter( - $this->tokens, - fn(OAuthToken $t) => $t->getDid() === $did, - ) - ); - } - - public function save(OAuthToken $token): void - { - $this->tokens[$token->getTokenId()] = $token; - } - - public function deleteByTokenId(string $tokenId): void - { - unset($this->tokens[$tokenId]); - } - - public function deleteAllForDid(string $did): void - { - $this->tokens = array_filter( - $this->tokens, - fn(OAuthToken $t) => $t->getDid() !== $did, - ); - } -} diff --git a/src/Infrastructure/Persistence/OAuth/InMemoryUsedRefreshTokenRepository.php b/src/Infrastructure/Persistence/OAuth/InMemoryUsedRefreshTokenRepository.php deleted file mode 100644 index 076b469..0000000 --- a/src/Infrastructure/Persistence/OAuth/InMemoryUsedRefreshTokenRepository.php +++ /dev/null @@ -1,42 +0,0 @@ - keyed by refreshToken string */ - private array $tokens = []; - - /** - * @param UsedRefreshToken[] $seeds - */ - public function __construct(array $seeds = []) - { - foreach ($seeds as $token) { - $this->tokens[$token->getRefreshToken()] = $token; - } - } - - public function exists(string $refreshToken): bool - { - return isset($this->tokens[$refreshToken]); - } - - public function save(UsedRefreshToken $usedRefreshToken): void - { - $this->tokens[$usedRefreshToken->getRefreshToken()] = $usedRefreshToken; - } - - public function deleteAllForTokenId(int $tokenId): void - { - $this->tokens = array_filter( - $this->tokens, - fn(UsedRefreshToken $t) => $t->getTokenId() !== $tokenId, - ); - } -} diff --git a/src/Infrastructure/Persistence/OAuth/SqliteAccountDeviceRepository.php b/src/Infrastructure/Persistence/OAuth/SqliteAccountDeviceRepository.php new file mode 100644 index 0000000..7be13f2 --- /dev/null +++ b/src/Infrastructure/Persistence/OAuth/SqliteAccountDeviceRepository.php @@ -0,0 +1,88 @@ +db->fetchOne( + 'SELECT * FROM account_device WHERE did = ? AND device_id = ?', + [$did, $deviceId] + ); + + if ($row === null) { + throw new AccountDeviceNotFoundException(); + } + + return $this->hydrate($row); + } + + /** + * @return AccountDevice[] + */ + public function findAllForDid(string $did): array + { + $rows = $this->db->fetchAll( + 'SELECT * FROM account_device WHERE did = ? ORDER BY device_id', + [$did] + ); + + $result = []; + foreach ($rows as $row) { + $result[] = $this->hydrate($row); + } + return $result; + } + + public function save(AccountDevice $accountDevice): void + { + $this->db->execute( + 'INSERT INTO account_device (did, device_id, created_at, updated_at) + VALUES (:did, :device_id, :created_at, :updated_at) + ON CONFLICT(did, device_id) DO UPDATE SET + created_at = excluded.created_at, + updated_at = excluded.updated_at', + [ + 'did' => $accountDevice->getDid(), + 'device_id' => $accountDevice->getDeviceId(), + 'created_at' => $accountDevice->getCreatedAt()->format(DATE_ATOM), + 'updated_at' => $accountDevice->getUpdatedAt()->format(DATE_ATOM), + ] + ); + } + + public function deleteByDidAndDeviceId(string $did, string $deviceId): void + { + $this->db->execute( + 'DELETE FROM account_device WHERE did = ? AND device_id = ?', + [$did, $deviceId] + ); + } + + /** + * @param array $row + */ + private function hydrate(array $row): AccountDevice + { + return new AccountDevice( + did: Row::str($row, 'did'), + deviceId: Row::str($row, 'device_id'), + createdAt: new DateTimeImmutable(Row::str($row, 'created_at')), + updatedAt: new DateTimeImmutable(Row::str($row, 'updated_at')), + ); + } +} diff --git a/src/Infrastructure/Persistence/OAuth/SqliteAuthorizationRequestRepository.php b/src/Infrastructure/Persistence/OAuth/SqliteAuthorizationRequestRepository.php new file mode 100644 index 0000000..14adc6a --- /dev/null +++ b/src/Infrastructure/Persistence/OAuth/SqliteAuthorizationRequestRepository.php @@ -0,0 +1,121 @@ +db->fetchOne( + 'SELECT * FROM authorization_request WHERE id = ?', + [$id] + ); + + if ($row === null) { + throw new AuthorizationRequestNotFoundException(); + } + + return $this->hydrate($row); + } + + public function findByCode(string $code): AuthorizationRequest + { + $row = $this->db->fetchOne( + 'SELECT * FROM authorization_request WHERE code = ?', + [$code] + ); + + if ($row === null) { + throw new AuthorizationRequestNotFoundException(); + } + + return $this->hydrate($row); + } + + public function save(AuthorizationRequest $request): void + { + $clientAuth = $request->getClientAuth(); + $clientAuthJson = $clientAuth === null + ? null + : json_encode($clientAuth, JSON_THROW_ON_ERROR); + + $this->db->execute( + 'INSERT INTO authorization_request + (id, did, device_id, client_id, client_auth_json, parameters_json, expires_at, code) + VALUES + (:id, :did, :device_id, :client_id, :client_auth_json, :parameters_json, :expires_at, :code) + ON CONFLICT(id) DO UPDATE SET + did = excluded.did, + device_id = excluded.device_id, + client_id = excluded.client_id, + client_auth_json = excluded.client_auth_json, + parameters_json = excluded.parameters_json, + expires_at = excluded.expires_at, + code = excluded.code', + [ + 'id' => $request->getId(), + 'did' => $request->getDid(), + 'device_id' => $request->getDeviceId(), + 'client_id' => $request->getClientId(), + 'client_auth_json' => $clientAuthJson, + 'parameters_json' => json_encode($request->getParameters(), JSON_THROW_ON_ERROR), + 'expires_at' => $request->getExpiresAt()->format(DATE_ATOM), + 'code' => $request->getCode(), + ] + ); + } + + public function deleteById(string $id): void + { + $this->db->execute('DELETE FROM authorization_request WHERE id = ?', [$id]); + } + + public function deleteExpired(): int + { + return $this->db->execute( + 'DELETE FROM authorization_request WHERE expires_at < ?', + [(new DateTimeImmutable())->format(DATE_ATOM)] + ); + } + + /** + * @param array $row + */ + private function hydrate(array $row): AuthorizationRequest + { + $clientAuthJson = Row::nstr($row, 'client_auth_json'); + $clientAuth = $clientAuthJson === null + ? null + : json_decode($clientAuthJson, true, 512, JSON_THROW_ON_ERROR); + $parameters = json_decode(Row::str($row, 'parameters_json'), true, 512, JSON_THROW_ON_ERROR); + + assert(is_array($parameters)); + assert($clientAuth === null || is_array($clientAuth)); + /** @var array $parameters */ + /** @var array|null $clientAuth */ + + return new AuthorizationRequest( + id: Row::str($row, 'id'), + did: Row::nstr($row, 'did'), + deviceId: Row::nstr($row, 'device_id'), + clientId: Row::str($row, 'client_id'), + clientAuth: $clientAuth, + parameters: $parameters, + expiresAt: new DateTimeImmutable(Row::str($row, 'expires_at')), + code: Row::nstr($row, 'code'), + ); + } +} diff --git a/src/Infrastructure/Persistence/OAuth/SqliteAuthorizedClientRepository.php b/src/Infrastructure/Persistence/OAuth/SqliteAuthorizedClientRepository.php new file mode 100644 index 0000000..8d9ebc9 --- /dev/null +++ b/src/Infrastructure/Persistence/OAuth/SqliteAuthorizedClientRepository.php @@ -0,0 +1,100 @@ +db->fetchOne( + 'SELECT * FROM authorized_client WHERE did = ? AND client_id = ?', + [$did, $clientId] + ); + + if ($row === null) { + throw new AuthorizedClientNotFoundException(); + } + + return $this->hydrate($row); + } + + /** + * @return AuthorizedClient[] + */ + public function findAllForDid(string $did): array + { + $rows = $this->db->fetchAll( + 'SELECT * FROM authorized_client WHERE did = ? ORDER BY client_id', + [$did] + ); + + $result = []; + foreach ($rows as $row) { + $result[] = $this->hydrate($row); + } + return $result; + } + + public function save(AuthorizedClient $authorizedClient): void + { + $this->db->execute( + 'INSERT INTO authorized_client (did, client_id, created_at, updated_at, data_json) + VALUES (:did, :client_id, :created_at, :updated_at, :data_json) + ON CONFLICT(did, client_id) DO UPDATE SET + created_at = excluded.created_at, + updated_at = excluded.updated_at, + data_json = excluded.data_json', + [ + 'did' => $authorizedClient->getDid(), + 'client_id' => $authorizedClient->getClientId(), + 'created_at' => $authorizedClient->getCreatedAt()->format(DATE_ATOM), + 'updated_at' => $authorizedClient->getUpdatedAt()->format(DATE_ATOM), + 'data_json' => json_encode($authorizedClient->getData(), JSON_THROW_ON_ERROR), + ] + ); + } + + public function deleteByDidAndClientId(string $did, string $clientId): void + { + $this->db->execute( + 'DELETE FROM authorized_client WHERE did = ? AND client_id = ?', + [$did, $clientId] + ); + } + + public function deleteAllForDid(string $did): void + { + $this->db->execute('DELETE FROM authorized_client WHERE did = ?', [$did]); + } + + /** + * @param array $row + */ + private function hydrate(array $row): AuthorizedClient + { + $data = json_decode(Row::str($row, 'data_json'), true, 512, JSON_THROW_ON_ERROR); + assert(is_array($data)); + /** @var array $data */ + + return new AuthorizedClient( + did: Row::str($row, 'did'), + clientId: Row::str($row, 'client_id'), + createdAt: new DateTimeImmutable(Row::str($row, 'created_at')), + updatedAt: new DateTimeImmutable(Row::str($row, 'updated_at')), + data: $data, + ); + } +} diff --git a/src/Infrastructure/Persistence/OAuth/SqliteDeviceRepository.php b/src/Infrastructure/Persistence/OAuth/SqliteDeviceRepository.php new file mode 100644 index 0000000..a79371c --- /dev/null +++ b/src/Infrastructure/Persistence/OAuth/SqliteDeviceRepository.php @@ -0,0 +1,61 @@ +db->fetchOne('SELECT * FROM device WHERE id = ?', [$id]); + + if ($row === null) { + throw new DeviceNotFoundException(); + } + + return new Device( + id: Row::str($row, 'id'), + sessionId: Row::str($row, 'session_id'), + userAgent: Row::nstr($row, 'user_agent'), + ipAddress: Row::str($row, 'ip_address'), + lastSeenAt: new DateTimeImmutable(Row::str($row, 'last_seen_at')), + ); + } + + public function save(Device $device): void + { + $this->db->execute( + 'INSERT INTO device (id, session_id, user_agent, ip_address, last_seen_at) + VALUES (:id, :session_id, :user_agent, :ip_address, :last_seen_at) + ON CONFLICT(id) DO UPDATE SET + session_id = excluded.session_id, + user_agent = excluded.user_agent, + ip_address = excluded.ip_address, + last_seen_at = excluded.last_seen_at', + [ + 'id' => $device->getId(), + 'session_id' => $device->getSessionId(), + 'user_agent' => $device->getUserAgent(), + 'ip_address' => $device->getIpAddress(), + 'last_seen_at' => $device->getLastSeenAt()->format(DATE_ATOM), + ] + ); + } + + public function deleteById(string $id): void + { + $this->db->execute('DELETE FROM device WHERE id = ?', [$id]); + } +} diff --git a/src/Infrastructure/Persistence/OAuth/SqliteOAuthTokenRepository.php b/src/Infrastructure/Persistence/OAuth/SqliteOAuthTokenRepository.php new file mode 100644 index 0000000..58527ec --- /dev/null +++ b/src/Infrastructure/Persistence/OAuth/SqliteOAuthTokenRepository.php @@ -0,0 +1,160 @@ +fetchOneBy('token_id = ?', [$tokenId]); + } + + public function findByCode(string $code): OAuthToken + { + return $this->fetchOneBy('code = ?', [$code]); + } + + public function findByRefreshToken(string $refreshToken): OAuthToken + { + return $this->fetchOneBy('current_refresh_token = ?', [$refreshToken]); + } + + /** + * @return OAuthToken[] + */ + public function findAllForDid(string $did): array + { + $rows = $this->db->fetchAll( + 'SELECT * FROM oauth_token WHERE did = ? ORDER BY id', + [$did] + ); + + $result = []; + foreach ($rows as $row) { + $result[] = $this->hydrate($row); + } + return $result; + } + + public function save(OAuthToken $token): void + { + $details = $token->getDetails(); + $detailsJson = $details === null + ? null + : json_encode($details, JSON_THROW_ON_ERROR); + + $this->db->execute( + 'INSERT INTO oauth_token + (id, did, token_id, created_at, updated_at, expires_at, client_id, + client_auth_json, device_id, parameters_json, details_json, code, + current_refresh_token, scope) + VALUES + (:id, :did, :token_id, :created_at, :updated_at, :expires_at, :client_id, + :client_auth_json, :device_id, :parameters_json, :details_json, :code, + :current_refresh_token, :scope) + ON CONFLICT(token_id) DO UPDATE SET + did = excluded.did, + created_at = excluded.created_at, + updated_at = excluded.updated_at, + expires_at = excluded.expires_at, + client_id = excluded.client_id, + client_auth_json = excluded.client_auth_json, + device_id = excluded.device_id, + parameters_json = excluded.parameters_json, + details_json = excluded.details_json, + code = excluded.code, + current_refresh_token = excluded.current_refresh_token, + scope = excluded.scope', + [ + 'id' => $token->getId() === 0 ? null : $token->getId(), + 'did' => $token->getDid(), + 'token_id' => $token->getTokenId(), + 'created_at' => $token->getCreatedAt()->format(DATE_ATOM), + 'updated_at' => $token->getUpdatedAt()->format(DATE_ATOM), + 'expires_at' => $token->getExpiresAt()->format(DATE_ATOM), + 'client_id' => $token->getClientId(), + 'client_auth_json' => json_encode($token->getClientAuth(), JSON_THROW_ON_ERROR), + 'device_id' => $token->getDeviceId(), + 'parameters_json' => json_encode($token->getParameters(), JSON_THROW_ON_ERROR), + 'details_json' => $detailsJson, + 'code' => $token->getCode(), + 'current_refresh_token' => $token->getCurrentRefreshToken(), + 'scope' => $token->getScope(), + ] + ); + } + + public function deleteByTokenId(string $tokenId): void + { + $this->db->execute('DELETE FROM oauth_token WHERE token_id = ?', [$tokenId]); + } + + public function deleteAllForDid(string $did): void + { + $this->db->execute('DELETE FROM oauth_token WHERE did = ?', [$did]); + } + + /** + * @param array $params + */ + private function fetchOneBy(string $where, array $params): OAuthToken + { + $row = $this->db->fetchOne("SELECT * FROM oauth_token WHERE $where", $params); + + if ($row === null) { + throw new OAuthTokenNotFoundException(); + } + + return $this->hydrate($row); + } + + /** + * @param array $row + */ + private function hydrate(array $row): OAuthToken + { + $clientAuth = json_decode(Row::str($row, 'client_auth_json'), true, 512, JSON_THROW_ON_ERROR); + $parameters = json_decode(Row::str($row, 'parameters_json'), true, 512, JSON_THROW_ON_ERROR); + $detailsJson = Row::nstr($row, 'details_json'); + $details = $detailsJson === null + ? null + : json_decode($detailsJson, true, 512, JSON_THROW_ON_ERROR); + + assert(is_array($clientAuth)); + assert(is_array($parameters)); + assert($details === null || is_array($details)); + /** @var array $clientAuth */ + /** @var array $parameters */ + /** @var array|null $details */ + + return new OAuthToken( + id: Row::int($row, 'id'), + did: Row::str($row, 'did'), + tokenId: Row::str($row, 'token_id'), + createdAt: new DateTimeImmutable(Row::str($row, 'created_at')), + updatedAt: new DateTimeImmutable(Row::str($row, 'updated_at')), + expiresAt: new DateTimeImmutable(Row::str($row, 'expires_at')), + clientId: Row::str($row, 'client_id'), + clientAuth: $clientAuth, + deviceId: Row::nstr($row, 'device_id'), + parameters: $parameters, + details: $details, + code: Row::nstr($row, 'code'), + currentRefreshToken: Row::nstr($row, 'current_refresh_token'), + scope: Row::nstr($row, 'scope'), + ); + } +} diff --git a/src/Infrastructure/Persistence/OAuth/SqliteUsedRefreshTokenRepository.php b/src/Infrastructure/Persistence/OAuth/SqliteUsedRefreshTokenRepository.php new file mode 100644 index 0000000..a3f1a8b --- /dev/null +++ b/src/Infrastructure/Persistence/OAuth/SqliteUsedRefreshTokenRepository.php @@ -0,0 +1,46 @@ +db->pdo()->prepare( + 'SELECT 1 FROM used_refresh_token WHERE refresh_token = ?' + ); + $stmt->execute([$refreshToken]); + + return $stmt->fetchColumn() !== false; + } + + public function save(UsedRefreshToken $usedRefreshToken): void + { + $stmt = $this->db->pdo()->prepare( + 'INSERT INTO used_refresh_token (refresh_token, token_id) VALUES (?, ?) + ON CONFLICT(refresh_token) DO UPDATE SET token_id = excluded.token_id' + ); + $stmt->execute([ + $usedRefreshToken->getRefreshToken(), + $usedRefreshToken->getTokenId(), + ]); + } + + public function deleteAllForTokenId(int $tokenId): void + { + $stmt = $this->db->pdo()->prepare( + 'DELETE FROM used_refresh_token WHERE token_id = ?' + ); + $stmt->execute([$tokenId]); + } +} diff --git a/src/Infrastructure/Persistence/Preference/InMemoryAccountPrefRepository.php b/src/Infrastructure/Persistence/Preference/InMemoryAccountPrefRepository.php deleted file mode 100644 index bcb8f45..0000000 --- a/src/Infrastructure/Persistence/Preference/InMemoryAccountPrefRepository.php +++ /dev/null @@ -1,93 +0,0 @@ - keyed by id */ - private array $prefs = []; - - private int $nextId = 1; - - /** - * @param AccountPref[] $seeds - */ - public function __construct(array $seeds = []) - { - foreach ($seeds as $pref) { - $this->prefs[$pref->getId()] = $pref; - if ($pref->getId() >= $this->nextId) { - $this->nextId = $pref->getId() + 1; - } - } - } - - /** - * @return AccountPref[] - */ - public function findAll(): array - { - return array_values($this->prefs); - } - - /** - * @return AccountPref[] - */ - public function findByName(string $name): array - { - return array_values( - array_filter( - $this->prefs, - fn(AccountPref $p) => $p->getName() === $name, - ) - ); - } - - public function findById(int $id): AccountPref - { - if (!isset($this->prefs[$id])) { - throw new AccountPrefNotFoundException(); - } - - return $this->prefs[$id]; - } - - public function save(AccountPref $pref): AccountPref - { - $id = $pref->getId() === 0 ? $this->nextId++ : $pref->getId(); - - $saved = new AccountPref( - id: $id, - name: $pref->getName(), - valueJson: $pref->getValueJson(), - ); - $this->prefs[$id] = $saved; - - return $saved; - } - - public function deleteById(int $id): void - { - unset($this->prefs[$id]); - } - - public function deleteByName(string $name): void - { - foreach ($this->prefs as $id => $pref) { - if ($pref->getName() === $name) { - unset($this->prefs[$id]); - } - } - } - - public function deleteAll(): void - { - $this->prefs = []; - } -} diff --git a/src/Infrastructure/Persistence/Preference/SqliteAccountPrefRepository.php b/src/Infrastructure/Persistence/Preference/SqliteAccountPrefRepository.php new file mode 100644 index 0000000..479671d --- /dev/null +++ b/src/Infrastructure/Persistence/Preference/SqliteAccountPrefRepository.php @@ -0,0 +1,117 @@ +db->fetchAll('SELECT * FROM account_pref ORDER BY id'); + + $result = []; + foreach ($rows as $row) { + $result[] = $this->hydrate($row); + } + return $result; + } + + /** + * @return AccountPref[] + */ + public function findByName(string $name): array + { + $rows = $this->db->fetchAll( + 'SELECT * FROM account_pref WHERE name = ? ORDER BY id', + [$name] + ); + + $result = []; + foreach ($rows as $row) { + $result[] = $this->hydrate($row); + } + return $result; + } + + public function findById(int $id): AccountPref + { + $row = $this->db->fetchOne('SELECT * FROM account_pref WHERE id = ?', [$id]); + + if ($row === null) { + throw new AccountPrefNotFoundException(); + } + + return $this->hydrate($row); + } + + public function save(AccountPref $pref): AccountPref + { + if ($pref->getId() === 0) { + $this->db->execute( + 'INSERT INTO account_pref (name, value_json) VALUES (?, ?)', + [$pref->getName(), $pref->getValueJson()] + ); + + return new AccountPref( + id: (int) $this->db->pdo()->lastInsertId(), + name: $pref->getName(), + valueJson: $pref->getValueJson(), + ); + } + + $this->db->execute( + 'INSERT INTO account_pref (id, name, value_json) VALUES (:id, :name, :value_json) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + value_json = excluded.value_json', + [ + 'id' => $pref->getId(), + 'name' => $pref->getName(), + 'value_json' => $pref->getValueJson(), + ] + ); + + return $pref; + } + + public function deleteById(int $id): void + { + $this->db->execute('DELETE FROM account_pref WHERE id = ?', [$id]); + } + + public function deleteByName(string $name): void + { + $this->db->execute('DELETE FROM account_pref WHERE name = ?', [$name]); + } + + public function deleteAll(): void + { + $this->db->pdo()->exec('DELETE FROM account_pref'); + } + + /** + * @param array $row + */ + private function hydrate(array $row): AccountPref + { + return new AccountPref( + id: Row::int($row, 'id'), + name: Row::str($row, 'name'), + valueJson: Row::str($row, 'value_json'), + ); + } +} diff --git a/src/Infrastructure/Persistence/Record/InMemoryBacklinkRepository.php b/src/Infrastructure/Persistence/Record/InMemoryBacklinkRepository.php deleted file mode 100644 index dc8b62a..0000000 --- a/src/Infrastructure/Persistence/Record/InMemoryBacklinkRepository.php +++ /dev/null @@ -1,77 +0,0 @@ -entries = $seeds; - } - - /** - * @return Backlink[] - */ - public function findByUri(string $uri): array - { - return array_values( - array_filter( - $this->entries, - fn(Backlink $b) => $b->getUri() === $uri, - ) - ); - } - - /** - * @return Backlink[] - */ - public function findByLinkTo(string $linkTo): array - { - return array_values( - array_filter( - $this->entries, - fn(Backlink $b) => $b->getLinkTo() === $linkTo, - ) - ); - } - - public function save(Backlink $backlink): void - { - foreach ($this->entries as $existing) { - if ( - $existing->getUri() === $backlink->getUri() - && $existing->getPath() === $backlink->getPath() - && $existing->getLinkTo() === $backlink->getLinkTo() - ) { - return; - } - } - $this->entries[] = $backlink; - } - - public function deleteByUri(string $uri): void - { - $this->entries = array_values( - array_filter( - $this->entries, - fn(Backlink $b) => $b->getUri() !== $uri, - ) - ); - } - - public function deleteAll(): void - { - $this->entries = []; - } -} diff --git a/src/Infrastructure/Persistence/Record/InMemoryRecordBlobRepository.php b/src/Infrastructure/Persistence/Record/InMemoryRecordBlobRepository.php deleted file mode 100644 index 5893479..0000000 --- a/src/Infrastructure/Persistence/Record/InMemoryRecordBlobRepository.php +++ /dev/null @@ -1,81 +0,0 @@ -entries = $seeds; - } - - /** - * @return RecordBlob[] - */ - public function findByBlobCid(string $blobCid): array - { - return array_values( - array_filter( - $this->entries, - fn(RecordBlob $rb) => $rb->getBlobCid() === $blobCid, - ) - ); - } - - /** - * @return RecordBlob[] - */ - public function findByRecordUri(string $recordUri): array - { - return array_values( - array_filter( - $this->entries, - fn(RecordBlob $rb) => $rb->getRecordUri() === $recordUri, - ) - ); - } - - public function save(RecordBlob $recordBlob): void - { - foreach ($this->entries as $existing) { - if ( - $existing->getBlobCid() === $recordBlob->getBlobCid() - && $existing->getRecordUri() === $recordBlob->getRecordUri() - ) { - return; - } - } - $this->entries[] = $recordBlob; - } - - public function deleteByRecordUri(string $recordUri): void - { - $this->entries = array_values( - array_filter( - $this->entries, - fn(RecordBlob $rb) => $rb->getRecordUri() !== $recordUri, - ) - ); - } - - public function deleteByBlobCid(string $blobCid): void - { - $this->entries = array_values( - array_filter( - $this->entries, - fn(RecordBlob $rb) => $rb->getBlobCid() !== $blobCid, - ) - ); - } -} diff --git a/src/Infrastructure/Persistence/Record/InMemoryRecordRepository.php b/src/Infrastructure/Persistence/Record/InMemoryRecordRepository.php deleted file mode 100644 index a15f26d..0000000 --- a/src/Infrastructure/Persistence/Record/InMemoryRecordRepository.php +++ /dev/null @@ -1,70 +0,0 @@ - keyed by uri */ - private array $records = []; - - /** - * @param Record[] $seeds - */ - public function __construct(array $seeds = []) - { - foreach ($seeds as $record) { - $this->records[$record->getUri()] = $record; - } - } - - public function findByUri(string $uri): Record - { - if (!isset($this->records[$uri])) { - throw new RecordNotFoundException(); - } - - return $this->records[$uri]; - } - - /** - * @return Record[] - */ - public function findByCollection(string $collection): array - { - return array_values( - array_filter( - $this->records, - fn(Record $r) => $r->getCollection() === $collection, - ) - ); - } - - /** - * @return Record[] - */ - public function findAll(): array - { - return array_values($this->records); - } - - public function save(Record $record): void - { - $this->records[$record->getUri()] = $record; - } - - public function deleteByUri(string $uri): void - { - unset($this->records[$uri]); - } - - public function deleteAll(): void - { - $this->records = []; - } -} diff --git a/src/Infrastructure/Persistence/Record/SqliteBacklinkRepository.php b/src/Infrastructure/Persistence/Record/SqliteBacklinkRepository.php new file mode 100644 index 0000000..1b923a8 --- /dev/null +++ b/src/Infrastructure/Persistence/Record/SqliteBacklinkRepository.php @@ -0,0 +1,85 @@ +db->fetchAll( + 'SELECT * FROM backlink WHERE uri = ? ORDER BY path, link_to', + [$uri] + ); + + $result = []; + foreach ($rows as $row) { + $result[] = $this->hydrate($row); + } + return $result; + } + + /** + * @return Backlink[] + */ + public function findByLinkTo(string $linkTo): array + { + $rows = $this->db->fetchAll( + 'SELECT * FROM backlink WHERE link_to = ? ORDER BY uri, path', + [$linkTo] + ); + + $result = []; + foreach ($rows as $row) { + $result[] = $this->hydrate($row); + } + return $result; + } + + public function save(Backlink $backlink): void + { + $this->db->execute( + 'INSERT OR IGNORE INTO backlink (uri, path, link_to) VALUES (?, ?, ?)', + [ + $backlink->getUri(), + $backlink->getPath(), + $backlink->getLinkTo(), + ] + ); + } + + public function deleteByUri(string $uri): void + { + $this->db->execute('DELETE FROM backlink WHERE uri = ?', [$uri]); + } + + public function deleteAll(): void + { + $this->db->pdo()->exec('DELETE FROM backlink'); + } + + /** + * @param array $row + */ + private function hydrate(array $row): Backlink + { + return new Backlink( + uri: Row::str($row, 'uri'), + path: Row::str($row, 'path'), + linkTo: Row::str($row, 'link_to'), + ); + } +} diff --git a/src/Infrastructure/Persistence/Record/SqliteRecordBlobRepository.php b/src/Infrastructure/Persistence/Record/SqliteRecordBlobRepository.php new file mode 100644 index 0000000..305188f --- /dev/null +++ b/src/Infrastructure/Persistence/Record/SqliteRecordBlobRepository.php @@ -0,0 +1,80 @@ +db->fetchAll( + 'SELECT * FROM record_blob WHERE blob_cid = ? ORDER BY record_uri', + [$blobCid] + ); + + $result = []; + foreach ($rows as $row) { + $result[] = $this->hydrate($row); + } + return $result; + } + + /** + * @return RecordBlob[] + */ + public function findByRecordUri(string $recordUri): array + { + $rows = $this->db->fetchAll( + 'SELECT * FROM record_blob WHERE record_uri = ? ORDER BY blob_cid', + [$recordUri] + ); + + $result = []; + foreach ($rows as $row) { + $result[] = $this->hydrate($row); + } + return $result; + } + + public function save(RecordBlob $recordBlob): void + { + $this->db->execute( + 'INSERT OR IGNORE INTO record_blob (blob_cid, record_uri) VALUES (?, ?)', + [$recordBlob->getBlobCid(), $recordBlob->getRecordUri()] + ); + } + + public function deleteByRecordUri(string $recordUri): void + { + $this->db->execute('DELETE FROM record_blob WHERE record_uri = ?', [$recordUri]); + } + + public function deleteByBlobCid(string $blobCid): void + { + $this->db->execute('DELETE FROM record_blob WHERE blob_cid = ?', [$blobCid]); + } + + /** + * @param array $row + */ + private function hydrate(array $row): RecordBlob + { + return new RecordBlob( + blobCid: Row::str($row, 'blob_cid'), + recordUri: Row::str($row, 'record_uri'), + ); + } +} diff --git a/src/Infrastructure/Persistence/Record/SqliteRecordRepository.php b/src/Infrastructure/Persistence/Record/SqliteRecordRepository.php new file mode 100644 index 0000000..a02fdba --- /dev/null +++ b/src/Infrastructure/Persistence/Record/SqliteRecordRepository.php @@ -0,0 +1,111 @@ +db->fetchOne('SELECT * FROM record WHERE uri = ?', [$uri]); + + if ($row === null) { + throw new RecordNotFoundException(); + } + + return $this->hydrate($row); + } + + /** + * @return Record[] + */ + public function findByCollection(string $collection): array + { + $rows = $this->db->fetchAll( + 'SELECT * FROM record WHERE collection = ? ORDER BY uri', + [$collection] + ); + + $result = []; + foreach ($rows as $row) { + $result[] = $this->hydrate($row); + } + return $result; + } + + /** + * @return Record[] + */ + public function findAll(): array + { + $rows = $this->db->fetchAll('SELECT * FROM record ORDER BY uri'); + + $result = []; + foreach ($rows as $row) { + $result[] = $this->hydrate($row); + } + return $result; + } + + public function save(Record $record): void + { + $this->db->execute( + 'INSERT INTO record (uri, cid, collection, rkey, repo_rev, indexed_at, takedown_ref) + VALUES (:uri, :cid, :collection, :rkey, :repo_rev, :indexed_at, :takedown_ref) + ON CONFLICT(uri) DO UPDATE SET + cid = excluded.cid, + collection = excluded.collection, + rkey = excluded.rkey, + repo_rev = excluded.repo_rev, + indexed_at = excluded.indexed_at, + takedown_ref = excluded.takedown_ref', + [ + 'uri' => $record->getUri(), + 'cid' => $record->getCid(), + 'collection' => $record->getCollection(), + 'rkey' => $record->getRkey(), + 'repo_rev' => $record->getRepoRev(), + 'indexed_at' => $record->getIndexedAt()->format(DATE_ATOM), + 'takedown_ref' => $record->getTakedownRef(), + ] + ); + } + + public function deleteByUri(string $uri): void + { + $this->db->execute('DELETE FROM record WHERE uri = ?', [$uri]); + } + + public function deleteAll(): void + { + $this->db->pdo()->exec('DELETE FROM record'); + } + + /** + * @param array $row + */ + private function hydrate(array $row): Record + { + return new Record( + uri: Row::str($row, 'uri'), + cid: Row::str($row, 'cid'), + collection: Row::str($row, 'collection'), + rkey: Row::str($row, 'rkey'), + repoRev: Row::str($row, 'repo_rev'), + indexedAt: new DateTimeImmutable(Row::str($row, 'indexed_at')), + takedownRef: Row::nstr($row, 'takedown_ref'), + ); + } +} diff --git a/src/Infrastructure/Persistence/Repo/InMemoryRepoBlockRepository.php b/src/Infrastructure/Persistence/Repo/InMemoryRepoBlockRepository.php deleted file mode 100644 index 8f7362c..0000000 --- a/src/Infrastructure/Persistence/Repo/InMemoryRepoBlockRepository.php +++ /dev/null @@ -1,70 +0,0 @@ - keyed by cid */ - private array $blocks = []; - - /** - * @param RepoBlock[] $seeds - */ - public function __construct(array $seeds = []) - { - foreach ($seeds as $block) { - $this->blocks[$block->getCid()] = $block; - } - } - - public function findByCid(string $cid): RepoBlock - { - if (!isset($this->blocks[$cid])) { - throw new RepoBlockNotFoundException(); - } - - return $this->blocks[$cid]; - } - - /** - * @return RepoBlock[] - */ - public function findByRepoRev(string $repoRev): array - { - return array_values( - array_filter( - $this->blocks, - fn(RepoBlock $b) => $b->getRepoRev() === $repoRev, - ) - ); - } - - /** - * @return RepoBlock[] - */ - public function findAll(): array - { - return array_values($this->blocks); - } - - public function save(RepoBlock $block): void - { - $this->blocks[$block->getCid()] = $block; - } - - public function deleteByCid(string $cid): void - { - unset($this->blocks[$cid]); - } - - public function deleteAll(): void - { - $this->blocks = []; - } -} diff --git a/src/Infrastructure/Persistence/Repo/InMemoryRepoRootRepository.php b/src/Infrastructure/Persistence/Repo/InMemoryRepoRootRepository.php deleted file mode 100644 index 27d88af..0000000 --- a/src/Infrastructure/Persistence/Repo/InMemoryRepoRootRepository.php +++ /dev/null @@ -1,44 +0,0 @@ - keyed by did */ - private array $roots = []; - - /** - * @param RepoRoot[] $seeds - */ - public function __construct(array $seeds = []) - { - foreach ($seeds as $root) { - $this->roots[$root->getDid()] = $root; - } - } - - public function findByDid(string $did): RepoRoot - { - if (!isset($this->roots[$did])) { - throw new RepoRootNotFoundException(); - } - - return $this->roots[$did]; - } - - public function upsert(RepoRoot $repoRoot): void - { - $this->roots[$repoRoot->getDid()] = $repoRoot; - } - - public function deleteByDid(string $did): void - { - unset($this->roots[$did]); - } -} diff --git a/src/Infrastructure/Persistence/Repo/SqliteRepoBlockRepository.php b/src/Infrastructure/Persistence/Repo/SqliteRepoBlockRepository.php new file mode 100644 index 0000000..ab20fa6 --- /dev/null +++ b/src/Infrastructure/Persistence/Repo/SqliteRepoBlockRepository.php @@ -0,0 +1,112 @@ +db->fetchOne('SELECT * FROM repo_block WHERE cid = ?', [$cid]); + + if ($row === null) { + throw new RepoBlockNotFoundException(); + } + + return $this->hydrate($row); + } + + /** + * @return RepoBlock[] + */ + public function findByRepoRev(string $repoRev): array + { + $rows = $this->db->fetchAll( + 'SELECT * FROM repo_block WHERE repo_rev = ? ORDER BY cid', + [$repoRev] + ); + + $result = []; + foreach ($rows as $row) { + $result[] = $this->hydrate($row); + } + return $result; + } + + /** + * @return RepoBlock[] + */ + public function findAll(): array + { + $rows = $this->db->fetchAll('SELECT * FROM repo_block ORDER BY cid'); + + $result = []; + foreach ($rows as $row) { + $result[] = $this->hydrate($row); + } + return $result; + } + + public function save(RepoBlock $block): void + { + $stmt = $this->db->pdo()->prepare( + 'INSERT INTO repo_block (cid, repo_rev, size, content) + VALUES (:cid, :repo_rev, :size, :content) + ON CONFLICT(cid) DO UPDATE SET + repo_rev = excluded.repo_rev, + size = excluded.size, + content = excluded.content' + ); + assert($stmt !== false); + $stmt->bindValue(':cid', $block->getCid()); + $stmt->bindValue(':repo_rev', $block->getRepoRev()); + $stmt->bindValue(':size', $block->getSize(), PDO::PARAM_INT); + $stmt->bindValue(':content', $block->getContent(), PDO::PARAM_LOB); + $stmt->execute(); + } + + public function deleteByCid(string $cid): void + { + $this->db->execute('DELETE FROM repo_block WHERE cid = ?', [$cid]); + } + + public function deleteAll(): void + { + $this->db->pdo()->exec('DELETE FROM repo_block'); + } + + /** + * @param array $row + */ + private function hydrate(array $row): RepoBlock + { + // Some PDO/sqlite versions return BLOB columns as resources; normalize. + $content = $row['content'] ?? null; + if (is_resource($content)) { + $content = stream_get_contents($content); + if ($content === false) { + $content = ''; + } + } + assert(is_string($content)); + + return new RepoBlock( + cid: Row::str($row, 'cid'), + repoRev: Row::str($row, 'repo_rev'), + size: Row::int($row, 'size'), + content: $content, + ); + } +} diff --git a/src/Infrastructure/Persistence/Repo/SqliteRepoRootRepository.php b/src/Infrastructure/Persistence/Repo/SqliteRepoRootRepository.php new file mode 100644 index 0000000..c346d9f --- /dev/null +++ b/src/Infrastructure/Persistence/Repo/SqliteRepoRootRepository.php @@ -0,0 +1,58 @@ +db->fetchOne('SELECT * FROM repo_root WHERE did = ?', [$did]); + + if ($row === null) { + throw new RepoRootNotFoundException(); + } + + return new RepoRoot( + did: Row::str($row, 'did'), + cid: Row::str($row, 'cid'), + rev: Row::str($row, 'rev'), + indexedAt: new DateTimeImmutable(Row::str($row, 'indexed_at')), + ); + } + + public function upsert(RepoRoot $repoRoot): void + { + $this->db->execute( + 'INSERT INTO repo_root (did, cid, rev, indexed_at) + VALUES (:did, :cid, :rev, :indexed_at) + ON CONFLICT(did) DO UPDATE SET + cid = excluded.cid, + rev = excluded.rev, + indexed_at = excluded.indexed_at', + [ + 'did' => $repoRoot->getDid(), + 'cid' => $repoRoot->getCid(), + 'rev' => $repoRoot->getRev(), + 'indexed_at' => $repoRoot->getIndexedAt()->format(DATE_ATOM), + ] + ); + } + + public function deleteByDid(string $did): void + { + $this->db->execute('DELETE FROM repo_root WHERE did = ?', [$did]); + } +} diff --git a/src/Infrastructure/Persistence/Sequencer/InMemorySequencerRepository.php b/src/Infrastructure/Persistence/Sequencer/InMemorySequencerRepository.php deleted file mode 100644 index 126c868..0000000 --- a/src/Infrastructure/Persistence/Sequencer/InMemorySequencerRepository.php +++ /dev/null @@ -1,88 +0,0 @@ -counter = 0; - foreach ($seeds as $event) { - $this->events[] = $event; - if ($event->getSeq() > $this->counter) { - $this->counter = $event->getSeq(); - } - } - } - - public function append(string $did, string $eventType, string $event): int - { - $seq = ++$this->counter; - - $this->events[] = new RepoSeqEvent( - seq: $seq, - did: $did, - eventType: $eventType, - event: $event, - sequencedAt: new DateTimeImmutable(), - ); - - return $seq; - } - - public function latestSeq(): ?int - { - if (empty($this->events)) { - return null; - } - - return max(array_map(fn(RepoSeqEvent $e) => $e->getSeq(), $this->events)); - } - - /** - * @return RepoSeqEvent[] - */ - public function readRange(int $afterSeq, int $limit = 500): array - { - $results = array_filter( - $this->events, - fn(RepoSeqEvent $e) => $e->getSeq() > $afterSeq, - ); - - usort($results, fn(RepoSeqEvent $a, RepoSeqEvent $b) => $a->getSeq() <=> $b->getSeq()); - - return array_slice($results, 0, $limit); - } - - public function invalidateForDid(string $did): void - { - $this->events = array_map(function (RepoSeqEvent $e) use ($did): RepoSeqEvent { - if ($e->getDid() !== $did || $e->isInvalidated()) { - return $e; - } - - return new RepoSeqEvent( - seq: $e->getSeq(), - did: $e->getDid(), - eventType: $e->getEventType(), - event: $e->getEvent(), - sequencedAt: $e->getSequencedAt(), - invalidated: true, - ); - }, $this->events); - } -} diff --git a/src/Infrastructure/Persistence/Sequencer/SqliteSequencerRepository.php b/src/Infrastructure/Persistence/Sequencer/SqliteSequencerRepository.php new file mode 100644 index 0000000..988b324 --- /dev/null +++ b/src/Infrastructure/Persistence/Sequencer/SqliteSequencerRepository.php @@ -0,0 +1,88 @@ +db->pdo(); + $stmt = $pdo->prepare( + 'INSERT INTO repo_seq (did, event_type, event, sequenced_at, invalidated) + VALUES (?, ?, ?, ?, 0)' + ); + assert($stmt !== false); + $stmt->bindValue(1, $did); + $stmt->bindValue(2, $eventType); + $stmt->bindValue(3, $event, PDO::PARAM_LOB); + $stmt->bindValue(4, (new DateTimeImmutable())->format(DATE_ATOM)); + $stmt->execute(); + + return (int) $pdo->lastInsertId(); + } + + public function latestSeq(): ?int + { + $query = $this->db->pdo()->query('SELECT MAX(seq) FROM repo_seq'); + assert($query !== false); + $result = $query->fetchColumn(); + + if ($result === false || $result === null) { + return null; + } + + return (int) $result; + } + + /** + * @return RepoSeqEvent[] + */ + public function readRange(int $afterSeq, int $limit = 500): array + { + $stmt = $this->db->pdo()->prepare( + 'SELECT * FROM repo_seq WHERE seq > ? ORDER BY seq ASC LIMIT ?' + ); + assert($stmt !== false); + $stmt->bindValue(1, $afterSeq, PDO::PARAM_INT); + $stmt->bindValue(2, $limit, PDO::PARAM_INT); + $stmt->execute(); + + /** @var list> $rows */ + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); + + $events = []; + foreach ($rows as $row) { + $events[] = new RepoSeqEvent( + seq: Row::int($row, 'seq'), + did: Row::str($row, 'did'), + eventType: Row::str($row, 'event_type'), + event: Row::str($row, 'event'), + sequencedAt: new DateTimeImmutable(Row::str($row, 'sequenced_at')), + invalidated: Row::bool($row, 'invalidated'), + ); + } + + return $events; + } + + public function invalidateForDid(string $did): void + { + $this->db->execute( + 'UPDATE repo_seq SET invalidated = 1 WHERE did = ? AND invalidated = 0', + [$did] + ); + } +} diff --git a/tests/Infrastructure/Persistence/Account/AppPassword/InMemoryAppPasswordRepositoryTest.php b/tests/Infrastructure/Persistence/Account/AppPassword/SqliteAppPasswordRepositoryTest.php similarity index 57% rename from tests/Infrastructure/Persistence/Account/AppPassword/InMemoryAppPasswordRepositoryTest.php rename to tests/Infrastructure/Persistence/Account/AppPassword/SqliteAppPasswordRepositoryTest.php index 858fa2a..db5f765 100644 --- a/tests/Infrastructure/Persistence/Account/AppPassword/InMemoryAppPasswordRepositoryTest.php +++ b/tests/Infrastructure/Persistence/Account/AppPassword/SqliteAppPasswordRepositoryTest.php @@ -6,12 +6,22 @@ namespace Tests\Infrastructure\Persistence\Account\AppPassword; use App\Domain\Account\AppPassword\AppPassword; use App\Domain\Account\AppPassword\AppPasswordNotFoundException; -use App\Infrastructure\Persistence\Account\AppPassword\InMemoryAppPasswordRepository; +use App\Infrastructure\Database\Database; +use App\Infrastructure\Database\Schema\AccountSchema; +use App\Infrastructure\Persistence\Account\AppPassword\SqliteAppPasswordRepository; use DateTimeImmutable; use Tests\TestCase; -class InMemoryAppPasswordRepositoryTest extends TestCase +class SqliteAppPasswordRepositoryTest extends TestCase { + private function newRepo(): SqliteAppPasswordRepository + { + $db = new Database(':memory:'); + AccountSchema::apply($db); + + return new SqliteAppPasswordRepository($db); + } + private function makePassword(string $did = 'did:plc:alice', string $name = 'main'): AppPassword { return new AppPassword( @@ -24,24 +34,26 @@ class InMemoryAppPasswordRepositoryTest extends TestCase public function testFindAllForDid(): void { - $p1 = $this->makePassword(name: 'main'); - $p2 = $this->makePassword(name: 'ci'); - $repo = new InMemoryAppPasswordRepository([$p1, $p2]); + $repo = $this->newRepo(); + $repo->save($this->makePassword(name: 'main')); + $repo->save($this->makePassword(name: 'ci')); $this->assertCount(2, $repo->findAllForDid('did:plc:alice')); } public function testFindByDidAndName(): void { + $repo = $this->newRepo(); $p = $this->makePassword(); - $repo = new InMemoryAppPasswordRepository([$p]); + $repo->save($p); - $this->assertSame($p, $repo->findByDidAndName('did:plc:alice', 'main')); + $found = $repo->findByDidAndName('did:plc:alice', 'main'); + $this->assertSame('main', $found->getName()); } public function testFindThrowsWhenMissing(): void { - $repo = new InMemoryAppPasswordRepository(); + $repo = $this->newRepo(); $this->expectException(AppPasswordNotFoundException::class); $repo->findByDidAndName('did:plc:alice', 'nope'); @@ -49,8 +61,8 @@ class InMemoryAppPasswordRepositoryTest extends TestCase public function testDeleteByDidAndName(): void { - $p = $this->makePassword(); - $repo = new InMemoryAppPasswordRepository([$p]); + $repo = $this->newRepo(); + $repo->save($this->makePassword()); $repo->deleteByDidAndName('did:plc:alice', 'main'); $this->assertEmpty($repo->findAllForDid('did:plc:alice')); diff --git a/tests/Infrastructure/Persistence/Account/EmailToken/InMemoryEmailTokenRepositoryTest.php b/tests/Infrastructure/Persistence/Account/EmailToken/SqliteEmailTokenRepositoryTest.php similarity index 66% rename from tests/Infrastructure/Persistence/Account/EmailToken/InMemoryEmailTokenRepositoryTest.php rename to tests/Infrastructure/Persistence/Account/EmailToken/SqliteEmailTokenRepositoryTest.php index ce28f48..63c6cdd 100644 --- a/tests/Infrastructure/Persistence/Account/EmailToken/InMemoryEmailTokenRepositoryTest.php +++ b/tests/Infrastructure/Persistence/Account/EmailToken/SqliteEmailTokenRepositoryTest.php @@ -6,12 +6,22 @@ namespace Tests\Infrastructure\Persistence\Account\EmailToken; use App\Domain\Account\EmailToken\EmailToken; use App\Domain\Account\EmailToken\EmailTokenNotFoundException; -use App\Infrastructure\Persistence\Account\EmailToken\InMemoryEmailTokenRepository; +use App\Infrastructure\Database\Database; +use App\Infrastructure\Database\Schema\AccountSchema; +use App\Infrastructure\Persistence\Account\EmailToken\SqliteEmailTokenRepository; use DateTimeImmutable; use Tests\TestCase; -class InMemoryEmailTokenRepositoryTest extends TestCase +class SqliteEmailTokenRepositoryTest extends TestCase { + private function newRepo(): SqliteEmailTokenRepository + { + $db = new Database(':memory:'); + AccountSchema::apply($db); + + return new SqliteEmailTokenRepository($db); + } + private function makeToken( string $purpose = EmailToken::PURPOSE_CONFIRM_EMAIL, string $did = 'did:plc:alice', @@ -27,25 +37,27 @@ class InMemoryEmailTokenRepositoryTest extends TestCase public function testFindByPurposeAndDid(): void { + $repo = $this->newRepo(); $t = $this->makeToken(); - $repo = new InMemoryEmailTokenRepository([$t]); + $repo->save($t); $result = $repo->findByPurposeAndDid(EmailToken::PURPOSE_CONFIRM_EMAIL, 'did:plc:alice'); - $this->assertSame($t, $result); + $this->assertSame('abc123', $result->getToken()); } public function testFindByPurposeAndToken(): void { + $repo = $this->newRepo(); $t = $this->makeToken(); - $repo = new InMemoryEmailTokenRepository([$t]); + $repo->save($t); $result = $repo->findByPurposeAndToken(EmailToken::PURPOSE_CONFIRM_EMAIL, 'abc123'); - $this->assertSame($t, $result); + $this->assertSame('did:plc:alice', $result->getDid()); } public function testFindThrowsWhenMissing(): void { - $repo = new InMemoryEmailTokenRepository(); + $repo = $this->newRepo(); $this->expectException(EmailTokenNotFoundException::class); $repo->findByPurposeAndDid(EmailToken::PURPOSE_RESET_PASSWORD, 'did:plc:alice'); @@ -53,10 +65,9 @@ class InMemoryEmailTokenRepositoryTest extends TestCase public function testSaveOverwritesPreviousForSamePurposeAndDid(): void { - $t1 = $this->makeToken(token: 'old'); - $t2 = $this->makeToken(token: 'new'); - $repo = new InMemoryEmailTokenRepository([$t1]); - $repo->save($t2); + $repo = $this->newRepo(); + $repo->save($this->makeToken(token: 'old')); + $repo->save($this->makeToken(token: 'new')); $result = $repo->findByPurposeAndDid(EmailToken::PURPOSE_CONFIRM_EMAIL, 'did:plc:alice'); $this->assertSame('new', $result->getToken()); @@ -64,8 +75,8 @@ class InMemoryEmailTokenRepositoryTest extends TestCase public function testDeleteByPurposeAndDid(): void { - $t = $this->makeToken(); - $repo = new InMemoryEmailTokenRepository([$t]); + $repo = $this->newRepo(); + $repo->save($this->makeToken()); $repo->deleteByPurposeAndDid(EmailToken::PURPOSE_CONFIRM_EMAIL, 'did:plc:alice'); $this->expectException(EmailTokenNotFoundException::class); diff --git a/tests/Infrastructure/Persistence/Account/InMemoryAccountRepositoryTest.php b/tests/Infrastructure/Persistence/Account/InMemoryAccountRepositoryTest.php deleted file mode 100644 index 09411e2..0000000 --- a/tests/Infrastructure/Persistence/Account/InMemoryAccountRepositoryTest.php +++ /dev/null @@ -1,84 +0,0 @@ -assertEquals([$alice], $repository->findAll()); - } - - public function testFindAllSeedsFromSettings(): void - { - $settings = new Settings(['pds' => ['hostname' => 'pds.test']]); - $repository = new InMemoryAccountRepository($settings); - - $accounts = $repository->findAll(); - - $this->assertCount(3, $accounts); - $this->assertSame('did:web:alice.pds.test', $accounts[0]->getDid()); - $this->assertSame('alice@pds.test', $accounts[0]->getEmail()); - } - - public function testFindAccountByDid(): void - { - $alice = new Account('did:web:alice.pds.test', 'alice@pds.test', 'hash'); - $repository = new InMemoryAccountRepository(null, [$alice]); - - $this->assertEquals($alice, $repository->findAccountByDid('did:web:alice.pds.test')); - } - - public function testFindAccountByDidThrowsWhenMissing(): void - { - $repository = new InMemoryAccountRepository(null, []); - - $this->expectException(AccountNotFoundException::class); - $repository->findAccountByDid('did:web:missing.pds.test'); - } - - public function testFindAccountByEmailIsCaseInsensitive(): void - { - $alice = new Account('did:web:alice.pds.test', 'alice@pds.test', 'hash'); - $repository = new InMemoryAccountRepository(null, [$alice]); - - $this->assertEquals($alice, $repository->findAccountByEmail(' Alice@PDS.test ')); - } - - public function testFindAccountByEmailThrowsWhenMissing(): void - { - $repository = new InMemoryAccountRepository(null, []); - - $this->expectException(AccountNotFoundException::class); - $repository->findAccountByEmail('missing@pds.test'); - } - - public function testSeedDataMirrorsActorDids(): void - { - $settings = new Settings(['pds' => ['hostname' => 'pds.test']]); - $accountRepository = new InMemoryAccountRepository($settings); - $actorRepository = new \App\Infrastructure\Persistence\Actor\InMemoryActorRepository($settings); - - $accountDids = array_map( - static fn (Account $a): string => $a->getDid(), - $accountRepository->findAll() - ); - $actorDids = array_map( - static fn (\App\Domain\Actor\Actor $a): string => $a->getDid(), - $actorRepository->findAll() - ); - - $this->assertSame($actorDids, $accountDids); - } -} diff --git a/tests/Infrastructure/Persistence/Account/InviteCode/InMemoryInviteCodeRepositoryTest.php b/tests/Infrastructure/Persistence/Account/InviteCode/SqliteInviteCodeRepositoryTest.php similarity index 61% rename from tests/Infrastructure/Persistence/Account/InviteCode/InMemoryInviteCodeRepositoryTest.php rename to tests/Infrastructure/Persistence/Account/InviteCode/SqliteInviteCodeRepositoryTest.php index 6be49b3..2793c93 100644 --- a/tests/Infrastructure/Persistence/Account/InviteCode/InMemoryInviteCodeRepositoryTest.php +++ b/tests/Infrastructure/Persistence/Account/InviteCode/SqliteInviteCodeRepositoryTest.php @@ -7,12 +7,22 @@ namespace Tests\Infrastructure\Persistence\Account\InviteCode; use App\Domain\Account\InviteCode\InviteCode; use App\Domain\Account\InviteCode\InviteCodeNotFoundException; use App\Domain\Account\InviteCode\InviteCodeUse; -use App\Infrastructure\Persistence\Account\InviteCode\InMemoryInviteCodeRepository; +use App\Infrastructure\Database\Database; +use App\Infrastructure\Database\Schema\AccountSchema; +use App\Infrastructure\Persistence\Account\InviteCode\SqliteInviteCodeRepository; use DateTimeImmutable; use Tests\TestCase; -class InMemoryInviteCodeRepositoryTest extends TestCase +class SqliteInviteCodeRepositoryTest extends TestCase { + private function newRepo(): SqliteInviteCodeRepository + { + $db = new Database(':memory:'); + AccountSchema::apply($db); + + return new SqliteInviteCodeRepository($db); + } + private function makeCode( string $code = 'pds-abc-1234', string $forAccount = 'did:plc:alice', @@ -29,24 +39,25 @@ class InMemoryInviteCodeRepositoryTest extends TestCase public function testFindAll(): void { - $c1 = $this->makeCode('code1'); - $c2 = $this->makeCode('code2'); - $repo = new InMemoryInviteCodeRepository([$c1, $c2]); + $repo = $this->newRepo(); + $repo->save($this->makeCode('code1')); + $repo->save($this->makeCode('code2')); $this->assertCount(2, $repo->findAll()); } public function testFindByCode(): void { - $code = $this->makeCode(); - $repo = new InMemoryInviteCodeRepository([$code]); + $repo = $this->newRepo(); + $repo->save($this->makeCode()); - $this->assertSame($code, $repo->findByCode('pds-abc-1234')); + $found = $repo->findByCode('pds-abc-1234'); + $this->assertSame('pds-abc-1234', $found->getCode()); } public function testFindByCodeThrowsWhenMissing(): void { - $repo = new InMemoryInviteCodeRepository(); + $repo = $this->newRepo(); $this->expectException(InviteCodeNotFoundException::class); $repo->findByCode('no-such-code'); @@ -54,9 +65,9 @@ class InMemoryInviteCodeRepositoryTest extends TestCase public function testFindAllForAccount(): void { - $c1 = $this->makeCode('c1', 'did:plc:alice'); - $c2 = $this->makeCode('c2', 'did:plc:bob'); - $repo = new InMemoryInviteCodeRepository([$c1, $c2]); + $repo = $this->newRepo(); + $repo->save($this->makeCode('c1', 'did:plc:alice')); + $repo->save($this->makeCode('c2', 'did:plc:bob')); $results = $repo->findAllForAccount('did:plc:alice'); $this->assertCount(1, $results); @@ -65,8 +76,8 @@ class InMemoryInviteCodeRepositoryTest extends TestCase public function testDisable(): void { - $code = $this->makeCode(); - $repo = new InMemoryInviteCodeRepository([$code]); + $repo = $this->newRepo(); + $repo->save($this->makeCode()); $repo->disable('pds-abc-1234'); $this->assertTrue($repo->findByCode('pds-abc-1234')->isDisabled()); @@ -74,9 +85,9 @@ class InMemoryInviteCodeRepositoryTest extends TestCase public function testRecordUseAndFindUses(): void { - $code = $this->makeCode(); - $repo = new InMemoryInviteCodeRepository([$code]); - $use = new InviteCodeUse('pds-abc-1234', 'did:plc:bob', new DateTimeImmutable()); + $repo = $this->newRepo(); + $repo->save($this->makeCode()); + $use = new InviteCodeUse('pds-abc-1234', 'did:plc:bob', new DateTimeImmutable('2026-01-02T00:00:00Z')); $repo->recordUse($use); $uses = $repo->findUsesForCode('pds-abc-1234'); diff --git a/tests/Infrastructure/Persistence/Account/RefreshToken/InMemoryRefreshTokenRepositoryTest.php b/tests/Infrastructure/Persistence/Account/RefreshToken/SqliteRefreshTokenRepositoryTest.php similarity index 55% rename from tests/Infrastructure/Persistence/Account/RefreshToken/InMemoryRefreshTokenRepositoryTest.php rename to tests/Infrastructure/Persistence/Account/RefreshToken/SqliteRefreshTokenRepositoryTest.php index 75610c6..d6ff9b6 100644 --- a/tests/Infrastructure/Persistence/Account/RefreshToken/InMemoryRefreshTokenRepositoryTest.php +++ b/tests/Infrastructure/Persistence/Account/RefreshToken/SqliteRefreshTokenRepositoryTest.php @@ -6,11 +6,21 @@ namespace Tests\Infrastructure\Persistence\Account\RefreshToken; use App\Domain\Account\RefreshToken\RefreshToken; use App\Domain\Account\RefreshToken\RefreshTokenNotFoundException; -use App\Infrastructure\Persistence\Account\RefreshToken\InMemoryRefreshTokenRepository; +use App\Infrastructure\Database\Database; +use App\Infrastructure\Database\Schema\AccountSchema; +use App\Infrastructure\Persistence\Account\RefreshToken\SqliteRefreshTokenRepository; use Tests\TestCase; -class InMemoryRefreshTokenRepositoryTest extends TestCase +class SqliteRefreshTokenRepositoryTest extends TestCase { + private function newRepo(): SqliteRefreshTokenRepository + { + $db = new Database(':memory:'); + AccountSchema::apply($db); + + return new SqliteRefreshTokenRepository($db); + } + private function makeToken( string $id = 'token-abc', string $did = 'did:plc:alice', @@ -26,15 +36,16 @@ class InMemoryRefreshTokenRepositoryTest extends TestCase public function testFindById(): void { - $token = $this->makeToken(); - $repo = new InMemoryRefreshTokenRepository([$token]); + $repo = $this->newRepo(); + $repo->save($this->makeToken()); - $this->assertSame($token, $repo->findById('token-abc')); + $found = $repo->findById('token-abc'); + $this->assertSame('token-abc', $found->getId()); } public function testFindByIdThrowsWhenMissing(): void { - $repo = new InMemoryRefreshTokenRepository(); + $repo = $this->newRepo(); $this->expectException(RefreshTokenNotFoundException::class); $repo->findById('nope'); @@ -42,9 +53,9 @@ class InMemoryRefreshTokenRepositoryTest extends TestCase public function testFindAllForDid(): void { - $t1 = $this->makeToken('t1', 'did:plc:alice'); - $t2 = $this->makeToken('t2', 'did:plc:bob'); - $repo = new InMemoryRefreshTokenRepository([$t1, $t2]); + $repo = $this->newRepo(); + $repo->save($this->makeToken('t1', 'did:plc:alice')); + $repo->save($this->makeToken('t2', 'did:plc:bob')); $results = $repo->findAllForDid('did:plc:alice'); $this->assertCount(1, $results); @@ -53,9 +64,9 @@ class InMemoryRefreshTokenRepositoryTest extends TestCase public function testDeleteAllForDid(): void { - $t1 = $this->makeToken('t1', 'did:plc:alice'); - $t2 = $this->makeToken('t2', 'did:plc:alice'); - $repo = new InMemoryRefreshTokenRepository([$t1, $t2]); + $repo = $this->newRepo(); + $repo->save($this->makeToken('t1', 'did:plc:alice')); + $repo->save($this->makeToken('t2', 'did:plc:alice')); $repo->deleteAllForDid('did:plc:alice'); $this->assertEmpty($repo->findAllForDid('did:plc:alice')); diff --git a/tests/Infrastructure/Persistence/Account/SqliteAccountRepositoryTest.php b/tests/Infrastructure/Persistence/Account/SqliteAccountRepositoryTest.php new file mode 100644 index 0000000..4f7a53a --- /dev/null +++ b/tests/Infrastructure/Persistence/Account/SqliteAccountRepositoryTest.php @@ -0,0 +1,68 @@ +newRepo(); + $alice = new Account('did:web:alice.pds.test', 'alice@pds.test', 'hash'); + $repo->save($alice); + + $this->assertEquals([$alice], $repo->findAll()); + } + + public function testFindAccountByDid(): void + { + $repo = $this->newRepo(); + $alice = new Account('did:web:alice.pds.test', 'alice@pds.test', 'hash'); + $repo->save($alice); + + $found = $repo->findAccountByDid('did:web:alice.pds.test'); + $this->assertSame('did:web:alice.pds.test', $found->getDid()); + } + + public function testFindAccountByDidThrowsWhenMissing(): void + { + $repo = $this->newRepo(); + + $this->expectException(AccountNotFoundException::class); + $repo->findAccountByDid('did:web:missing.pds.test'); + } + + public function testFindAccountByEmailIsCaseInsensitive(): void + { + $repo = $this->newRepo(); + $alice = new Account('did:web:alice.pds.test', 'alice@pds.test', 'hash'); + $repo->save($alice); + + $found = $repo->findAccountByEmail(' Alice@PDS.test '); + $this->assertSame('alice@pds.test', $found->getEmail()); + } + + public function testFindAccountByEmailThrowsWhenMissing(): void + { + $repo = $this->newRepo(); + + $this->expectException(AccountNotFoundException::class); + $repo->findAccountByEmail('missing@pds.test'); + } +} diff --git a/tests/Infrastructure/Persistence/Actor/InMemoryActorRepositoryTest.php b/tests/Infrastructure/Persistence/Actor/InMemoryActorRepositoryTest.php deleted file mode 100644 index d467671..0000000 --- a/tests/Infrastructure/Persistence/Actor/InMemoryActorRepositoryTest.php +++ /dev/null @@ -1,81 +0,0 @@ -makeActor('did:web:alice.pds.test', 'alice.pds.test'); - $repository = new InMemoryActorRepository(null, [$alice]); - - $this->assertEquals([$alice], $repository->findAll()); - } - - public function testFindAllSeedsFromSettings(): void - { - $settings = new Settings(['pds' => ['hostname' => 'pds.test']]); - $repository = new InMemoryActorRepository($settings); - - $actors = $repository->findAll(); - - $this->assertCount(3, $actors); - $this->assertSame('did:web:alice.pds.test', $actors[0]->getDid()); - $this->assertSame('alice.pds.test', $actors[0]->getHandle()); - } - - public function testFindActorByDid(): void - { - $alice = $this->makeActor('did:web:alice.pds.test', 'alice.pds.test'); - $repository = new InMemoryActorRepository(null, [$alice]); - - $this->assertEquals($alice, $repository->findActorByDid('did:web:alice.pds.test')); - } - - public function testFindActorByDidThrowsWhenMissing(): void - { - $repository = new InMemoryActorRepository(null, []); - - $this->expectException(ActorNotFoundException::class); - $repository->findActorByDid('did:web:missing.pds.test'); - } - - public function testFindActorByHandleIsCaseInsensitive(): void - { - $alice = $this->makeActor('did:web:alice.pds.test', 'alice.pds.test'); - $repository = new InMemoryActorRepository(null, [$alice]); - - $this->assertEquals($alice, $repository->findActorByHandle(' ALICE.PDS.TEST ')); - } - - public function testFindActorByHandleThrowsWhenMissing(): void - { - $repository = new InMemoryActorRepository(null, []); - - $this->expectException(ActorNotFoundException::class); - $repository->findActorByHandle('missing.pds.test'); - } - - public function testFindActorByHandleSkipsActorsWithoutHandle(): void - { - $headless = $this->makeActor('did:web:headless.pds.test', null); - $repository = new InMemoryActorRepository(null, [$headless]); - - $this->expectException(ActorNotFoundException::class); - $repository->findActorByHandle('headless.pds.test'); - } -} diff --git a/tests/Infrastructure/Persistence/Actor/SqliteActorRepositoryTest.php b/tests/Infrastructure/Persistence/Actor/SqliteActorRepositoryTest.php new file mode 100644 index 0000000..3bca401 --- /dev/null +++ b/tests/Infrastructure/Persistence/Actor/SqliteActorRepositoryTest.php @@ -0,0 +1,86 @@ +newRepo(); + $alice = $this->makeActor('did:web:alice.pds.test', 'alice.pds.test'); + $repo->save($alice); + + $all = $repo->findAll(); + $this->assertCount(1, $all); + $this->assertSame('did:web:alice.pds.test', $all[0]->getDid()); + } + + public function testFindActorByDid(): void + { + $repo = $this->newRepo(); + $alice = $this->makeActor('did:web:alice.pds.test', 'alice.pds.test'); + $repo->save($alice); + + $found = $repo->findActorByDid('did:web:alice.pds.test'); + $this->assertSame('did:web:alice.pds.test', $found->getDid()); + } + + public function testFindActorByDidThrowsWhenMissing(): void + { + $repo = $this->newRepo(); + + $this->expectException(ActorNotFoundException::class); + $repo->findActorByDid('did:web:missing.pds.test'); + } + + public function testFindActorByHandleIsCaseInsensitive(): void + { + $repo = $this->newRepo(); + $alice = $this->makeActor('did:web:alice.pds.test', 'alice.pds.test'); + $repo->save($alice); + + $found = $repo->findActorByHandle(' ALICE.PDS.TEST '); + $this->assertSame('did:web:alice.pds.test', $found->getDid()); + } + + public function testFindActorByHandleThrowsWhenMissing(): void + { + $repo = $this->newRepo(); + + $this->expectException(ActorNotFoundException::class); + $repo->findActorByHandle('missing.pds.test'); + } + + public function testFindActorByHandleSkipsActorsWithoutHandle(): void + { + $repo = $this->newRepo(); + $headless = $this->makeActor('did:web:headless.pds.test', null); + $repo->save($headless); + + $this->expectException(ActorNotFoundException::class); + $repo->findActorByHandle('headless.pds.test'); + } +} diff --git a/tests/Infrastructure/Persistence/ActorStore/InMemoryActorStoreFactoryTest.php b/tests/Infrastructure/Persistence/ActorStore/SqliteActorStoreFactoryTest.php similarity index 57% rename from tests/Infrastructure/Persistence/ActorStore/InMemoryActorStoreFactoryTest.php rename to tests/Infrastructure/Persistence/ActorStore/SqliteActorStoreFactoryTest.php index 70fb6b9..9980a15 100644 --- a/tests/Infrastructure/Persistence/ActorStore/InMemoryActorStoreFactoryTest.php +++ b/tests/Infrastructure/Persistence/ActorStore/SqliteActorStoreFactoryTest.php @@ -5,15 +5,56 @@ declare(strict_types=1); namespace Tests\Infrastructure\Persistence\ActorStore; use App\Domain\Record\Record; -use App\Infrastructure\Persistence\ActorStore\InMemoryActorStoreFactory; +use App\Infrastructure\Persistence\ActorStore\SqliteActorStoreFactory; use DateTimeImmutable; use Tests\TestCase; -class InMemoryActorStoreFactoryTest extends TestCase +class SqliteActorStoreFactoryTest extends TestCase { + private string $tmpDir; + + protected function setUp(): void + { + parent::setUp(); + $this->tmpDir = sys_get_temp_dir() . '/phpds-test-' . uniqid('', true); + mkdir($this->tmpDir, 0o755, true); + } + + protected function tearDown(): void + { + parent::tearDown(); + $this->removeDirectory($this->tmpDir); + } + + private function removeDirectory(string $dir): void + { + if (!is_dir($dir)) { + return; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ); + foreach ($iterator as $file) { + assert($file instanceof \SplFileInfo); + if ($file->isDir()) { + @rmdir($file->getPathname()); + } else { + @unlink($file->getPathname()); + } + } + @rmdir($dir); + } + + private function newFactory(): SqliteActorStoreFactory + { + return new SqliteActorStoreFactory(':memory:', $this->tmpDir . '/blobs'); + } + public function testGetReturnsSameInstanceForSameDid(): void { - $factory = new InMemoryActorStoreFactory(); + $factory = $this->newFactory(); $storeA1 = $factory->get('did:plc:alice'); $storeA2 = $factory->get('did:plc:alice'); @@ -23,7 +64,7 @@ class InMemoryActorStoreFactoryTest extends TestCase public function testGetReturnsDifferentInstancesForDifferentDids(): void { - $factory = new InMemoryActorStoreFactory(); + $factory = $this->newFactory(); $alice = $factory->get('did:plc:alice'); $bob = $factory->get('did:plc:bob'); @@ -33,7 +74,7 @@ class InMemoryActorStoreFactoryTest extends TestCase public function testStoresAreIsolated(): void { - $factory = new InMemoryActorStoreFactory(); + $factory = $this->newFactory(); $record = new Record( uri: 'at://did:plc:alice/app.bsky.feed.post/k1', @@ -46,14 +87,13 @@ class InMemoryActorStoreFactoryTest extends TestCase $factory->get('did:plc:alice')->getRecords()->save($record); - // Bob's store must not see Alice's record $this->assertEmpty($factory->get('did:plc:bob')->getRecords()->findAll()); $this->assertCount(1, $factory->get('did:plc:alice')->getRecords()->findAll()); } public function testDestroyResetsState(): void { - $factory = new InMemoryActorStoreFactory(); + $factory = $this->newFactory(); $record = new Record( uri: 'at://did:plc:alice/app.bsky.feed.post/k1', @@ -67,13 +107,12 @@ class InMemoryActorStoreFactoryTest extends TestCase $factory->get('did:plc:alice')->getRecords()->save($record); $factory->destroy('did:plc:alice'); - // After destroy, a fresh empty store is created on next get $this->assertEmpty($factory->get('did:plc:alice')->getRecords()->findAll()); } public function testGetDid(): void { - $factory = new InMemoryActorStoreFactory(); + $factory = $this->newFactory(); $store = $factory->get('did:plc:alice'); $this->assertSame('did:plc:alice', $store->getDid()); diff --git a/tests/Infrastructure/Persistence/Blob/InMemoryBlobStoreTest.php b/tests/Infrastructure/Persistence/Blob/InMemoryBlobStoreTest.php deleted file mode 100644 index 003f089..0000000 --- a/tests/Infrastructure/Persistence/Blob/InMemoryBlobStoreTest.php +++ /dev/null @@ -1,83 +0,0 @@ -putTemp('hello bytes'); - $this->assertTrue($store->hasTemp($tempKey)); - $this->assertFalse($store->hasStored('bafyreicid1')); - - $store->makePermanent($tempKey, 'bafyreicid1'); - - $this->assertFalse($store->hasTemp($tempKey)); - $this->assertTrue($store->hasStored('bafyreicid1')); - $this->assertSame('hello bytes', $store->getBytes('bafyreicid1')); - } - - public function testMakePermanentThrowsForMissingTempKey(): void - { - $store = new InMemoryBlobStore(); - - $this->expectException(BlobNotFoundException::class); - $store->makePermanent('no-such-key', 'bafyreicid2'); - } - - public function testPutPermanentAndGetStream(): void - { - $store = new InMemoryBlobStore(); - $store->putPermanent('bafyreicid3', 'stream content'); - - $stream = $store->getStream('bafyreicid3'); - $this->assertSame('stream content', (string) $stream); - } - - public function testDeleteThrowsForMissing(): void - { - $store = new InMemoryBlobStore(); - - $this->expectException(BlobNotFoundException::class); - $store->delete('bafyreinone'); - } - - public function testDeleteMany(): void - { - $store = new InMemoryBlobStore(); - $store->putPermanent('cid1', 'a'); - $store->putPermanent('cid2', 'b'); - $store->deleteMany(['cid1', 'cid2']); - - $this->assertFalse($store->hasStored('cid1')); - $this->assertFalse($store->hasStored('cid2')); - } - - public function testQuarantineAndUnquarantine(): void - { - $store = new InMemoryBlobStore(); - $store->putPermanent('cidq', 'data'); - - $store->quarantine('cidq'); - $this->assertTrue($store->isQuarantined('cidq')); - - $store->unquarantine('cidq'); - $this->assertFalse($store->isQuarantined('cidq')); - } - - public function testQuarantineThrowsForMissing(): void - { - $store = new InMemoryBlobStore(); - - $this->expectException(BlobNotFoundException::class); - $store->quarantine('no-such-cid'); - } -} diff --git a/tests/Infrastructure/Persistence/Blob/InMemoryBlobRepositoryTest.php b/tests/Infrastructure/Persistence/Blob/SqliteBlobRepositoryTest.php similarity index 53% rename from tests/Infrastructure/Persistence/Blob/InMemoryBlobRepositoryTest.php rename to tests/Infrastructure/Persistence/Blob/SqliteBlobRepositoryTest.php index b7c338d..1c9d02b 100644 --- a/tests/Infrastructure/Persistence/Blob/InMemoryBlobRepositoryTest.php +++ b/tests/Infrastructure/Persistence/Blob/SqliteBlobRepositoryTest.php @@ -6,12 +6,22 @@ namespace Tests\Infrastructure\Persistence\Blob; use App\Domain\Blob\Blob; use App\Domain\Blob\BlobNotFoundException; -use App\Infrastructure\Persistence\Blob\InMemoryBlobRepository; +use App\Infrastructure\Database\Database; +use App\Infrastructure\Database\Schema\ActorStoreSchema; +use App\Infrastructure\Persistence\Blob\SqliteBlobRepository; use DateTimeImmutable; use Tests\TestCase; -class InMemoryBlobRepositoryTest extends TestCase +class SqliteBlobRepositoryTest extends TestCase { + private function newRepo(): SqliteBlobRepository + { + $db = new Database(':memory:'); + ActorStoreSchema::apply($db); + + return new SqliteBlobRepository($db); + } + private function makeBlob( string $cid = 'bafyreib', ?string $tempKey = null, @@ -27,15 +37,16 @@ class InMemoryBlobRepositoryTest extends TestCase public function testFindByCid(): void { - $blob = $this->makeBlob(); - $repo = new InMemoryBlobRepository([$blob]); + $repo = $this->newRepo(); + $repo->save($this->makeBlob()); - $this->assertSame($blob, $repo->findByCid('bafyreib')); + $found = $repo->findByCid('bafyreib'); + $this->assertSame('bafyreib', $found->getCid()); } public function testFindByCidThrowsWhenMissing(): void { - $repo = new InMemoryBlobRepository(); + $repo = $this->newRepo(); $this->expectException(BlobNotFoundException::class); $repo->findByCid('nope'); @@ -43,9 +54,9 @@ class InMemoryBlobRepositoryTest extends TestCase public function testFindTemporary(): void { - $perm = $this->makeBlob(cid: 'cid1', tempKey: null); - $temp = $this->makeBlob(cid: 'cid2', tempKey: 'tmpkey123'); - $repo = new InMemoryBlobRepository([$perm, $temp]); + $repo = $this->newRepo(); + $repo->save($this->makeBlob(cid: 'cid1', tempKey: null)); + $repo->save($this->makeBlob(cid: 'cid2', tempKey: 'tmpkey123')); $temps = $repo->findTemporary(); $this->assertCount(1, $temps); @@ -54,18 +65,18 @@ class InMemoryBlobRepositoryTest extends TestCase public function testFindAll(): void { - $b1 = $this->makeBlob(cid: 'c1'); - $b2 = $this->makeBlob(cid: 'c2'); - $repo = new InMemoryBlobRepository([$b1, $b2]); + $repo = $this->newRepo(); + $repo->save($this->makeBlob(cid: 'c1')); + $repo->save($this->makeBlob(cid: 'c2')); $this->assertCount(2, $repo->findAll()); } public function testDeleteAll(): void { - $b1 = $this->makeBlob(cid: 'c1'); - $b2 = $this->makeBlob(cid: 'c2'); - $repo = new InMemoryBlobRepository([$b1, $b2]); + $repo = $this->newRepo(); + $repo->save($this->makeBlob(cid: 'c1')); + $repo->save($this->makeBlob(cid: 'c2')); $repo->deleteAll(); $this->assertEmpty($repo->findAll()); diff --git a/tests/Infrastructure/Persistence/Did/InMemoryDidCacheRepositoryTest.php b/tests/Infrastructure/Persistence/Did/SqliteDidCacheRepositoryTest.php similarity index 74% rename from tests/Infrastructure/Persistence/Did/InMemoryDidCacheRepositoryTest.php rename to tests/Infrastructure/Persistence/Did/SqliteDidCacheRepositoryTest.php index 70cfcae..bb3d8a8 100644 --- a/tests/Infrastructure/Persistence/Did/InMemoryDidCacheRepositoryTest.php +++ b/tests/Infrastructure/Persistence/Did/SqliteDidCacheRepositoryTest.php @@ -5,14 +5,24 @@ declare(strict_types=1); namespace Tests\Infrastructure\Persistence\Did; use App\Domain\Did\DidDocEntryNotFoundException; -use App\Infrastructure\Persistence\Did\InMemoryDidCacheRepository; +use App\Infrastructure\Database\Database; +use App\Infrastructure\Database\Schema\DidCacheSchema; +use App\Infrastructure\Persistence\Did\SqliteDidCacheRepository; use Tests\TestCase; -class InMemoryDidCacheRepositoryTest extends TestCase +class SqliteDidCacheRepositoryTest extends TestCase { + private function newRepo(): SqliteDidCacheRepository + { + $db = new Database(':memory:'); + DidCacheSchema::apply($db); + + return new SqliteDidCacheRepository($db); + } + public function testSetAndGet(): void { - $repo = new InMemoryDidCacheRepository(); + $repo = $this->newRepo(); $doc = ['id' => 'did:plc:alice', '@context' => ['https://www.w3.org/ns/did/v1']]; $repo->set('did:plc:alice', $doc); @@ -23,7 +33,7 @@ class InMemoryDidCacheRepositoryTest extends TestCase public function testGetThrowsWhenMissing(): void { - $repo = new InMemoryDidCacheRepository(); + $repo = $this->newRepo(); $this->expectException(DidDocEntryNotFoundException::class); $repo->get('did:plc:missing'); @@ -31,7 +41,7 @@ class InMemoryDidCacheRepositoryTest extends TestCase public function testHas(): void { - $repo = new InMemoryDidCacheRepository(); + $repo = $this->newRepo(); $this->assertFalse($repo->has('did:plc:alice')); $repo->set('did:plc:alice', ['id' => 'did:plc:alice']); $this->assertTrue($repo->has('did:plc:alice')); @@ -39,7 +49,7 @@ class InMemoryDidCacheRepositoryTest extends TestCase public function testClear(): void { - $repo = new InMemoryDidCacheRepository(); + $repo = $this->newRepo(); $repo->set('did:plc:alice', ['id' => 'did:plc:alice']); $repo->clear('did:plc:alice'); @@ -48,7 +58,7 @@ class InMemoryDidCacheRepositoryTest extends TestCase public function testClearAll(): void { - $repo = new InMemoryDidCacheRepository(); + $repo = $this->newRepo(); $repo->set('did:plc:alice', ['id' => 'did:plc:alice']); $repo->set('did:plc:bob', ['id' => 'did:plc:bob']); $repo->clearAll(); @@ -59,7 +69,7 @@ class InMemoryDidCacheRepositoryTest extends TestCase public function testSetUpdatesExistingEntry(): void { - $repo = new InMemoryDidCacheRepository(); + $repo = $this->newRepo(); $repo->set('did:plc:alice', ['id' => 'did:plc:alice', 'version' => 1]); $repo->set('did:plc:alice', ['id' => 'did:plc:alice', 'version' => 2]); diff --git a/tests/Infrastructure/Persistence/Lexicon/InMemoryLexiconRepositoryTest.php b/tests/Infrastructure/Persistence/Lexicon/SqliteLexiconRepositoryTest.php similarity index 66% rename from tests/Infrastructure/Persistence/Lexicon/InMemoryLexiconRepositoryTest.php rename to tests/Infrastructure/Persistence/Lexicon/SqliteLexiconRepositoryTest.php index b466775..c34e26c 100644 --- a/tests/Infrastructure/Persistence/Lexicon/InMemoryLexiconRepositoryTest.php +++ b/tests/Infrastructure/Persistence/Lexicon/SqliteLexiconRepositoryTest.php @@ -6,12 +6,22 @@ namespace Tests\Infrastructure\Persistence\Lexicon; use App\Domain\Lexicon\LexiconEntry; use App\Domain\Lexicon\LexiconEntryNotFoundException; -use App\Infrastructure\Persistence\Lexicon\InMemoryLexiconRepository; +use App\Infrastructure\Database\Database; +use App\Infrastructure\Database\Schema\AccountSchema; +use App\Infrastructure\Persistence\Lexicon\SqliteLexiconRepository; use DateTimeImmutable; use Tests\TestCase; -class InMemoryLexiconRepositoryTest extends TestCase +class SqliteLexiconRepositoryTest extends TestCase { + private function newRepo(): SqliteLexiconRepository + { + $db = new Database(':memory:'); + AccountSchema::apply($db); + + return new SqliteLexiconRepository($db); + } + private function makeEntry(string $nsid = 'com.example.foo'): LexiconEntry { return new LexiconEntry( @@ -26,15 +36,16 @@ class InMemoryLexiconRepositoryTest extends TestCase public function testFindByNsid(): void { - $entry = $this->makeEntry(); - $repo = new InMemoryLexiconRepository([$entry]); + $repo = $this->newRepo(); + $repo->save($this->makeEntry()); - $this->assertSame($entry, $repo->findByNsid('com.example.foo')); + $found = $repo->findByNsid('com.example.foo'); + $this->assertSame('com.example.foo', $found->getNsid()); } public function testFindByNsidThrowsWhenMissing(): void { - $repo = new InMemoryLexiconRepository(); + $repo = $this->newRepo(); $this->expectException(LexiconEntryNotFoundException::class); $repo->findByNsid('nope'); @@ -42,17 +53,16 @@ class InMemoryLexiconRepositoryTest extends TestCase public function testFindAll(): void { - $repo = new InMemoryLexiconRepository([ - $this->makeEntry('com.example.a'), - $this->makeEntry('com.example.b'), - ]); + $repo = $this->newRepo(); + $repo->save($this->makeEntry('com.example.a')); + $repo->save($this->makeEntry('com.example.b')); $this->assertCount(2, $repo->findAll()); } public function testSaveInsertsAndUpdates(): void { - $repo = new InMemoryLexiconRepository(); + $repo = $this->newRepo(); $repo->save($this->makeEntry('com.example.a')); $this->assertCount(1, $repo->findAll()); @@ -72,7 +82,8 @@ class InMemoryLexiconRepositoryTest extends TestCase public function testDeleteByNsid(): void { - $repo = new InMemoryLexiconRepository([$this->makeEntry()]); + $repo = $this->newRepo(); + $repo->save($this->makeEntry()); $repo->deleteByNsid('com.example.foo'); $this->assertCount(0, $repo->findAll()); diff --git a/tests/Infrastructure/Persistence/OAuth/InMemoryAccountDeviceRepositoryTest.php b/tests/Infrastructure/Persistence/OAuth/SqliteAccountDeviceRepositoryTest.php similarity index 66% rename from tests/Infrastructure/Persistence/OAuth/InMemoryAccountDeviceRepositoryTest.php rename to tests/Infrastructure/Persistence/OAuth/SqliteAccountDeviceRepositoryTest.php index 389dcd2..b9d1307 100644 --- a/tests/Infrastructure/Persistence/OAuth/InMemoryAccountDeviceRepositoryTest.php +++ b/tests/Infrastructure/Persistence/OAuth/SqliteAccountDeviceRepositoryTest.php @@ -6,12 +6,22 @@ namespace Tests\Infrastructure\Persistence\OAuth; use App\Domain\OAuth\AccountDevice; use App\Domain\OAuth\AccountDeviceNotFoundException; -use App\Infrastructure\Persistence\OAuth\InMemoryAccountDeviceRepository; +use App\Infrastructure\Database\Database; +use App\Infrastructure\Database\Schema\AccountSchema; +use App\Infrastructure\Persistence\OAuth\SqliteAccountDeviceRepository; use DateTimeImmutable; use Tests\TestCase; -class InMemoryAccountDeviceRepositoryTest extends TestCase +class SqliteAccountDeviceRepositoryTest extends TestCase { + private function newRepo(): SqliteAccountDeviceRepository + { + $db = new Database(':memory:'); + AccountSchema::apply($db); + + return new SqliteAccountDeviceRepository($db); + } + private function makeEntry(string $did = 'did:plc:alice', string $deviceId = 'dev-1'): AccountDevice { return new AccountDevice( @@ -24,15 +34,16 @@ class InMemoryAccountDeviceRepositoryTest extends TestCase public function testFindByDidAndDeviceId(): void { - $entry = $this->makeEntry(); - $repo = new InMemoryAccountDeviceRepository([$entry]); + $repo = $this->newRepo(); + $repo->save($this->makeEntry()); - $this->assertSame($entry, $repo->findByDidAndDeviceId('did:plc:alice', 'dev-1')); + $found = $repo->findByDidAndDeviceId('did:plc:alice', 'dev-1'); + $this->assertSame('dev-1', $found->getDeviceId()); } public function testFindByDidAndDeviceIdThrowsWhenMissing(): void { - $repo = new InMemoryAccountDeviceRepository(); + $repo = $this->newRepo(); $this->expectException(AccountDeviceNotFoundException::class); $repo->findByDidAndDeviceId('did:plc:alice', 'dev-1'); @@ -40,11 +51,10 @@ class InMemoryAccountDeviceRepositoryTest extends TestCase public function testFindAllForDid(): void { - $repo = new InMemoryAccountDeviceRepository([ - $this->makeEntry('did:plc:alice', 'dev-1'), - $this->makeEntry('did:plc:alice', 'dev-2'), - $this->makeEntry('did:plc:bob', 'dev-3'), - ]); + $repo = $this->newRepo(); + $repo->save($this->makeEntry('did:plc:alice', 'dev-1')); + $repo->save($this->makeEntry('did:plc:alice', 'dev-2')); + $repo->save($this->makeEntry('did:plc:bob', 'dev-3')); $this->assertCount(2, $repo->findAllForDid('did:plc:alice')); $this->assertCount(1, $repo->findAllForDid('did:plc:bob')); @@ -53,7 +63,7 @@ class InMemoryAccountDeviceRepositoryTest extends TestCase public function testSaveInsertsNewAndUpdatesExisting(): void { - $repo = new InMemoryAccountDeviceRepository(); + $repo = $this->newRepo(); $repo->save($this->makeEntry()); $this->assertCount(1, $repo->findAllForDid('did:plc:alice')); @@ -74,10 +84,9 @@ class InMemoryAccountDeviceRepositoryTest extends TestCase public function testDeleteByDidAndDeviceId(): void { - $repo = new InMemoryAccountDeviceRepository([ - $this->makeEntry('did:plc:alice', 'dev-1'), - $this->makeEntry('did:plc:alice', 'dev-2'), - ]); + $repo = $this->newRepo(); + $repo->save($this->makeEntry('did:plc:alice', 'dev-1')); + $repo->save($this->makeEntry('did:plc:alice', 'dev-2')); $repo->deleteByDidAndDeviceId('did:plc:alice', 'dev-1'); diff --git a/tests/Infrastructure/Persistence/OAuth/InMemoryAuthorizationRequestRepositoryTest.php b/tests/Infrastructure/Persistence/OAuth/SqliteAuthorizationRequestRepositoryTest.php similarity index 60% rename from tests/Infrastructure/Persistence/OAuth/InMemoryAuthorizationRequestRepositoryTest.php rename to tests/Infrastructure/Persistence/OAuth/SqliteAuthorizationRequestRepositoryTest.php index 97d99e1..9c41f33 100644 --- a/tests/Infrastructure/Persistence/OAuth/InMemoryAuthorizationRequestRepositoryTest.php +++ b/tests/Infrastructure/Persistence/OAuth/SqliteAuthorizationRequestRepositoryTest.php @@ -6,12 +6,22 @@ namespace Tests\Infrastructure\Persistence\OAuth; use App\Domain\OAuth\AuthorizationRequest; use App\Domain\OAuth\AuthorizationRequestNotFoundException; -use App\Infrastructure\Persistence\OAuth\InMemoryAuthorizationRequestRepository; +use App\Infrastructure\Database\Database; +use App\Infrastructure\Database\Schema\AccountSchema; +use App\Infrastructure\Persistence\OAuth\SqliteAuthorizationRequestRepository; use DateTimeImmutable; use Tests\TestCase; -class InMemoryAuthorizationRequestRepositoryTest extends TestCase +class SqliteAuthorizationRequestRepositoryTest extends TestCase { + private function newRepo(): SqliteAuthorizationRequestRepository + { + $db = new Database(':memory:'); + AccountSchema::apply($db); + + return new SqliteAuthorizationRequestRepository($db); + } + private function makeRequest( string $id = 'req-1', ?string $code = 'auth-code', @@ -31,16 +41,19 @@ class InMemoryAuthorizationRequestRepositoryTest extends TestCase public function testFindByIdAndFindByCode(): void { - $req = $this->makeRequest(); - $repo = new InMemoryAuthorizationRequestRepository([$req]); + $repo = $this->newRepo(); + $repo->save($this->makeRequest()); + + $byId = $repo->findById('req-1'); + $byCode = $repo->findByCode('auth-code'); - $this->assertSame($req, $repo->findById('req-1')); - $this->assertSame($req, $repo->findByCode('auth-code')); + $this->assertSame('req-1', $byId->getId()); + $this->assertSame('req-1', $byCode->getId()); } public function testFindByIdThrowsWhenMissing(): void { - $repo = new InMemoryAuthorizationRequestRepository(); + $repo = $this->newRepo(); $this->expectException(AuthorizationRequestNotFoundException::class); $repo->findById('nope'); @@ -48,7 +61,8 @@ class InMemoryAuthorizationRequestRepositoryTest extends TestCase public function testFindByCodeThrowsWhenMissing(): void { - $repo = new InMemoryAuthorizationRequestRepository([$this->makeRequest(code: null)]); + $repo = $this->newRepo(); + $repo->save($this->makeRequest(code: null)); $this->expectException(AuthorizationRequestNotFoundException::class); $repo->findByCode('nope'); @@ -56,7 +70,7 @@ class InMemoryAuthorizationRequestRepositoryTest extends TestCase public function testSaveInsertsAndUpdates(): void { - $repo = new InMemoryAuthorizationRequestRepository(); + $repo = $this->newRepo(); $repo->save($this->makeRequest(id: 'req-1', code: 'a')); $repo->save($this->makeRequest(id: 'req-1', code: 'b')); @@ -65,7 +79,8 @@ class InMemoryAuthorizationRequestRepositoryTest extends TestCase public function testDeleteById(): void { - $repo = new InMemoryAuthorizationRequestRepository([$this->makeRequest()]); + $repo = $this->newRepo(); + $repo->save($this->makeRequest()); $repo->deleteById('req-1'); $this->expectException(AuthorizationRequestNotFoundException::class); @@ -74,14 +89,16 @@ class InMemoryAuthorizationRequestRepositoryTest extends TestCase public function testDeleteExpiredRemovesPastEntriesOnly(): void { - $past = $this->makeRequest(id: 'expired', expiresAt: new DateTimeImmutable('-1 hour')); - $future = $this->makeRequest(id: 'live', expiresAt: new DateTimeImmutable('+1 hour')); - $repo = new InMemoryAuthorizationRequestRepository([$past, $future]); + $repo = $this->newRepo(); + $repo->save($this->makeRequest(id: 'expired', expiresAt: new DateTimeImmutable('-1 hour'))); + $repo->save($this->makeRequest(id: 'live', expiresAt: new DateTimeImmutable('+1 hour'))); $removed = $repo->deleteExpired(); $this->assertSame(1, $removed); - $this->assertSame($future, $repo->findById('live')); + $live = $repo->findById('live'); + $this->assertSame('live', $live->getId()); + $this->expectException(AuthorizationRequestNotFoundException::class); $repo->findById('expired'); } diff --git a/tests/Infrastructure/Persistence/OAuth/InMemoryAuthorizedClientRepositoryTest.php b/tests/Infrastructure/Persistence/OAuth/SqliteAuthorizedClientRepositoryTest.php similarity index 62% rename from tests/Infrastructure/Persistence/OAuth/InMemoryAuthorizedClientRepositoryTest.php rename to tests/Infrastructure/Persistence/OAuth/SqliteAuthorizedClientRepositoryTest.php index 2059f53..9003950 100644 --- a/tests/Infrastructure/Persistence/OAuth/InMemoryAuthorizedClientRepositoryTest.php +++ b/tests/Infrastructure/Persistence/OAuth/SqliteAuthorizedClientRepositoryTest.php @@ -6,12 +6,22 @@ namespace Tests\Infrastructure\Persistence\OAuth; use App\Domain\OAuth\AuthorizedClient; use App\Domain\OAuth\AuthorizedClientNotFoundException; -use App\Infrastructure\Persistence\OAuth\InMemoryAuthorizedClientRepository; +use App\Infrastructure\Database\Database; +use App\Infrastructure\Database\Schema\AccountSchema; +use App\Infrastructure\Persistence\OAuth\SqliteAuthorizedClientRepository; use DateTimeImmutable; use Tests\TestCase; -class InMemoryAuthorizedClientRepositoryTest extends TestCase +class SqliteAuthorizedClientRepositoryTest extends TestCase { + private function newRepo(): SqliteAuthorizedClientRepository + { + $db = new Database(':memory:'); + AccountSchema::apply($db); + + return new SqliteAuthorizedClientRepository($db); + } + private function makeEntry( string $did = 'did:plc:alice', string $clientId = 'https://app.test/client.json', @@ -27,15 +37,16 @@ class InMemoryAuthorizedClientRepositoryTest extends TestCase public function testFindByDidAndClientId(): void { - $entry = $this->makeEntry(); - $repo = new InMemoryAuthorizedClientRepository([$entry]); + $repo = $this->newRepo(); + $repo->save($this->makeEntry()); - $this->assertSame($entry, $repo->findByDidAndClientId('did:plc:alice', 'https://app.test/client.json')); + $found = $repo->findByDidAndClientId('did:plc:alice', 'https://app.test/client.json'); + $this->assertSame('did:plc:alice', $found->getDid()); } public function testFindByDidAndClientIdThrowsWhenMissing(): void { - $repo = new InMemoryAuthorizedClientRepository(); + $repo = $this->newRepo(); $this->expectException(AuthorizedClientNotFoundException::class); $repo->findByDidAndClientId('did:plc:alice', 'nope'); @@ -43,11 +54,10 @@ class InMemoryAuthorizedClientRepositoryTest extends TestCase public function testFindAllForDid(): void { - $repo = new InMemoryAuthorizedClientRepository([ - $this->makeEntry('did:plc:alice', 'c1'), - $this->makeEntry('did:plc:alice', 'c2'), - $this->makeEntry('did:plc:bob', 'c3'), - ]); + $repo = $this->newRepo(); + $repo->save($this->makeEntry('did:plc:alice', 'c1')); + $repo->save($this->makeEntry('did:plc:alice', 'c2')); + $repo->save($this->makeEntry('did:plc:bob', 'c3')); $this->assertCount(2, $repo->findAllForDid('did:plc:alice')); $this->assertCount(1, $repo->findAllForDid('did:plc:bob')); @@ -55,7 +65,7 @@ class InMemoryAuthorizedClientRepositoryTest extends TestCase public function testSaveInsertsAndUpdates(): void { - $repo = new InMemoryAuthorizedClientRepository(); + $repo = $this->newRepo(); $repo->save($this->makeEntry()); $this->assertCount(1, $repo->findAllForDid('did:plc:alice')); @@ -71,16 +81,15 @@ class InMemoryAuthorizedClientRepositoryTest extends TestCase $this->assertCount(1, $repo->findAllForDid('did:plc:alice')); $this->assertSame( ['new' => true], - $repo->findByDidAndClientId('did:plc:alice', 'https://app.test/client.json')->getData() + $repo->findByDidAndClientId('did:plc:alice', 'https://app.test/client.json')->getData(), ); } public function testDeleteByDidAndClientId(): void { - $repo = new InMemoryAuthorizedClientRepository([ - $this->makeEntry('did:plc:alice', 'c1'), - $this->makeEntry('did:plc:alice', 'c2'), - ]); + $repo = $this->newRepo(); + $repo->save($this->makeEntry('did:plc:alice', 'c1')); + $repo->save($this->makeEntry('did:plc:alice', 'c2')); $repo->deleteByDidAndClientId('did:plc:alice', 'c1'); @@ -89,11 +98,10 @@ class InMemoryAuthorizedClientRepositoryTest extends TestCase public function testDeleteAllForDid(): void { - $repo = new InMemoryAuthorizedClientRepository([ - $this->makeEntry('did:plc:alice', 'c1'), - $this->makeEntry('did:plc:alice', 'c2'), - $this->makeEntry('did:plc:bob', 'c3'), - ]); + $repo = $this->newRepo(); + $repo->save($this->makeEntry('did:plc:alice', 'c1')); + $repo->save($this->makeEntry('did:plc:alice', 'c2')); + $repo->save($this->makeEntry('did:plc:bob', 'c3')); $repo->deleteAllForDid('did:plc:alice'); diff --git a/tests/Infrastructure/Persistence/OAuth/InMemoryDeviceRepositoryTest.php b/tests/Infrastructure/Persistence/OAuth/SqliteDeviceRepositoryTest.php similarity index 66% rename from tests/Infrastructure/Persistence/OAuth/InMemoryDeviceRepositoryTest.php rename to tests/Infrastructure/Persistence/OAuth/SqliteDeviceRepositoryTest.php index d658118..c075570 100644 --- a/tests/Infrastructure/Persistence/OAuth/InMemoryDeviceRepositoryTest.php +++ b/tests/Infrastructure/Persistence/OAuth/SqliteDeviceRepositoryTest.php @@ -6,12 +6,22 @@ namespace Tests\Infrastructure\Persistence\OAuth; use App\Domain\OAuth\Device; use App\Domain\OAuth\DeviceNotFoundException; -use App\Infrastructure\Persistence\OAuth\InMemoryDeviceRepository; +use App\Infrastructure\Database\Database; +use App\Infrastructure\Database\Schema\AccountSchema; +use App\Infrastructure\Persistence\OAuth\SqliteDeviceRepository; use DateTimeImmutable; use Tests\TestCase; -class InMemoryDeviceRepositoryTest extends TestCase +class SqliteDeviceRepositoryTest extends TestCase { + private function newRepo(): SqliteDeviceRepository + { + $db = new Database(':memory:'); + AccountSchema::apply($db); + + return new SqliteDeviceRepository($db); + } + private function makeDevice(string $id = 'dev-1'): Device { return new Device( @@ -25,15 +35,16 @@ class InMemoryDeviceRepositoryTest extends TestCase public function testFindById(): void { - $device = $this->makeDevice(); - $repo = new InMemoryDeviceRepository([$device]); + $repo = $this->newRepo(); + $repo->save($this->makeDevice()); - $this->assertSame($device, $repo->findById('dev-1')); + $found = $repo->findById('dev-1'); + $this->assertSame('dev-1', $found->getId()); } public function testFindByIdThrowsWhenMissing(): void { - $repo = new InMemoryDeviceRepository(); + $repo = $this->newRepo(); $this->expectException(DeviceNotFoundException::class); $repo->findById('nope'); @@ -41,7 +52,7 @@ class InMemoryDeviceRepositoryTest extends TestCase public function testSaveInsertsAndUpdates(): void { - $repo = new InMemoryDeviceRepository(); + $repo = $this->newRepo(); $repo->save($this->makeDevice()); $updated = new Device( @@ -58,7 +69,8 @@ class InMemoryDeviceRepositoryTest extends TestCase public function testDeleteById(): void { - $repo = new InMemoryDeviceRepository([$this->makeDevice()]); + $repo = $this->newRepo(); + $repo->save($this->makeDevice()); $repo->deleteById('dev-1'); $this->expectException(DeviceNotFoundException::class); diff --git a/tests/Infrastructure/Persistence/OAuth/InMemoryOAuthTokenRepositoryTest.php b/tests/Infrastructure/Persistence/OAuth/SqliteOAuthTokenRepositoryTest.php similarity index 62% rename from tests/Infrastructure/Persistence/OAuth/InMemoryOAuthTokenRepositoryTest.php rename to tests/Infrastructure/Persistence/OAuth/SqliteOAuthTokenRepositoryTest.php index 5a62467..8c8fbfc 100644 --- a/tests/Infrastructure/Persistence/OAuth/InMemoryOAuthTokenRepositoryTest.php +++ b/tests/Infrastructure/Persistence/OAuth/SqliteOAuthTokenRepositoryTest.php @@ -6,21 +6,30 @@ namespace Tests\Infrastructure\Persistence\OAuth; use App\Domain\OAuth\OAuthToken; use App\Domain\OAuth\OAuthTokenNotFoundException; -use App\Infrastructure\Persistence\OAuth\InMemoryOAuthTokenRepository; +use App\Infrastructure\Database\Database; +use App\Infrastructure\Database\Schema\AccountSchema; +use App\Infrastructure\Persistence\OAuth\SqliteOAuthTokenRepository; use DateTimeImmutable; use Tests\TestCase; -class InMemoryOAuthTokenRepositoryTest extends TestCase +class SqliteOAuthTokenRepositoryTest extends TestCase { + private function newRepo(): SqliteOAuthTokenRepository + { + $db = new Database(':memory:'); + AccountSchema::apply($db); + + return new SqliteOAuthTokenRepository($db); + } + private function makeToken( - int $id = 1, string $tokenId = 'tok-1', string $did = 'did:plc:alice', ?string $code = 'code-1', ?string $refresh = 'refresh-1', ): OAuthToken { return new OAuthToken( - id: $id, + id: 0, did: $did, tokenId: $tokenId, createdAt: new DateTimeImmutable('2026-01-01T00:00:00Z'), @@ -39,15 +48,16 @@ class InMemoryOAuthTokenRepositoryTest extends TestCase public function testFindByTokenId(): void { - $token = $this->makeToken(); - $repo = new InMemoryOAuthTokenRepository([$token]); + $repo = $this->newRepo(); + $repo->save($this->makeToken()); - $this->assertSame($token, $repo->findByTokenId('tok-1')); + $found = $repo->findByTokenId('tok-1'); + $this->assertSame('tok-1', $found->getTokenId()); } public function testFindByTokenIdThrowsWhenMissing(): void { - $repo = new InMemoryOAuthTokenRepository(); + $repo = $this->newRepo(); $this->expectException(OAuthTokenNotFoundException::class); $repo->findByTokenId('nope'); @@ -55,14 +65,16 @@ class InMemoryOAuthTokenRepositoryTest extends TestCase public function testFindByCode(): void { - $repo = new InMemoryOAuthTokenRepository([$this->makeToken()]); + $repo = $this->newRepo(); + $repo->save($this->makeToken()); $this->assertSame('tok-1', $repo->findByCode('code-1')->getTokenId()); } public function testFindByCodeThrowsWhenMissing(): void { - $repo = new InMemoryOAuthTokenRepository([$this->makeToken(code: null)]); + $repo = $this->newRepo(); + $repo->save($this->makeToken(code: null)); $this->expectException(OAuthTokenNotFoundException::class); $repo->findByCode('nope'); @@ -70,14 +82,16 @@ class InMemoryOAuthTokenRepositoryTest extends TestCase public function testFindByRefreshToken(): void { - $repo = new InMemoryOAuthTokenRepository([$this->makeToken()]); + $repo = $this->newRepo(); + $repo->save($this->makeToken()); $this->assertSame('tok-1', $repo->findByRefreshToken('refresh-1')->getTokenId()); } public function testFindByRefreshTokenThrowsWhenMissing(): void { - $repo = new InMemoryOAuthTokenRepository([$this->makeToken(refresh: null)]); + $repo = $this->newRepo(); + $repo->save($this->makeToken(refresh: null)); $this->expectException(OAuthTokenNotFoundException::class); $repo->findByRefreshToken('nope'); @@ -85,11 +99,10 @@ class InMemoryOAuthTokenRepositoryTest extends TestCase public function testFindAllForDid(): void { - $repo = new InMemoryOAuthTokenRepository([ - $this->makeToken(id: 1, tokenId: 't1', did: 'did:plc:alice', code: 'c1', refresh: 'r1'), - $this->makeToken(id: 2, tokenId: 't2', did: 'did:plc:alice', code: 'c2', refresh: 'r2'), - $this->makeToken(id: 3, tokenId: 't3', did: 'did:plc:bob', code: 'c3', refresh: 'r3'), - ]); + $repo = $this->newRepo(); + $repo->save($this->makeToken(tokenId: 't1', did: 'did:plc:alice', code: 'c1', refresh: 'r1')); + $repo->save($this->makeToken(tokenId: 't2', did: 'did:plc:alice', code: 'c2', refresh: 'r2')); + $repo->save($this->makeToken(tokenId: 't3', did: 'did:plc:bob', code: 'c3', refresh: 'r3')); $this->assertCount(2, $repo->findAllForDid('did:plc:alice')); $this->assertCount(1, $repo->findAllForDid('did:plc:bob')); @@ -97,7 +110,7 @@ class InMemoryOAuthTokenRepositoryTest extends TestCase public function testSaveInsertsAndUpdates(): void { - $repo = new InMemoryOAuthTokenRepository(); + $repo = $this->newRepo(); $repo->save($this->makeToken()); $repo->save($this->makeToken(code: 'updated')); @@ -106,7 +119,8 @@ class InMemoryOAuthTokenRepositoryTest extends TestCase public function testDeleteByTokenId(): void { - $repo = new InMemoryOAuthTokenRepository([$this->makeToken()]); + $repo = $this->newRepo(); + $repo->save($this->makeToken()); $repo->deleteByTokenId('tok-1'); $this->expectException(OAuthTokenNotFoundException::class); @@ -115,10 +129,9 @@ class InMemoryOAuthTokenRepositoryTest extends TestCase public function testDeleteAllForDid(): void { - $repo = new InMemoryOAuthTokenRepository([ - $this->makeToken(id: 1, tokenId: 't1', did: 'did:plc:alice', code: 'c1', refresh: 'r1'), - $this->makeToken(id: 2, tokenId: 't2', did: 'did:plc:bob', code: 'c2', refresh: 'r2'), - ]); + $repo = $this->newRepo(); + $repo->save($this->makeToken(tokenId: 't1', did: 'did:plc:alice', code: 'c1', refresh: 'r1')); + $repo->save($this->makeToken(tokenId: 't2', did: 'did:plc:bob', code: 'c2', refresh: 'r2')); $repo->deleteAllForDid('did:plc:alice'); diff --git a/tests/Infrastructure/Persistence/OAuth/InMemoryUsedRefreshTokenRepositoryTest.php b/tests/Infrastructure/Persistence/OAuth/SqliteUsedRefreshTokenRepositoryTest.php similarity index 54% rename from tests/Infrastructure/Persistence/OAuth/InMemoryUsedRefreshTokenRepositoryTest.php rename to tests/Infrastructure/Persistence/OAuth/SqliteUsedRefreshTokenRepositoryTest.php index c1d2449..6ec6d67 100644 --- a/tests/Infrastructure/Persistence/OAuth/InMemoryUsedRefreshTokenRepositoryTest.php +++ b/tests/Infrastructure/Persistence/OAuth/SqliteUsedRefreshTokenRepositoryTest.php @@ -5,16 +5,25 @@ declare(strict_types=1); namespace Tests\Infrastructure\Persistence\OAuth; use App\Domain\OAuth\UsedRefreshToken; -use App\Infrastructure\Persistence\OAuth\InMemoryUsedRefreshTokenRepository; +use App\Infrastructure\Database\Database; +use App\Infrastructure\Database\Schema\AccountSchema; +use App\Infrastructure\Persistence\OAuth\SqliteUsedRefreshTokenRepository; use Tests\TestCase; -class InMemoryUsedRefreshTokenRepositoryTest extends TestCase +class SqliteUsedRefreshTokenRepositoryTest extends TestCase { - public function testExistsReturnsTrueForSeededTokens(): void + private function newRepo(): SqliteUsedRefreshTokenRepository { - $repo = new InMemoryUsedRefreshTokenRepository([ - new UsedRefreshToken(1, 'refresh-a'), - ]); + $db = new Database(':memory:'); + AccountSchema::apply($db); + + return new SqliteUsedRefreshTokenRepository($db); + } + + public function testExistsReturnsTrueForSavedTokens(): void + { + $repo = $this->newRepo(); + $repo->save(new UsedRefreshToken(1, 'refresh-a')); $this->assertTrue($repo->exists('refresh-a')); $this->assertFalse($repo->exists('refresh-b')); @@ -22,7 +31,7 @@ class InMemoryUsedRefreshTokenRepositoryTest extends TestCase public function testSaveMakesTokenExist(): void { - $repo = new InMemoryUsedRefreshTokenRepository(); + $repo = $this->newRepo(); $repo->save(new UsedRefreshToken(1, 'refresh-a')); $this->assertTrue($repo->exists('refresh-a')); @@ -30,9 +39,8 @@ class InMemoryUsedRefreshTokenRepositoryTest extends TestCase public function testSaveOverwritesPriorEntryForSameRefreshTokenString(): void { - $repo = new InMemoryUsedRefreshTokenRepository([ - new UsedRefreshToken(1, 'refresh-a'), - ]); + $repo = $this->newRepo(); + $repo->save(new UsedRefreshToken(1, 'refresh-a')); $repo->save(new UsedRefreshToken(2, 'refresh-a')); $this->assertTrue($repo->exists('refresh-a')); @@ -43,11 +51,10 @@ class InMemoryUsedRefreshTokenRepositoryTest extends TestCase public function testDeleteAllForTokenId(): void { - $repo = new InMemoryUsedRefreshTokenRepository([ - new UsedRefreshToken(1, 'refresh-a'), - new UsedRefreshToken(1, 'refresh-b'), - new UsedRefreshToken(2, 'refresh-c'), - ]); + $repo = $this->newRepo(); + $repo->save(new UsedRefreshToken(1, 'refresh-a')); + $repo->save(new UsedRefreshToken(1, 'refresh-b')); + $repo->save(new UsedRefreshToken(2, 'refresh-c')); $repo->deleteAllForTokenId(1); diff --git a/tests/Infrastructure/Persistence/Preference/InMemoryAccountPrefRepositoryTest.php b/tests/Infrastructure/Persistence/Preference/SqliteAccountPrefRepositoryTest.php similarity index 74% rename from tests/Infrastructure/Persistence/Preference/InMemoryAccountPrefRepositoryTest.php rename to tests/Infrastructure/Persistence/Preference/SqliteAccountPrefRepositoryTest.php index 674320f..61f3893 100644 --- a/tests/Infrastructure/Persistence/Preference/InMemoryAccountPrefRepositoryTest.php +++ b/tests/Infrastructure/Persistence/Preference/SqliteAccountPrefRepositoryTest.php @@ -6,11 +6,21 @@ namespace Tests\Infrastructure\Persistence\Preference; use App\Domain\Preference\AccountPref; use App\Domain\Preference\AccountPrefNotFoundException; -use App\Infrastructure\Persistence\Preference\InMemoryAccountPrefRepository; +use App\Infrastructure\Database\Database; +use App\Infrastructure\Database\Schema\ActorStoreSchema; +use App\Infrastructure\Persistence\Preference\SqliteAccountPrefRepository; use Tests\TestCase; -class InMemoryAccountPrefRepositoryTest extends TestCase +class SqliteAccountPrefRepositoryTest extends TestCase { + private function newRepo(): SqliteAccountPrefRepository + { + $db = new Database(':memory:'); + ActorStoreSchema::apply($db); + + return new SqliteAccountPrefRepository($db); + } + private function makePref( string $name = 'muted-words', int $id = 0, @@ -20,7 +30,7 @@ class InMemoryAccountPrefRepositoryTest extends TestCase public function testSaveAssignsAutoIncrementedId(): void { - $repo = new InMemoryAccountPrefRepository(); + $repo = $this->newRepo(); $saved = $repo->save($this->makePref()); $this->assertSame(1, $saved->getId()); @@ -28,7 +38,7 @@ class InMemoryAccountPrefRepositoryTest extends TestCase public function testFindById(): void { - $repo = new InMemoryAccountPrefRepository(); + $repo = $this->newRepo(); $saved = $repo->save($this->makePref()); $result = $repo->findById($saved->getId()); @@ -37,7 +47,7 @@ class InMemoryAccountPrefRepositoryTest extends TestCase public function testFindByIdThrowsWhenMissing(): void { - $repo = new InMemoryAccountPrefRepository(); + $repo = $this->newRepo(); $this->expectException(AccountPrefNotFoundException::class); $repo->findById(999); @@ -45,7 +55,7 @@ class InMemoryAccountPrefRepositoryTest extends TestCase public function testFindByName(): void { - $repo = new InMemoryAccountPrefRepository(); + $repo = $this->newRepo(); $repo->save($this->makePref(name: 'muted-words')); $repo->save($this->makePref(name: 'muted-words')); $repo->save($this->makePref(name: 'theme')); @@ -56,7 +66,7 @@ class InMemoryAccountPrefRepositoryTest extends TestCase public function testDeleteByName(): void { - $repo = new InMemoryAccountPrefRepository(); + $repo = $this->newRepo(); $repo->save($this->makePref(name: 'muted-words')); $repo->save($this->makePref(name: 'theme')); $repo->deleteByName('muted-words'); @@ -67,7 +77,7 @@ class InMemoryAccountPrefRepositoryTest extends TestCase public function testDeleteAll(): void { - $repo = new InMemoryAccountPrefRepository(); + $repo = $this->newRepo(); $repo->save($this->makePref()); $repo->save($this->makePref(name: 'foo')); $repo->deleteAll(); diff --git a/tests/Infrastructure/Persistence/Record/InMemoryBacklinkRepositoryTest.php b/tests/Infrastructure/Persistence/Record/SqliteBacklinkRepositoryTest.php similarity index 57% rename from tests/Infrastructure/Persistence/Record/InMemoryBacklinkRepositoryTest.php rename to tests/Infrastructure/Persistence/Record/SqliteBacklinkRepositoryTest.php index 2889047..14c0132 100644 --- a/tests/Infrastructure/Persistence/Record/InMemoryBacklinkRepositoryTest.php +++ b/tests/Infrastructure/Persistence/Record/SqliteBacklinkRepositoryTest.php @@ -5,17 +5,27 @@ declare(strict_types=1); namespace Tests\Infrastructure\Persistence\Record; use App\Domain\Record\Backlink; -use App\Infrastructure\Persistence\Record\InMemoryBacklinkRepository; +use App\Infrastructure\Database\Database; +use App\Infrastructure\Database\Schema\ActorStoreSchema; +use App\Infrastructure\Persistence\Record\SqliteBacklinkRepository; use Tests\TestCase; -class InMemoryBacklinkRepositoryTest extends TestCase +class SqliteBacklinkRepositoryTest extends TestCase { + private function newRepo(): SqliteBacklinkRepository + { + $db = new Database(':memory:'); + ActorStoreSchema::apply($db); + + return new SqliteBacklinkRepository($db); + } + public function testFindByUriAndLinkTo(): void { - $a = new Backlink('at://alice/like/1', 'subject.uri', 'at://bob/post/1'); - $b = new Backlink('at://alice/like/2', 'subject.uri', 'at://bob/post/1'); - $c = new Backlink('at://alice/like/3', 'subject.uri', 'at://carol/post/9'); - $repo = new InMemoryBacklinkRepository([$a, $b, $c]); + $repo = $this->newRepo(); + $repo->save(new Backlink('at://alice/like/1', 'subject.uri', 'at://bob/post/1')); + $repo->save(new Backlink('at://alice/like/2', 'subject.uri', 'at://bob/post/1')); + $repo->save(new Backlink('at://alice/like/3', 'subject.uri', 'at://carol/post/9')); $this->assertCount(1, $repo->findByUri('at://alice/like/1')); $this->assertCount(2, $repo->findByLinkTo('at://bob/post/1')); @@ -24,7 +34,7 @@ class InMemoryBacklinkRepositoryTest extends TestCase public function testSaveIsIdempotentForDuplicateTriple(): void { - $repo = new InMemoryBacklinkRepository(); + $repo = $this->newRepo(); $bl = new Backlink('at://alice/like/1', 'subject.uri', 'at://bob/post/1'); $repo->save($bl); @@ -36,7 +46,7 @@ class InMemoryBacklinkRepositoryTest extends TestCase public function testSaveAddsWhenPathOrLinkToDiffers(): void { - $repo = new InMemoryBacklinkRepository(); + $repo = $this->newRepo(); $repo->save(new Backlink('at://alice/like/1', 'subject.uri', 'at://bob/post/1')); $repo->save(new Backlink('at://alice/like/1', 'reply.parent.uri', 'at://bob/post/1')); @@ -45,10 +55,9 @@ class InMemoryBacklinkRepositoryTest extends TestCase public function testDeleteByUri(): void { - $repo = new InMemoryBacklinkRepository([ - new Backlink('at://alice/like/1', 'subject.uri', 'at://bob/post/1'), - new Backlink('at://alice/like/2', 'subject.uri', 'at://bob/post/1'), - ]); + $repo = $this->newRepo(); + $repo->save(new Backlink('at://alice/like/1', 'subject.uri', 'at://bob/post/1')); + $repo->save(new Backlink('at://alice/like/2', 'subject.uri', 'at://bob/post/1')); $repo->deleteByUri('at://alice/like/1'); @@ -58,9 +67,8 @@ class InMemoryBacklinkRepositoryTest extends TestCase public function testDeleteAll(): void { - $repo = new InMemoryBacklinkRepository([ - new Backlink('at://alice/like/1', 'subject.uri', 'at://bob/post/1'), - ]); + $repo = $this->newRepo(); + $repo->save(new Backlink('at://alice/like/1', 'subject.uri', 'at://bob/post/1')); $repo->deleteAll(); diff --git a/tests/Infrastructure/Persistence/Record/InMemoryRecordBlobRepositoryTest.php b/tests/Infrastructure/Persistence/Record/SqliteRecordBlobRepositoryTest.php similarity index 50% rename from tests/Infrastructure/Persistence/Record/InMemoryRecordBlobRepositoryTest.php rename to tests/Infrastructure/Persistence/Record/SqliteRecordBlobRepositoryTest.php index e1e05ac..b64f067 100644 --- a/tests/Infrastructure/Persistence/Record/InMemoryRecordBlobRepositoryTest.php +++ b/tests/Infrastructure/Persistence/Record/SqliteRecordBlobRepositoryTest.php @@ -5,18 +5,27 @@ declare(strict_types=1); namespace Tests\Infrastructure\Persistence\Record; use App\Domain\Record\RecordBlob; -use App\Infrastructure\Persistence\Record\InMemoryRecordBlobRepository; +use App\Infrastructure\Database\Database; +use App\Infrastructure\Database\Schema\ActorStoreSchema; +use App\Infrastructure\Persistence\Record\SqliteRecordBlobRepository; use Tests\TestCase; -class InMemoryRecordBlobRepositoryTest extends TestCase +class SqliteRecordBlobRepositoryTest extends TestCase { + private function newRepo(): SqliteRecordBlobRepository + { + $db = new Database(':memory:'); + ActorStoreSchema::apply($db); + + return new SqliteRecordBlobRepository($db); + } + public function testFindByBlobCidAndRecordUri(): void { - $repo = new InMemoryRecordBlobRepository([ - new RecordBlob('blob-1', 'at://alice/post/1'), - new RecordBlob('blob-2', 'at://alice/post/1'), - new RecordBlob('blob-1', 'at://alice/post/2'), - ]); + $repo = $this->newRepo(); + $repo->save(new RecordBlob('blob-1', 'at://alice/post/1')); + $repo->save(new RecordBlob('blob-2', 'at://alice/post/1')); + $repo->save(new RecordBlob('blob-1', 'at://alice/post/2')); $this->assertCount(2, $repo->findByBlobCid('blob-1')); $this->assertCount(2, $repo->findByRecordUri('at://alice/post/1')); @@ -25,7 +34,7 @@ class InMemoryRecordBlobRepositoryTest extends TestCase public function testSaveIsIdempotent(): void { - $repo = new InMemoryRecordBlobRepository(); + $repo = $this->newRepo(); $repo->save(new RecordBlob('blob-1', 'at://alice/post/1')); $repo->save(new RecordBlob('blob-1', 'at://alice/post/1')); @@ -34,11 +43,10 @@ class InMemoryRecordBlobRepositoryTest extends TestCase public function testDeleteByRecordUri(): void { - $repo = new InMemoryRecordBlobRepository([ - new RecordBlob('blob-1', 'at://alice/post/1'), - new RecordBlob('blob-2', 'at://alice/post/1'), - new RecordBlob('blob-1', 'at://alice/post/2'), - ]); + $repo = $this->newRepo(); + $repo->save(new RecordBlob('blob-1', 'at://alice/post/1')); + $repo->save(new RecordBlob('blob-2', 'at://alice/post/1')); + $repo->save(new RecordBlob('blob-1', 'at://alice/post/2')); $repo->deleteByRecordUri('at://alice/post/1'); @@ -48,11 +56,10 @@ class InMemoryRecordBlobRepositoryTest extends TestCase public function testDeleteByBlobCid(): void { - $repo = new InMemoryRecordBlobRepository([ - new RecordBlob('blob-1', 'at://alice/post/1'), - new RecordBlob('blob-1', 'at://alice/post/2'), - new RecordBlob('blob-2', 'at://alice/post/3'), - ]); + $repo = $this->newRepo(); + $repo->save(new RecordBlob('blob-1', 'at://alice/post/1')); + $repo->save(new RecordBlob('blob-1', 'at://alice/post/2')); + $repo->save(new RecordBlob('blob-2', 'at://alice/post/3')); $repo->deleteByBlobCid('blob-1'); diff --git a/tests/Infrastructure/Persistence/Record/InMemoryRecordRepositoryTest.php b/tests/Infrastructure/Persistence/Record/SqliteRecordRepositoryTest.php similarity index 57% rename from tests/Infrastructure/Persistence/Record/InMemoryRecordRepositoryTest.php rename to tests/Infrastructure/Persistence/Record/SqliteRecordRepositoryTest.php index d13774c..13dcae8 100644 --- a/tests/Infrastructure/Persistence/Record/InMemoryRecordRepositoryTest.php +++ b/tests/Infrastructure/Persistence/Record/SqliteRecordRepositoryTest.php @@ -6,12 +6,22 @@ namespace Tests\Infrastructure\Persistence\Record; use App\Domain\Record\Record; use App\Domain\Record\RecordNotFoundException; -use App\Infrastructure\Persistence\Record\InMemoryRecordRepository; +use App\Infrastructure\Database\Database; +use App\Infrastructure\Database\Schema\ActorStoreSchema; +use App\Infrastructure\Persistence\Record\SqliteRecordRepository; use DateTimeImmutable; use Tests\TestCase; -class InMemoryRecordRepositoryTest extends TestCase +class SqliteRecordRepositoryTest extends TestCase { + private function newRepo(): SqliteRecordRepository + { + $db = new Database(':memory:'); + ActorStoreSchema::apply($db); + + return new SqliteRecordRepository($db); + } + private function makeRecord( string $collection = 'app.bsky.feed.post', string $rkey = 'tid001', @@ -30,15 +40,16 @@ class InMemoryRecordRepositoryTest extends TestCase public function testFindByUri(): void { - $record = $this->makeRecord(); - $repo = new InMemoryRecordRepository([$record]); + $repo = $this->newRepo(); + $repo->save($this->makeRecord()); - $this->assertSame($record, $repo->findByUri('at://did:plc:alice/app.bsky.feed.post/tid001')); + $found = $repo->findByUri('at://did:plc:alice/app.bsky.feed.post/tid001'); + $this->assertSame('at://did:plc:alice/app.bsky.feed.post/tid001', $found->getUri()); } public function testFindByUriThrowsWhenMissing(): void { - $repo = new InMemoryRecordRepository(); + $repo = $this->newRepo(); $this->expectException(RecordNotFoundException::class); $repo->findByUri('at://did:plc:alice/app.bsky.feed.post/missing'); @@ -46,9 +57,9 @@ class InMemoryRecordRepositoryTest extends TestCase public function testFindByCollection(): void { - $r1 = $this->makeRecord(rkey: 'k1', collection: 'app.bsky.feed.post'); - $r2 = $this->makeRecord(rkey: 'k2', collection: 'app.bsky.actor.profile'); - $repo = new InMemoryRecordRepository([$r1, $r2]); + $repo = $this->newRepo(); + $repo->save($this->makeRecord(rkey: 'k1', collection: 'app.bsky.feed.post')); + $repo->save($this->makeRecord(rkey: 'k2', collection: 'app.bsky.actor.profile')); $posts = $repo->findByCollection('app.bsky.feed.post'); $this->assertCount(1, $posts); @@ -57,17 +68,17 @@ class InMemoryRecordRepositoryTest extends TestCase public function testFindAll(): void { - $r1 = $this->makeRecord(rkey: 'k1'); - $r2 = $this->makeRecord(rkey: 'k2'); - $repo = new InMemoryRecordRepository([$r1, $r2]); + $repo = $this->newRepo(); + $repo->save($this->makeRecord(rkey: 'k1')); + $repo->save($this->makeRecord(rkey: 'k2')); $this->assertCount(2, $repo->findAll()); } public function testDeleteByUri(): void { - $record = $this->makeRecord(); - $repo = new InMemoryRecordRepository([$record]); + $repo = $this->newRepo(); + $repo->save($this->makeRecord()); $repo->deleteByUri('at://did:plc:alice/app.bsky.feed.post/tid001'); $this->expectException(RecordNotFoundException::class); diff --git a/tests/Infrastructure/Persistence/Repo/InMemoryRepoBlockRepositoryTest.php b/tests/Infrastructure/Persistence/Repo/SqliteRepoBlockRepositoryTest.php similarity index 55% rename from tests/Infrastructure/Persistence/Repo/InMemoryRepoBlockRepositoryTest.php rename to tests/Infrastructure/Persistence/Repo/SqliteRepoBlockRepositoryTest.php index 7b368bc..aa42612 100644 --- a/tests/Infrastructure/Persistence/Repo/InMemoryRepoBlockRepositoryTest.php +++ b/tests/Infrastructure/Persistence/Repo/SqliteRepoBlockRepositoryTest.php @@ -6,11 +6,21 @@ namespace Tests\Infrastructure\Persistence\Repo; use App\Domain\Repo\RepoBlock; use App\Domain\Repo\RepoBlockNotFoundException; -use App\Infrastructure\Persistence\Repo\InMemoryRepoBlockRepository; +use App\Infrastructure\Database\Database; +use App\Infrastructure\Database\Schema\ActorStoreSchema; +use App\Infrastructure\Persistence\Repo\SqliteRepoBlockRepository; use Tests\TestCase; -class InMemoryRepoBlockRepositoryTest extends TestCase +class SqliteRepoBlockRepositoryTest extends TestCase { + private function newRepo(): SqliteRepoBlockRepository + { + $db = new Database(':memory:'); + ActorStoreSchema::apply($db); + + return new SqliteRepoBlockRepository($db); + } + private function makeBlock( string $cid = 'bafyreiblock', string $rev = '3aaaa', @@ -25,15 +35,16 @@ class InMemoryRepoBlockRepositoryTest extends TestCase public function testFindByCidReturnsSeededBlock(): void { - $block = $this->makeBlock(); - $repo = new InMemoryRepoBlockRepository([$block]); + $repo = $this->newRepo(); + $repo->save($this->makeBlock()); - $this->assertSame($block, $repo->findByCid('bafyreiblock')); + $found = $repo->findByCid('bafyreiblock'); + $this->assertSame('bafyreiblock', $found->getCid()); } public function testFindByCidThrowsWhenMissing(): void { - $repo = new InMemoryRepoBlockRepository(); + $repo = $this->newRepo(); $this->expectException(RepoBlockNotFoundException::class); $repo->findByCid('bafyreinone'); @@ -41,9 +52,9 @@ class InMemoryRepoBlockRepositoryTest extends TestCase public function testFindByRepoRev(): void { - $b1 = $this->makeBlock(cid: 'cid1', rev: 'rev-a'); - $b2 = $this->makeBlock(cid: 'cid2', rev: 'rev-b'); - $repo = new InMemoryRepoBlockRepository([$b1, $b2]); + $repo = $this->newRepo(); + $repo->save($this->makeBlock(cid: 'cid1', rev: 'rev-a')); + $repo->save($this->makeBlock(cid: 'cid2', rev: 'rev-b')); $results = $repo->findByRepoRev('rev-a'); $this->assertCount(1, $results); @@ -52,9 +63,8 @@ class InMemoryRepoBlockRepositoryTest extends TestCase public function testSaveAndDeleteByCid(): void { - $repo = new InMemoryRepoBlockRepository(); - $block = $this->makeBlock(); - $repo->save($block); + $repo = $this->newRepo(); + $repo->save($this->makeBlock()); $repo->deleteByCid('bafyreiblock'); $this->expectException(RepoBlockNotFoundException::class); @@ -63,9 +73,9 @@ class InMemoryRepoBlockRepositoryTest extends TestCase public function testDeleteAll(): void { - $b1 = $this->makeBlock(cid: 'cid1'); - $b2 = $this->makeBlock(cid: 'cid2'); - $repo = new InMemoryRepoBlockRepository([$b1, $b2]); + $repo = $this->newRepo(); + $repo->save($this->makeBlock(cid: 'cid1')); + $repo->save($this->makeBlock(cid: 'cid2')); $repo->deleteAll(); $this->assertEmpty($repo->findAll()); diff --git a/tests/Infrastructure/Persistence/Repo/InMemoryRepoRootRepositoryTest.php b/tests/Infrastructure/Persistence/Repo/SqliteRepoRootRepositoryTest.php similarity index 59% rename from tests/Infrastructure/Persistence/Repo/InMemoryRepoRootRepositoryTest.php rename to tests/Infrastructure/Persistence/Repo/SqliteRepoRootRepositoryTest.php index 6fcbf09..8755705 100644 --- a/tests/Infrastructure/Persistence/Repo/InMemoryRepoRootRepositoryTest.php +++ b/tests/Infrastructure/Persistence/Repo/SqliteRepoRootRepositoryTest.php @@ -6,12 +6,22 @@ namespace Tests\Infrastructure\Persistence\Repo; use App\Domain\Repo\RepoRoot; use App\Domain\Repo\RepoRootNotFoundException; -use App\Infrastructure\Persistence\Repo\InMemoryRepoRootRepository; +use App\Infrastructure\Database\Database; +use App\Infrastructure\Database\Schema\ActorStoreSchema; +use App\Infrastructure\Persistence\Repo\SqliteRepoRootRepository; use DateTimeImmutable; use Tests\TestCase; -class InMemoryRepoRootRepositoryTest extends TestCase +class SqliteRepoRootRepositoryTest extends TestCase { + private function newRepo(): SqliteRepoRootRepository + { + $db = new Database(':memory:'); + ActorStoreSchema::apply($db); + + return new SqliteRepoRootRepository($db); + } + private function makeRoot(string $did = 'did:plc:abc123'): RepoRoot { return new RepoRoot( @@ -24,15 +34,16 @@ class InMemoryRepoRootRepositoryTest extends TestCase public function testFindByDidReturnsSeededRoot(): void { - $root = $this->makeRoot(); - $repo = new InMemoryRepoRootRepository([$root]); + $repo = $this->newRepo(); + $repo->upsert($this->makeRoot()); - $this->assertSame($root, $repo->findByDid('did:plc:abc123')); + $found = $repo->findByDid('did:plc:abc123'); + $this->assertSame('did:plc:abc123', $found->getDid()); } public function testFindByDidThrowsWhenMissing(): void { - $repo = new InMemoryRepoRootRepository(); + $repo = $this->newRepo(); $this->expectException(RepoRootNotFoundException::class); $repo->findByDid('did:plc:missing'); @@ -40,17 +51,18 @@ class InMemoryRepoRootRepositoryTest extends TestCase public function testUpsertStoresRoot(): void { - $repo = new InMemoryRepoRootRepository(); + $repo = $this->newRepo(); $root = $this->makeRoot(); $repo->upsert($root); - $this->assertSame($root, $repo->findByDid('did:plc:abc123')); + $found = $repo->findByDid('did:plc:abc123'); + $this->assertSame('bafyreiabc', $found->getCid()); } public function testUpsertOverwritesExisting(): void { - $root1 = $this->makeRoot(); - $repo = new InMemoryRepoRootRepository([$root1]); + $repo = $this->newRepo(); + $repo->upsert($this->makeRoot()); $root2 = new RepoRoot('did:plc:abc123', 'bafyreinew', '3bbbb', new DateTimeImmutable()); $repo->upsert($root2); @@ -60,8 +72,8 @@ class InMemoryRepoRootRepositoryTest extends TestCase public function testDeleteByDid(): void { - $root = $this->makeRoot(); - $repo = new InMemoryRepoRootRepository([$root]); + $repo = $this->newRepo(); + $repo->upsert($this->makeRoot()); $repo->deleteByDid('did:plc:abc123'); $this->expectException(RepoRootNotFoundException::class); diff --git a/tests/Infrastructure/Persistence/Sequencer/InMemorySequencerRepositoryTest.php b/tests/Infrastructure/Persistence/Sequencer/SqliteSequencerRepositoryTest.php similarity index 80% rename from tests/Infrastructure/Persistence/Sequencer/InMemorySequencerRepositoryTest.php rename to tests/Infrastructure/Persistence/Sequencer/SqliteSequencerRepositoryTest.php index 6b2924e..c2a114e 100644 --- a/tests/Infrastructure/Persistence/Sequencer/InMemorySequencerRepositoryTest.php +++ b/tests/Infrastructure/Persistence/Sequencer/SqliteSequencerRepositoryTest.php @@ -5,14 +5,24 @@ declare(strict_types=1); namespace Tests\Infrastructure\Persistence\Sequencer; use App\Domain\Sequencer\RepoSeqEvent; -use App\Infrastructure\Persistence\Sequencer\InMemorySequencerRepository; +use App\Infrastructure\Database\Database; +use App\Infrastructure\Database\Schema\SequencerSchema; +use App\Infrastructure\Persistence\Sequencer\SqliteSequencerRepository; use Tests\TestCase; -class InMemorySequencerRepositoryTest extends TestCase +class SqliteSequencerRepositoryTest extends TestCase { + private function newRepo(): SqliteSequencerRepository + { + $db = new Database(':memory:'); + SequencerSchema::apply($db); + + return new SqliteSequencerRepository($db); + } + public function testAppendReturnsMonotonicallyIncreasingSeq(): void { - $repo = new InMemorySequencerRepository(); + $repo = $this->newRepo(); $seq1 = $repo->append('did:plc:alice', RepoSeqEvent::TYPE_APPEND, 'event1'); $seq2 = $repo->append('did:plc:alice', RepoSeqEvent::TYPE_IDENTITY, 'event2'); @@ -25,13 +35,13 @@ class InMemorySequencerRepositoryTest extends TestCase public function testLatestSeqReturnsNullWhenEmpty(): void { - $repo = new InMemorySequencerRepository(); + $repo = $this->newRepo(); $this->assertNull($repo->latestSeq()); } public function testLatestSeqReturnsHighestSeq(): void { - $repo = new InMemorySequencerRepository(); + $repo = $this->newRepo(); $repo->append('did:plc:alice', RepoSeqEvent::TYPE_APPEND, 'e1'); $repo->append('did:plc:alice', RepoSeqEvent::TYPE_APPEND, 'e2'); @@ -40,10 +50,10 @@ class InMemorySequencerRepositoryTest extends TestCase public function testReadRangeReturnsEventsAfterGivenSeq(): void { - $repo = new InMemorySequencerRepository(); - $repo->append('did:plc:alice', RepoSeqEvent::TYPE_APPEND, 'e1'); // seq=1 - $repo->append('did:plc:alice', RepoSeqEvent::TYPE_APPEND, 'e2'); // seq=2 - $repo->append('did:plc:alice', RepoSeqEvent::TYPE_APPEND, 'e3'); // seq=3 + $repo = $this->newRepo(); + $repo->append('did:plc:alice', RepoSeqEvent::TYPE_APPEND, 'e1'); + $repo->append('did:plc:alice', RepoSeqEvent::TYPE_APPEND, 'e2'); + $repo->append('did:plc:alice', RepoSeqEvent::TYPE_APPEND, 'e3'); $results = $repo->readRange(1); $this->assertCount(2, $results); @@ -53,7 +63,7 @@ class InMemorySequencerRepositoryTest extends TestCase public function testReadRangeRespectsLimit(): void { - $repo = new InMemorySequencerRepository(); + $repo = $this->newRepo(); for ($i = 0; $i < 10; $i++) { $repo->append('did:plc:alice', RepoSeqEvent::TYPE_APPEND, "e{$i}"); } @@ -64,7 +74,7 @@ class InMemorySequencerRepositoryTest extends TestCase public function testInvalidateForDidMarksEvents(): void { - $repo = new InMemorySequencerRepository(); + $repo = $this->newRepo(); $repo->append('did:plc:alice', RepoSeqEvent::TYPE_APPEND, 'e1'); $repo->append('did:plc:bob', RepoSeqEvent::TYPE_APPEND, 'e2'); $repo->invalidateForDid('did:plc:alice'); @@ -79,7 +89,7 @@ class InMemorySequencerRepositoryTest extends TestCase public function testReadRangeResultsAreInAscendingOrder(): void { - $repo = new InMemorySequencerRepository(); + $repo = $this->newRepo(); $repo->append('did:plc:a', RepoSeqEvent::TYPE_APPEND, 'e1'); $repo->append('did:plc:b', RepoSeqEvent::TYPE_APPEND, 'e2'); $repo->append('did:plc:c', RepoSeqEvent::TYPE_APPEND, 'e3'); diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 3abaad7..937ee39 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -1,6 +1,7 @@