diff --git a/src/Controller/Api/Search/SearchRetrieveApi.php b/src/Controller/Api/Search/SearchRetrieveApi.php index da658ef8f4..327ad6af33 100644 --- a/src/Controller/Api/Search/SearchRetrieveApi.php +++ b/src/Controller/Api/Search/SearchRetrieveApi.php @@ -4,17 +4,19 @@ namespace App\Controller\Api\Search; +use App\ActivityPub\ActorHandle; use App\Controller\Api\BaseApi; use App\Controller\Traits\PrivateContentTrait; -use App\Entity\Contracts\ContentInterface; -use App\Factory\MagazineFactory; -use App\Factory\UserFactory; +use App\DTO\SearchResponseDto; +use App\Entity\Entry; +use App\Entity\EntryComment; +use App\Entity\Magazine; +use App\Entity\Post; +use App\Entity\PostComment; +use App\Entity\User; use App\Repository\SearchRepository; -use App\Schema\ContentSchema; use App\Schema\PaginationSchema; -use App\Schema\SearchActorSchema; use App\Service\SearchManager; -use App\Service\SettingsManager; use Nelmio\ApiDocBundle\Attribute\Model; use OpenApi\Attributes as OA; use Symfony\Component\HttpFoundation\JsonResponse; @@ -27,34 +29,23 @@ class SearchRetrieveApi extends BaseApi #[OA\Response( response: 200, - description: 'Returns a paginated list of content, along with any ActivityPub actors that matched the query by username, or ActivityPub objects that matched the query by URL. Actors and objects are not paginated', + description: 'Returns a paginated list of content, along with any ActivityPub actors that matched the query by username, or ActivityPub objects that matched the query by URL. AP-Objects are not paginated.', content: new OA\JsonContent( type: 'object', properties: [ new OA\Property( property: 'items', type: 'array', - items: new OA\Items( - ref: new Model(type: ContentSchema::class) - ) + items: new OA\Items(ref: new Model(type: SearchResponseDto::class)) ), new OA\Property( property: 'pagination', ref: new Model(type: PaginationSchema::class) ), new OA\Property( - property: 'apActors', + property: 'apResults', type: 'array', - items: new OA\Items( - ref: new Model(type: SearchActorSchema::class) - ) - ), - new OA\Property( - property: 'apObjects', - type: 'array', - items: new OA\Items( - ref: new Model(type: ContentSchema::class) - ) + items: new OA\Items(ref: new Model(type: SearchResponseDto::class)) ), ] ), @@ -127,15 +118,13 @@ class SearchRetrieveApi extends BaseApi #[OA\Tag(name: 'search')] public function __invoke( SearchManager $manager, - UserFactory $userFactory, - MagazineFactory $magazineFactory, - SettingsManager $settingsManager, RateLimiterFactoryInterface $apiReadLimiter, RateLimiterFactoryInterface $anonymousApiReadLimiter, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter); $request = $this->request->getCurrentRequest(); + /** @var string $q */ $q = $request->get('q'); if (null === $q) { throw new BadRequestHttpException(); @@ -152,49 +141,82 @@ public function __invoke( throw new BadRequestHttpException(); } + /** @var ?SearchResponseDto $searchResults */ + $searchResults = []; $items = $manager->findPaginated($this->getUser(), $q, $page, $perPage, authorId: $authorId, magazineId: $magazineId, specificType: $type); - $dtos = []; - foreach ($items->getCurrentPageResults() as $value) { - \assert($value instanceof ContentInterface); - array_push($dtos, $this->serializeContentInterface($value)); + foreach ($items->getCurrentPageResults() as $item) { + $searchResults[] = $this->serializeItem($item); } - $response = $this->serializePaginated($dtos, $items); - - $response['apActors'] = []; - $response['apObjects'] = []; - $actors = []; - $objects = []; - if (!$settingsManager->get('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN') || $this->getUser()) { - $actors = $manager->findActivityPubActorsByUsername($q); - $objects = $manager->findActivityPubObjectsByURL($q); - } + /** @var ?SearchResponseDto $apResults */ + $apResults = []; + if ($this->federatedSearchAllowed()) { + if ($handle = ActorHandle::parse($q)) { + $actors = $manager->findActivityPubActorsByUsername($handle); + foreach ($actors as $actor) { + $apResults[] = $this->serializeItem($actor['object']); + } + } else { + $objects = $manager->findActivityPubObjectsByURL($q); + foreach ($objects['errors'] as $error) { + /** @var \Exception $error */ + $this->logger->warning( + 'Exception while resolving URL {url}: {type}: {msg}', + [ + 'url' => $q, + 'type' => \get_class($error), + 'msg' => $error->getMessage(), + ] + ); + } - foreach ($actors as $actor) { - switch ($actor['type']) { - case 'user': - $response['apActors'][] = [ - 'type' => 'user', - 'object' => $this->serializeUser($userFactory->createDto($actor['object'])), - ]; - break; - case 'magazine': - $response['apActors'][] = [ - 'type' => 'magazine', - 'object' => $this->serializeMagazine($magazineFactory->createDto($actor['object'])), - ]; - break; + foreach ($objects['results'] as $object) { + $apResults[] = $this->serializeItem($object['object']); + } } } - foreach ($objects as $object) { - \assert($object instanceof ContentInterface); - $response['apObjects'][] = $this->serializeContentInterface($object); - } + $response = $this->serializePaginated($searchResults, $items); + $response['apResults'] = $apResults; return new JsonResponse( $response, headers: $headers ); } + + private function federatedSearchAllowed(): bool + { + return !$this->settingsManager->get('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN') + || $this->getUser(); + } + + private function serializeItem(object $item): ?SearchResponseDto + { + if ($item instanceof Entry) { + $this->handlePrivateContent($item); + + return new SearchResponseDto(entry: $this->serializeEntry($this->entryFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item))); + } elseif ($item instanceof Post) { + $this->handlePrivateContent($item); + + return new SearchResponseDto(post: $this->serializePost($this->postFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item))); + } elseif ($item instanceof EntryComment) { + $this->handlePrivateContent($item); + + return new SearchResponseDto(entryComment: $this->serializeEntryComment($this->entryCommentFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item))); + } elseif ($item instanceof PostComment) { + $this->handlePrivateContent($item); + + return new SearchResponseDto(postComment: $this->serializePostComment($this->postCommentFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item))); + } elseif ($item instanceof Magazine) { + return new SearchResponseDto(magazine: $this->serializeMagazine($this->magazineFactory->createDto($item))); + } elseif ($item instanceof User) { + return new SearchResponseDto(user: $this->serializeUser($this->userFactory->createDto($item))); + } else { + $this->logger->error('Unexpected result type: '.\get_class($item)); + + return null; + } + } } diff --git a/src/Controller/SearchController.php b/src/Controller/SearchController.php index e408921ada..6884cf0245 100644 --- a/src/Controller/SearchController.php +++ b/src/Controller/SearchController.php @@ -6,30 +6,20 @@ use App\ActivityPub\ActorHandle; use App\DTO\SearchDto; -use App\Entity\Magazine; -use App\Entity\User; use App\Form\SearchType; -use App\Message\ActivityPub\Inbox\CreateMessage; -use App\Service\ActivityPub\ApHttpClientInterface; -use App\Service\ActivityPubManager; use App\Service\SearchManager; use App\Service\SettingsManager; use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\Messenger\MessageBusInterface; -use Symfony\Component\Messenger\Stamp\TransportNamesStamp; class SearchController extends AbstractController { public function __construct( private readonly SearchManager $manager, - private readonly ActivityPubManager $activityPubManager, - private readonly ApHttpClientInterface $apHttpClient, private readonly SettingsManager $settingsManager, private readonly LoggerInterface $logger, - private readonly MessageBusInterface $bus, ) { } @@ -52,7 +42,7 @@ public function __invoke(Request $request): Response if (str_contains($query, '@') && $this->federatedSearchAllowed()) { if ($handle = ActorHandle::parse($query)) { $this->logger->debug('searching for a matched webfinger {query}', ['query' => $query]); - $objects = $this->lookupHandle($handle); + $objects = $this->manager->findActivityPubActorsByUsername($handle); } else { $this->logger->debug("query doesn't look like a valid handle...", ['query' => $query]); } @@ -108,100 +98,15 @@ private function federatedSearchAllowed(): bool || $this->getUser(); } - private function lookupHandle(ActorHandle $handle): array - { - $objects = []; - $name = $handle->plainHandle(); - - try { - $webfinger = $this->activityPubManager->webfinger($name); - foreach ($webfinger->getProfileIds() as $profileId) { - $this->logger->debug('Found "{profileId}" at "{name}"', ['profileId' => $profileId, 'name' => $name]); - - // if actor object exists or successfully created - $object = $this->activityPubManager->findActorOrCreate($profileId); - if (!empty($object)) { - if ($object instanceof Magazine) { - $type = 'magazine'; - } elseif ($object instanceof User && '!' !== $handle->prefix) { - $type = 'user'; - } - - $objects[] = [ - 'type' => $type, - 'object' => $object, - ]; - } - } - } catch (\Exception $e) { - $this->logger->warning( - 'an error occurred during webfinger lookup of "{handle}": {exceptionClass}: {message}', - [ - 'handle' => $name, - 'exceptionClass' => \get_class($e), - 'message' => $e->getMessage(), - ] - ); - } - - return $objects; - } - private function findObjectsByApUrl(string $url): array { - $objects = $this->manager->findByApId($url); - if (0 === \sizeof($objects)) { - // the url could resolve to a different id. - try { - $body = $this->apHttpClient->getActivityObject($url); - $apId = $body['id']; - $objects = $this->manager->findByApId($apId); - } catch (\Exception $e) { - $body = null; - $apId = $url; - $this->addFlash('error', $e->getMessage()); - } - - if (0 === \sizeof($objects) && null !== $body) { - // maybe it is an entry, post, etc. - try { - // process the message in the sync transport, so that the created content is directly visible - $this->bus->dispatch(new CreateMessage($body), [new TransportNamesStamp('sync')]); - $objects = $this->manager->findByApId($apId); - } catch (\Exception $e) { - $this->addFlash('error', $e->getMessage()); - } - } + $result = $this->manager->findActivityPubObjectsByURL($url); - if (0 === \sizeof($objects)) { - // maybe it is a magazine or user - try { - $this->activityPubManager->findActorOrCreate($apId); - $objects = $this->manager->findByApId($apId); - } catch (\Exception $e) { - $this->addFlash('error', $e->getMessage()); - } - } + foreach ($result['errors'] as $error) { + /** @var \Exception $error */ + $this->addFlash('error', $error->getMessage()); } - return $this->mapApResultsToViewModel($objects); - } - - private function mapApResultsToViewModel(array $objects): array - { - return array_map(function ($object) { - if ($object instanceof Magazine) { - $type = 'magazine'; - } elseif ($object instanceof User) { - $type = 'user'; - } else { - $type = 'subject'; - } - - return [ - 'type' => $type, - 'object' => $object, - ]; - }, $objects); + return $result['results']; } } diff --git a/src/DTO/SearchResponseDto.php b/src/DTO/SearchResponseDto.php new file mode 100644 index 0000000000..fc2cec821b --- /dev/null +++ b/src/DTO/SearchResponseDto.php @@ -0,0 +1,24 @@ +transformer); $pagerfanta = new Pagerfanta($adapter); + $pagerfanta->setMaxPerPage($perPage); $pagerfanta->setCurrentPage($page); return $pagerfanta; diff --git a/src/Service/SearchManager.php b/src/Service/SearchManager.php index 3cc9465307..c1b400e55e 100644 --- a/src/Service/SearchManager.php +++ b/src/Service/SearchManager.php @@ -4,18 +4,20 @@ namespace App\Service; +use App\ActivityPub\ActorHandle; use App\Entity\Magazine; use App\Entity\User; -use App\Message\ActivityPub\Inbox\ActivityMessage; +use App\Message\ActivityPub\Inbox\CreateMessage; use App\Repository\DomainRepository; use App\Repository\MagazineRepository; use App\Repository\SearchRepository; use App\Service\ActivityPub\ApHttpClientInterface; -use App\Utils\RegPatterns; use Pagerfanta\Adapter\ArrayAdapter; use Pagerfanta\Pagerfanta; use Pagerfanta\PagerfantaInterface; +use Psr\Log\LoggerInterface; use Symfony\Component\Messenger\MessageBusInterface; +use Symfony\Component\Messenger\Stamp\TransportNamesStamp; class SearchManager { @@ -26,6 +28,7 @@ public function __construct( private readonly ActivityPubManager $activityPubManager, private readonly MessageBusInterface $bus, private readonly ApHttpClientInterface $apHttpClient, + private readonly LoggerInterface $logger, ) { } @@ -57,7 +60,7 @@ public function findPaginated( ?string $specificType = null, ?\DateTimeImmutable $sinceDate = null, ): PagerfantaInterface { - return $this->repository->search($queryingUser, $val, $page, authorId: $authorId, magazineId: $magazineId, specificType: $specificType, sinceDate: $sinceDate); + return $this->repository->search($queryingUser, $val, $page, authorId: $authorId, magazineId: $magazineId, specificType: $specificType, sinceDate: $sinceDate, perPage: $perPage); } public function findByApId(string $url): array @@ -71,64 +74,131 @@ public function findRelated(string $query): array } /** - * @param string $query One or more canonical ActivityPub usernames, such as kbinMeta@kbin.social or @ernest@kbin.social (anything that matches RegPatterns::AP_USER) + * @param ActorHandle $handle a valid handle (can be obtained from string via ActorHandle::parse()) * - * @return array a list of magazines or users that were found using the given identifiers, empty if none were found or no @ is in the query + * @return array ['type' => 'magazine'|'user', 'object' => Magazine|User][] */ - public function findActivityPubActorsByUsername(string $query): array + public function findActivityPubActorsByUsername(ActorHandle $handle): array { - if (false === str_contains($query, '@')) { - return []; - } - $objects = []; - $name = str_starts_with($query, '!') ? '@'.substr($query, 1) : $query; - $name = str_starts_with($name, '@') ? $name : '@'.$name; - preg_match(RegPatterns::AP_USER, $name, $matches); - if (\count(array_filter($matches)) >= 4) { - try { - $webfinger = $this->activityPubManager->webfinger($name); - foreach ($webfinger->getProfileIds() as $profileId) { - $object = $this->activityPubManager->findActorOrCreate($profileId); - if (!empty($object)) { - if ($object instanceof Magazine) { - $type = 'magazine'; - } elseif ($object instanceof User) { - $type = 'user'; - } - - $objects[] = [ - 'type' => $type, - 'object' => $object, - ]; + $name = $handle->plainHandle(); + + try { + $webfinger = $this->activityPubManager->webfinger($name); + foreach ($webfinger->getProfileIds() as $profileId) { + $this->logger->debug('Found "{profileId}" at "{name}"', ['profileId' => $profileId, 'name' => $name]); + + // if actor object exists or successfully created + $object = $this->activityPubManager->findActorOrCreate($profileId); + if (!empty($object)) { + if ($object instanceof Magazine) { + $type = 'magazine'; + } elseif ($object instanceof User && '!' !== $handle->prefix) { + $type = 'user'; + } else { + $this->logger->error( + 'Unexpected AP object type: {type} , handle: {handle}', + [ + 'type' => \get_class($object), + 'handle' => $name, + ] + ); + continue; } + + $objects[] = [ + 'type' => $type, + 'object' => $object, + ]; } - } catch (\Exception $e) { } + } catch (\Exception $e) { + $this->logger->warning( + 'an error occurred during webfinger lookup of "{handle}": {exceptionClass}: {message}', + [ + 'handle' => $name, + 'exceptionClass' => \get_class($e), + 'message' => $e->getMessage(), + ] + ); } - return $objects ?? []; + return $objects; } /** - * @param string $query a string that may or may not be a URL + * Will dispatch a getActivityObject request if a valid URL was provided but no item was found locally. * - * @return array A list of objects found by the given query, or an empty array if none were found. - * Will dispatch a getActivityObject request if a valid URL was provided but no item was found - * locally. + * @param string $url a string that may or may not be a URL + * + * @return array array ['results' => ['type' => 'magazine'|'user'|'subject', 'object' => Magazine|User|ContentInterface][], 'errors' => Exception[]] */ - public function findActivityPubObjectsByURL(string $query): array + public function findActivityPubObjectsByURL(string $url): array { - if (false === filter_var($query, FILTER_VALIDATE_URL)) { - return []; + if (false === filter_var($url, FILTER_VALIDATE_URL)) { + return [ + 'results' => [], + 'errors' => [], + ]; } - $objects = $this->findByApId($query); - if (!$objects) { - $body = $this->apHttpClient->getActivityObject($query, false); - $this->bus->dispatch(new ActivityMessage($body)); + $exceptions = []; + $objects = $this->findByApId($url); + if (0 === \sizeof($objects)) { + // the url could resolve to a different id. + try { + $body = $this->apHttpClient->getActivityObject($url); + $apId = $body['id']; + $objects = $this->findByApId($apId); + } catch (\Exception $e) { + $body = null; + $apId = $url; + $exceptions[] = $e; + } + + if (0 === \sizeof($objects) && null !== $body) { + // maybe it is an entry, post, etc. + try { + // process the message in the sync transport, so that the created content is directly visible + $this->bus->dispatch(new CreateMessage($body), [new TransportNamesStamp('sync')]); + $objects = $this->findByApId($apId); + } catch (\Exception $e) { + $exceptions[] = $e; + } + } + + if (0 === \sizeof($objects)) { + // maybe it is a magazine or user + try { + $this->activityPubManager->findActorOrCreate($apId); + $objects = $this->findByApId($apId); + } catch (\Exception $e) { + $exceptions[] = $e; + } + } } - return $objects ?? []; + return [ + 'results' => $this->mapApResultsToSearchModel($objects), + 'errors' => $exceptions, + ]; + } + + private function mapApResultsToSearchModel(array $objects): array + { + return array_map(function ($object) { + if ($object instanceof Magazine) { + $type = 'magazine'; + } elseif ($object instanceof User) { + $type = 'user'; + } else { + $type = 'subject'; + } + + return [ + 'type' => $type, + 'object' => $object, + ]; + }, $objects); } } diff --git a/tests/Functional/Controller/Api/Search/SearchApiTest.php b/tests/Functional/Controller/Api/Search/SearchApiTest.php index 6e522911b3..f02205a43d 100644 --- a/tests/Functional/Controller/Api/Search/SearchApiTest.php +++ b/tests/Functional/Controller/Api/Search/SearchApiTest.php @@ -6,7 +6,6 @@ use App\Service\ActivityPub\ApHttpClient; use App\Service\ActivityPub\ApHttpClientInterface; -use App\Service\SettingsManager; use App\Tests\WebTestCase; use phpseclib3\Crypt\RSA; use Psr\Log\LoggerInterface; @@ -15,8 +14,11 @@ class SearchApiTest extends WebTestCase { - public const SEARCH_PAGINATED_KEYS = ['items', 'pagination', 'apActors', 'apObjects']; - public const SEARCH_AP_ACTOR_KEYS = ['type', 'object']; + // These tests do work, but we should not do requests to a remote server when running tests + private const RUN_AP_SEARCHES = false; + + public const SEARCH_PAGINATED_KEYS = ['items', 'pagination', 'apResults']; + public const SEARCH_ITEM_KEYS = ['entry', 'entryComment', 'post', 'postComment', 'magazine', 'user']; private RSA\PrivateKey $key; @@ -43,21 +45,8 @@ public function testApiCanFindEntryByTitleAnonymous(): void self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); - self::assertIsArray($jsonData); - self::assertArrayKeysMatch(self::SEARCH_PAGINATED_KEYS, $jsonData); - self::assertIsArray($jsonData['items']); - self::assertCount(1, $jsonData['items']); - self::assertIsArray($jsonData['pagination']); - self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); - self::assertIsArray($jsonData['apActors']); - self::assertEmpty($jsonData['apActors']); - self::assertIsArray($jsonData['apObjects']); - self::assertEmpty($jsonData['apObjects']); - - self::assertIsArray($jsonData['items'][0]); - self::assertArrayKeysMatch(array_merge(['itemType'], self::ENTRY_RESPONSE_KEYS), $jsonData['items'][0]); - self::assertSame('entry', $jsonData['items'][0]['itemType']); - self::assertSame($entry->getId(), $jsonData['items'][0]['entryId']); + self::validateResponseOuterData($jsonData, 1, 0); + self::validateResponseItemData($jsonData['items'][0], 'entry', $entry->getId()); } public function testApiCanFindContentByBodyAnonymous(): void @@ -72,39 +61,18 @@ public function testApiCanFindContentByBodyAnonymous(): void self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); - self::assertIsArray($jsonData); - self::assertArrayKeysMatch(self::SEARCH_PAGINATED_KEYS, $jsonData); - self::assertIsArray($jsonData['items']); - self::assertCount(2, $jsonData['items']); - self::assertIsArray($jsonData['pagination']); - self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); - self::assertIsArray($jsonData['apActors']); - self::assertEmpty($jsonData['apActors']); - self::assertIsArray($jsonData['apObjects']); - self::assertEmpty($jsonData['apObjects']); + self::validateResponseOuterData($jsonData, 2, 0); foreach ($jsonData['items'] as $item) { - self::assertIsArray($item); - self::assertArrayHasKey('itemType', $item); - switch ($item['itemType']) { - case 'entry': - self::assertArrayKeysMatch(array_merge(['itemType'], self::ENTRY_RESPONSE_KEYS), $item); - self::assertSame($entry->getId(), $item['entryId']); - break; - case 'entry_comment': - self::assertNotReached('No entry_comment should have been found'); - break; - case 'post': - self::assertArrayKeysMatch(array_merge(['itemType'], self::POST_RESPONSE_KEYS), $item); - self::assertSame($post->getId(), $item['postId']); - break; - case 'post_comment': - self::assertNotReached('No post_comment should have been found'); - break; - default: - self::assertNotReached(); - break; + if (null !== $item['entry']) { + $type = 'entry'; + $id = $entry->getId(); + } else { + $type = 'post'; + $id = $post->getId(); } + + self::validateResponseItemData($item, $type, $id); } } @@ -120,39 +88,18 @@ public function testApiCanFindCommentsByBodyAnonymous(): void self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); - self::assertIsArray($jsonData); - self::assertArrayKeysMatch(self::SEARCH_PAGINATED_KEYS, $jsonData); - self::assertIsArray($jsonData['items']); - self::assertCount(2, $jsonData['items']); - self::assertIsArray($jsonData['pagination']); - self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); - self::assertIsArray($jsonData['apActors']); - self::assertEmpty($jsonData['apActors']); - self::assertIsArray($jsonData['apObjects']); - self::assertEmpty($jsonData['apObjects']); + self::validateResponseOuterData($jsonData, 2, 0); foreach ($jsonData['items'] as $item) { - self::assertIsArray($item); - self::assertArrayHasKey('itemType', $item); - switch ($item['itemType']) { - case 'entry': - self::assertNotReached('No entry should have been found'); - break; - case 'entry_comment': - self::assertArrayKeysMatch(array_merge(['itemType'], self::ENTRY_COMMENT_RESPONSE_KEYS), $item); - self::assertSame($entryComment->getId(), $item['commentId']); - break; - case 'post': - self::assertNotReached('No post should have been found'); - break; - case 'post_comment': - self::assertArrayKeysMatch(array_merge(['itemType'], self::POST_COMMENT_RESPONSE_KEYS), $item); - self::assertSame($postComment->getId(), $item['commentId']); - break; - default: - self::assertNotReached(); - break; + if (null !== $item['entryComment']) { + $type = 'entryComment'; + $id = $entryComment->getId(); + } else { + $type = 'postComment'; + $id = $postComment->getId(); } + + self::validateResponseItemData($item, $type, $id); } } @@ -167,16 +114,7 @@ public function testApiCannotFindRemoteUserAnonymousWhenOptionSet(): void self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); - self::assertIsArray($jsonData); - self::assertArrayKeysMatch(self::SEARCH_PAGINATED_KEYS, $jsonData); - self::assertIsArray($jsonData['items']); - self::assertEmpty($jsonData['items']); - self::assertIsArray($jsonData['pagination']); - self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); - self::assertIsArray($jsonData['apActors']); - self::assertEmpty($jsonData['apActors']); - self::assertIsArray($jsonData['apObjects']); - self::assertEmpty($jsonData['apObjects']); + self::validateResponseOuterData($jsonData, 0, 0); // Seems like settings can persist in the test environment? Might only be for bare metal setups $settingsManager->set('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN', $value); @@ -193,25 +131,18 @@ public function testApiCannotFindRemoteMagazineAnonymousWhenOptionSet(): void self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); - self::assertIsArray($jsonData); - self::assertArrayKeysMatch(self::SEARCH_PAGINATED_KEYS, $jsonData); - self::assertIsArray($jsonData['items']); - self::assertEmpty($jsonData['items']); - self::assertIsArray($jsonData['pagination']); - self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); - self::assertIsArray($jsonData['apActors']); - self::assertEmpty($jsonData['apActors']); - self::assertIsArray($jsonData['apObjects']); - self::assertEmpty($jsonData['apObjects']); + self::validateResponseOuterData($jsonData, 0, 0); // Seems like settings can persist in the test environment? Might only be for bare metal setups $settingsManager->set('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN', $value); } - /* - * These tests do work, but we should not do requests to a remote server when running tests - public function testApiCanFindRemoteUserAnonymousWhenOptionUnset(): void + public function testApiCanFindRemoteUserByHandleAnonymous(): void { + if (!self::RUN_AP_SEARCHES) { + return; + } + $settingsManager = $this->settingsManager; $value = $settingsManager->get('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN'); $settingsManager->set('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN', false); @@ -219,74 +150,54 @@ public function testApiCanFindRemoteUserAnonymousWhenOptionUnset(): void $this->getUserByUsername('test'); $this->setCacheKeysForApHttpClient($domain); - $this->client->request('GET', "/api/search?q=@eugen@mastodon.social"); + $this->client->request('GET', '/api/search?q=@eugen@mastodon.social'); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); - self::assertIsArray($jsonData); - self::assertArrayKeysMatch(self::SEARCH_PAGINATED_KEYS, $jsonData); - self::assertIsArray($jsonData['items']); - self::assertEmpty($jsonData['items']); - self::assertIsArray($jsonData['pagination']); - self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); - self::assertIsArray($jsonData['apActors']); - self::assertCount(1, $jsonData['apActors']); - self::assertIsArray($jsonData['apObjects']); - self::assertEmpty($jsonData['apObjects']); - - self::assertIsArray($jsonData['apActors'][0]); - self::assertArrayKeysMatch(self::SEARCH_AP_ACTOR_KEYS, $jsonData['apActors'][0]); - self::assertSame('user', $jsonData['apActors'][0]['type']); - self::assertIsArray($jsonData['apActors'][0]['object']); - self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData['apActors'][0]['object']); - self::assertSame('eugen@mastodon.social', $jsonData['apActors'][0]['object']['apId']); + self::validateResponseOuterData($jsonData, 0, 1); + self::validateResponseItemData($jsonData['apResults'][0], 'user', null, 'eugen@mastodon.social'); // Seems like settings can persist in the test environment? Might only be for bare metal setups $settingsManager->set('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN', $value); + self::getContainer()->get(ApHttpClientInterface::class)->replacement = null; } - public function testApiCanFindRemoteMagazineAnonymousWhenOptionUnset(): void - { // Admin user must exist to retrieve a remote magazine since remote mods aren't federated (yet) + public function testApiCanFindRemoteMagazineByHandleAnonymous(): void + { + if (!self::RUN_AP_SEARCHES) { + return; + } + + // Admin user must exist to retrieve a remote magazine since remote mods aren't federated (yet) $this->getUserByUsername('admin', isAdmin: true); $settingsManager = $this->settingsManager; $value = $settingsManager->get('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN'); $settingsManager->set('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN', false); $domain = $settingsManager->get('KBIN_DOMAIN'); - $logger = $this->loggerInterface; - $this->setCacheKeysForApHttpClient($domain, $logger); + $this->setCacheKeysForApHttpClient($domain, $this->logger); $this->getMagazineByName('testMag'); - $this->client->request('GET', "/api/search?q=!technology@lemmy.world"); + $this->client->request('GET', '/api/search?q=!technology@lemmy.world'); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); - self::assertIsArray($jsonData); - self::assertArrayKeysMatch(self::SEARCH_PAGINATED_KEYS, $jsonData); - self::assertIsArray($jsonData['items']); - self::assertEmpty($jsonData['items']); - self::assertIsArray($jsonData['pagination']); - self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); - self::assertIsArray($jsonData['apActors']); - self::assertCount(1, $jsonData['apActors']); - self::assertIsArray($jsonData['apObjects']); - self::assertEmpty($jsonData['apObjects']); - - self::assertIsArray($jsonData['apActors'][0]); - self::assertArrayKeysMatch(self::SEARCH_AP_ACTOR_KEYS, $jsonData['apActors'][0]); - self::assertSame('magazine', $jsonData['apActors'][0]['type']); - self::assertIsArray($jsonData['apActors'][0]['object']); - self::assertArrayKeysMatch(self::MAGAZINE_RESPONSE_KEYS, $jsonData['apActors'][0]['object']); - self::assertSame('technology@lemmy.world', $jsonData['apActors'][0]['object']['apId']); + self::validateResponseOuterData($jsonData, 0, 1); + self::validateResponseItemData($jsonData['apResults'][0], 'magazine', null, 'technology@lemmy.world'); // Seems like settings can persist in the test environment? Might only be for bare metal setups $settingsManager->set('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN', $value); + self::getContainer()->get(ApHttpClientInterface::class)->replacement = null; } - public function testApiCanFindRemoteUser(): void + public function testApiCanFindRemoteUserByUrl(): void { + if (!self::RUN_AP_SEARCHES) { + return; + } + $settingsManager = $this->settingsManager; $value = $settingsManager->get('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN'); $settingsManager->set('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN', true); @@ -296,35 +207,56 @@ public function testApiCanFindRemoteUser(): void $this->client->loginUser($this->getUserByUsername('user')); - $this->client->request('GET', "/api/search?q=@eugen@mastodon.social"); + $this->client->request('GET', '/api/search?q=https%3A%2F%2Fmastodon.social%2F%40eugen'); + + self::assertResponseIsSuccessful(); + $jsonData = self::getJsonResponse($this->client); + + self::validateResponseOuterData($jsonData, 0, 1); + self::validateResponseItemData($jsonData['apResults'][0], 'user', null, 'eugen@mastodon.social'); + + // Seems like settings can persist in the test environment? Might only be for bare metal setups + $settingsManager->set('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN', $value); + self::getContainer()->get(ApHttpClientInterface::class)->replacement = null; + } + + public function testApiCanFindRemoteMagazineByUrl(): void + { + if (!self::RUN_AP_SEARCHES) { + return; + } + + $this->getUserByUsername('admin', isAdmin: true); + + $settingsManager = $this->settingsManager; + $value = $settingsManager->get('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN'); + $settingsManager->set('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN', true); + $domain = $settingsManager->get('KBIN_DOMAIN'); + $this->setCacheKeysForApHttpClient($domain); + + $this->client->loginUser($this->getUserByUsername('user')); + + $this->getMagazineByName('testMag'); + + $this->client->request('GET', '/api/search?q=https%3A%2F%2Flemmy.world%2Fc%2Ftechnology'); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); - self::assertIsArray($jsonData); - self::assertArrayKeysMatch(self::SEARCH_PAGINATED_KEYS, $jsonData); - self::assertIsArray($jsonData['items']); - self::assertEmpty($jsonData['items']); - self::assertIsArray($jsonData['pagination']); - self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); - self::assertIsArray($jsonData['apActors']); - self::assertCount(1, $jsonData['apActors']); - self::assertIsArray($jsonData['apObjects']); - self::assertEmpty($jsonData['apObjects']); - - self::assertIsArray($jsonData['apActors'][0]); - self::assertArrayKeysMatch(self::SEARCH_AP_ACTOR_KEYS, $jsonData['apActors'][0]); - self::assertSame('user', $jsonData['apActors'][0]['type']); - self::assertIsArray($jsonData['apActors'][0]['object']); - self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData['apActors'][0]['object']); - self::assertSame('eugen@mastodon.social', $jsonData['apActors'][0]['object']['apId']); + self::validateResponseOuterData($jsonData, 0, 1); + self::validateResponseItemData($jsonData['apResults'][0], 'magazine', null, 'technology@lemmy.world'); // Seems like settings can persist in the test environment? Might only be for bare metal setups $settingsManager->set('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN', $value); + self::getContainer()->get(ApHttpClientInterface::class)->replacement = null; } - public function testApiCanFindRemoteMagazine(): void + public function testApiCanFindRemotePostByUrl(): void { + if (!self::RUN_AP_SEARCHES) { + return; + } + $this->getUserByUsername('admin', isAdmin: true); $settingsManager = $this->settingsManager; @@ -334,35 +266,129 @@ public function testApiCanFindRemoteMagazine(): void $this->setCacheKeysForApHttpClient($domain); $this->client->loginUser($this->getUserByUsername('user')); + $this->getMagazineByName('testMag'); - $this->client->request('GET', "/api/search?q=!technology@lemmy.world"); + $this->client->request('GET', '/api/search?q=https%3A%2F%2Flemmy.world%2Fpost%2F44358216'); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); - self::assertIsArray($jsonData); - self::assertArrayKeysMatch(self::SEARCH_PAGINATED_KEYS, $jsonData); - self::assertIsArray($jsonData['items']); - self::assertEmpty($jsonData['items']); - self::assertIsArray($jsonData['pagination']); - self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); - self::assertIsArray($jsonData['apActors']); - self::assertCount(1, $jsonData['apActors']); - self::assertIsArray($jsonData['apObjects']); - self::assertEmpty($jsonData['apObjects']); - - self::assertIsArray($jsonData['apActors'][0]); - self::assertArrayKeysMatch(self::SEARCH_AP_ACTOR_KEYS, $jsonData['apActors'][0]); - self::assertSame('magazine', $jsonData['apActors'][0]['type']); - self::assertIsArray($jsonData['apActors'][0]['object']); - self::assertArrayKeysMatch(self::MAGAZINE_RESPONSE_KEYS, $jsonData['apActors'][0]['object']); - self::assertSame('technology@lemmy.world', $jsonData['apActors'][0]['object']['apId']); + self::validateResponseOuterData($jsonData, 0, 1); + self::validateResponseItemData($jsonData['apResults'][0], 'entry', null, 'https://sh.itjust.works/post/56929452'); // Seems like settings can persist in the test environment? Might only be for bare metal setups $settingsManager->set('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN', $value); + self::getContainer()->get(ApHttpClientInterface::class)->replacement = null; + } + + private static function validateResponseOuterData(array $data, int $expectedLength, int $expectedApLength): void + { + self::assertIsArray($data); + self::assertArrayKeysMatch(self::SEARCH_PAGINATED_KEYS, $data); + self::assertIsArray($data['items']); + self::assertCount($expectedLength, $data['items']); + self::assertIsArray($data['pagination']); + self::assertArrayKeysMatch(self::PAGINATION_KEYS, $data['pagination']); + self::assertSame($expectedLength, $data['pagination']['count']); + self::assertIsArray($data['apResults']); + self::assertCount($expectedApLength, $data['apResults']); + } + + private static function validateResponseItemData(array $data, string $expectedType, ?int $expectedId = null, ?string $expectedApId = null): void + { + self::assertIsArray($data); + self::assertArrayKeysMatch(self::SEARCH_ITEM_KEYS, $data); + + switch ($expectedType) { + case 'entry': + self::assertNotNull($data['entry']); + self::assertNull($data['entryComment']); + self::assertNull($data['post']); + self::assertNull($data['postComment']); + self::assertNull($data['magazine']); + self::assertNull($data['user']); + self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $data['entry']); + if (null !== $expectedId) { + self::assertSame($expectedId, $data['entry']['entryId']); + } else { + self::assertSame($expectedApId, $data['entry']['apId']); + } + break; + case 'entryComment': + self::assertNotNull($data['entryComment']); + self::assertNull($data['entry']); + self::assertNull($data['post']); + self::assertNull($data['postComment']); + self::assertNull($data['magazine']); + self::assertNull($data['user']); + self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $data['entryComment']); + if (null !== $expectedId) { + self::assertSame($expectedId, $data['entryComment']['commentId']); + } else { + self::assertSame($expectedApId, $data['entryComment']['apId']); + } + break; + case 'post': + self::assertNotNull($data['post']); + self::assertNull($data['entry']); + self::assertNull($data['entryComment']); + self::assertNull($data['postComment']); + self::assertNull($data['magazine']); + self::assertNull($data['user']); + self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $data['post']); + if (null !== $expectedId) { + self::assertSame($expectedId, $data['post']['postId']); + } else { + self::assertSame($expectedApId, $data['post']['apId']); + } + break; + case 'postComment': + self::assertNotNull($data['postComment']); + self::assertNull($data['entry']); + self::assertNull($data['entryComment']); + self::assertNull($data['post']); + self::assertNull($data['magazine']); + self::assertNull($data['user']); + self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $data['postComment']); + if (null !== $expectedId) { + self::assertSame($expectedId, $data['postComment']['commentId']); + } else { + self::assertSame($expectedApId, $data['postComment']['apId']); + } + break; + case 'magazine': + self::assertNotNull($data['magazine']); + self::assertNull($data['entry']); + self::assertNull($data['entryComment']); + self::assertNull($data['post']); + self::assertNull($data['postComment']); + self::assertNull($data['user']); + self::assertArrayKeysMatch(self::MAGAZINE_RESPONSE_KEYS, $data['magazine']); + if (null !== $expectedId) { + self::assertSame($expectedId, $data['magazine']['magazineId']); + } else { + self::assertSame($expectedApId, $data['magazine']['apId']); + } + break; + case 'user': + self::assertNotNull($data['user']); + self::assertNull($data['entry']); + self::assertNull($data['entryComment']); + self::assertNull($data['post']); + self::assertNull($data['postComment']); + self::assertNull($data['magazine']); + self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $data['user']); + if (null !== $expectedId) { + self::assertSame($expectedId, $data['user']['userId']); + } else { + self::assertSame($expectedApId, $data['user']['apId']); + } + break; + default: + throw new \AssertionError(); + } } - */ private function setCacheKeysForApHttpClient(string $domain, ?LoggerInterface $logger = null): void { @@ -394,7 +420,8 @@ private function setCacheKeysForApHttpClient(string $domain, ?LoggerInterface $l $this->magazineRepository, $this->siteRepository, $this->projectInfoService, + $this->eventDispatcher, ); - self::getContainer()->set(ApHttpClientInterface::class, $apHttpClient); + self::getContainer()->get(ApHttpClientInterface::class)->replacement = $apHttpClient; } } diff --git a/tests/Service/ApHttpClientProxy.php b/tests/Service/ApHttpClientProxy.php new file mode 100644 index 0000000000..c97f26c4aa --- /dev/null +++ b/tests/Service/ApHttpClientProxy.php @@ -0,0 +1,84 @@ +replacement ?? $this->defaultClient; + } + + public function getActivityObject(string $url, bool $decoded = true): array|string|null + { + return $this->client()->getActivityObject($url, $decoded); + } + + public function getActivityObjectCacheKey(string $url): string + { + return $this->client()->getActivityObjectCacheKey($url); + } + + public function getInboxUrl(string $apProfileId): string + { + return $this->client()->getInboxUrl($apProfileId); + } + + public function getWebfingerObject(string $url): ?array + { + return $this->client()->getWebfingerObject($url); + } + + public function getActorObject(string $apProfileId): ?array + { + return $this->client()->getActorObject($apProfileId); + } + + public function invalidateActorObjectCache(string $apProfileId): void + { + $this->client()->invalidateActorObjectCache($apProfileId); + } + + public function invalidateCollectionObjectCache(string $apAddress): void + { + $this->client()->invalidateCollectionObjectCache($apAddress); + } + + public function getCollectionObject(string $apAddress): ?array + { + return $this->client()->getCollectionObject($apAddress); + } + + public function post(string $url, Magazine|User $actor, ?array $body = null, bool $useOldPrivateKey = false): void + { + $this->client()->post($url, $actor, $body, $useOldPrivateKey); + } + + public function fetchInstanceNodeInfoEndpoints(string $domain, bool $decoded = true): array|string|null + { + return $this->client()->fetchInstanceNodeInfoEndpoints($domain, $decoded); + } + + public function fetchInstanceNodeInfo(string $url, bool $decoded = true): array|string|null + { + return $this->client()->fetchInstanceNodeInfo($url, $decoded); + } + + public function getInstancePublicKey(): string + { + return $this->client()->getInstancePublicKey(); + } +} diff --git a/tests/WebTestCase.php b/tests/WebTestCase.php index 5e07478875..37b17d1386 100644 --- a/tests/WebTestCase.php +++ b/tests/WebTestCase.php @@ -56,6 +56,7 @@ use App\Service\SettingsManager; use App\Service\UserManager; use App\Service\VoteManager; +use App\Tests\Service\ApHttpClientProxy; use App\Tests\Service\TestingApHttpClient; use App\Tests\Service\TestingImageManager; use Doctrine\Common\Collections\ArrayCollection; @@ -183,7 +184,7 @@ public function setUp(): void $this->client = static::createClient(); $this->testingApHttpClient = new TestingApHttpClient(); - self::getContainer()->set(ApHttpClientInterface::class, $this->testingApHttpClient); + self::getContainer()->set(ApHttpClientInterface::class, new ApHttpClientProxy($this->testingApHttpClient)); $this->imageManager = new TestingImageManager( $this->getContainer()->getParameter('kbin_storage_url'), @@ -245,6 +246,7 @@ public function setUp(): void $this->magazineFactory = $this->getService(MagazineFactory::class); $this->groupFactory = $this->getService(GroupFactory::class); $this->pageFactory = $this->getService(EntryPageFactory::class); + $this->tombstoneFactory = $this->getService(TombstoneFactory::class); $this->createWrapper = $this->getService(CreateWrapper::class); $this->likeWrapper = $this->getService(LikeWrapper::class); @@ -255,6 +257,8 @@ public function setUp(): void $this->requestStack = $this->getService(RequestStack::class); $this->router = $this->getService(RouterInterface::class); $this->bus = $this->getService(MessageBusInterface::class); + $this->projectInfoService = $this->getService(ProjectInfoService::class); + $this->logger = $this->getService(LoggerInterface::class); // clear all cache before every test $app = new Application($this->client->getKernel());